From 961345084dfe37a58dc042d787062a090a756e9e Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Mon, 7 Sep 2026 13:20:49 -0500 Subject: [PATCH 1/5] Pass send flow manager into coin control CoinControlManager now accepts an optional weak SendFlowManager dependency instead of reaching into AppManager.shared directly. CoinControlContainer injects app.sendFlowManager when creating the manager, so coin control can safely update send flow state even if the linked send flow is released before coin control closes. --- ios/Cove/CoinControlManager.swift | 18 +++++++++++++----- .../CoinControlFlow/CoinControlContainer.swift | 6 +++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/ios/Cove/CoinControlManager.swift b/ios/Cove/CoinControlManager.swift index 871e6518b..29c530697 100644 --- a/ios/Cove/CoinControlManager.swift +++ b/ios/Cove/CoinControlManager.swift @@ -31,6 +31,10 @@ private enum CoinControlManagerError: LocalizedError { @ObservationIgnored private let rustBridge = DispatchQueue(label: "cove.CoinControlManager.rustbridge", qos: .userInitiated) + /// AppManager owns the linked send flow and may release it before coin control closes + @ObservationIgnored + private weak var sendFlowManager: SendFlowManager? + let id: WalletId private(set) var sort: CoinControlListSort? = .some(.date(.descending)) @@ -74,8 +78,12 @@ private enum CoinControlManagerError: LocalizedError { ) } - public init(_ rust: RustCoinControlManager) { + public init( + _ rust: RustCoinControlManager, + sendFlowManager: SendFlowManager? = nil + ) { self.id = rust.id() + self.sendFlowManager = sendFlowManager self.utxos = rust.utxos() self.lockStateLoadFailed = rust.lockStateLoadFailed() @@ -150,20 +158,20 @@ private enum CoinControlManagerError: LocalizedError { } public func continuePressed() { - guard let sfm = AppManager.shared.sendFlowManager else { return } + guard let sendFlowManager else { return } self.updateSendFlowManagerTask?.cancel() self.updateSendFlowManagerTask = nil - sfm.dispatch(.setCoinControlMode(selectedUtxos())) + sendFlowManager.dispatch(.setCoinControlMode(selectedUtxos())) } private func updateSendFlowManager() { - guard let sfm = AppManager.shared.sendFlowManager else { return } + guard let sendFlowManager else { return } self.updateSendFlowManagerTask?.cancel() self.updateSendFlowManagerTask = Task { try? await Task.sleep(for: .milliseconds(100)) guard !Task.isCancelled else { return } - sfm.dispatch(.setCoinControlMode(selectedUtxos())) + sendFlowManager.dispatch(.setCoinControlMode(selectedUtxos())) } } diff --git a/ios/Cove/Flows/CoinControlFlow/CoinControlContainer.swift b/ios/Cove/Flows/CoinControlFlow/CoinControlContainer.swift index 7b5f6a922..5ababbe9b 100644 --- a/ios/Cove/Flows/CoinControlFlow/CoinControlContainer.swift +++ b/ios/Cove/Flows/CoinControlFlow/CoinControlContainer.swift @@ -74,7 +74,11 @@ private struct CoinControlLoadedView: View { let rustManager = try await walletManager.newCoinControlManager() guard !Task.isCancelled else { return } - let manager = CoinControlManager(rustManager) + let manager = CoinControlManager( + rustManager, + sendFlowManager: app.sendFlowManager + ) + self.manager = manager app.setCoinControlManager(manager) } catch { From 194a276ecac4f251ec51f804394ed48fd4ea8951 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 8 Sep 2026 12:51:54 -0500 Subject: [PATCH 2/5] Resolve send flow on coin control continue Navigation can replace the cached SendFlowManager, so a weak stored reference goes stale. Resolve the current app-owned instance by wallet id when continuing or updating selection. --- ios/Cove/CoinControlManager.swift | 13 ++++---- .../CoinControlContainer.swift | 4 ++- ios/CoveTests/CoinControlManagerTests.swift | 32 +++++++++++++++++-- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/ios/Cove/CoinControlManager.swift b/ios/Cove/CoinControlManager.swift index 29c530697..aab9252ae 100644 --- a/ios/Cove/CoinControlManager.swift +++ b/ios/Cove/CoinControlManager.swift @@ -31,9 +31,9 @@ private enum CoinControlManagerError: LocalizedError { @ObservationIgnored private let rustBridge = DispatchQueue(label: "cove.CoinControlManager.rustbridge", qos: .userInitiated) - /// AppManager owns the linked send flow and may release it before coin control closes + /// Resolves the current app-owned send flow because navigation can replace the cached instance @ObservationIgnored - private weak var sendFlowManager: SendFlowManager? + private let resolveSendFlowManager: (WalletId) -> SendFlowManager? let id: WalletId @@ -80,10 +80,10 @@ private enum CoinControlManagerError: LocalizedError { public init( _ rust: RustCoinControlManager, - sendFlowManager: SendFlowManager? = nil + resolveSendFlowManager: @escaping (WalletId) -> SendFlowManager? = { _ in nil } ) { self.id = rust.id() - self.sendFlowManager = sendFlowManager + self.resolveSendFlowManager = resolveSendFlowManager self.utxos = rust.utxos() self.lockStateLoadFailed = rust.lockStateLoadFailed() @@ -158,7 +158,7 @@ private enum CoinControlManagerError: LocalizedError { } public func continuePressed() { - guard let sendFlowManager else { return } + guard let sendFlowManager = resolveSendFlowManager(id) else { return } self.updateSendFlowManagerTask?.cancel() self.updateSendFlowManagerTask = nil @@ -166,11 +166,12 @@ private enum CoinControlManagerError: LocalizedError { } private func updateSendFlowManager() { - guard let sendFlowManager else { return } self.updateSendFlowManagerTask?.cancel() self.updateSendFlowManagerTask = Task { try? await Task.sleep(for: .milliseconds(100)) guard !Task.isCancelled else { return } + guard let sendFlowManager = resolveSendFlowManager(id) else { return } + sendFlowManager.dispatch(.setCoinControlMode(selectedUtxos())) } } diff --git a/ios/Cove/Flows/CoinControlFlow/CoinControlContainer.swift b/ios/Cove/Flows/CoinControlFlow/CoinControlContainer.swift index 5ababbe9b..2c3a6ac31 100644 --- a/ios/Cove/Flows/CoinControlFlow/CoinControlContainer.swift +++ b/ios/Cove/Flows/CoinControlFlow/CoinControlContainer.swift @@ -76,7 +76,9 @@ private struct CoinControlLoadedView: View { let manager = CoinControlManager( rustManager, - sendFlowManager: app.sendFlowManager + resolveSendFlowManager: { [weak app] walletId in + app?.cachedSendFlowManager(id: walletId) + } ) self.manager = manager diff --git a/ios/CoveTests/CoinControlManagerTests.swift b/ios/CoveTests/CoinControlManagerTests.swift index 593283cc8..b5e0c4591 100644 --- a/ios/CoveTests/CoinControlManagerTests.swift +++ b/ios/CoveTests/CoinControlManagerTests.swift @@ -1,29 +1,42 @@ @testable import Cove import CoveCore +import os import XCTest final class CoinControlManagerTests: XCTestCase { @MainActor func testSelectAllUpdatesSelectionAndTotal() async { + let sendFlowResolutionProbe = SendFlowResolutionProbe() let manager = CoinControlManager( - RustCoinControlManager.previewNew(outputCount: 2, changeCount: 0) + RustCoinControlManager.previewNew(outputCount: 2, changeCount: 0), + resolveSendFlowManager: sendFlowResolutionProbe.resolve ) defer { manager.close() } + XCTAssertEqual(sendFlowResolutionProbe.count, 0) manager.dispatch(.toggleSelectAll) let deadline = ContinuousClock.now + .seconds(2) - while manager.selected.count != 2 || manager.totalSelectedSats == 0 { + while manager.selected.count != 2 + || manager.totalSelectedSats == 0 + || sendFlowResolutionProbe.count == 0 + { guard ContinuousClock.now < deadline else { XCTFail( "select all did not update selection and total: " - + "selected=\(manager.selected.count) total=\(manager.totalSelectedSats)" + + "selected=\(manager.selected.count) total=\(manager.totalSelectedSats) " + + "sendFlowResolutions=\(sendFlowResolutionProbe.count)" ) return } await drainMainQueue() } + + let resolutionCount = sendFlowResolutionProbe.count + manager.continuePressed() + + XCTAssertEqual(sendFlowResolutionProbe.count, resolutionCount + 1) } @MainActor @@ -35,3 +48,16 @@ final class CoinControlManagerTests: XCTestCase { } } } + +private final class SendFlowResolutionProbe: @unchecked Sendable { + private let resolutionCount = OSAllocatedUnfairLock(initialState: 0) + + var count: Int { + resolutionCount.withLock { $0 } + } + + func resolve(_: WalletId) -> SendFlowManager? { + resolutionCount.withLock { $0 += 1 } + return nil + } +} From f8e84cd7d345af85d04c4122ac28c11e13d72c22 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 8 Sep 2026 13:19:01 -0500 Subject: [PATCH 3/5] Cancel pending coin control send update Continue now cancels the debounced send-flow update before resolving the manager, and sleep is injectable so tests can prove the pending resolve does not run. --- ios/Cove/CoinControlManager.swift | 11 +++++-- .../CoinControlFlow/UtxoListScreen.swift | 8 +++-- .../CoinControlFlow/UtxoRowPreview.swift | 6 +++- ios/CoveTests/CoinControlManagerTests.swift | 33 +++++++++++++++++++ .../HotWalletCreateScreenLayoutTests.swift | 5 ++- 5 files changed, 56 insertions(+), 7 deletions(-) diff --git a/ios/Cove/CoinControlManager.swift b/ios/Cove/CoinControlManager.swift index aab9252ae..98729114a 100644 --- a/ios/Cove/CoinControlManager.swift +++ b/ios/Cove/CoinControlManager.swift @@ -14,6 +14,7 @@ private enum CoinControlManagerError: LocalizedError { @Observable final class CoinControlManager: ReconcilingManager, CoinControlManagerReconciler { typealias Message = CoinControlManagerReconcileMessage typealias Action = CoinControlManagerAction + typealias Sleep = @MainActor (Duration) async throws -> Void private struct SelectionState { var selected: Set = [] @@ -34,6 +35,8 @@ private enum CoinControlManagerError: LocalizedError { /// Resolves the current app-owned send flow because navigation can replace the cached instance @ObservationIgnored private let resolveSendFlowManager: (WalletId) -> SendFlowManager? + @ObservationIgnored + private let sleep: Sleep let id: WalletId @@ -80,10 +83,12 @@ private enum CoinControlManagerError: LocalizedError { public init( _ rust: RustCoinControlManager, - resolveSendFlowManager: @escaping (WalletId) -> SendFlowManager? = { _ in nil } + resolveSendFlowManager: @escaping (WalletId) -> SendFlowManager?, + sleep: @escaping Sleep = { try await Task.sleep(for: $0) } ) { self.id = rust.id() self.resolveSendFlowManager = resolveSendFlowManager + self.sleep = sleep self.utxos = rust.utxos() self.lockStateLoadFailed = rust.lockStateLoadFailed() @@ -158,9 +163,9 @@ private enum CoinControlManagerError: LocalizedError { } public func continuePressed() { - guard let sendFlowManager = resolveSendFlowManager(id) else { return } self.updateSendFlowManagerTask?.cancel() self.updateSendFlowManagerTask = nil + guard let sendFlowManager = resolveSendFlowManager(id) else { return } sendFlowManager.dispatch(.setCoinControlMode(selectedUtxos())) } @@ -168,7 +173,7 @@ private enum CoinControlManagerError: LocalizedError { private func updateSendFlowManager() { self.updateSendFlowManagerTask?.cancel() self.updateSendFlowManagerTask = Task { - try? await Task.sleep(for: .milliseconds(100)) + try? await sleep(.milliseconds(100)) guard !Task.isCancelled else { return } guard let sendFlowManager = resolveSendFlowManager(id) else { return } diff --git a/ios/Cove/Flows/CoinControlFlow/UtxoListScreen.swift b/ios/Cove/Flows/CoinControlFlow/UtxoListScreen.swift index e0297620c..5e8cc7358 100644 --- a/ios/Cove/Flows/CoinControlFlow/UtxoListScreen.swift +++ b/ios/Cove/Flows/CoinControlFlow/UtxoListScreen.swift @@ -613,7 +613,10 @@ private struct UtxoRow: View { #Preview { AsyncPreview { UtxoListScreen( - manager: CoinControlManager(RustCoinControlManager.previewNew()) + manager: CoinControlManager( + RustCoinControlManager.previewNew(), + resolveSendFlowManager: { _ in nil } + ) ) .environment(WalletManager(preview: .only)) } @@ -623,7 +626,8 @@ private struct UtxoRow: View { AsyncPreview { UtxoListScreen( manager: CoinControlManager( - RustCoinControlManager.previewNew(outputCount: 0, changeCount: 0) + RustCoinControlManager.previewNew(outputCount: 0, changeCount: 0), + resolveSendFlowManager: { _ in nil } ) ) .environment(WalletManager(preview: .only)) diff --git a/ios/Cove/Flows/CoinControlFlow/UtxoRowPreview.swift b/ios/Cove/Flows/CoinControlFlow/UtxoRowPreview.swift index 3f59a22a1..69e798082 100644 --- a/ios/Cove/Flows/CoinControlFlow/UtxoRowPreview.swift +++ b/ios/Cove/Flows/CoinControlFlow/UtxoRowPreview.swift @@ -54,7 +54,11 @@ struct UtxoRowPreview: View { #Preview("UTXORowPreview") { AsyncPreview { - let manager = CoinControlManager(RustCoinControlManager.previewNew()) + let manager = CoinControlManager( + RustCoinControlManager.previewNew(), + resolveSendFlowManager: { _ in nil } + ) + UtxoRowPreview(displayAmount: manager.displayAmount, utxo: manager.utxos[0]) .environment(WalletManager(preview: .only)) } diff --git a/ios/CoveTests/CoinControlManagerTests.swift b/ios/CoveTests/CoinControlManagerTests.swift index b5e0c4591..9c9137c05 100644 --- a/ios/CoveTests/CoinControlManagerTests.swift +++ b/ios/CoveTests/CoinControlManagerTests.swift @@ -39,6 +39,39 @@ final class CoinControlManagerTests: XCTestCase { XCTAssertEqual(sendFlowResolutionProbe.count, resolutionCount + 1) } + @MainActor + func testContinueCancelsPendingSendFlowUpdateWhenManagerIsUnavailable() async { + let sendFlowResolutionProbe = SendFlowResolutionProbe() + let sleepStarted = XCTestExpectation(description: "send-flow update sleep started") + let sleepCompleted = XCTestExpectation(description: "send-flow update sleep completed") + var sleepContinuation: CheckedContinuation? + let manager = CoinControlManager( + RustCoinControlManager.previewNew(outputCount: 2, changeCount: 0), + resolveSendFlowManager: sendFlowResolutionProbe.resolve, + sleep: { _ in + try await withCheckedThrowingContinuation { continuation in + sleepContinuation = continuation + sleepStarted.fulfill() + } + sleepCompleted.fulfill() + } + ) + defer { manager.close() } + + manager.dispatch(.toggleSelectAll) + let sleepStartedResult = await XCTWaiter.fulfillment(of: [sleepStarted], timeout: 2) + XCTAssertEqual(sleepStartedResult, .completed) + + manager.continuePressed() + XCTAssertEqual(sendFlowResolutionProbe.count, 1) + + sleepContinuation?.resume(returning: ()) + let sleepCompletedResult = await XCTWaiter.fulfillment(of: [sleepCompleted], timeout: 2) + XCTAssertEqual(sleepCompletedResult, .completed) + + XCTAssertEqual(sendFlowResolutionProbe.count, 1) + } + @MainActor private func drainMainQueue() async { await withCheckedContinuation { continuation in diff --git a/ios/CoveTests/HotWalletCreateScreenLayoutTests.swift b/ios/CoveTests/HotWalletCreateScreenLayoutTests.swift index acc3912cf..54bc0f57b 100644 --- a/ios/CoveTests/HotWalletCreateScreenLayoutTests.swift +++ b/ios/CoveTests/HotWalletCreateScreenLayoutTests.swift @@ -444,7 +444,10 @@ final class HotWalletCreateScreenLayoutTests: XCTestCase { let image = render( view: NavigationStack { UtxoListScreen( - manager: CoinControlManager(RustCoinControlManager.previewNew()) + manager: CoinControlManager( + RustCoinControlManager.previewNew(), + resolveSendFlowManager: { _ in nil } + ) ) .environment(WalletManager(preview: .only)) } From 10fe85b0832d44c725552980bd447536b661918a Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 8 Sep 2026 14:23:01 -0500 Subject: [PATCH 4/5] Apply coin control on send route entry Route arguments own the selected UTXOs, so apply them when the send flow loads instead of coupling CoinControlManager to the send manager during selection. --- ios/Cove/CoinControlManager.swift | 39 +---------- .../CoinControlContainer.swift | 7 +- .../CoinControlFlow/UtxoListScreen.swift | 9 +-- .../CoinControlFlow/UtxoRowPreview.swift | 5 +- .../SendFlowCoinControlSetAmountScreen.swift | 32 ++++----- .../Flows/SendFlow/SendFlowContainer.swift | 7 +- ios/Cove/Flows/SendFlow/SendFlowManager.swift | 11 ++++ ios/CoveTests/CoinControlManagerTests.swift | 65 +------------------ .../HotWalletCreateScreenLayoutTests.swift | 5 +- ios/CoveTests/WalletTransitionTests.swift | 34 +++++++++- 10 files changed, 70 insertions(+), 144 deletions(-) diff --git a/ios/Cove/CoinControlManager.swift b/ios/Cove/CoinControlManager.swift index 98729114a..f80bbfb66 100644 --- a/ios/Cove/CoinControlManager.swift +++ b/ios/Cove/CoinControlManager.swift @@ -14,7 +14,6 @@ private enum CoinControlManagerError: LocalizedError { @Observable final class CoinControlManager: ReconcilingManager, CoinControlManagerReconciler { typealias Message = CoinControlManagerReconcileMessage typealias Action = CoinControlManagerAction - typealias Sleep = @MainActor (Duration) async throws -> Void private struct SelectionState { var selected: Set = [] @@ -32,12 +31,6 @@ private enum CoinControlManagerError: LocalizedError { @ObservationIgnored private let rustBridge = DispatchQueue(label: "cove.CoinControlManager.rustbridge", qos: .userInitiated) - /// Resolves the current app-owned send flow because navigation can replace the cached instance - @ObservationIgnored - private let resolveSendFlowManager: (WalletId) -> SendFlowManager? - @ObservationIgnored - private let sleep: Sleep - let id: WalletId private(set) var sort: CoinControlListSort? = .some(.date(.descending)) @@ -48,8 +41,6 @@ private enum CoinControlManagerError: LocalizedError { var lockStateLoadFailed: Bool var unit: Unit = .sat - private var updateSendFlowManagerTask: Task? = nil - private var rust: RustCoinControlManager? { rustState.withLock { $0.rust } } @@ -81,14 +72,8 @@ private enum CoinControlManagerError: LocalizedError { ) } - public init( - _ rust: RustCoinControlManager, - resolveSendFlowManager: @escaping (WalletId) -> SendFlowManager?, - sleep: @escaping Sleep = { try await Task.sleep(for: $0) } - ) { + public init(_ rust: RustCoinControlManager) { self.id = rust.id() - self.resolveSendFlowManager = resolveSendFlowManager - self.sleep = sleep self.utxos = rust.utxos() self.lockStateLoadFailed = rust.lockStateLoadFailed() @@ -106,8 +91,6 @@ private enum CoinControlManagerError: LocalizedError { guard takeRustForClose() != nil else { return } logger.debug("Closing CoinControlManager") - updateSendFlowManagerTask?.cancel() - updateSendFlowManagerTask = nil } private func takeRustForClose() -> RustCoinControlManager? { @@ -162,25 +145,6 @@ private enum CoinControlManagerError: LocalizedError { Int(self.totalSelected.asSats()) } - public func continuePressed() { - self.updateSendFlowManagerTask?.cancel() - self.updateSendFlowManagerTask = nil - guard let sendFlowManager = resolveSendFlowManager(id) else { return } - - sendFlowManager.dispatch(.setCoinControlMode(selectedUtxos())) - } - - private func updateSendFlowManager() { - self.updateSendFlowManagerTask?.cancel() - self.updateSendFlowManagerTask = Task { - try? await sleep(.milliseconds(100)) - guard !Task.isCancelled else { return } - guard let sendFlowManager = resolveSendFlowManager(id) else { return } - - sendFlowManager.dispatch(.setCoinControlMode(selectedUtxos())) - } - } - var canApplyReconcileMessages: Bool { rust != nil } @@ -196,7 +160,6 @@ private enum CoinControlManagerError: LocalizedError { case let .updateSearch(search): withAnimation { self.search = search } case let .updateSelectedUtxos(utxos: selected, totalSelected): - updateSendFlowManager() withAnimation { self.selection = SelectionState( selected: Set(selected), diff --git a/ios/Cove/Flows/CoinControlFlow/CoinControlContainer.swift b/ios/Cove/Flows/CoinControlFlow/CoinControlContainer.swift index 2c3a6ac31..c52b43a7a 100644 --- a/ios/Cove/Flows/CoinControlFlow/CoinControlContainer.swift +++ b/ios/Cove/Flows/CoinControlFlow/CoinControlContainer.swift @@ -74,12 +74,7 @@ private struct CoinControlLoadedView: View { let rustManager = try await walletManager.newCoinControlManager() guard !Task.isCancelled else { return } - let manager = CoinControlManager( - rustManager, - resolveSendFlowManager: { [weak app] walletId in - app?.cachedSendFlowManager(id: walletId) - } - ) + let manager = CoinControlManager(rustManager) self.manager = manager app.setCoinControlManager(manager) diff --git a/ios/Cove/Flows/CoinControlFlow/UtxoListScreen.swift b/ios/Cove/Flows/CoinControlFlow/UtxoListScreen.swift index 5e8cc7358..279450cd0 100644 --- a/ios/Cove/Flows/CoinControlFlow/UtxoListScreen.swift +++ b/ios/Cove/Flows/CoinControlFlow/UtxoListScreen.swift @@ -52,7 +52,6 @@ struct UtxoListScreen: View { } private func continueToSend() { - manager.continuePressed() navigate( RouteFactory() .coinControlSend( @@ -613,10 +612,7 @@ private struct UtxoRow: View { #Preview { AsyncPreview { UtxoListScreen( - manager: CoinControlManager( - RustCoinControlManager.previewNew(), - resolveSendFlowManager: { _ in nil } - ) + manager: CoinControlManager(RustCoinControlManager.previewNew()) ) .environment(WalletManager(preview: .only)) } @@ -626,8 +622,7 @@ private struct UtxoRow: View { AsyncPreview { UtxoListScreen( manager: CoinControlManager( - RustCoinControlManager.previewNew(outputCount: 0, changeCount: 0), - resolveSendFlowManager: { _ in nil } + RustCoinControlManager.previewNew(outputCount: 0, changeCount: 0) ) ) .environment(WalletManager(preview: .only)) diff --git a/ios/Cove/Flows/CoinControlFlow/UtxoRowPreview.swift b/ios/Cove/Flows/CoinControlFlow/UtxoRowPreview.swift index 69e798082..0160dfe54 100644 --- a/ios/Cove/Flows/CoinControlFlow/UtxoRowPreview.swift +++ b/ios/Cove/Flows/CoinControlFlow/UtxoRowPreview.swift @@ -54,10 +54,7 @@ struct UtxoRowPreview: View { #Preview("UTXORowPreview") { AsyncPreview { - let manager = CoinControlManager( - RustCoinControlManager.previewNew(), - resolveSendFlowManager: { _ in nil } - ) + let manager = CoinControlManager(RustCoinControlManager.previewNew()) UtxoRowPreview(displayAmount: manager.displayAmount, utxo: manager.utxos[0]) .environment(WalletManager(preview: .only)) diff --git a/ios/Cove/Flows/SendFlow/SendFlowCoinControlSetAmountScreen.swift b/ios/Cove/Flows/SendFlow/SendFlowCoinControlSetAmountScreen.swift index 48996d69e..13c304788 100644 --- a/ios/Cove/Flows/SendFlow/SendFlowCoinControlSetAmountScreen.swift +++ b/ios/Cove/Flows/SendFlow/SendFlowCoinControlSetAmountScreen.swift @@ -159,30 +159,23 @@ struct SendFlowCoinControlSetAmountScreen: View { private func prepareScreen() async { let isAlreadyValid = validate() - let shouldShowLoading = !isAlreadyValid || utxos == sendFlowManager.utxos() - - if shouldShowLoading { - Task { - await MainActor.run { - withAnimation( - .easeInOut(duration: 1.5).delay(0.4), - completionCriteria: .removed - ) { - loadingOpacity = 0 - } completion: { - isLoading = false - if validate() { presenter.focusField = .none } - } + + Task { + await MainActor.run { + withAnimation( + .easeInOut(duration: 1.5).delay(0.4), + completionCriteria: .removed + ) { + loadingOpacity = 0 + } completion: { + isLoading = false + if validate() { presenter.focusField = .none } } } - } else { - presenter.focusField = .none } // HACK: Bug in SwiftUI where keyboard toolbar is broken - if shouldShowLoading { - try? await Task.sleep(for: .milliseconds(700)) - } + try? await Task.sleep(for: .milliseconds(700)) await MainActor.run { if !isAlreadyValid { presenter.focusField = .address } @@ -194,7 +187,6 @@ struct SendFlowCoinControlSetAmountScreen: View { } private func screenAppeared() { - sendFlowManager.dispatch(.setCoinControlMode(utxos)) if validate(), utxos == sendFlowManager.utxos() { isLoading = false loadingOpacity = 0 diff --git a/ios/Cove/Flows/SendFlow/SendFlowContainer.swift b/ios/Cove/Flows/SendFlow/SendFlowContainer.swift index c5041cde6..21fd4b0d5 100644 --- a/ios/Cove/Flows/SendFlow/SendFlowContainer.swift +++ b/ios/Cove/Flows/SendFlow/SendFlowContainer.swift @@ -74,11 +74,13 @@ private struct SendFlowLoadedView: View { ) } - private func applyRouteArguments(to sendFlowManager: SendFlowManager) { + private func applyRouteArguments(to sendFlowManager: SendFlowManager) async { switch sendRoute { case let .setAmount(id: _, address: address, amount: amount): if let address { sendFlowManager.setAddress(address) } if let amount { sendFlowManager.setAmount(amount) } + case let .coinControlSetAmount(id: _, utxos: utxos): + await sendFlowManager.prepareCoinControl(utxos: utxos) default: () } @@ -99,7 +101,8 @@ private struct SendFlowLoadedView: View { let sendFlowManagerId = ObjectIdentifier(sendFlowManager) initializedSendFlowManagerId = nil - applyRouteArguments(to: sendFlowManager) + await applyRouteArguments(to: sendFlowManager) + guard !Task.isCancelled else { return } // rust handles alert + popRoute on failure if await sendFlowManager.waitForInit() { diff --git a/ios/Cove/Flows/SendFlow/SendFlowManager.swift b/ios/Cove/Flows/SendFlow/SendFlowManager.swift index a2db5ff51..b0a30fb82 100644 --- a/ios/Cove/Flows/SendFlow/SendFlowManager.swift +++ b/ios/Cove/Flows/SendFlow/SendFlowManager.swift @@ -258,6 +258,17 @@ private enum SendFlowManagerAccessError: LocalizedError { return await rust.waitForInit() } + /// Applies the route-owned coin selection before the send screen becomes visible + func prepareCoinControl(utxos: [Utxo]) async { + dispatch(.setCoinControlMode(utxos)) + + await withCheckedContinuation { continuation in + rustBridge.async { + continuation.resume() + } + } + } + func amountExceedsBalance() -> Bool { withRustOr(false) { $0.amountExceedsBalance() } } diff --git a/ios/CoveTests/CoinControlManagerTests.swift b/ios/CoveTests/CoinControlManagerTests.swift index 9c9137c05..593283cc8 100644 --- a/ios/CoveTests/CoinControlManagerTests.swift +++ b/ios/CoveTests/CoinControlManagerTests.swift @@ -1,75 +1,29 @@ @testable import Cove import CoveCore -import os import XCTest final class CoinControlManagerTests: XCTestCase { @MainActor func testSelectAllUpdatesSelectionAndTotal() async { - let sendFlowResolutionProbe = SendFlowResolutionProbe() let manager = CoinControlManager( - RustCoinControlManager.previewNew(outputCount: 2, changeCount: 0), - resolveSendFlowManager: sendFlowResolutionProbe.resolve + RustCoinControlManager.previewNew(outputCount: 2, changeCount: 0) ) defer { manager.close() } - XCTAssertEqual(sendFlowResolutionProbe.count, 0) manager.dispatch(.toggleSelectAll) let deadline = ContinuousClock.now + .seconds(2) - while manager.selected.count != 2 - || manager.totalSelectedSats == 0 - || sendFlowResolutionProbe.count == 0 - { + while manager.selected.count != 2 || manager.totalSelectedSats == 0 { guard ContinuousClock.now < deadline else { XCTFail( "select all did not update selection and total: " - + "selected=\(manager.selected.count) total=\(manager.totalSelectedSats) " - + "sendFlowResolutions=\(sendFlowResolutionProbe.count)" + + "selected=\(manager.selected.count) total=\(manager.totalSelectedSats)" ) return } await drainMainQueue() } - - let resolutionCount = sendFlowResolutionProbe.count - manager.continuePressed() - - XCTAssertEqual(sendFlowResolutionProbe.count, resolutionCount + 1) - } - - @MainActor - func testContinueCancelsPendingSendFlowUpdateWhenManagerIsUnavailable() async { - let sendFlowResolutionProbe = SendFlowResolutionProbe() - let sleepStarted = XCTestExpectation(description: "send-flow update sleep started") - let sleepCompleted = XCTestExpectation(description: "send-flow update sleep completed") - var sleepContinuation: CheckedContinuation? - let manager = CoinControlManager( - RustCoinControlManager.previewNew(outputCount: 2, changeCount: 0), - resolveSendFlowManager: sendFlowResolutionProbe.resolve, - sleep: { _ in - try await withCheckedThrowingContinuation { continuation in - sleepContinuation = continuation - sleepStarted.fulfill() - } - sleepCompleted.fulfill() - } - ) - defer { manager.close() } - - manager.dispatch(.toggleSelectAll) - let sleepStartedResult = await XCTWaiter.fulfillment(of: [sleepStarted], timeout: 2) - XCTAssertEqual(sleepStartedResult, .completed) - - manager.continuePressed() - XCTAssertEqual(sendFlowResolutionProbe.count, 1) - - sleepContinuation?.resume(returning: ()) - let sleepCompletedResult = await XCTWaiter.fulfillment(of: [sleepCompleted], timeout: 2) - XCTAssertEqual(sleepCompletedResult, .completed) - - XCTAssertEqual(sendFlowResolutionProbe.count, 1) } @MainActor @@ -81,16 +35,3 @@ final class CoinControlManagerTests: XCTestCase { } } } - -private final class SendFlowResolutionProbe: @unchecked Sendable { - private let resolutionCount = OSAllocatedUnfairLock(initialState: 0) - - var count: Int { - resolutionCount.withLock { $0 } - } - - func resolve(_: WalletId) -> SendFlowManager? { - resolutionCount.withLock { $0 += 1 } - return nil - } -} diff --git a/ios/CoveTests/HotWalletCreateScreenLayoutTests.swift b/ios/CoveTests/HotWalletCreateScreenLayoutTests.swift index 54bc0f57b..acc3912cf 100644 --- a/ios/CoveTests/HotWalletCreateScreenLayoutTests.swift +++ b/ios/CoveTests/HotWalletCreateScreenLayoutTests.swift @@ -444,10 +444,7 @@ final class HotWalletCreateScreenLayoutTests: XCTestCase { let image = render( view: NavigationStack { UtxoListScreen( - manager: CoinControlManager( - RustCoinControlManager.previewNew(), - resolveSendFlowManager: { _ in nil } - ) + manager: CoinControlManager(RustCoinControlManager.previewNew()) ) .environment(WalletManager(preview: .only)) } diff --git a/ios/CoveTests/WalletTransitionTests.swift b/ios/CoveTests/WalletTransitionTests.swift index 628d525b3..4af963722 100644 --- a/ios/CoveTests/WalletTransitionTests.swift +++ b/ios/CoveTests/WalletTransitionTests.swift @@ -1,5 +1,6 @@ @testable import Cove import CoveCore +import os import XCTest final class WalletTransitionTests: XCTestCase { @@ -424,6 +425,30 @@ final class WalletTransitionTests: XCTestCase { } } + @MainActor + func testPreparingCoinControlDispatchesRouteUtxosBeforeReturning() async { + let walletManager = WalletManager(preview: .only) + defer { walletManager.close() } + + let rustManager = TestSendFlowRustManager(walletId: walletManager.id) + let presenter = SendFlowPresenter( + routing: TestSendFlowRouting(), + manager: walletManager + ) + let manager = SendFlowManager(rustManager, presenter: presenter) + defer { manager.close() } + + let utxos = RustCoinControlManager.previewNew(outputCount: 2, changeCount: 0).utxos() + await manager.prepareCoinControl(utxos: utxos) + + guard case let .setCoinControlMode(dispatchedUtxos)? = rustManager.actions.last else { + XCTFail("expected coin-control route preparation") + return + } + + XCTAssertEqual(dispatchedUtxos, utxos) + } + @MainActor func testWalletCacheClearInvalidatesMatchingRetainedSendFlowManagers() throws { let (walletManager, retainedManager) = try makeSendFlowManager() @@ -474,6 +499,11 @@ private final class TestSendFlowRouting: SendFlowRouting { private final class TestSendFlowRustManager: SendFlowRustManaging { private let id: WalletId + private let dispatchedActions = OSAllocatedUnfairLock(initialState: [SendFlowManagerAction]()) + + var actions: [SendFlowManagerAction] { + dispatchedActions.withLock { $0 } + } init(walletId: WalletId) { self.id = walletId @@ -504,7 +534,9 @@ private final class TestSendFlowRustManager: SendFlowRustManaging { func utxos() -> [Utxo]? { nil } func maxSendMinusFees() -> Amount? { nil } func maxSendMinusFeesAndSmallUtxo() -> Amount? { nil } - func dispatch(action _: SendFlowManagerAction) {} + func dispatch(action: SendFlowManagerAction) { + dispatchedActions.withLock { $0.append(action) } + } } @MainActor From 09208cf3e5206ea66e10b247f5f116737fa58847 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 8 Sep 2026 14:31:55 -0500 Subject: [PATCH 5/5] Create `dispatchAndWait` that describes the action --- ios/Cove/Flows/SendFlow/SendFlowManager.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ios/Cove/Flows/SendFlow/SendFlowManager.swift b/ios/Cove/Flows/SendFlow/SendFlowManager.swift index b0a30fb82..d3169fe6b 100644 --- a/ios/Cove/Flows/SendFlow/SendFlowManager.swift +++ b/ios/Cove/Flows/SendFlow/SendFlowManager.swift @@ -260,7 +260,12 @@ private enum SendFlowManagerAccessError: LocalizedError { /// Applies the route-owned coin selection before the send screen becomes visible func prepareCoinControl(utxos: [Utxo]) async { - dispatch(.setCoinControlMode(utxos)) + await dispatchAndWait(.setCoinControlMode(utxos)) + } + + /// Waits until Rust synchronously handles an action, but not for tasks that the action starts + private func dispatchAndWait(_ action: Action) async { + dispatch(action) await withCheckedContinuation { continuation in rustBridge.async {