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

Fields

No fields configured for Task.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions