Skip to content

iOS: hardware wallet connection reliability and UX polish #613

Description

@piotr-iohk

Part of #589.

Follow-up polish and parity items deferred from feat: home-screen hardware wallet (watch-only) #605. Android counterpart: synonymdev/bitkit-android#1030.

This issue tracks iOS-specific UX and connection-state gaps found during manual QA of #605. Scope grows as later stacked PRs land (#611, #612, settings screen, etc.).

Checklist

  • Settings entry placement — move Trezor Hardware Wallet from Settings → Advanced into Dev SettingsSettings entry placement)
  • Home connection indicator (QA 4a / 4b) — grey the Home BLE badge on disconnect / BT-off, green again on auto-reconnect (§ Home connection indicator)
  • Home connection icon placement — move HwWalletConnectionIcon from the amount row to the device-name row (Figma / Android parity) (§ Home connection icon placement)
  • Device busy / ping cadence while Trezor is locked — back off instead of reopening the transport during a locked-device THP handshake (needs bitkit-core / upstream work) (§ Device busy / ping cadence)
  • BLE power-off doesn't fail in-flight operations — resume pending continuations with an error in the power-off path (§ Deferred follow-ups from PR #612)
  • "0" count badge in General Settings — only show the Hardware Wallets count when > 0Deferred follow-ups from PR #612)
  • Duplicated device-removal teardown — consolidate removeDevice + forgetDevice onto HwWalletManagerDeferred follow-ups from PR #612)
  • Stale expectedDisconnectPaths marker — suppressed a genuine BLE disconnect (phantom "connected" session) — fixed in feat: hardware wallets settings screen #612
  • Rename sheet blocking reset-to-default
  • Connect-wizard passphrase (hidden wallet) step — Figma "Passphrase" button on Paired → "Enter Passphrase" screen to add a passphrase-protected hidden wallet as a separate watch-only balance; blocked on a known-device storage-identity change (§ Passphrase / hidden-wallet step)

Scope detail

Settings entry placement

Move Trezor Hardware Wallet from Settings → Advanced into Dev Settings, for consistency with the dev-gated entry point. Already agreed in #605 review; deferred to a polish PR rather than blocking the home-screen slice.

Home connection indicator (QA 4a / 4b)

From #605 QA notes (manual tests 4a / 4b):

  • 4a. BLE-connected device → disable phone Bluetooth: Home hardware-row indicator turns grey; watch-only balance stays visible.
  • 4b. Re-enable Bluetooth: indicator turns green again on its own (auto-reconnect, no pairing prompt).

Current behaviour (4a fails): the Home BLE badge stays green when phone Bluetooth is disabled or the device is out of range. Android greys it out on disconnect.

Root cause: HwWalletConnectionIcon already supports green vs grey, but HwWallet.isConnected follows trezorManager.connectedDevice, which is only cleared on explicit disconnect / connect failure. iOS lacks Android's TrezorRepo.observeExternalDisconnects() / externalDisconnect wiring from TrezorBLEManager.didDisconnectPeripheral (and BT-off state) → TrezorManager.

Expected fix direction:

  • On peripheral disconnect or bluetoothState != .poweredOn, clear live connection state for the Home badge (without suppressing auto-reconnect).
  • Verify 4b end-to-end after 4a fix: BT back on → auto-reconnect → badge green again.

Home connection icon placement (Figma / Android parity)

Splitting this out from the §1 observation in #612 QA: the green/grey glyph state is correct, but on Home it's placed on the wrong row.

Current (iOS): in HardwareWalletsGrid.HardwareWalletCell the HwWalletConnectionIcon sits on the amount row, next to MoneyText (Bitkit/Components/HardwareWalletsGrid.swift:67):

VStack(alignment: .leading) {
    CaptionMText(wallet.name)          // name row  ← icon should live here
    HStack { btc-circle-blue; MoneyText; HwWalletConnectionIcon }  // amount row ← icon is here today
}

Expected (Figma 40364-130833): the glyph sits next to the device name, with the balance on its own row below.

