diff --git a/Flipcash/Core/AppDelegate.swift b/Flipcash/Core/AppDelegate.swift index f96792248..2616f4bdd 100644 --- a/Flipcash/Core/AppDelegate.swift +++ b/Flipcash/Core/AppDelegate.swift @@ -314,6 +314,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { logger.error("Push notification registration failed", metadata: ["error": "\(error)"]) + + // Before login there is no session to keep it for; the status page only exists after. + sessionContainer?.pushController.didFailToRegisterForRemoteNotifications(with: error) } } diff --git a/Flipcash/Core/Controllers/PushController.swift b/Flipcash/Core/Controllers/PushController.swift index 235c628b4..141c6b956 100644 --- a/Flipcash/Core/Controllers/PushController.swift +++ b/Flipcash/Core/Controllers/PushController.swift @@ -6,6 +6,7 @@ // import UIKit +import os import UniformTypeIdentifiers import FlipcashCore @@ -43,6 +44,9 @@ class PushController { set { delegate.isArchived = newValue } } + /// The last `didFailToRegisterForRemoteNotifications` error this launch, for the report. + @ObservationIgnored private(set) var lastAPNsError: String? + @ObservationIgnored private let owner: KeyPair @ObservationIgnored private let client: FlipClient @ObservationIgnored private let center: UNUserNotificationCenter @@ -50,7 +54,10 @@ class PushController { @ObservationIgnored private var apnsToken: Data? @ObservationIgnored private var uploadedFirebaseToken: String? + /// The token sent in the latest `AddToken` attempt, or `nil` when that attempt fetched none. + @ObservationIgnored private var attemptedFirebaseToken: String? @ObservationIgnored private var activeObserver: Any? + @ObservationIgnored private let registrationStore: NotificationRegistrationStore // MARK: - Init - @@ -59,6 +66,7 @@ class PushController { self.client = client self.center = .current() self.delegate = NotificationDelegate() + self.registrationStore = NotificationRegistrationStore(owner: owner.publicKey) delegate.didReceiveFCMToken = { [weak self] token in try await self?.didReceiveFirebaseToken(token: token) @@ -109,9 +117,16 @@ class PushController { try await didReceiveFirebaseToken(token: token) } } + + /// Called by the app delegate when APNs registration fails. Kept for the status page's report. + func didFailToRegisterForRemoteNotifications(with error: Error) { + lastAPNsError = "\(error)" + } /// Deletes the FCM token from the server and unregisters from APNs. func prepareForLogout() { + registrationStore.clear() + Task { guard uploadedFirebaseToken != nil else { return @@ -148,9 +163,79 @@ class PushController { /// Re-fetches the notification authorization status from the system. func refreshAuthorizationStatus() async { - authorizationStatus = await Self.fetchStatus() + authorizationStatus = await Self.fetchStatus().authorizationStatus } - + + // MARK: - Notification Status - + + /// The last recorded `AddToken` outcome for this account, from any call site. + var savedRegistration: NotificationStatus.RegistrationRecord? { + registrationStore.record + } + + /// The last six characters of the token sent in the latest attempt, for the report. + var tokenSuffix: String? { + attemptedFirebaseToken.map { String($0.suffix(6)) } + } + + /// Fetches the current FCM token and calls `AddToken` with it, whether or not this token was + /// already uploaded, so the status page sees a fresh answer. One + /// ``NotificationStatus/registrationTimeout`` covers the token delete, the fetch and `AddToken`. + /// + /// Saves the outcome only when a token was fetched, since otherwise `AddToken` never ran, and + /// saves nothing when the caller is cancelled. + func checkRegistration(deletingToken: Bool = false) async -> NotificationStatus.Registration { + let deadline = ContinuousClock.now + NotificationStatus.registrationTimeout + let messaging = Messaging.messaging() + if deletingToken { + do { + try await Self.withDeadline(deadline) { try await Messaging.messaging().deleteToken() } + } catch { + ErrorReporting.captureError(error, reason: "Failed to delete local FCM token") + } + } + + let token: String + do { + token = try await Self.withDeadline(deadline) { try await Messaging.messaging().token() } + } catch { + guard !Task.isCancelled else { return .checking } + attemptedFirebaseToken = nil + if messaging.apnsToken == nil { + logger.warning("No APNs token for registration check", metadata: ["error": "\(error)"]) + return .noAPNsToken + } + ErrorReporting.captureError(error, reason: "Failed to fetch FCM token for registration check") + return .finished(error is CancellationError ? .transport : .unknown) + } + + guard let result = await upload(token: token, source: .statusPage, deadline: deadline) else { + return .checking + } + return .finished(result) + } + + /// Asks Flipcash to send its test notification to every phone signed in to this account, + /// giving up after ``NotificationStatus/testTimeout``. + func sendTestPush() async -> NotificationStatus.TestResult { + do { + let client = client + let owner = owner + try await Self.withDeadline(.now + NotificationStatus.testTimeout) { + try await client.sendTestPush(owner: owner) + } + return .sent + } catch let error as ErrorSendTestPush { + ErrorReporting.captureError(error, reason: "Failed to send test push") + return .init(error) + } catch is CancellationError { + return .transport + } catch { + ErrorReporting.captureError(error, reason: "Failed to send test push") + return .failed + } + } + // MARK: - Firebase - private func didReceiveFirebaseToken(token: String?) async throws { @@ -161,13 +246,67 @@ class PushController { } if uploadedFirebaseToken != token { - try await client.addToken( - token: token, - installationID: try await Self.installationID(), - owner: owner - ) + // A first upload this launch follows login or launch; any later one is a rotation. + let source: NotificationStatus.RegistrationSource = uploadedFirebaseToken == nil ? .login : .refresh + await upload(token: token, source: source, deadline: .now + NotificationStatus.registrationTimeout) + } + } + + /// Calls `AddToken`, caches the token on success and records the outcome for this account. + /// Returns `nil`, saving nothing, when the caller was cancelled before it finished. + @discardableResult + private func upload( + token: String, + source: NotificationStatus.RegistrationSource, + deadline: ContinuousClock.Instant + ) async -> NotificationStatus.RegistrationResult? { + let result: NotificationStatus.RegistrationResult + do { + let client = client + let owner = owner + try await Self.withDeadline(deadline) { + try await client.addToken( + token: token, + installationID: try await Self.installationID(), + owner: owner + ) + } uploadedFirebaseToken = token // Cache uploaded token - + result = .ok + } catch let error as ErrorAddToken { + result = .init(error) + ErrorReporting.captureError(error, reason: "Failed to add push token", metadata: ["source": source.rawValue]) + } catch is CancellationError { + result = .transport + } catch { + result = .unknown + ErrorReporting.captureError(error, reason: "Failed to add push token", metadata: ["source": source.rawValue]) + } + + // The page closed mid-run, which drops the run; a timeout cancels only the call. + guard !Task.isCancelled else { return nil } + + attemptedFirebaseToken = token + registrationStore.save(result, source: source) + logger.info("Recorded push registration", metadata: ["source": "\(source.rawValue)", "result": "\(result.rawValue)"]) + return result + } + + /// Runs `operation`, throwing `CancellationError` at `deadline` or when the caller is + /// cancelled, even if `operation` ignores cancellation. + nonisolated static func withDeadline( + _ deadline: ContinuousClock.Instant, + _ operation: @escaping @Sendable () async throws -> T + ) async throws -> T { + // A task group would wait for an operation that ignores cancellation, so the result is + // raced through one continuation and the loser is cancelled and left behind. + let race = DeadlineRace() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + race.start(continuation, deadline: deadline, operation: operation) + } + } onCancel: { + race.finish(.failure(CancellationError())) } } @@ -231,8 +370,8 @@ extension PushController { return granted } - static func fetchStatus() async -> UNAuthorizationStatus { - await UNUserNotificationCenter.current().notificationSettings().authorizationStatus + static func fetchStatus() async -> UNNotificationSettings { + await UNUserNotificationCenter.current().notificationSettings() } static func installationID() async throws -> String { @@ -399,3 +538,70 @@ private class NotificationDelegate: NSObject, @preconcurrency UNUserNotification extension PushController { static let mock: PushController = PushController(owner: .mock, client: .mock) } + +// MARK: - DeadlineRace - + +/// Resumes one continuation with whichever of an operation, its deadline or the caller's +/// cancellation finishes first, then cancels the rest. +private nonisolated final class DeadlineRace: Sendable { + + private struct State { + var continuation: CheckedContinuation? + var result: Result? + var tasks: [Task] = [] + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + func start( + _ continuation: CheckedContinuation, + deadline: ContinuousClock.Instant, + operation: @escaping @Sendable () async throws -> T + ) { + // The caller may already be cancelled, in which case `finish` ran first. + let early = state.withLock { state in + if state.result == nil { state.continuation = continuation } + return state.result + } + if let early { + continuation.resume(with: early) + return + } + + let work = Task { + do { + finish(.success(try await operation())) + } catch { + finish(.failure(error)) + } + } + let timer = Task { + try? await Task.sleep(until: deadline, clock: .continuous) + finish(.failure(CancellationError())) + } + let isFinished = state.withLock { state in + if state.result == nil { state.tasks = [work, timer] } + return state.result != nil + } + if isFinished { + work.cancel() + timer.cancel() + } + } + + /// Settles the race with `result` if it has not settled yet. + func finish(_ result: Result) { + let winner = state.withLock { state -> (CheckedContinuation?, [Task])? in + guard state.result == nil else { return nil } + state.result = result + defer { + state.continuation = nil + state.tasks = [] + } + return (state.continuation, state.tasks) + } + guard let (continuation, tasks) = winner else { return } + tasks.forEach { $0.cancel() } + continuation?.resume(with: result) + } +} diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index ed419f5f7..a6d5010d7 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -96,6 +96,8 @@ extension AppRouter { case settingsAppSettings case settingsAccountSelection case settingsApplicationLogs + /// The notification status page, opened from Settings › Advanced. + case settingsNotificationStatus case blockedUsers case trustedWebsites case accessKey @@ -180,7 +182,7 @@ extension AppRouter { .changeDisplayName, .changeProfilePicture, .username, .setMinimumTip, .settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection, - .settingsApplicationLogs, .blockedUsers, .trustedWebsites, .accessKey, .withdraw: + .settingsApplicationLogs, .settingsNotificationStatus, .blockedUsers, .trustedWebsites, .accessKey, .withdraw: return .you case .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup, .newPrivateGroup, .tipConversation, .tipConversationWithKeyboard, .chatMessage, .userProfile, .chatProfile, @@ -230,6 +232,7 @@ extension AppRouter { case .settingsAppSettings: "settingsAppSettings" case .settingsAccountSelection: "settingsAccountSelection" case .settingsApplicationLogs: "settingsApplicationLogs" + case .settingsNotificationStatus: "settingsNotificationStatus" case .blockedUsers: "blockedUsers" case .trustedWebsites: "trustedWebsites" case .accessKey: "accessKey" @@ -307,7 +310,7 @@ extension AppRouter { .settings, .accountInfo, .editProfile, .editBio, .editFeaturedGroups, .changeCoverPicture, .changeDisplayName, .changeProfilePicture, .settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection, - .settingsApplicationLogs, .blockedUsers, .trustedWebsites, .accessKey, .withdraw, + .settingsApplicationLogs, .settingsNotificationStatus, .blockedUsers, .trustedWebsites, .accessKey, .withdraw, .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup, .newPrivateGroup, .archivedChats, .chatSearch, .requests, .groupInvite: return nil diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index 91f1000e5..2576c1dcf 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -172,6 +172,9 @@ struct DestinationView: View { case .settingsApplicationLogs: ApplicationLogsScreen() + case .settingsNotificationStatus: + NotificationStatusScreen() + case .blockedUsers: BlockedUsersScreen() diff --git a/Flipcash/Core/Screens/Main/Currency Swap/InlineSwapCompletion.swift b/Flipcash/Core/Screens/Main/Currency Swap/InlineSwapCompletion.swift index 345069a0d..4903fafa1 100644 --- a/Flipcash/Core/Screens/Main/Currency Swap/InlineSwapCompletion.swift +++ b/Flipcash/Core/Screens/Main/Currency Swap/InlineSwapCompletion.swift @@ -211,7 +211,7 @@ struct NotificationPrompt { static let shownKey = "flipcash-transaction-notify-prompt-shown" var defaults: UserDefaults = .standard - var status: () async -> UNAuthorizationStatus = { await PushController.fetchStatus() } + var status: () async -> UNAuthorizationStatus = { await PushController.fetchStatus().authorizationStatus } var authorize: () async -> Void = { _ = try? await PushController.authorizeAndRegister() } var hasShown: Bool { defaults.bool(forKey: Self.shownKey) } diff --git a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift index fc21cddf4..0eb7ab67f 100644 --- a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift +++ b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift @@ -52,7 +52,7 @@ class OnboardingViewModel { /// - finishLogin: opens the session once onboarding is done. init( container: Container, - pushStatus: @escaping () async -> UNAuthorizationStatus = { await PushController.fetchStatus() }, + pushStatus: @escaping () async -> UNAuthorizationStatus = { await PushController.fetchStatus().authorizationStatus }, hasDisplayName: ((InitializedAccount) async -> Bool)? = nil, finishLogin: ((InitializedAccount) -> Void)? = nil ) { diff --git a/Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift b/Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift new file mode 100644 index 000000000..2e382cca4 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift @@ -0,0 +1,404 @@ +// +// NotificationStatusScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// Settings › Advanced › Notifications: whether notifications can reach this phone, what to fix +/// when they can't, and a test notification sent by Flipcash. +struct NotificationStatusScreen: View { + + @Environment(PushController.self) private var pushController + @Environment(ToastController.self) private var toasts + @Environment(\.scenePhase) private var scenePhase + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + @State private var model: NotificationStatusViewModel? + + var body: some View { + // The page lays out every check before the first read so nothing moves as results land; + // status changes, hidden rows and the fold all animate in place. + let page = model?.page + let shown = page ?? NotificationStatus.checkingPage() + // At accessibility sizes a pinned footer would cover most of the screen. + let scrollsFooter = dynamicTypeSize.isAccessibilitySize + + Background(color: .backgroundMain) { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + SummaryCard(summary: shown.summary) + .padding(.horizontal, 20) + .padding(.top, 20) + + checks(page: shown) + + if scrollsFooter { + footer(page: page) + } + } + .padding(.bottom, 20) + .animation(.smooth, value: shown) + .animation(.smooth, value: model?.isExpanded) + } + } + // A scroll edge bar, so the checks fade out under the buttons. + .scrollEdgeBar(.bottom) { + if !scrollsFooter { + footer(page: page) + .toastClearance(toasts) + } + } + .navigationTitle("Notifications") + .toolbarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + checkAgainButton + } + } + .task { + let model = model ?? NotificationStatusViewModel(pushController: pushController) + self.model = model + await model.runChecks() + } + .onChange(of: scenePhase) { _, phase in + // Coming back from Settings after a fix re-reads the phone's settings; registration + // keeps its last result. + guard phase == .active, let model else { return } + Task { await model.rereadSettings() } + } + .onDisappear { + model?.stop() + } + } + + private var checkAgainButton: some View { + Button { + guard let model else { return } + Task { await model.checkAgain() } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(model?.isRunning != false) + .accessibilityLabel("Check Again") + .accessibilityIdentifier("notification-status-check-again") + } + + @ViewBuilder + private func checks(page: NotificationStatus.Page) -> some View { + let isExpanded = model?.isExpanded == true + + // When every check passes, the folded row stays as the toggle above the groups it opens. + if let foldedTitle = page.foldedTitle { + FoldedRow(title: foldedTitle, status: page.foldedStatus, isExpanded: isExpanded) { + model?.isExpanded.toggle() + } + .padding(.horizontal, 20) + .padding(.top, 12) + .transition(.opacity) + } + + if page.foldedTitle == nil || isExpanded { + VStack(alignment: .leading, spacing: 0) { + ForEach(NotificationStatus.Group.allCases, id: \.self) { group in + let rows = page.rows(in: group) + if !rows.isEmpty { + // Title case, which `SettingsSectionHeader` would uppercase; padded to match it. + ListHeader(group.title) + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 3) + CheckGroup(rows: rows) { fix in + guard let model else { return } + Task { await model.perform(fix) } + } + .padding(.horizontal, 20) + } + } + } + .transition(.opacity) + } + } + + /// `page` is `nil` until the phone's settings have been read; the test stays off until then. + @ViewBuilder + private func footer(page: NotificationStatus.Page?) -> some View { + let isTestSent = model?.isTestSent == true + let isSendingTest = model?.isSendingTest == true + let isTestEnabled = page?.isTestEnabled == true + + VStack(spacing: 12) { + Button { + guard let model else { return } + Task { + guard let result = await model.sendTest() else { return } + toasts.show(Self.toast(for: result)) + } + } label: { + ButtonStateLabel( + isTestSent ? "Send Again" : "Send Test Notification", + state: isSendingTest ? .loading : .normal + ) + } + .buttonStyle(isTestSent ? .filled20 : .filled) + // Both fills are translucent; without this the checks show through them. + .background(Color.backgroundMain, in: RoundedRectangle(cornerRadius: Metrics.buttonRadius)) + .disabled(!isTestEnabled || isSendingTest) + .accessibilityIdentifier("notification-status-send-test") + + ShareLink(item: model?.report ?? "") { + Text("Share Report") + } + .buttonStyle(.subtle) + .disabled(model == nil) + + Group { + if page != nil, !isTestEnabled { + Text("Turn on notifications to send a test.") + .foregroundStyle(Color.textSecondary) + } else { + Text("Flipcash sends the test to every phone signed in to this account.") + .foregroundStyle(Color.textSecondary) + } + } + .font(.appTextCaption) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 20) + } + + private static func toast(for result: NotificationStatus.TestResult) -> ToastController.Toast { + switch result { + case .sent: + .init( + "Test notification sent", + systemImage: "checkmark.circle.fill", + messageIdentifier: "notification-status-test-sent", + duration: .seconds(2) + ) + case .transport: + .init( + "Couldn't reach Flipcash", + systemImage: "exclamationmark.circle.fill", + messageIdentifier: "notification-status-test-unreachable" + ) + case .failed: + .init( + "Couldn't send a test notification", + systemImage: "exclamationmark.circle.fill", + messageIdentifier: "notification-status-test-failed" + ) + } + } +} + +// MARK: - Summary - + +private struct SummaryCard: View { + + let summary: NotificationStatus.Summary + + var body: some View { + HStack(spacing: 12) { + StatusMark(status: summary.status, size: .large) + + VStack(alignment: .leading, spacing: 2) { + Text(summary.title) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + if let detail = summary.detail { + Text(detail) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + } + } + .contentTransition(.opacity) + Spacer(minLength: 0) + } + .padding(16) + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + .accessibilityElement(children: .combine) + } +} + +// MARK: - Rows - + +private struct FoldedRow: View { + + let title: String + let status: NotificationStatus.Status + let isExpanded: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + StatusMark(status: status, size: .small) + Text(title) + .font(.appTextMessage) + .foregroundStyle(Color.textMain) + Spacer(minLength: 0) + Image(systemName: "chevron.down") + .foregroundStyle(Color.textSecondary) + .rotationEffect(.degrees(isExpanded ? 180 : 0)) + } + .padding(16) + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityValue(isExpanded ? "Expanded" : "Collapsed") + .accessibilityIdentifier("notification-status-folded") + } +} + +private struct CheckGroup: View { + + let rows: [NotificationStatus.Row] + let onFix: (NotificationStatus.Fix) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(rows) { row in + CheckRow(row: row, onFix: onFix) + } + } + .padding(16) + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + } +} + +private struct CheckRow: View { + + let row: NotificationStatus.Row + let onFix: (NotificationStatus.Fix) -> Void + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + var body: some View { + // At accessibility sizes the fix button drops under the text, so the title keeps the + // full width instead of breaking mid-word beside it. + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 12) { + label + fixButton + } + } else { + HStack(spacing: 12) { + label + fixButton + } + } + } + + private var label: some View { + HStack(spacing: 12) { + StatusMark(status: row.status, size: .small) + + // Every row, Checking included, is as tall as a title over two lines of detail, so a + // result that wraps doesn't move the rows below it. + ZStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: " ") + .font(.appTextMessage) + Text(verbatim: " ") + .font(.appTextCaption) + .lineLimit(2, reservesSpace: true) + } + .hidden() + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(row.title) + .font(.appTextMessage) + .foregroundStyle(Color.textMain) + if let detail = row.detail { + Text(detail) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + } + } + .contentTransition(.opacity) + .accessibilityElement(children: .combine) + } + + Spacer(minLength: 0) + } + } + + @ViewBuilder + private var fixButton: some View { + if let fix = row.fix, let fixTitle = row.fixTitle { + Button(fixTitle) { onFix(fix) } + .font(.appTextSmall) + .foregroundStyle(Color.textAction) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Capsule().fill(Color.action)) + .buttonStyle(.plain) + .disabled(row.status == .checking) + .accessibilityIdentifier("notification-status-fix-\(row.check.rawValue)") + .transition(.opacity) + } + } +} + +// MARK: - Status Mark - + +private struct StatusMark: View { + + enum Size { + case small + case large + + var width: CGFloat { self == .small ? 16 : 26 } + var height: CGFloat { self == .small ? 24 : 40 } + var glyph: Font { self == .small ? .appTextCaption : .appTextMedium } + } + + let status: NotificationStatus.Status + let size: Size + + var body: some View { + ZStack { + Capsule() + .fill(color) + if status == .checking { + ProgressView() + .controlSize(.mini) + .tint(Color.textAction) + .transition(.opacity) + } else { + Image(systemName: glyph) + .font(size.glyph) + .foregroundStyle(Color.textAction) + .contentTransition(.symbolEffect(.replace)) + .transition(.opacity) + } + } + .frame(width: size.width, height: size.height) + .accessibilityLabel(Text(status.rawValue)) + } + + private var color: Color { + switch status { + case .pass: .textSuccess + case .warning: .textWarning + case .fail: .textError + case .checking: .textSecondary + } + } + + private var glyph: String { + switch status { + case .pass: "checkmark" + case .warning, .fail: "exclamationmark" + case .checking: "questionmark" + } + } +} diff --git a/Flipcash/Core/Screens/Settings/NotificationStatusViewModel.swift b/Flipcash/Core/Screens/Settings/NotificationStatusViewModel.swift new file mode 100644 index 000000000..80ccff51d --- /dev/null +++ b/Flipcash/Core/Screens/Settings/NotificationStatusViewModel.swift @@ -0,0 +1,290 @@ +// +// NotificationStatusViewModel.swift +// Flipcash +// + +import UIKit +import FlipcashCore +import UserNotifications + +/// Runs the notification status page's checks and holds what the page shows. The rules live in +/// ``NotificationStatus``; this type gathers the inputs and acts on the fixes. +@Observable +final class NotificationStatusViewModel { + + private(set) var snapshot: NotificationStatus.Snapshot? + private(set) var registration: NotificationStatus.Registration = .checking + /// Whether the folded "N checks passed" row is open. Lasts until the page closes or the user + /// folds it again. + var isExpanded = false + /// Whether Flipcash accepted a test notification since the page opened. + private(set) var isTestSent = false + /// Whether a `SendTestPush` call is in flight, during which the test button is disabled. + private(set) var isSendingTest = false + /// The page as it stood when "Check Again" was tapped, shown with every row Checking until + /// that run ends. + private var recheckingFrom: NotificationStatus.Page? + /// The saved `AddToken` record as it stood when the current registration started, before + /// that attempt overwrote it. + private var savedBeforeRegistration: NotificationStatus.RegistrationRecord? + /// The summary the page showed when a re-run started, kept until that run's registration + /// finishes. `nil` on the page's first run, which shows "Checking…". + private var heldSummary: NotificationStatus.Summary? + /// Whether a run has finished since the page opened, after which the summary keeps + /// "Checked just now" through every later run. + private var hasChecked = false + + @ObservationIgnored private let pushController: PushController + @ObservationIgnored private let readSettings: () async -> NotificationStatus.Snapshot + @ObservationIgnored private let registrationCheck: (_ deletingToken: Bool) async -> NotificationStatus.Registration + @ObservationIgnored private let savedRegistration: () -> NotificationStatus.RegistrationRecord? + @ObservationIgnored private let requestAuthorization: () async throws -> Bool + @ObservationIgnored private let sendTestPush: () async -> NotificationStatus.TestResult + @ObservationIgnored private let recheckPause: Duration + @ObservationIgnored private var registrationTask: Task? + + /// `readSettings`, `registrationCheck`, `savedRegistration`, `requestAuthorization` and + /// `sendTestPush` default to the phone's settings, ``PushController/checkRegistration(deletingToken:)``, + /// ``PushController/savedRegistration``, ``PushController/authorizeAndRegister()`` and + /// ``PushController/sendTestPush()``. + /// `recheckPause` is the least time "Check Again" shows the rows as Checking, so a fast + /// re-check still reads as one. + init( + pushController: PushController, + readSettings: (() async -> NotificationStatus.Snapshot)? = nil, + registrationCheck: ((_ deletingToken: Bool) async -> NotificationStatus.Registration)? = nil, + savedRegistration: (() -> NotificationStatus.RegistrationRecord?)? = nil, + requestAuthorization: (() async throws -> Bool)? = nil, + sendTestPush: (() async -> NotificationStatus.TestResult)? = nil, + recheckPause: Duration = .milliseconds(500) + ) { + self.pushController = pushController + self.readSettings = readSettings ?? { NotificationStatus.Snapshot(await PushController.fetchStatus()) } + self.registrationCheck = registrationCheck ?? { [pushController] in + await pushController.checkRegistration(deletingToken: $0) + } + self.savedRegistration = savedRegistration ?? { [pushController] in pushController.savedRegistration } + self.requestAuthorization = requestAuthorization ?? { try await PushController.authorizeAndRegister() } + self.sendTestPush = sendTestPush ?? { [pushController] in await pushController.sendTestPush() } + self.recheckPause = recheckPause + } + + /// `nil` until the phone's settings have been read once. + var page: NotificationStatus.Page? { + if let recheckingFrom { + return recheckingFrom.rechecking + } + return snapshot.map { + var page = NotificationStatus.evaluate( + snapshot: $0, + registration: registration, + saved: savedBeforeRegistration, + hasChecked: hasChecked, + now: .now + ) + if let heldSummary { + page.summary = heldSummary + } + return page + } + } + + /// Whether a run is in progress, during which "Check Again" is disabled. + var isRunning: Bool { + recheckingFrom != nil || registration == .checking + } + + var report: String { + NotificationStatus.report( + snapshot: snapshot ?? .init(authorization: .notDetermined), + registration: registration, + saved: savedRegistration(), + context: .init( + appVersion: Self.appVersion, + osVersion: UIDevice.current.systemVersion, + deviceModel: Self.deviceModel, + tokenSuffix: pushController.tokenSuffix, + lastAPNsError: pushController.lastAPNsError + ), + now: .now + ) + } + + // MARK: - Checks - + + /// Reads the phone's settings and re-registers, unless a registration is already running. + func runChecks() async { + await refreshSettings() + if registrationTask == nil { + register() + } + } + + /// Re-runs every check, including a fresh `AddToken`, keeping the rows the page shows now. + /// After a rejected push token it deletes the local token first, as "Try Again" does. + /// Returns once the run has finished; does nothing while a run is already in progress. + func checkAgain() async { + guard !isRunning, let page else { return } + recheckingFrom = page + register(deletingToken: registration == .finished(.invalidPushToken)) + async let pause: Void? = try? Task.sleep(for: recheckPause) + await refreshSettings() + await registrationTask?.value + _ = await pause + recheckingFrom = nil + } + + /// Re-reads only the phone's settings, for a return to the foreground. Registration keeps its + /// last result, so a folded page doesn't unfold and refold. + func rereadSettings() async { + await refreshSettings() + } + + /// Drops an unfinished registration when the page goes away; the cancelled attempt saves + /// nothing. + func stop() { + registrationTask?.cancel() + registrationTask = nil + } + + private func refreshSettings() async { + snapshot = await readSettings() + } + + /// `shownSummary` is the summary to keep while this run goes, when the page has already + /// changed since the user acted; it defaults to the one showing now. + private func register(deletingToken: Bool = false, keeping shownSummary: NotificationStatus.Summary? = nil) { + registrationTask?.cancel() + if hasChecked { + heldSummary = shownSummary ?? heldSummary ?? page?.summary + } + savedBeforeRegistration = savedRegistration() + registration = .checking + registrationTask = Task { [registrationCheck] in + let result = await registrationCheck(deletingToken) + guard !Task.isCancelled else { return } + registration = result + hasChecked = true + heldSummary = nil + registrationTask = nil + } + } + + // MARK: - Actions - + + func perform(_ fix: NotificationStatus.Fix) async { + switch fix { + case .openNotificationSettings: + guard let url = URL(string: UIApplication.openNotificationSettingsURLString) else { return } + await UIApplication.shared.open(url) + case .requestAuthorization: + let shownSummary = page?.summary + var granted = false + do { + granted = try await requestAuthorization() + } catch { + ErrorReporting.captureError(error, reason: "Failed to request notification authorization") + } + await refreshSettings() + // The phone can only register once notifications are allowed. + if granted { + register(keeping: shownSummary) + } + case .retryRegistration: + register() + case .retryRegistrationDeletingToken: + register(deletingToken: true) + } + } + + /// Re-reads the permission first: if it went away while the page was open, nothing is sent + /// and the page shows notifications as off. Returns how the send went, or `nil` when nothing + /// was sent. + @discardableResult + func sendTest() async -> NotificationStatus.TestResult? { + guard !isSendingTest else { return nil } + isSendingTest = true + defer { isSendingTest = false } + + await refreshSettings() + guard page?.isTestEnabled == true else { return nil } + + let result = await sendTestPush() + if result == .sent { + isTestSent = true + } + return result + } + + // MARK: - Device - + + private static var appVersion: String { + let info = Bundle.main.infoDictionary + let version = info?["CFBundleShortVersionString"] as? String ?? "?" + let build = info?["CFBundleVersion"] as? String ?? "?" + return "\(version) (\(build))" + } + + /// The hardware identifier, such as "iPhone17,1". The simulator reports the device it emulates. + private static var deviceModel: String { + if let simulated = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] { + return simulated + } + var info = utsname() + uname(&info) + return withUnsafeBytes(of: &info.machine) { bytes in + String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self) + } + } +} + +// MARK: - Snapshot - + +extension NotificationStatus.Snapshot { + + init(_ settings: UNNotificationSettings) { + self.init( + authorization: .init(settings.authorizationStatus), + alert: .init(settings.alertSetting), + lockScreen: .init(settings.lockScreenSetting), + sound: .init(settings.soundSetting), + badge: .init(settings.badgeSetting), + alertStyle: .init(settings.alertStyle) + ) + } +} + +private extension NotificationStatus.Snapshot.Authorization { + init(_ status: UNAuthorizationStatus) { + switch status { + case .notDetermined: self = .notDetermined + case .denied: self = .denied + case .authorized: self = .authorized + case .provisional: self = .provisional + case .ephemeral: self = .ephemeral + @unknown default: self = .authorized + } + } +} + +private extension NotificationStatus.Snapshot.Setting { + init(_ setting: UNNotificationSetting) { + switch setting { + case .enabled: self = .enabled + case .disabled: self = .disabled + case .notSupported: self = .notSupported + @unknown default: self = .notSupported + } + } +} + +private extension NotificationStatus.Snapshot.AlertStyle { + init(_ style: UNAlertStyle) { + switch style { + case .none: self = .none + case .banner: self = .banner + case .alert: self = .alert + @unknown default: self = .banner + } + } +} diff --git a/Flipcash/Core/Screens/Settings/SettingsScreen.swift b/Flipcash/Core/Screens/Settings/SettingsScreen.swift index 7978bdc69..4f129a207 100644 --- a/Flipcash/Core/Screens/Settings/SettingsScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsScreen.swift @@ -121,6 +121,11 @@ struct SettingsScreen: View { router.push(.settingsApplicationLogs) } + SettingsRow(systemImage: "bell", title: "Notifications", insets: insets) { + router.push(.settingsNotificationStatus) + } + .accessibilityIdentifier("settings-notifications-row") + if betaFlags.canSwitchAccounts { SettingsRow(asset: .switchAccounts, title: "Switch Accounts", badge: .beta, insets: insets) { router.push(.settingsAccountSelection) diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationRegistrationStore.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationRegistrationStore.swift new file mode 100644 index 000000000..52ef593e6 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationRegistrationStore.swift @@ -0,0 +1,46 @@ +// +// NotificationRegistrationStore.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// Keeps the last `AddToken` outcome for one account, so the notification status page can report +/// it and soften a transport failure. Never stores the token itself. +public final class NotificationRegistrationStore { + + private let defaults: UserDefaults + private let key: String + + public init(defaults: UserDefaults = .standard, owner: PublicKey) { + self.defaults = defaults + self.key = "flipcash-\(owner.base58)-notification-registration" + } + + /// The saved outcome. Unreadable data is treated as none. + public var record: NotificationStatus.RegistrationRecord? { + get { + guard let data = defaults.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(NotificationStatus.RegistrationRecord.self, from: data) + } + set { + guard let newValue, let data = try? JSONEncoder().encode(newValue) else { + defaults.removeObject(forKey: key) + return + } + defaults.set(data, forKey: key) + } + } + + /// Saves a new outcome, keeping the time of the last `ok` from the record it replaces. + public func save(_ result: NotificationStatus.RegistrationResult, source: NotificationStatus.RegistrationSource, at: Date = .now) { + record = .recording(result, source: source, at: at, after: record) + } + + /// Removes the saved outcome. Called on logout. + public func clear() { + defaults.removeObject(forKey: key) + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationStatus.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationStatus.swift new file mode 100644 index 000000000..fed0f5596 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationStatus.swift @@ -0,0 +1,549 @@ +// +// NotificationStatus.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// The Settings › Advanced › Notifications page, as a pure function from what the phone reports +/// and how the last push registration went to the rows, summary and report the page shows. +/// +/// Free of `UserNotifications` and UIKit types so the same rules can move to shared code later; +/// the app maps `UNNotificationSettings` into ``Snapshot`` at the edge. +public enum NotificationStatus { + + /// How long a registration may take before it counts as unreachable. + public static let registrationTimeout: Duration = .seconds(10) + + /// How long `SendTestPush` may take before it counts as unreachable. + public static let testTimeout: Duration = .seconds(10) +} + +// MARK: - Input - + +extension NotificationStatus { + + /// The phone's notification settings for this app, mirroring `UNNotificationSettings`. + public struct Snapshot: Equatable, Sendable { + + public enum Authorization: String, Sendable { + case notDetermined + case denied + case authorized + case provisional + case ephemeral + } + + public enum Setting: String, Sendable { + case enabled + case disabled + /// The device can't have this setting; it is never treated as off. + case notSupported + + var isOff: Bool { + switch self { + case .disabled: true + case .enabled, .notSupported: false + } + } + } + + public enum AlertStyle: String, Sendable { + case none + case banner + case alert + } + + public var authorization: Authorization + public var alert: Setting + public var lockScreen: Setting + public var sound: Setting + public var badge: Setting + public var alertStyle: AlertStyle + + public init( + authorization: Authorization, + alert: Setting = .enabled, + lockScreen: Setting = .enabled, + sound: Setting = .enabled, + badge: Setting = .enabled, + alertStyle: AlertStyle = .banner + ) { + self.authorization = authorization + self.alert = alert + self.lockScreen = lockScreen + self.sound = sound + self.badge = badge + self.alertStyle = alertStyle + } + } + + /// How an `AddToken` call ended. Persisted, so the raw values are stable. + public enum RegistrationResult: String, Codable, Sendable { + case ok + case invalidPushToken + case transport + case unknown + + /// Classifies a failed `AddToken` call. A dropped or cancelled call couldn't reach + /// Flipcash; anything else the server didn't accept is unknown. + public init(_ error: ErrorAddToken) { + switch error { + case .invalidPushToken: self = .invalidPushToken + case .transportFailure, .cancelled: self = .transport + case .ok, .unknown, .rejected: self = .unknown + } + } + } + + /// How a `SendTestPush` call went, which picks the toast the page shows. + public enum TestResult: Equatable, Sendable { + case sent + case transport + case failed + + /// Classifies a failed `SendTestPush` call. A dropped or cancelled call couldn't reach + /// Flipcash; anything else the server didn't accept is a failed send. + public init(_ error: ErrorSendTestPush) { + switch error { + case .transportFailure, .cancelled: self = .transport + case .ok, .unknown, .rejected: self = .failed + } + } + } + + /// Which path made an `AddToken` call. Persisted, so the raw values are stable. + public enum RegistrationSource: String, Codable, Sendable { + case login + case refresh + case statusPage + } + + /// The last `AddToken` outcome from any call site, saved per account. + public struct RegistrationRecord: Codable, Equatable, Sendable { + public var at: Date + public var result: RegistrationResult + public var source: RegistrationSource + /// When an `AddToken` call from any call site last returned `ok`. Only a newer `ok` + /// replaces it, so a later failure keeps it. + public var lastRegistrationOkAt: Date? + + public init(at: Date, result: RegistrationResult, source: RegistrationSource, lastRegistrationOkAt: Date? = nil) { + self.at = at + self.result = result + self.source = source + self.lastRegistrationOkAt = lastRegistrationOkAt + } + + /// The record for a new outcome at `at`, carrying forward the last `ok` time from `previous`. + public static func recording( + _ result: RegistrationResult, + source: RegistrationSource, + at: Date, + after previous: RegistrationRecord? + ) -> RegistrationRecord { + let okAt: Date? = switch result { + case .ok: at + case .invalidPushToken, .transport, .unknown: previous?.lastRegistrationOkAt + } + return RegistrationRecord(at: at, result: result, source: source, lastRegistrationOkAt: okAt) + } + } + + /// Where the page's own registration check stands. + public enum Registration: Equatable, Sendable { + /// The check is running. + case checking + /// Firebase had no token to give, because the phone has no APNs token. + case noAPNsToken + /// `AddToken` answered, or timed out. + case finished(RegistrationResult) + } +} + +// MARK: - Output - + +extension NotificationStatus { + + public enum Status: String, Sendable { + case pass + case warning + case fail + case checking + } + + public enum Check: String, CaseIterable, Sendable { + /// i1 + case permission + /// i2 + case display + /// i3 + case soundsAndBadges + /// i4 + case registration + + /// The check's name, used in the report and the passing row. + public var name: String { + switch self { + case .permission: "Notifications Allowed" + case .display: "Banners and Lock Screen" + case .soundsAndBadges: "Sounds and Badges" + case .registration: "Registered for Push" + } + } + + public var group: Group { + switch self { + case .permission, .display, .soundsAndBadges: .phone + case .registration: .flipcash + } + } + } + + public enum Group: String, CaseIterable, Sendable { + case phone + case flipcash + + public var title: String { + switch self { + case .phone: "On This Phone" + case .flipcash: "With Flipcash" + } + } + } + + /// What a row's trailing button does. + public enum Fix: Equatable, Sendable { + /// Opens this app's notification settings in the Settings app. + case openNotificationSettings + /// Shows the system permission prompt in the app. + case requestAuthorization + /// Runs the registration again. + case retryRegistration + /// Deletes the local push token, then runs the registration again. + case retryRegistrationDeletingToken + } + + public struct Row: Equatable, Identifiable, Sendable { + public var check: Check + public var status: Status + public var title: String + public var detail: String? + public var fix: Fix? + /// The raw value behind the status, for the report. + public var raw: String? + + public var id: Check { check } + + /// The label on the fix button. The permission row says "Turn On" whether it opens + /// Settings or asks in the app. + public var fixTitle: String? { + guard let fix else { return nil } + if check == .permission { return "Turn On" } + switch fix { + case .openNotificationSettings, .requestAuthorization: + return "Fix" + case .retryRegistration, .retryRegistrationDeletingToken: + return "Try Again" + } + } + } + + public struct Summary: Equatable, Sendable { + public var status: Status + public var title: String + public var detail: String? + } + + public struct Page: Equatable, Sendable { + /// Visible rows in check order. Rows that don't apply are already left out. + public var rows: [Row] + public var summary: Summary + /// Whether "Send Test Notification" can be tapped. + public var isTestEnabled: Bool + /// The title of the row that folds both groups when every check passes. + public var foldedTitle: String? + /// The mark on the folded row: Pass, or Checking while the checks run again. + public var foldedStatus: Status { + rows.contains { $0.status == .checking } ? .checking : .pass + } + + public func rows(in group: Group) -> [Row] { + rows.filter { $0.check.group == group } + } + + /// This page while its checks run again: the same rows with the same text, each marked + /// Checking, so nothing on the page moves until the new results land. The summary stays + /// the last finished run's until the new one finishes. + public var rechecking: Page { + var page = self + for index in page.rows.indices { + page.rows[index].status = .checking + } + return page + } + } +} + +// MARK: - Evaluate - + +extension NotificationStatus { + + /// What the page shows before the phone's settings have been read: every check, still running. + public static func checkingPage() -> Page { + Page( + rows: Check.allCases.map { check in + switch check { + case .permission, .display, .soundsAndBadges: + Row(check: check, status: .checking, title: check.name, raw: "checking") + case .registration: + registrationRow(.checking, saved: nil, now: .distantPast, locale: .current) + } + }, + summary: Summary(status: .checking, title: "Checking…"), + isTestEnabled: false + ) + } + + /// Builds the page from the phone's settings and the registration check. + /// + /// `saved` is the `AddToken` record as it stood before this registration attempt started; + /// a past `ok` in it softens a transport failure into a warning, since the server most likely + /// still holds a good registration. `hasChecked` is whether a run on this page has finished + /// before, which keeps the summary's "Checked just now" up while a later run is in progress. + public static func evaluate( + snapshot: Snapshot, + registration: Registration, + saved: RegistrationRecord?, + hasChecked: Bool = false, + now: Date, + locale: Locale = .current + ) -> Page { + let permission = permissionRow(snapshot) + let permissionFails = permission.status == .fail + + var rows = [permission] + if !permissionFails { + rows.append(displayRow(snapshot)) + rows.append(soundsAndBadgesRow(snapshot)) + } + rows.append(registrationRow(registration, saved: saved, now: now, locale: locale)) + + let allPass = rows.allSatisfy { $0.status == .pass } + + return Page( + rows: rows, + summary: summary(rows, hasChecked: hasChecked), + isTestEnabled: !permissionFails, + foldedTitle: allPass ? plural(rows.count, "check passed", "checks passed") : nil + ) + } + + static func permissionRow(_ snapshot: Snapshot) -> Row { + let raw = snapshot.authorization.rawValue + switch snapshot.authorization { + case .authorized, .provisional, .ephemeral: + return Row(check: .permission, status: .pass, title: Check.permission.name, detail: "Allowed in iOS Settings", raw: raw) + case .denied: + return Row(check: .permission, status: .fail, title: "Notifications are turned off", detail: "Turned off for Flipcash in iOS Settings", fix: .openNotificationSettings, raw: raw) + case .notDetermined: + return Row(check: .permission, status: .fail, title: "Notifications aren't turned on yet", detail: "Flipcash hasn't asked yet", fix: .requestAuthorization, raw: raw) + } + } + + static func displayRow(_ snapshot: Snapshot) -> Row { + let bannersOff = snapshot.alert.isOff || snapshot.alertStyle == .none + let lockScreenOff = snapshot.lockScreen.isOff + + var off: [String] = [] + if snapshot.alert.isOff { off.append("alert disabled") } + if snapshot.alertStyle == .none { off.append("alertStyle none") } + if lockScreenOff { off.append("lockScreen disabled") } + let raw = off.isEmpty ? nil : off.joined(separator: ", ") + + if bannersOff { + return Row(check: .display, status: .warning, title: "Banners are off", detail: "New notifications only go to Notification Center", fix: .openNotificationSettings, raw: raw) + } + if lockScreenOff { + return Row(check: .display, status: .warning, title: "Hidden on the Lock Screen", fix: .openNotificationSettings, raw: raw) + } + return Row(check: .display, status: .pass, title: Check.display.name, detail: "Both on", raw: raw) + } + + static func soundsAndBadgesRow(_ snapshot: Snapshot) -> Row { + let title: String? = switch (snapshot.sound.isOff, snapshot.badge.isOff) { + case (true, true): "Sounds and badges are off" + case (true, false): "Sounds are off" + case (false, true): "Badges are off" + case (false, false): nil + } + + var off: [String] = [] + if snapshot.sound.isOff { off.append("sound disabled") } + if snapshot.badge.isOff { off.append("badge disabled") } + let raw = off.isEmpty ? nil : off.joined(separator: ", ") + + guard let title else { + return Row(check: .soundsAndBadges, status: .pass, title: Check.soundsAndBadges.name, detail: "Both on", raw: raw) + } + return Row(check: .soundsAndBadges, status: .warning, title: title, fix: .openNotificationSettings, raw: raw) + } + + static func registrationRow(_ registration: Registration, saved: RegistrationRecord?, now: Date, locale: Locale) -> Row { + let name = Check.registration.name + switch registration { + case .checking: + return Row(check: .registration, status: .checking, title: "Checking registration…", detail: "Contacting Flipcash", raw: "checking") + case .noAPNsToken: + return Row(check: .registration, status: .fail, title: "This phone isn't registered with Apple", fix: .retryRegistration, raw: "noAPNsToken") + case .finished(.ok): + return Row(check: .registration, status: .pass, title: name, detail: "Confirmed just now", raw: RegistrationResult.ok.rawValue) + case .finished(.invalidPushToken): + return Row(check: .registration, status: .fail, title: "Flipcash rejected this phone's push token", fix: .retryRegistrationDeletingToken, raw: RegistrationResult.invalidPushToken.rawValue) + case .finished(.transport): + let raw = RegistrationResult.transport.rawValue + if let okAt = saved?.lastRegistrationOkAt { + return Row(check: .registration, status: .warning, title: "Couldn't reach Flipcash", detail: "Last confirmed \(relative(okAt, to: now, locale: locale))", fix: .retryRegistration, raw: raw) + } + return Row(check: .registration, status: .fail, title: "Couldn't reach Flipcash", detail: "Check your connection", fix: .retryRegistration, raw: raw) + case .finished(.unknown): + return Row(check: .registration, status: .fail, title: "Registration failed", fix: .retryRegistration, raw: RegistrationResult.unknown.rawValue) + } + } + + static func summary(_ rows: [Row], hasChecked: Bool) -> Summary { + let fails = rows.filter { $0.status == .fail } + let warnings = rows.filter { $0.status == .warning } + // The second line waits for the first run to finish, then stays through later runs. + let isChecking = rows.contains { $0.status == .checking } + let checked = hasChecked || !isChecking ? "Checked just now" : nil + + if !fails.isEmpty { + let title = fails.contains { $0.check == .permission } + ? "Notifications are off" + : plural(fails.count, "thing to fix", "things to fix") + return Summary(status: .fail, title: title, detail: checked) + } + if !warnings.isEmpty { + return Summary(status: .warning, title: plural(warnings.count, "thing to check", "things to check"), detail: checked) + } + if isChecking { + return Summary(status: .checking, title: "Checking…", detail: checked) + } + return Summary(status: .pass, title: "Notifications are working", detail: checked) + } + + static func plural(_ count: Int, _ one: String, _ many: String) -> String { + "\(count) \(count == 1 ? one : many)" + } + + /// Under a minute reads "just now"; the formatter would say "0 minutes ago". + static func relative(_ date: Date, to now: Date, locale: Locale) -> String { + guard abs(now.timeIntervalSince(date)) >= 60 else { return "just now" } + let formatter = RelativeDateTimeFormatter() + formatter.locale = locale + formatter.unitsStyle = .full + return formatter.localizedString(for: date, relativeTo: now) + } +} + +// MARK: - Report - + +extension NotificationStatus { + + /// What the report says about the device and app, beyond the checks themselves. + public struct ReportContext: Sendable { + public var appVersion: String + public var osVersion: String + public var deviceModel: String + /// The last six characters of the token sent in the latest attempt, or `nil` when that + /// attempt fetched none. + public var tokenSuffix: String? + /// The last `didFailToRegisterForRemoteNotifications` error, if any. + public var lastAPNsError: String? + + public init(appVersion: String, osVersion: String, deviceModel: String, tokenSuffix: String?, lastAPNsError: String?) { + self.appVersion = appVersion + self.osVersion = osVersion + self.deviceModel = deviceModel + self.tokenSuffix = tokenSuffix + self.lastAPNsError = lastAPNsError + } + } + + /// The plain-text report for support. Lists every check, including the ones the page hides, + /// with the raw value behind each status. Never includes the full token or any account id. + public static func report( + snapshot: Snapshot, + registration: Registration, + saved: RegistrationRecord?, + context: ReportContext, + now: Date, + timeZone: TimeZone = .current + ) -> String { + let rows = [ + permissionRow(snapshot), + displayRow(snapshot), + soundsAndBadgesRow(snapshot), + registrationRow(registration, saved: saved, now: now, locale: Locale(identifier: "en_US_POSIX")), + ] + + var lines = [ + "Flipcash notification report", + "\(timestamp(now, timeZone: timeZone)) (\(utcOffset(timeZone, at: now)))", + "App \(context.appVersion) · iOS \(context.osVersion) · \(context.deviceModel)", + "", + ] + + for row in rows { + var raw = row.raw + if row.check == .registration { + switch registration { + case .checking: + raw = nil + case .noAPNsToken: + // This run saved nothing, so the record is from an older attempt. + break + case .finished(let result): + // `, ,