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
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions BitwardenShared/UI/Billing/Extensions/Alert+Billing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
18 changes: 18 additions & 0 deletions BitwardenShared/UI/Billing/Extensions/Alert+BillingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, Never>(
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, Never>(
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, Never>(
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, Never>(
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
Expand Down
Loading