Parity: Android Home renders it via WalletBalanceView titleTrailing — i.e. on the name row. This is a Home-only gap (from the #605 layout), not introduced by #612; Settings → Hardware Wallets already has the badge left of the name and matches.

Fix: move HwWalletConnectionIcon out of the amount HStack and onto the CaptionMText(wallet.name) row (name + trailing glyph), leaving the btc-circle-blue + MoneyText row unchanged. Small layout-only tweak to the shared home cell.

Note: distinct from the "Home connection indicator (QA 4a/4b)" grey/green state item above — that one is the disconnect-state wiring; this one is purely icon placement.

Device busy / ping cadence while Trezor is locked

Deferred from #611 QA (comment). Android counterpart: synonymdev/bitkit-android#1030 (device-state / ping-cadence).

Behaviour: while connecting with the Trezor locked (waiting for the user to unlock on-device), Bitkit keeps reopening the transport instead of backing off, which can keep the device busy. Observed in session logs ~09:10:41–09:11:02 UTC:

Time (UTC) Event
09:10:41 User-initiated connect while device locked
09:10:49 THP handshake → DeviceLocked
09:10:49–51 Handshake retry: closeDevice → wait 2000ms → openDevice again
09:10:51 / 09:10:56 / 09:11:00 Further openDevice attempts on same path
09:11:02 Connection failed: THP handshake failed: … DeviceLocked

Root cause / where it lives: the closeDevice → wait 2000ms → openDevice reopen loop and the DeviceLocked outcome are driven by the upstream trezor-connect-rs crate (CallbackTransport::acquire). iOS is pinned to bitkit-core 0.3.4 (includes trezor-connect-rs 0.3.3 and synonymdev/bitkit-core#106). Locally:

  • bitkit-core connect() does a single transport.acquire(...) with no retry wrapper.
  • The Swift transport callbacks openDevice/closeDevice (TrezorTransport.swift) are pass-throughs with no back-off — each crate-driven openDevice re-runs the full TrezorBLEManager.connect() 3-attempt (1s/2s) BLE loop underneath, so one crate reopen can spawn several BLE link attempts.
  • The Swift transport currently returns errorCode: nil for open/read/write/call failures, so core's structured DeviceBusy plumbing (feat: expose trezor lock state bitkit-core#104) cannot trigger from Swift-originated transport failures.
  • DeviceLocked is never surfaced as a distinct, retryable signal — it only arrives as the substring "THP handshake failed: DeviceLocked" after the crate's loop already failed, purely to produce a UI message.

Expected fix direction (one or both):

  • upstream/core: make THP DeviceLocked during CallbackTransport.acquire(...) a non-retryable / back-off-aware state instead of continuing the close/open handshake retry loop. Add a bitkit-core issue and check with @coreyphillips.
  • iOS: wire the new core primitives (TrezorFeatures.unlocked, trezor_refresh_features(), TrezorError.DeviceBusy, TrezorTransportErrorCode.DeviceBusy from feat: expose trezor lock state bitkit-core#104) into the transport/manager layer so the app can back off and avoid amplifying retries while the device is locked/busy (e.g. not re-running the 3-attempt BLE connect loop on every crate-driven openDevice, and not auto-reconnecting while a locked-device handshake is in flight).
  • Align with Android's handling in Hardware wallet connection reliability and UX polish bitkit-android#1030.

Distinct from the wrong/cancelled-PIN failure typing already fixed by synonymdev/bitkit-core#106 — this is locked-device THP handshake churn during session acquisition, before a usable session/features result exists.

Deferred follow-ups from PR #612 review

Minor items surfaced while reviewing PR #612 (Hardware Wallets settings screen), intentionally left out of that PR:

  • BLE power-off doesn't fail in-flight operationsTrezorBLEManager.centralManagerDidUpdateState (the power-off block, ~TrezorBLEManager.swift:571) clears connection state but does not resume pending connect / service-discovery / notification continuations, unlike didDisconnectPeripheral (~lines 633–636). It's bounded by the per-op timeouts (30s / 10s / 5s), so it's a multi-second stall rather than a hang. Fix: resume those continuations with an error in the power-off path too.
  • "0" count badge in General Settings — the Hardware Wallets row (GeneralSettingsView.swift:~118) always renders String(hwWalletManager.wallets.count), showing a literal 0 when no devices are paired. Show the count only when > 0.
  • Duplicated device-removal teardownHardwareWalletsSettingsScreen.remove(_:) (~:467) duplicates the removeDevice + forgetDevice-loop sequence that also lives in HardwareWalletScreen. Consolidate onto HwWalletManager so removal semantics live in one place.

Two related bugs from the same review were already fixed in PR #612 (see checklist): a stale expectedDisconnectPaths marker that could suppress a genuine BLE disconnect (phantom "connected" session), and the rename sheet blocking reset-to-default.

Passphrase / hidden-wallet step

Figma: Paired w/ Passphrase button 40364-133759, Enter Passphrase 46312-107873. Deferred from the connect-flow PR (#614).

The Paired step gains a Passphrase button (next to Finish) that opens an Enter Passphrase screen (shield illustration, passphrase field, Back / Continue). Entering a passphrase should add the funds of the passphrase-protected (hidden) wallet as well, as watch-only.

Not implemented on iOS or bitkit-android master — both HwPairedView / HwPairedSheet have only Finish; no reference port exists.

Already present (reusable):

  • Passphrase mechanics on TrezorManager: submitPassphrase(_:) / setWalletMode(.passphraseHost:), requestPassphraseWallet(), the on-device-vs-host chooser, passphraseEntryCapable; TrezorUiHandler mode plumbing; TrezorPassphraseSheet (dev-screen UI reference); the shield-figure illustration asset.
  • HwWalletManager already renders any distinct-xpub entry as its own tile / balance (grouping via HwWalletId.derive), so a separate hidden entry would surface as a second watch-only wallet with no extra engine work.

Blocker (why deferred): saveCurrentDeviceAsKnown() (TrezorManager.swift:485) keys the stored TrezorKnownDevice by the physical device.id, and TrezorKnownDeviceStorage.save() replaces same-id — so opening a hidden session overwrites / merges into the standard entry instead of persisting a second wallet. Making the hidden wallet an additional balance needs a composite storage identity (device.id + mode / xpub-signature) and a non-clobbering save path, which touches the reconnect / forget / rename identity shared across the already-merged hardware-wallet work.

Open question: any passphrase opens a valid (possibly empty) hidden wallet — there is no "wrong passphrase" to reject — so a rule is needed for whether / when to persist an empty hidden wallet (e.g. only after a watcher reports funds, or always + removable from Settings).

Related work

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions