diff --git a/BitwardenResources/Localizations/en.lproj/Localizable.strings b/BitwardenResources/Localizations/en.lproj/Localizable.strings index ee1902efb3..13794d4370 100644 --- a/BitwardenResources/Localizations/en.lproj/Localizable.strings +++ b/BitwardenResources/Localizations/en.lproj/Localizable.strings @@ -1169,6 +1169,12 @@ "OpeningCheckout" = "Opening checkout…"; "UpgradePending" = "Upgrade pending"; "YourUpgradeIsBeingProcessedDescriptionLong" = "Your upgrade is being processed. You'll remain on the free plan until it's complete. Sync now to check, or continue and it will update automatically."; +/* Generic button label for declining an action without dismissing it permanently. */ +"NotNow" = "Not now"; +/* Alert title shown when a sync attempt for a pending Premium upgrade fails. */ +"SyncUnsuccessful" = "Sync unsuccessful"; +/* Alert message shown when a sync attempt for a pending Premium upgrade fails. */ +"WeCouldntSyncYourVaultWithTheServerDescriptionLong" = "We couldn't sync your vault with the server. Your vault may not reflect your latest changes. You can try again now or it will sync automatically later."; "UpgradedToPremium" = "Upgraded to Premium"; "SubscriptionNeedsAttention" = "Your subscription needs attention"; "CheckYourPlanForDetails" = "Check your plan for details."; diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift index 7e5bdd6a66..9155fe1760 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift @@ -402,7 +402,11 @@ class MockStateService: StateService, ActiveAccountStateProvider, AutofillStateS func getLastSyncTime(userId: String?) async throws -> Date? { let userId = try unwrapUserId(userId) - return lastSyncTimeByUserId[userId] + // Falls back to `lastSyncTimeSubject` for tests that drive it directly via `.send(_:)` + // instead of going through `setLastSyncTime(_:userId:)`, which keeps both stores in + // sync (mirroring `DefaultStateService`, where both calls read from the same + // underlying store). + return lastSyncTimeByUserId[userId] ?? lastSyncTimeSubject.value } func getLastSyncMonotonicTime(userId: String?) async throws -> TimeInterval? { @@ -806,6 +810,7 @@ class MockStateService: StateService, ActiveAccountStateProvider, AutofillStateS func setLastSyncTime(_ date: Date?, userId: String?) async throws { let userId = try unwrapUserId(userId) lastSyncTimeByUserId[userId] = date + lastSyncTimeSubject.value = date } func getLastUserShouldConnectToWatch() async -> Bool { diff --git a/BitwardenShared/UI/Billing/Extensions/Alert+Billing.swift b/BitwardenShared/UI/Billing/Extensions/Alert+Billing.swift index 9f61007273..3359e56717 100644 --- a/BitwardenShared/UI/Billing/Extensions/Alert+Billing.swift +++ b/BitwardenShared/UI/Billing/Extensions/Alert+Billing.swift @@ -60,6 +60,33 @@ extension Alert { ) } + /// An alert shown when a sync attempt for a pending Premium upgrade fails. + /// + /// - Parameter tryAgainHandler: A closure called when the user taps "Try again". + /// - Returns: An `Alert` with "Not now" and "Try again" actions. + /// + static func syncUnsuccessful( + tryAgainHandler: @escaping () async -> Void, + ) -> Alert { + Alert( + title: Localizations.syncUnsuccessful, + message: Localizations.weCouldntSyncYourVaultWithTheServerDescriptionLong, + alertActions: [ + AlertAction( + title: Localizations.notNow, + style: .cancel, + ), + AlertAction( + title: Localizations.tryAgain, + style: .default, + handler: { _, _ in + await tryAgainHandler() + }, + ), + ], + ) + } + /// An alert shown when a Premium upgrade is still being processed. /// /// - Parameter syncNowHandler: A closure called when the user taps "Sync now". diff --git a/BitwardenShared/UI/Billing/Extensions/Alert+BillingTests.swift b/BitwardenShared/UI/Billing/Extensions/Alert+BillingTests.swift index 1a3f26ed7d..4e4cc9e07c 100644 --- a/BitwardenShared/UI/Billing/Extensions/Alert+BillingTests.swift +++ b/BitwardenShared/UI/Billing/Extensions/Alert+BillingTests.swift @@ -47,6 +47,24 @@ class AlertBillingTests: BitwardenTestCase { XCTAssertEqual(tryAgainAction.style, .default) } + /// `syncUnsuccessful(tryAgainHandler:)` builds an `Alert` with the correct title, message, and actions. + func test_syncUnsuccessful() { + let subject = Alert.syncUnsuccessful {} + + XCTAssertEqual(subject.title, Localizations.syncUnsuccessful) + XCTAssertEqual(subject.message, Localizations.weCouldntSyncYourVaultWithTheServerDescriptionLong) + XCTAssertEqual(subject.preferredStyle, .alert) + XCTAssertEqual(subject.alertActions.count, 2) + + let notNowAction = subject.alertActions[0] + XCTAssertEqual(notNowAction.title, Localizations.notNow) + XCTAssertEqual(notNowAction.style, .cancel) + + let tryAgainAction = subject.alertActions[1] + XCTAssertEqual(tryAgainAction.title, Localizations.tryAgain) + XCTAssertEqual(tryAgainAction.style, .default) + } + /// `upgradePending(syncNowHandler:)` builds an `Alert` with the correct title, message, and actions. func test_upgradePending() { let subject = Alert.upgradePending {} diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift index 0b435728a9..a78f74af0d 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift @@ -743,11 +743,30 @@ extension VaultListProcessor { /// Streams live updates to the Premium upgrade pending state, hiding or re-evaluating the /// upsell action card immediately rather than waiting for the next screen appearance — /// needed because dismissing the "Upgrade Pending" alert returns to this same screen - /// without a fresh `.appeared`. + /// without a fresh `.appeared`. Also shows the "Sync Unsuccessful" alert the first time a + /// sync attempt for the pending upgrade fails. /// private func streamPremiumUpgradePendingState() async { + var lastAttemptFailed = false for await pendingState in services.billingService.premiumUpgradePendingStatePublisher().values { await updatePremiumUpgradeActionCardVisibility(isPending: pendingState.isPending) + + // Only show the alert on a false-to-true transition — the publisher replays its + // current value to this subscription immediately (covering a stale failure still + // persisted from a previous app session), but without tracking the transition, any + // later emission that still has `lastAttemptFailed: true` (e.g. triggered by an + // unrelated `isPending` change) would incorrectly re-show the alert. + if pendingState.lastAttemptFailed, !lastAttemptFailed { + coordinator.showAlert(.syncUnsuccessful { [weak self] in + // Reset the tracker before retrying — if the retry's own sync also fails, + // the resulting `lastAttemptFailed: true` emission must be treated as a new + // transition rather than a duplicate, or this explicit, user-initiated + // retry would fail with no feedback at all. + lastAttemptFailed = false + await self?.services.billingService.premiumStatusChanged() + }) + } + lastAttemptFailed = pendingState.lastAttemptFailed } } diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift index 6e91b5c812..dab1da89ae 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift @@ -1252,6 +1252,119 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ task.cancel() } + /// `perform(_:)` with `.streamPremiumUpgradePendingState` shows the "Sync unsuccessful" + /// alert the first time the publisher reports a failed sync attempt. + @MainActor + func test_perform_streamPremiumUpgradePendingState_showsSyncUnsuccessfulAlert() throws { + let pendingStateSubject = CurrentValueSubject( + PremiumUpgradePendingState(isPending: true, lastAttemptFailed: false), + ) + billingService.premiumUpgradePendingStatePublisherReturnValue = pendingStateSubject.eraseToAnyPublisher() + + let task = Task { + await subject.perform(.streamPremiumUpgradePendingState) + } + defer { task.cancel() } + + pendingStateSubject.send(PremiumUpgradePendingState(isPending: true, lastAttemptFailed: true)) + waitFor(!coordinator.alertShown.isEmpty) + + XCTAssertEqual(coordinator.alertShown.count, 1) + XCTAssertEqual(coordinator.alertShown.last?.title, Localizations.syncUnsuccessful) + } + + /// `perform(_:)` with `.streamPremiumUpgradePendingState` does not re-show the "Sync + /// unsuccessful" alert on a later, duplicate emission that still reports a failed attempt. + @MainActor + func test_perform_streamPremiumUpgradePendingState_doesNotReshowSyncUnsuccessfulAlert() { + billingRepository.isInAppUpgradeAvailableReturnValue = true + let pendingStateSubject = CurrentValueSubject( + PremiumUpgradePendingState(isPending: true, lastAttemptFailed: false), + ) + billingService.premiumUpgradePendingStatePublisherReturnValue = pendingStateSubject.eraseToAnyPublisher() + + let task = Task { + await subject.perform(.streamPremiumUpgradePendingState) + } + defer { task.cancel() } + + pendingStateSubject.send(PremiumUpgradePendingState(isPending: true, lastAttemptFailed: true)) + waitFor(!coordinator.alertShown.isEmpty) + XCTAssertEqual(coordinator.alertShown.count, 1) + + // A duplicate emission with the same `lastAttemptFailed: true` (e.g. triggered by an + // unrelated `isPending` change) should not re-show the alert. Combine delivers a single + // publisher's emissions in order, so sending a distinct, independently-observable + // follow-up and waiting on it proves the duplicate before it was already processed — + // without that, `waitFor` could return before the duplicate was even handled. + pendingStateSubject.send(PremiumUpgradePendingState(isPending: true, lastAttemptFailed: true)) + pendingStateSubject.send(PremiumUpgradePendingState(isPending: false, lastAttemptFailed: true)) + waitFor(subject.state.shouldShowPremiumUpgradeActionCard) + + XCTAssertEqual(coordinator.alertShown.count, 1) + } + + /// `perform(_:)` with `.streamPremiumUpgradePendingState` — tapping "Try again" on the + /// "Sync unsuccessful" alert retries via `premiumStatusChanged()`. + /// + /// Kept synchronous (not `async`) deliberately: this test runs a background `Task` + /// consuming an infinite stream while also driving another async call (`tapAction`) from + /// the test body. If the test function itself were `async`, it would run on Swift's + /// cooperative thread pool, and the blocking `waitFor` helper below would starve that same + /// pool, preventing the background `Task` from ever being scheduled — a real deadlock this + /// test hit before being fixed. Running synchronously (on the XCTest main thread, not the + /// cooperative pool) avoids that; `tapAction`'s own async call is driven via a second `Task` + /// polled with `waitFor` instead of an inline `await`. + @MainActor + func test_perform_streamPremiumUpgradePendingState_syncUnsuccessfulAlert_tryAgain() throws { + let pendingStateSubject = CurrentValueSubject( + PremiumUpgradePendingState(isPending: true, lastAttemptFailed: true), + ) + billingService.premiumUpgradePendingStatePublisherReturnValue = pendingStateSubject.eraseToAnyPublisher() + + let task = Task { + await subject.perform(.streamPremiumUpgradePendingState) + } + defer { task.cancel() } + + waitFor(!coordinator.alertShown.isEmpty) + let alert = try XCTUnwrap(coordinator.alertShown.last) + + Task { + try await alert.tapAction(title: Localizations.tryAgain) + } + waitFor(billingService.premiumStatusChangedCallsCount == 1) + } + + /// `perform(_:)` with `.streamPremiumUpgradePendingState` re-shows the "Sync Unsuccessful" + /// alert if the user's own "Try again" retry also fails — the transition tracker must reset + /// before retrying, or this explicit, user-initiated retry would fail with no feedback. + @MainActor + func test_perform_streamPremiumUpgradePendingState_syncUnsuccessfulAlert_tryAgainFails() throws { + let pendingStateSubject = CurrentValueSubject( + PremiumUpgradePendingState(isPending: true, lastAttemptFailed: true), + ) + billingService.premiumUpgradePendingStatePublisherReturnValue = pendingStateSubject.eraseToAnyPublisher() + billingService.premiumStatusChangedClosure = { + pendingStateSubject.send(PremiumUpgradePendingState(isPending: true, lastAttemptFailed: true)) + } + + let task = Task { + await subject.perform(.streamPremiumUpgradePendingState) + } + defer { task.cancel() } + + waitFor(!coordinator.alertShown.isEmpty) + let alert = try XCTUnwrap(coordinator.alertShown.last) + + Task { + try await alert.tapAction(title: Localizations.tryAgain) + } + waitFor(coordinator.alertShown.count == 2) + + XCTAssertEqual(coordinator.alertShown.last?.title, Localizations.syncUnsuccessful) + } + /// `perform(_:)` with `.streamShowWebIcons` requests the value of the show /// web icons parameter from the state service. @MainActor