Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,22 @@ struct PaykitPaymentRequestPollingSchedule {
struct AppScene: View {
private static let initialPaykitSyncRetryDelays = Array(repeating: Duration.seconds(2), count: 14)

static func shouldRetryNodeStart(
state: NodeLifecycleState,
isConnected: Bool,
walletExists: Bool?,
isRecoveryShown: Bool,
returnedFromBackground: Bool
) -> Bool {
guard returnedFromBackground, isConnected, walletExists == true, !isRecoveryShown, case .errorStarting = state else { return false }
return true
}

/// A restart can still be in flight when the Recovery quick action lands; the node it started must not keep running under Recovery.
static func shouldStopNodeStartedUnderRecovery(state: NodeLifecycleState, isRecoveryShown: Bool) -> Bool {
isRecoveryShown && state == .running
}

@Environment(\.scenePhase) var scenePhase
@EnvironmentObject private var session: SessionManager

Expand Down Expand Up @@ -249,6 +265,9 @@ struct AppScene: View {
@State private var didWalletBackupRestoreFail = false
@State private var isPinVerified: Bool = false
@State private var showRecoveryScreen = false
/// Set when the app enters the background, so that only a return from the background retries a failed node start,
/// not a brief inactive phase (Control Center, notification shade, Face ID).
@State private var wasInBackground = false

/// Check if there's a critical update available
private var hasCriticalUpdate: Bool {
Expand Down Expand Up @@ -1051,6 +1070,7 @@ struct AppScene: View {
Logger.info("Scene phase changed: \(newPhase)", context: "AppScene")

if newPhase == .background {
wasInBackground = true
if settings.pinEnabled {
// If PIN is enabled, lock the app when the app goes to the background
isPinVerified = false
Expand All @@ -1062,6 +1082,8 @@ struct AppScene: View {
if newPhase == .active {
// Called even behind the PIN screen, so a background release still pending is called off.
hwWalletManager.onAppBecameActive()
let returnedFromBackground = wasInBackground
wasInBackground = false
// Reconnect a known hardware device so its connection indicator turns green again;
if isPinVerified || !settings.pinEnabled {
Task { await hwWalletManager.reconnectOnForeground() }
Expand All @@ -1070,6 +1092,15 @@ struct AppScene: View {
if retryPendingWalletRestoreIfNeeded() {
return
}
if Self.shouldRetryNodeStart(
state: wallet.nodeLifecycleState,
isConnected: network.isConnected,
walletExists: wallet.walletExists,
isRecoveryShown: showRecoveryScreen,
returnedFromBackground: returnedFromBackground
) {
restartNode(reason: "App returned to foreground")
}
Task {
if pubkyProfile.isInitialized {
await pubkyProfile.checkAdoptedSource()
Expand Down Expand Up @@ -1561,16 +1592,35 @@ struct AppScene: View {
// Restart node if necessary (e.g. create/restore was skipped due to offline)
switch wallet.nodeLifecycleState {
case .stopped, .initializing, .errorStarting:
Logger.info("Network restored, retrying wallet start...", context: "AppScene")
Task {
await startWallet()
}
restartNode(reason: "Network restored")
default:
break
}
}
}

private func restartNode(reason: String) {
Task {
// Checked when the task runs, because the Recovery quick action can be handled after the caller decided to restart.
guard !showRecoveryScreen else {
Logger.info("\(reason), skipping wallet start in recovery mode", context: "AppScene")
return
}
Logger.info("\(reason), retrying wallet start...", context: "AppScene")
await startWallet()

// Recovery can open while the start is in flight; stop the node this restart started instead of leaving it running under Recovery.
if Self.shouldStopNodeStartedUnderRecovery(state: wallet.nodeLifecycleState, isRecoveryShown: showRecoveryScreen) {
Logger.info("\(reason), stopping the node started while recovery mode opened", context: "AppScene")
do {
try await wallet.stopLightningNode()
} catch {
Logger.warn("Failed to stop the node under recovery mode: \(error)", context: "AppScene")
}
}
}
}

private func handleQuickAction(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let shortcutType = userInfo["shortcutType"] as? String
Expand Down
63 changes: 63 additions & 0 deletions BitkitTests/NodeStartRetryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
@testable import Bitkit
import XCTest

final class NodeStartRetryTests: XCTestCase {
private struct StartFailure: Error {}

private let failedStart = NodeLifecycleState.errorStarting(cause: StartFailure())

private func shouldRetry(
_ state: NodeLifecycleState,
isConnected: Bool = true,
walletExists: Bool? = true,
isRecoveryShown: Bool = false,
returnedFromBackground: Bool = true
) -> Bool {
AppScene.shouldRetryNodeStart(
state: state,
isConnected: isConnected,
walletExists: walletExists,
isRecoveryShown: isRecoveryShown,
returnedFromBackground: returnedFromBackground
)
}

func testRetriesAFailedStartWhenConnectedWithAWallet() {
XCTAssertTrue(shouldRetry(failedStart))
}

func testDoesNotRetryWhileOffline() {
XCTAssertFalse(shouldRetry(failedStart, isConnected: false))
}

func testDoesNotRetryWhileTheRecoveryScreenIsShown() {
XCTAssertFalse(shouldRetry(failedStart, isRecoveryShown: true))
}

func testDoesNotRetryOnAnInactiveBlipThatNeverEnteredTheBackground() {
XCTAssertFalse(shouldRetry(failedStart, returnedFromBackground: false))
}

func testStopsANodeThatStartedWhileRecoveryOpened() {
XCTAssertTrue(AppScene.shouldStopNodeStartedUnderRecovery(state: .running, isRecoveryShown: true))
}

func testLeavesTheNodeAloneOutsideRecoveryOrBeforeItRuns() {
XCTAssertFalse(AppScene.shouldStopNodeStartedUnderRecovery(state: .running, isRecoveryShown: false))
for state in [NodeLifecycleState.stopped, .starting, .stopping, .initializing, failedStart] {
XCTAssertFalse(AppScene.shouldStopNodeStartedUnderRecovery(state: state, isRecoveryShown: true), "\(state)")
}
}

func testDoesNotRetryWithoutAWallet() {
for walletExists in [false, nil] {
XCTAssertFalse(shouldRetry(failedStart, walletExists: walletExists))
}
}

func testDoesNotRetryStatesThatAreNotAFailedStart() {
for state in [NodeLifecycleState.stopped, .starting, .running, .stopping, .initializing] {
XCTAssertFalse(shouldRetry(state), "\(state)")
}
}
}
1 change: 1 addition & 0 deletions changelog.d/next/848.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed the wallet staying on "Connection issues" after a failed node start until the app was relaunched; it now retries when the app returns to the foreground.
Loading