From 6ae49c28f1ed3b461aed935b8b5b29ad525904c0 Mon Sep 17 00:00:00 2001 From: Sasha Weiss Date: Tue, 17 Feb 2026 09:50:31 -0800 Subject: [PATCH] Implement interactions, handle edge cases in CLVBackupProgressView --- Signal/AppLaunch/AppEnvironment.swift | 10 +- .../BackupAttachmentDownloadTracker.swift | 76 ++-- .../BackupAttachmentUploadTracker.swift | 87 ++--- Signal/Backups/BackupDisablingManager.swift | 10 + .../BackupSettingsViewController.swift | 68 +++- Signal/Symbols.xcassets/eye/Contents.json | 6 + .../eye/eye-slash.imageset/Contents.json | 16 + .../eye/eye-slash.imageset/eye-slash.pdf | Bin 0 -> 3974 bytes .../Chat List/CLVTableDataSource.swift | 66 ++-- ...ontroller+BackupDownloadProgressView.swift | 14 +- ...istViewController+BackupProgressView.swift | 352 +++++++++++++----- .../BackupAttachmentDownloadTrackerTest.swift | 68 ++-- .../BackupAttachmentUploadTrackerTest.swift | 49 ++- ...AttachmentDownloadQueueStatusManager.swift | 90 +++-- ...upAttachmentUploadQueueStatusManager.swift | 37 +- .../BackupExportJobStore.swift | 12 +- SignalServiceKit/Environment/AppSetup.swift | 2 +- .../Util/NSNotificationCenter+OWS.swift | 17 + 18 files changed, 633 insertions(+), 347 deletions(-) create mode 100644 Signal/Symbols.xcassets/eye/Contents.json create mode 100644 Signal/Symbols.xcassets/eye/eye-slash.imageset/Contents.json create mode 100644 Signal/Symbols.xcassets/eye/eye-slash.imageset/eye-slash.pdf diff --git a/Signal/AppLaunch/AppEnvironment.swift b/Signal/AppLaunch/AppEnvironment.swift index 972508e350..b481e86b57 100644 --- a/Signal/AppLaunch/AppEnvironment.swift +++ b/Signal/AppLaunch/AppEnvironment.swift @@ -58,9 +58,11 @@ public class AppEnvironment: NSObject { let authCredentialStore = AuthCredentialStore() let backupAttachmentUploadEraStore = BackupAttachmentUploadEraStore() let backupCDNCredentialStore = BackupCDNCredentialStore() - let backupNonceStore = BackupNonceMetadataStore() + let backupExportJobStore = BackupExportJobStore() let backupSettingsStore = BackupSettingsStore() + let backupNonceStore = BackupNonceMetadataStore() let backupSubscriptionIssueStore = BackupSubscriptionIssueStore() + let clvBackupProgressViewStore = CLVBackupProgressView.Store() let badgeManager = BadgeManager( badgeCountFetcher: DependenciesBridge.shared.badgeCountFetcher, @@ -76,11 +78,11 @@ public class AppEnvironment: NSObject { db: DependenciesBridge.shared.db, ) self.backupAttachmentDownloadTracker = BackupAttachmentDownloadTracker( - backupAttachmentDownloadQueueStatusReporter: DependenciesBridge.shared.backupAttachmentDownloadQueueStatusManager, + backupAttachmentDownloadQueueStatusManager: DependenciesBridge.shared.backupAttachmentDownloadQueueStatusManager, backupAttachmentDownloadProgress: DependenciesBridge.shared.backupAttachmentDownloadProgress, ) self.backupAttachmentUploadTracker = BackupAttachmentUploadTracker( - backupAttachmentUploadQueueStatusReporter: DependenciesBridge.shared.backupAttachmentUploadQueueStatusManager, + backupAttachmentUploadQueueStatusManager: DependenciesBridge.shared.backupAttachmentUploadQueueStatusManager, backupAttachmentUploadProgress: DependenciesBridge.shared.backupAttachmentUploadProgress, ) self.badgeManager = badgeManager @@ -90,9 +92,11 @@ public class AppEnvironment: NSObject { backupAttachmentCoordinator: DependenciesBridge.shared.backupAttachmentCoordinator, backupAttachmentDownloadQueueStatusManager: DependenciesBridge.shared.backupAttachmentDownloadQueueStatusManager, backupCDNCredentialStore: backupCDNCredentialStore, + backupExportJobStore: backupExportJobStore, backupKeyService: DependenciesBridge.shared.backupKeyService, backupPlanManager: DependenciesBridge.shared.backupPlanManager, backupSettingsStore: backupSettingsStore, + clvBackupProgressViewStore: clvBackupProgressViewStore, db: DependenciesBridge.shared.db, tsAccountManager: DependenciesBridge.shared.tsAccountManager, ) diff --git a/Signal/Backups/BackupAttachmentDownloadTracker.swift b/Signal/Backups/BackupAttachmentDownloadTracker.swift index b38d09f448..6cd38f2ded 100644 --- a/Signal/Backups/BackupAttachmentDownloadTracker.swift +++ b/Signal/Backups/BackupAttachmentDownloadTracker.swift @@ -8,7 +8,7 @@ import SignalServiceKit /// Manages async streams of `DownloadUpdate`s, which represent the state and /// progress of Backup Attachment downloads. /// -/// - SeeAlso `BackupAttachmentDownloadQueueStatusReporter` +/// - SeeAlso `BackupAttachmentDownloadQueueStatusManager` /// - SeeAlso `BackupAttachmentDownloadProgress` /// /// - SeeAlso ``BackupAttachmentUploadTracker`` @@ -23,7 +23,6 @@ final class BackupAttachmentDownloadTracker { case pausedNeedsWifi case pausedNeedsInternet case outOfDiskSpace(bytesRequired: UInt64) - case appBackgrounded case notRegisteredAndReady } @@ -51,21 +50,21 @@ final class BackupAttachmentDownloadTracker { } } - private let backupAttachmentDownloadQueueStatusReporter: BackupAttachmentDownloadQueueStatusReporter + private let backupAttachmentDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusManager private let backupAttachmentDownloadProgress: BackupAttachmentDownloadProgress init( - backupAttachmentDownloadQueueStatusReporter: BackupAttachmentDownloadQueueStatusReporter, + backupAttachmentDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusManager, backupAttachmentDownloadProgress: BackupAttachmentDownloadProgress, ) { - self.backupAttachmentDownloadQueueStatusReporter = backupAttachmentDownloadQueueStatusReporter + self.backupAttachmentDownloadQueueStatusManager = backupAttachmentDownloadQueueStatusManager self.backupAttachmentDownloadProgress = backupAttachmentDownloadProgress } func updates() -> AsyncStream { return AsyncStream { continuation in let tracker = Tracker( - backupAttachmentDownloadQueueStatusReporter: backupAttachmentDownloadQueueStatusReporter, + backupAttachmentDownloadQueueStatusManager: backupAttachmentDownloadQueueStatusManager, backupAttachmentDownloadProgress: backupAttachmentDownloadProgress, continuation: continuation, ) @@ -102,16 +101,16 @@ private class Tracker { let streamContinuation: AsyncStream.Continuation } - private let backupAttachmentDownloadQueueStatusReporter: BackupAttachmentDownloadQueueStatusReporter + private let backupAttachmentDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusManager private let backupAttachmentDownloadProgress: BackupAttachmentDownloadProgress private let state: SeriallyAccessedState init( - backupAttachmentDownloadQueueStatusReporter: BackupAttachmentDownloadQueueStatusReporter, + backupAttachmentDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusManager, backupAttachmentDownloadProgress: BackupAttachmentDownloadProgress, continuation: AsyncStream.Continuation, ) { - self.backupAttachmentDownloadQueueStatusReporter = backupAttachmentDownloadQueueStatusReporter + self.backupAttachmentDownloadQueueStatusManager = backupAttachmentDownloadQueueStatusManager self.backupAttachmentDownloadProgress = backupAttachmentDownloadProgress self.state = SeriallyAccessedState(State(streamContinuation: continuation)) } @@ -163,13 +162,13 @@ private class Tracker { return ( observer, - backupAttachmentDownloadQueueStatusReporter.currentStatus(for: .fullsize), + backupAttachmentDownloadQueueStatusManager.beginObservingIfNecessary(for: .fullsize), ) } @MainActor private func handleDownloadQueueStatusUpdate() { - let queueStatus = backupAttachmentDownloadQueueStatusReporter.currentStatus(for: .fullsize) + let queueStatus = backupAttachmentDownloadQueueStatusManager.currentStatus(for: .fullsize) state.enqueueUpdate { [self] _state in _state.lastReportedDownloadQueueStatus = queueStatus @@ -204,33 +203,34 @@ private class Tracker { return } - let downloadUpdateState: DownloadUpdate.State = { - switch lastReportedDownloadQueueStatus { - case .empty: - return .empty - case .running: - return .running - case .suspended: - return .suspended - case .noWifiReachability: - return .pausedNeedsWifi - case .noReachability: - return .pausedNeedsInternet - case .lowBattery: - return .pausedLowBattery - case .lowPowerMode: - return .pausedLowPowerMode - case .lowDiskSpace: - return .outOfDiskSpace(bytesRequired: max( - lastReportedDownloadProgress.remainingUnitCount, - backupAttachmentDownloadQueueStatusReporter.minimumRequiredDiskSpaceToCompleteDownloads(), - )) - case .notRegisteredAndReady: - return .notRegisteredAndReady - case .appBackgrounded: - return .appBackgrounded - } - }() + let downloadUpdateState: DownloadUpdate.State + switch lastReportedDownloadQueueStatus { + case .appBackgrounded: + // Don't emit an update when the app is backgrounded, so callers are + // left with the last update before backgrounding. + return + case .empty: + downloadUpdateState = .empty + case .running: + downloadUpdateState = .running + case .suspended: + downloadUpdateState = .suspended + case .noWifiReachability: + downloadUpdateState = .pausedNeedsWifi + case .noReachability: + downloadUpdateState = .pausedNeedsInternet + case .lowBattery: + downloadUpdateState = .pausedLowBattery + case .lowPowerMode: + downloadUpdateState = .pausedLowPowerMode + case .lowDiskSpace: + downloadUpdateState = .outOfDiskSpace(bytesRequired: max( + lastReportedDownloadProgress.remainingUnitCount, + backupAttachmentDownloadQueueStatusManager.minimumRequiredDiskSpaceToCompleteDownloads(), + )) + case .notRegisteredAndReady: + downloadUpdateState = .notRegisteredAndReady + } streamContinuation.yield(DownloadUpdate( state: downloadUpdateState, diff --git a/Signal/Backups/BackupAttachmentUploadTracker.swift b/Signal/Backups/BackupAttachmentUploadTracker.swift index 8fd746ec29..0bb190948f 100644 --- a/Signal/Backups/BackupAttachmentUploadTracker.swift +++ b/Signal/Backups/BackupAttachmentUploadTracker.swift @@ -9,7 +9,7 @@ import SwiftUI /// Manages async streams of `UploadUpdate`s, which represent the state and /// progress of Backup Attachment uploads. /// -/// - SeeAlso `BackupAttachmentUploadQueueStatusReporter` +/// - SeeAlso `BackupAttachmentUploadQueueStatusManager` /// - SeeAlso `BackupAttachmentUploadProgress` /// /// - SeeAlso ``BackupAttachmentDownloadTracker`` @@ -17,10 +17,14 @@ final class BackupAttachmentUploadTracker { struct UploadUpdate: Equatable { enum State { case running + case suspended + case empty + case notRegisteredAndReady case pausedLowBattery case pausedLowPowerMode case pausedNeedsWifi case pausedNeedsInternet + case hasConsumedMediaTierCapacity } let state: State @@ -47,21 +51,21 @@ final class BackupAttachmentUploadTracker { } } - private let backupAttachmentUploadQueueStatusReporter: BackupAttachmentUploadQueueStatusReporter + private let backupAttachmentUploadQueueStatusManager: BackupAttachmentUploadQueueStatusManager private let backupAttachmentUploadProgress: BackupAttachmentUploadProgress init( - backupAttachmentUploadQueueStatusReporter: BackupAttachmentUploadQueueStatusReporter, + backupAttachmentUploadQueueStatusManager: BackupAttachmentUploadQueueStatusManager, backupAttachmentUploadProgress: BackupAttachmentUploadProgress, ) { - self.backupAttachmentUploadQueueStatusReporter = backupAttachmentUploadQueueStatusReporter + self.backupAttachmentUploadQueueStatusManager = backupAttachmentUploadQueueStatusManager self.backupAttachmentUploadProgress = backupAttachmentUploadProgress } - func updates() -> AsyncStream { + func updates() -> AsyncStream { return AsyncStream { continuation in let tracker = Tracker( - backupAttachmentUploadQueueStatusReporter: backupAttachmentUploadQueueStatusReporter, + backupAttachmentUploadQueueStatusManager: backupAttachmentUploadQueueStatusManager, backupAttachmentUploadProgress: backupAttachmentUploadProgress, continuation: continuation, ) @@ -95,19 +99,19 @@ private class Tracker { var uploadQueueStatusObserver: NotificationCenter.Observer? var uploadProgressObserver: BackupAttachmentUploadProgress.Observer? - let streamContinuation: AsyncStream.Continuation + let streamContinuation: AsyncStream.Continuation } - private let backupAttachmentUploadQueueStatusReporter: BackupAttachmentUploadQueueStatusReporter + private let backupAttachmentUploadQueueStatusManager: BackupAttachmentUploadQueueStatusManager private let backupAttachmentUploadProgress: BackupAttachmentUploadProgress private let state: SeriallyAccessedState init( - backupAttachmentUploadQueueStatusReporter: BackupAttachmentUploadQueueStatusReporter, + backupAttachmentUploadQueueStatusManager: BackupAttachmentUploadQueueStatusManager, backupAttachmentUploadProgress: BackupAttachmentUploadProgress, - continuation: AsyncStream.Continuation, + continuation: AsyncStream.Continuation, ) { - self.backupAttachmentUploadQueueStatusReporter = backupAttachmentUploadQueueStatusReporter + self.backupAttachmentUploadQueueStatusManager = backupAttachmentUploadQueueStatusManager self.backupAttachmentUploadProgress = backupAttachmentUploadProgress self.state = SeriallyAccessedState(State( streamContinuation: continuation, @@ -145,14 +149,14 @@ private class Tracker { guard let self else { return } handleQueueStatusUpdate( - backupAttachmentUploadQueueStatusReporter.currentStatus(for: .fullsize), + backupAttachmentUploadQueueStatusManager.currentStatus(for: .fullsize), ) } // Now that we're observing updates, handle the initial value as if we'd // just gotten it in an update. handleQueueStatusUpdate( - backupAttachmentUploadQueueStatusReporter.currentStatus(for: .fullsize), + backupAttachmentUploadQueueStatusManager.beginObservingIfNecessary(for: .fullsize), ) return uploadQueueStatusObserver @@ -213,38 +217,35 @@ private class Tracker { return } - guard lastReportedUploadProgress.totalUnitCount > 0 else { - // We have no meaningful progress to report on. + let uploadUpdateState: UploadUpdate.State + switch lastReportedUploadQueueStatus { + case .appBackgrounded: + // Don't emit an update when the app is backgrounded, so callers are + // left with the last update before backgrounding. return + case .running: + uploadUpdateState = .running + case .suspended: + uploadUpdateState = .suspended + case .empty: + uploadUpdateState = .empty + case .notRegisteredAndReady: + uploadUpdateState = .notRegisteredAndReady + case .noReachability: + uploadUpdateState = .pausedNeedsInternet + case .noWifiReachability: + uploadUpdateState = .pausedNeedsWifi + case .lowBattery: + uploadUpdateState = .pausedLowBattery + case .lowPowerMode: + uploadUpdateState = .pausedLowPowerMode + case .hasConsumedMediaTierCapacity: + uploadUpdateState = .hasConsumedMediaTierCapacity } - let uploadUpdateState: UploadUpdate.State? = { - switch lastReportedUploadQueueStatus { - case .empty: - return nil - case .notRegisteredAndReady, .appBackgrounded, .suspended: - return nil - case .running: - return .running - case .noReachability: - return .pausedNeedsInternet - case .noWifiReachability: - return .pausedNeedsWifi - case .lowBattery: - return .pausedLowBattery - case .lowPowerMode: - return .pausedLowPowerMode - case .hasConsumedMediaTierCapacity: - // This gets bubbled up via other mechanisms; to the UI - // this upload state doesn't show a bar so its nil. - return nil - } - }() - - if let uploadUpdateState { - streamContinuation.yield(UploadUpdate(state: uploadUpdateState, progress: lastReportedUploadProgress)) - } else { - streamContinuation.yield(nil) - } + streamContinuation.yield(UploadUpdate( + state: uploadUpdateState, + progress: lastReportedUploadProgress, + )) } } diff --git a/Signal/Backups/BackupDisablingManager.swift b/Signal/Backups/BackupDisablingManager.swift index f41e8ce148..2ce2cb2b09 100644 --- a/Signal/Backups/BackupDisablingManager.swift +++ b/Signal/Backups/BackupDisablingManager.swift @@ -24,9 +24,11 @@ final class BackupDisablingManager { private let backupAttachmentCoordinator: BackupAttachmentCoordinator private let backupAttachmentDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusManager private let backupCDNCredentialStore: BackupCDNCredentialStore + private let backupExportJobStore: BackupExportJobStore private let backupKeyService: BackupKeyService private let backupPlanManager: BackupPlanManager private let backupSettingsStore: BackupSettingsStore + private let clvBackupProgressViewStore: CLVBackupProgressView.Store private let db: DB private let kvStore: KeyValueStore private let logger: PrefixedLogger @@ -39,9 +41,11 @@ final class BackupDisablingManager { backupAttachmentCoordinator: BackupAttachmentCoordinator, backupAttachmentDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusManager, backupCDNCredentialStore: BackupCDNCredentialStore, + backupExportJobStore: BackupExportJobStore, backupKeyService: BackupKeyService, backupPlanManager: BackupPlanManager, backupSettingsStore: BackupSettingsStore, + clvBackupProgressViewStore: CLVBackupProgressView.Store, db: DB, tsAccountManager: TSAccountManager, ) { @@ -50,9 +54,11 @@ final class BackupDisablingManager { self.backupAttachmentCoordinator = backupAttachmentCoordinator self.backupAttachmentDownloadQueueStatusManager = backupAttachmentDownloadQueueStatusManager self.backupCDNCredentialStore = backupCDNCredentialStore + self.backupExportJobStore = backupExportJobStore self.backupKeyService = backupKeyService self.backupPlanManager = backupPlanManager self.backupSettingsStore = backupSettingsStore + self.clvBackupProgressViewStore = clvBackupProgressViewStore self.db = db self.kvStore = KeyValueStore(collection: "BackupDisablingManager") self.logger = PrefixedLogger(prefix: "[Backups]") @@ -212,6 +218,10 @@ final class BackupDisablingManager { // Wipe these, which are now outdated. backupSettingsStore.resetLastBackupDetails(tx: tx) backupSettingsStore.resetShouldAllowBackupUploadsOnCellular(tx: tx) + backupExportJobStore.wipe(tx: tx) + + // We want to re-show this if we reenable Backups later. + clvBackupProgressViewStore.setIsHidden(false, tx: tx) // With Backups disabled, these credentials are no longer valid // and are no longer safe to use. diff --git a/Signal/Backups/BackupSettingsViewController.swift b/Signal/Backups/BackupSettingsViewController.swift index 785de15bd2..c6e3a9c42f 100644 --- a/Signal/Backups/BackupSettingsViewController.swift +++ b/Signal/Backups/BackupSettingsViewController.swift @@ -264,7 +264,7 @@ class BackupSettingsViewController: let downloadViewUpdateState: BackupAttachmentDownloadProgressView.DownloadUpdate.State switch downloadTrackerUpdate.state { - case .empty, .appBackgrounded, .notRegisteredAndReady: + case .empty, .notRegisteredAndReady: viewModel.latestBackupAttachmentDownloadUpdate = nil return false case .running: @@ -296,10 +296,36 @@ class BackupSettingsViewController: await deviceSleepManager.manageBlockForUpdateStream( backupAttachmentUploadTracker.updates(), label: "BackupSettings.BackupUploads", - ) { [weak self] uploadUpdate in + ) { [weak self] uploadTrackerUpdate in guard let self else { return false } - viewModel.latestBackupAttachmentUploadUpdate = uploadUpdate - return uploadUpdate != nil + + let uploadViewUpdateState: BackupAttachmentUploadProgressView.UploadUpdate.State + switch uploadTrackerUpdate.state { + case .empty, + .suspended, + .notRegisteredAndReady, + .hasConsumedMediaTierCapacity: + viewModel.latestBackupAttachmentUploadUpdate = nil + return false + case .running: + uploadViewUpdateState = .running + case .pausedLowBattery: + uploadViewUpdateState = .pausedLowBattery + case .pausedLowPowerMode: + uploadViewUpdateState = .pausedLowPowerMode + case .pausedNeedsWifi: + uploadViewUpdateState = .pausedNeedsWifi + case .pausedNeedsInternet: + uploadViewUpdateState = .pausedNeedsInternet + } + + viewModel.latestBackupAttachmentUploadUpdate = BackupAttachmentUploadProgressView.UploadUpdate( + state: uploadViewUpdateState, + bytesUploaded: uploadTrackerUpdate.bytesUploaded, + totalBytesToUpload: uploadTrackerUpdate.totalBytesToUpload, + percentageUploaded: uploadTrackerUpdate.percentageUploaded, + ) + return true } }, Task.detached { [weak self] in @@ -1455,7 +1481,7 @@ private class BackupSettingsViewModel: ObservableObject { @Published var latestBackupExportProgressUpdate: OWSSequentialProgress? @Published var latestBackupAttachmentDownloadUpdate: BackupAttachmentDownloadProgressView.DownloadUpdate? - @Published var latestBackupAttachmentUploadUpdate: BackupAttachmentUploadTracker.UploadUpdate? + @Published var latestBackupAttachmentUploadUpdate: BackupAttachmentUploadProgressView.UploadUpdate? @Published var lastBackupDetails: BackupSettingsStore.LastBackupDetails? @Published var shouldAllowBackupUploadsOnCellular: Bool @@ -1482,7 +1508,7 @@ private class BackupSettingsViewModel: ObservableObject { failedToDisableBackupsRemotely: Bool, latestBackupExportProgressUpdate: OWSSequentialProgress?, latestBackupAttachmentDownloadUpdate: BackupAttachmentDownloadProgressView.DownloadUpdate?, - latestBackupAttachmentUploadUpdate: BackupAttachmentUploadTracker.UploadUpdate?, + latestBackupAttachmentUploadUpdate: BackupAttachmentUploadProgressView.UploadUpdate?, lastBackupDetails: BackupSettingsStore.LastBackupDetails?, shouldAllowBackupUploadsOnCellular: Bool, mediaTierCapacityOverflow: UInt64?, @@ -2077,7 +2103,7 @@ private struct BackupExportProgressView: View { } let latestExportProgressUpdate: OWSSequentialProgress - let latestAttachmentUploadUpdate: BackupAttachmentUploadTracker.UploadUpdate? + let latestAttachmentUploadUpdate: BackupAttachmentUploadProgressView.UploadUpdate? private var progressBarState: ProgressBarState { switch latestExportProgressUpdate.currentStep { @@ -2317,8 +2343,8 @@ private struct PulsingProgressBar: View { // MARK: - private struct BackupAttachmentDownloadProgressView: View { - struct DownloadUpdate { - enum State { + struct DownloadUpdate: Equatable { + enum State: Equatable { case running case suspended case pausedLowBattery @@ -2477,7 +2503,22 @@ private struct BackupAttachmentDownloadProgressView: View { // MARK: - private struct BackupAttachmentUploadProgressView: View { - let latestUploadUpdate: BackupAttachmentUploadTracker.UploadUpdate + struct UploadUpdate: Equatable { + enum State { + case running + case pausedLowBattery + case pausedLowPowerMode + case pausedNeedsWifi + case pausedNeedsInternet + } + + let state: State + let bytesUploaded: UInt64 + let totalBytesToUpload: UInt64 + let percentageUploaded: Float + } + + let latestUploadUpdate: UploadUpdate var body: some View { VStack(alignment: .leading) { @@ -2495,7 +2536,7 @@ private struct BackupAttachmentUploadProgressView: View { } static func subtitleText( - uploadUpdate: BackupAttachmentUploadTracker.UploadUpdate?, + uploadUpdate: BackupAttachmentUploadProgressView.UploadUpdate?, ) -> String { guard let uploadUpdate else { return String(OWSLocalizedString( @@ -3025,7 +3066,7 @@ private extension BackupSettingsViewModel { failedToDisableBackupsRemotely: Bool = false, latestBackupExportProgressUpdate: OWSSequentialProgress? = nil, latestBackupAttachmentDownloadUpdateState: BackupAttachmentDownloadProgressView.DownloadUpdate.State? = nil, - latestBackupAttachmentUploadUpdateState: BackupAttachmentUploadTracker.UploadUpdate.State? = nil, + latestBackupAttachmentUploadUpdateState: BackupAttachmentUploadProgressView.UploadUpdate.State? = nil, mediaTierCapacityOverflow: UInt64? = nil, hasBackupFailed: Bool = false, isBackgroundAppRefreshDisabled: Bool = false, @@ -3074,10 +3115,11 @@ private extension BackupSettingsViewModel { ) }, latestBackupAttachmentUploadUpdate: latestBackupAttachmentUploadUpdateState.map { - BackupAttachmentUploadTracker.UploadUpdate( + BackupAttachmentUploadProgressView.UploadUpdate( state: $0, bytesUploaded: 400_000_000, totalBytesToUpload: 1_600_000_000, + percentageUploaded: 0.4 / 1.6, ) }, lastBackupDetails: BackupSettingsStore.LastBackupDetails( diff --git a/Signal/Symbols.xcassets/eye/Contents.json b/Signal/Symbols.xcassets/eye/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/Signal/Symbols.xcassets/eye/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Signal/Symbols.xcassets/eye/eye-slash.imageset/Contents.json b/Signal/Symbols.xcassets/eye/eye-slash.imageset/Contents.json new file mode 100644 index 0000000000..bef48b17df --- /dev/null +++ b/Signal/Symbols.xcassets/eye/eye-slash.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "eye-slash.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Signal/Symbols.xcassets/eye/eye-slash.imageset/eye-slash.pdf b/Signal/Symbols.xcassets/eye/eye-slash.imageset/eye-slash.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4818038a86b0c036d1e9e81c793058acb5f09fe0 GIT binary patch literal 3974 zcma)9d0bT09xrmKL9`M~1@t6^YFbklk z6NH)v!+O2mThH=VY0_YX%jLohCd_1dfgN7jY$bsjyp&oO3d4jVz_bz#t|oAm5+YI2 zRF#eZrW)NdDUeDm_-dU-PN7RBFeb+on3B-Kh&KX{KLNb398qe$DIdHgDg|sn)nGfr z=m!~8f2o8=;Dj6_9WxHfy&yR;CNUW+$4P^Da#WcXKsR_vF&V0p6Lh{?Jg&u4<(Rua zO!^?l$4(k0R$%_{M1`aUB%}^an`lHZnl=VyF@o=gDgUb~t0R=E@yJGVtb%TYr#!Zj zn&PgO%5*4MGgiq(aa=(qfLer=S{RpFMbmVIr$#jdAMuCB&8Z0gtqDdUC@D~bu%!sW z#8ShU64;?9ixhmBDja-~_k~8@5#z<-FgfHpC1qKpm$As+Bd~~COXe6nCa}q+!}K60xfH8x~KgLM(otj z#QXDJQmk9-E?PS~!Tqm=iQm4dK2{K`UaNjtF#VODGM@XXbVF}xY&X64?6ncUGHp%L zX-DBa&tV(4n|lf)8$UXEv34Lge@nUeu&nx%%4M|Ck-A&=e|p#(Q}AX~UDnH&W0FeS zy-TyoZN%$Gk~=G!_s<{cyBM@{-kht2TT2w%U+Q&TfTX+sdid(st!FoOcvmbhFOio0 zwI}fcEo1Yk@MTrYAFP{Rx()mMWd5bziq3@6Ca0k{+R)ic=57{Y1S;qLC>7yLdr zRNQ%`+c0JB{O6QIk?h!&Lx+Y3HK(7v#Mo=I`HtHc=-`<}`rP$1ZshhqmCU&7Y?D=T z|ENp*Z}T@*Z15bJ{+fHn*MCz%Y)ekboR~B1Ymu&o;P8-;U{s4qAu|6WDA}@$W7$R4 zS%^xm(uhIh#i%YGf;-pnbG*?Gx1+GZwN&8 z&6p!`dBsn&?Wb*=V}M^j@XNxHpU$1jx?qEUQNWq={o{kO%`Of9SX!lTSfF~uTkqdH zdbg`8u=iQkDDn7@>-w`5TXtkWDjU3Y^+&~nyI0x|yDoK}pKH53VBuHzGpBX_>;2?y z#8!+>^qZkJhA_F+^65< zZg1i~eK(2KwxsBKQB5xVaoCAS##Z)+Bf`i7t46{Gwa2ej$1VD?X=CVZE#L1z=9&W> z<{5#=9ns^LZ$@2dOIkCpQ`VW+cs^!xB&TD#{jvnb&hLkRjumwFN9f}uo=YANXE?gI z)-L#Wwa4y_A6&xkFn|7Tm)GyVwZnOKhUl@wTTZS? z&ddAadv|C@WeqLA9vsU5MtLdV>cimTdheXk85e78XI*&o{yg=@&sK4!+LRQD?XuAm z+cVV1nM>a)n&#~bDrNnUlRtk8+?|mIAwKWKeOyX;JEp8Ft?gicuqLlD z<<^bPqi&1qrpP{WF7sPcv2_|BebU}<#}iAi$n4X!`yS3c1;xM59%#*7w|u?xK6cG$ z*Ue)3lZ%-h3u|PByY8<1#L=!RDqm51C+yx;*A3;q9RnZV{4-rqHr?gHu9EO8o3`d2 znHg6(AZBO z+n_BGwzj3u*Lv4KPi(lt&Eh|a&`ROGsoc^R&A=6U*~I&vZgmHzG|-6Mo;8?9^N`YEKSH1et2(g z(*B*#X1aB~>XG+u-QxPHPd5~v@<*2)td4RSO#D6i!P@qdI~*Gw!ZIsHR`tD}vDNZvt6Y1^x4FKmG3<}~=6>p2nl$GKZ7l~?c8 zziDJ-r!YQ+2!lD+$q0U@TD`4LRRw8qY3?uyhY zqYe@)NU2m20L_eId^RCUqnWshFs(|bkziVgY?fwe6eh*dV3mPEl~%`s29a-Jm3*3iH;<;|DS^K zbYzUQL}Lab0?W4Kb8H2&c8;=$5?CxUhS%s3NqO>TXm-X(NAN<6#Rkw_dhbHQvSXd58pz93qLf$pax^=i2dAZH!Y0_}?X0 zrGaf$0WtumXbcGPr5Wp*{DpkkY!(}m*;!-=2qU$kw8;E?z)hVf^92<%LB?fI+K2cc zptQ&DLwqLVAwHbR``D~WcuZd(P#5Fzn7+P%&Pv5?93v6yVo zCCoYq4T{S#4UH^~Z~?eJZhl-}ZW4pT UIContextMenuConfiguration? { - AssertIsOnMainThread() - - guard let viewController = self.viewController else { - owsFailDebug("Missing viewController.") - return nil - } - guard viewController.canPresentPreview(fromIndexPath: indexPath) else { - return nil - } - guard let threadUniqueId = renderState.threadUniqueId(forIndexPath: indexPath) else { - return nil - } - - return UIContextMenuConfiguration( - identifier: threadUniqueId as NSString, - previewProvider: { [weak viewController] in - viewController?.createPreviewController(atIndexPath: indexPath) - }, - actionProvider: { _ in - // nil for now. But we may want to add options like "Pin" or "Mute" in the future + switch renderState.sections[indexPath.section].type { + case .pinned, + .unpinned: + guard + let chatListViewController = viewController, + chatListViewController.canPresentPreview(fromIndexPath: indexPath), + let threadUniqueId = renderState.threadUniqueId(forIndexPath: indexPath) + else { return nil - }, - ) + } + + return UIContextMenuConfiguration( + identifier: threadUniqueId as NSString, + previewProvider: { + chatListViewController.createPreviewController(atIndexPath: indexPath) + }, + ) + case .backupProgressView: + let contextMenuActions = viewState.backupProgressView.contextMenuActions() + + return UIContextMenuConfiguration(actionProvider: { _ in + return UIMenu(children: contextMenuActions) + }) + case .reminders, + .backupDownloadProgressView, + .archiveButton, + .inboxFilterFooter: + return nil + } } func tableView( _ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration, ) -> UITargetedPreview? { - AssertIsOnMainThread() - - guard let threadId = configuration.identifier as? String else { - owsFailDebug("Unexpected context menu configuration identifier") - return nil - } - guard let indexPath = renderState.indexPath(forUniqueId: threadId) else { - Logger.warn("No index path for threadId: \(threadId).") - return nil - } - guard tableView.window != nil else { - Logger.warn("Dismissing tableView not in view hierarchy") + guard + let threadId = configuration.identifier as? String, + let indexPath = renderState.indexPath(forUniqueId: threadId) + else { return nil } diff --git a/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+BackupDownloadProgressView.swift b/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+BackupDownloadProgressView.swift index 049bb97fcf..b42c5f1a84 100644 --- a/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+BackupDownloadProgressView.swift +++ b/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+BackupDownloadProgressView.swift @@ -45,7 +45,7 @@ public class CLVBackupDownloadProgressView { self.deviceSleepManager = deviceSleepManager backupAttachmentDownloadProgressView = BackupAttachmentDownloadProgressView( - backupAttachmentDownloadQueueStatusReporter: DependenciesBridge.shared.backupAttachmentDownloadQueueStatusManager, + backupAttachmentDownloadQueueStatusManager: DependenciesBridge.shared.backupAttachmentDownloadQueueStatusManager, backupAttachmentDownloadStore: backupAttachmentDownloadStore, backupSettingsStore: backupSettingsStore, db: db, @@ -191,7 +191,7 @@ public class CLVBackupDownloadProgressView { } switch latestDownloadUpdate.state { - case .suspended, .notRegisteredAndReady, .appBackgrounded: + case .suspended, .notRegisteredAndReady: return nil case .empty: if @@ -341,18 +341,18 @@ private class BackupAttachmentDownloadProgressView: UIView { } } - private let backupAttachmentDownloadQueueStatusReporter: BackupAttachmentDownloadQueueStatusReporter! + private let backupAttachmentDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusManager! private let backupAttachmentDownloadStore: BackupAttachmentDownloadStore! private let backupSettingsStore: BackupSettingsStore! private let db: DB! init( - backupAttachmentDownloadQueueStatusReporter: BackupAttachmentDownloadQueueStatusReporter, + backupAttachmentDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusManager, backupAttachmentDownloadStore: BackupAttachmentDownloadStore, backupSettingsStore: BackupSettingsStore, db: DB, ) { - self.backupAttachmentDownloadQueueStatusReporter = backupAttachmentDownloadQueueStatusReporter + self.backupAttachmentDownloadQueueStatusManager = backupAttachmentDownloadQueueStatusManager self.backupAttachmentDownloadStore = backupAttachmentDownloadStore self.backupSettingsStore = backupSettingsStore self.db = db @@ -369,7 +369,7 @@ private class BackupAttachmentDownloadProgressView: UIView { } fileprivate init(forPreview: (), state: State) { - self.backupAttachmentDownloadQueueStatusReporter = nil + self.backupAttachmentDownloadQueueStatusManager = nil self.backupAttachmentDownloadStore = nil self.backupSettingsStore = nil self.db = nil @@ -939,7 +939,7 @@ private class BackupAttachmentDownloadProgressView: UIView { action: { sheet in // Clear previous out of space errors, so they can try // again to download. - self.backupAttachmentDownloadQueueStatusReporter.checkAvailableDiskSpace( + self.backupAttachmentDownloadQueueStatusManager.checkAvailableDiskSpace( clearPreviousOutOfSpaceErrors: true, ) sheet.dismiss(animated: true) diff --git a/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+BackupProgressView.swift b/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+BackupProgressView.swift index 6b69c1723a..f35eeb152d 100644 --- a/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+BackupProgressView.swift +++ b/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+BackupProgressView.swift @@ -5,24 +5,70 @@ import PureLayout import SignalServiceKit +import SignalUI import UIKit -class CLVBackupProgressView { +private extension Notification.Name { + static let isHiddenDidChange = Notification.Name("CLVBackupProgressView.isHiddenDidChange") +} + +class CLVBackupProgressView: BackupProgressView.Delegate { + + struct Store { + private enum Keys { + static let isHidden = "isHidden" + static let earliestBackupDateToConsider = "earliestBackupDateToConsider" + } + + private let kvStore = NewKeyValueStore(collection: "CLVBackupProgressView") + + func isHidden(tx: DBReadTransaction) -> Bool { + return kvStore.fetchValue(Bool.self, forKey: Keys.isHidden, tx: tx) ?? false + } + + func setIsHidden(_ value: Bool, tx: DBWriteTransaction) { + kvStore.writeValue(value, forKey: Keys.isHidden, tx: tx) + + tx.addSyncCompletion { + NotificationCenter.default.postOnMainThread( + name: .isHiddenDidChange, + object: nil, + ) + } + } + + fileprivate func earliestBackupDateToConsider(tx: DBReadTransaction) -> Date? { + kvStore.fetchValue(Date.self, forKey: Keys.earliestBackupDateToConsider, tx: tx) + } + + fileprivate func setEarliestBackupDateToConsider(_ value: Date, tx: DBWriteTransaction) { + kvStore.writeValue(value, forKey: Keys.earliestBackupDateToConsider, tx: tx) + } + } private struct State { - var updateStreamTasks: [Task] = [] - var isVisible: Bool = false var deviceSleepBlock: DeviceSleepBlockObject? + var earliestBackupDateToConsider: Date = .distantFuture + var isHidden: Bool = false + var lastBackupDetails: BackupSettingsStore.LastBackupDetails? + + // nil if we've never yet gotten an update. .some(nil) if we have gotten + // an update, and that update was nil. + var lastExportJobProgressUpdate: OWSSequentialProgress?? var lastUploadTrackerUpdate: BackupAttachmentUploadTracker.UploadUpdate? - var lastExportJobUpdate: BackupExportJobRunnerUpdate? + + var updateStreamTasks: [Task] = [] } private let backupAttachmentUploadTracker: BackupAttachmentUploadTracker private let backupExportJobRunner: BackupExportJobRunner + private let backupSettingsStore: BackupSettingsStore + private let dateProvider: DateProvider private let db: DB private let deviceSleepManager: DeviceSleepManager + private let store: Store weak var chatListViewController: ChatListViewController? let backupProgressViewCell: UITableViewCell @@ -33,8 +79,11 @@ class CLVBackupProgressView { init() { self.backupAttachmentUploadTracker = AppEnvironment.shared.backupAttachmentUploadTracker self.backupExportJobRunner = DependenciesBridge.shared.backupExportJobRunner + self.backupSettingsStore = BackupSettingsStore() + self.dateProvider = { Date() } self.db = DependenciesBridge.shared.db self.deviceSleepManager = DependenciesBridge.shared.deviceSleepManager.owsFailUnwrap("Missing DeviceSleepManager!") + self.store = Store() self.backupProgressViewCell = UITableViewCell() self.backupProgressViewCell.backgroundColor = .Signal.background @@ -43,6 +92,7 @@ class CLVBackupProgressView { self.backupProgressViewCell.contentView.addSubview(self.backupProgressView) self.backupProgressView.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(hMargin: 12, vMargin: 12)) + self.backupProgressView.delegate = self } // MARK: - @@ -76,8 +126,41 @@ class CLVBackupProgressView { // MARK: - func startTracking() { + let storedEarliestBackupDateToConsider: Date? + let isHidden: Bool + let lastBackupDetails: BackupSettingsStore.LastBackupDetails? + ( + storedEarliestBackupDateToConsider, + isHidden, + lastBackupDetails, + ) = db.read { tx in + ( + store.earliestBackupDateToConsider(tx: tx), + store.isHidden(tx: tx), + backupSettingsStore.lastBackupDetails(tx: tx), + ) + } + + // As a one-time migration, store "now" as the earliest Backup date to + // consider. This avoids us showing the "Complete" state for users who + // already had Backups enabled and running when we introduced this view. + let earliestBackupDateToConsider: Date + if let storedEarliestBackupDateToConsider { + earliestBackupDateToConsider = storedEarliestBackupDateToConsider + } else { + earliestBackupDateToConsider = dateProvider() + db.write { tx in + store.setEarliestBackupDateToConsider(earliestBackupDateToConsider, tx: tx) + } + } + state.update { _state in guard _state.updateStreamTasks.isEmpty else { return } + + _state.earliestBackupDateToConsider = earliestBackupDateToConsider + _state.isHidden = isHidden + _state.lastBackupDetails = lastBackupDetails + _state.updateStreamTasks = _startTracking() } } @@ -89,7 +172,7 @@ class CLVBackupProgressView { guard let self else { return } state.update { _state in - _state.lastUploadTrackerUpdate = uploadTrackerUpdate + _state.lastUploadTrackerUpdate = .some(uploadTrackerUpdate) self.setViewStateForState(state: &_state) } } @@ -99,11 +182,46 @@ class CLVBackupProgressView { guard let self else { return } state.update { _state in - _state.lastExportJobUpdate = exportJobUpdate + switch exportJobUpdate { + case .progress(let progressUpdate): + _state.lastExportJobProgressUpdate = progressUpdate + case nil, .completion: + _state.lastExportJobProgressUpdate = .some(nil) + } self.setViewStateForState(state: &_state) } } }, + NotificationCenter.default.startTaskTrackingNotifications( + named: .lastBackupDetailsDidChange, + onNotification: { [weak self] in + guard let self else { return } + + let lastBackupDetails = db.read { tx in + self.backupSettingsStore.lastBackupDetails(tx: tx) + } + + state.update { _state in + _state.lastBackupDetails = lastBackupDetails + self.setViewStateForState(state: &_state) + } + }, + ), + NotificationCenter.default.startTaskTrackingNotifications( + named: .isHiddenDidChange, + onNotification: { [weak self] in + guard let self else { return } + + let isHidden = db.read { tx in + self.store.isHidden(tx: tx) + } + + state.update { _state in + _state.isHidden = isHidden + self.setViewStateForState(state: &_state) + } + }, + ), ] } @@ -135,37 +253,59 @@ class CLVBackupProgressView { } private func viewStateForState(state: State) -> BackupProgressView.ViewState? { - switch state.lastExportJobUpdate { - case .progress(let sequentialProgress): - switch sequentialProgress.currentStep { + guard + let lastExportJobProgressUpdate = state.lastExportJobProgressUpdate, + let lastUploadTrackerUpdate = state.lastUploadTrackerUpdate + else { + // Never show the view until we've received our initial updates. + return nil + } + + if state.isHidden { + return nil + } + + if let progressUpdate = lastExportJobProgressUpdate { + switch progressUpdate.currentStep { case .backupFileExport, .backupFileUpload: - let percentExportCompleted = sequentialProgress.progress(for: .backupFileExport)?.percentComplete ?? 0 - let percentUploadCompleted = sequentialProgress.progress(for: .backupFileUpload)?.percentComplete ?? 0 + let percentExportCompleted = progressUpdate.progress(for: .backupFileExport)?.percentComplete ?? 0 + let percentUploadCompleted = progressUpdate.progress(for: .backupFileUpload)?.percentComplete ?? 0 let percentComplete = (0.95 * percentExportCompleted) + (0.05 * percentUploadCompleted) return .backupFilePreparation(percentComplete: percentComplete) case .attachmentUpload, .attachmentProcessing: break } - case nil, .completion: - break } - if let lastUploadTrackerUpdate = state.lastUploadTrackerUpdate { - switch lastUploadTrackerUpdate.state { - case .running: - return .attachmentUploadRunning( - bytesUploaded: lastUploadTrackerUpdate.bytesUploaded, - totalBytesToUpload: lastUploadTrackerUpdate.totalBytesToUpload, - ) - case .pausedLowBattery: - return .attachmentUploadPausedLowBattery - case .pausedLowPowerMode: - return .attachmentUploadPausedLowPowerMode - case .pausedNeedsWifi: - return .attachmentUploadPausedNoWifi - case .pausedNeedsInternet: - return .attachmentUploadPausedNoInternet - } + switch lastUploadTrackerUpdate.state { + case .empty: + break + case .running: + return .attachmentUploadRunning( + bytesUploaded: lastUploadTrackerUpdate.bytesUploaded, + totalBytesToUpload: lastUploadTrackerUpdate.totalBytesToUpload, + ) + case .suspended, + .notRegisteredAndReady, + .hasConsumedMediaTierCapacity: + return nil + case .pausedLowBattery: + return .attachmentUploadPausedLowBattery + case .pausedLowPowerMode: + return .attachmentUploadPausedLowPowerMode + case .pausedNeedsWifi: + return .attachmentUploadPausedNoWifi + case .pausedNeedsInternet: + return .attachmentUploadPausedNoInternet + } + + // Check this after uploads, since we don't want to show "complete" + // until uploads are done even if we've made a Backup file. + if + let lastBackupDetails = state.lastBackupDetails, + lastBackupDetails.date > state.earliestBackupDateToConsider + { + return .complete } return nil @@ -181,7 +321,6 @@ class CLVBackupProgressView { case .attachmentUploadPausedLowBattery: false case .attachmentUploadPausedLowPowerMode: false case .complete: false - case .failed: false case nil: false } @@ -201,12 +340,102 @@ class CLVBackupProgressView { deviceSleepManager.removeBlock(blockObject: deviceSleepBlock) } } + + // MARK: - ExportProgressView.Delegate + + func didTapDismissButton() { + db.write { tx in + store.setIsHidden(true, tx: tx) + } + } + + func didTapPausedWifiResumeButton() { + let actionSheet = ActionSheetController( + title: "Resume Using Cellular Data?", + message: "Backing up your media using cellular data may result in data charges. Your backup may take a long time to upload, keep Signal open to avoid interruptions.", + ) + actionSheet.addAction(ActionSheetAction( + title: "Resume", + handler: { [self] _ in + db.write { tx in + backupSettingsStore.setShouldAllowBackupUploadsOnCellular(true, tx: tx) + } + }, + )) + actionSheet.addAction(ActionSheetAction( + title: "Later on Wi-Fi", + )) + + chatListViewController?.presentActionSheet(actionSheet) + } + + // MARK: - + + /// Actions to display in a context menu for the owning row in the table + /// view. + func contextMenuActions() -> [UIAction] { + let hideAction = UIAction(title: "Hide", image: .eyeSlash) { [self] _ in + db.write { tx in + store.setIsHidden(true, tx: tx) + } + chatListViewController?.presentToast(text: "View backup progress in Backup Settings") + } + + let cancelAction = UIAction(title: "Cancel backup", image: .xCircle) { [self] _ in + let actionSheet = ActionSheetController( + title: "Cancel Backup?", + message: "Canceling your backup will not delete your backup. You can resume your backup at any time from Backup Settings.", + ) + actionSheet.addAction(ActionSheetAction( + title: "Cancel Backup", + handler: { [self] _ in + // Cancel the BackupExportJob, and pause uploads. + backupExportJobRunner.cancelIfRunning() + db.write { + backupSettingsStore.setIsBackupUploadQueueSuspended(true, tx: $0) + } + chatListViewController?.presentToast(text: "Backup canceled") + }, + )) + actionSheet.addAction(ActionSheetAction( + title: "Continue Backup", + )) + chatListViewController?.presentActionSheet(actionSheet) + } + + switch backupProgressView.viewState { + case nil: + return [] + case .complete: + return [hideAction] + case .backupFilePreparation, + .attachmentUploadRunning, + .attachmentUploadPausedNoWifi, + .attachmentUploadPausedNoInternet, + .attachmentUploadPausedLowBattery, + .attachmentUploadPausedLowPowerMode: + return [hideAction, cancelAction] + } + } +} + +// MARK: - + +extension ChatListViewController { + func handleBackupProgressViewTapped() { + SignalApp.shared.showAppSettings(mode: .backups()) + } } // MARK: - private class BackupProgressView: UIView { + protocol Delegate: AnyObject { + func didTapDismissButton() + func didTapPausedWifiResumeButton() + } + enum ViewState: Equatable, Identifiable { case backupFilePreparation(percentComplete: Float) case attachmentUploadRunning(bytesUploaded: UInt64, totalBytesToUpload: UInt64) @@ -215,7 +444,6 @@ private class BackupProgressView: UIView { case attachmentUploadPausedLowBattery case attachmentUploadPausedLowPowerMode case complete - case failed var id: String { return switch self { @@ -226,7 +454,6 @@ private class BackupProgressView: UIView { case .attachmentUploadPausedLowBattery: "attachmentUploadPausedLowBattery" case .attachmentUploadPausedLowPowerMode: "attachmentUploadPausedLowPowerMode" case .complete: "complete" - case .failed: "failed" } } } @@ -265,7 +492,8 @@ private class BackupProgressView: UIView { private let trailingAccessoryPausedLowBatteryLabel = UILabel() private let trailingAccessoryPausedLowPowerModeLabel = UILabel() private let trailingAccessoryCompleteDismissButton = UIButton() - private let trailingAccessoryFailedDetailsButton = UIButton() + + weak var delegate: Delegate? init(viewState: ViewState?) { self.viewState = viewState @@ -311,7 +539,7 @@ private class BackupProgressView: UIView { trailingAccessoryPausedWifiResumeButton.titleLabel?.adjustsFontForContentSizeCategory = true trailingAccessoryPausedWifiResumeButton.addAction( UIAction { [weak self] _ in - self?.didTapPausedWifiResumeButton() + self?.delegate?.didTapPausedWifiResumeButton() }, for: .touchUpInside, ) @@ -322,7 +550,7 @@ private class BackupProgressView: UIView { trailingAccessoryCompleteDismissButton.tintColor = .Signal.secondaryLabel trailingAccessoryCompleteDismissButton.addAction( UIAction { [weak self] _ in - self?.didTapDismissButton() + self?.delegate?.didTapDismissButton() }, for: .touchUpInside, ) @@ -339,22 +567,6 @@ private class BackupProgressView: UIView { Self.configure(label: trailingAccessoryPausedLowPowerModeLabel, color: .Signal.secondaryLabel) trailingAccessoryPausedLowPowerModeLabel.text = "Low Power Mode…" - trailingAccessoryContainerView.addArrangedSubview(trailingAccessoryFailedDetailsButton) - trailingAccessoryFailedDetailsButton.translatesAutoresizingMaskIntoConstraints = false - trailingAccessoryFailedDetailsButton.setTitle( - "See Details", - for: .normal, - ) - trailingAccessoryFailedDetailsButton.setTitleColor(.Signal.label, for: .normal) - trailingAccessoryFailedDetailsButton.titleLabel?.font = .dynamicTypeSubheadline.semibold() - trailingAccessoryFailedDetailsButton.titleLabel?.adjustsFontForContentSizeCategory = true - trailingAccessoryFailedDetailsButton.addAction( - UIAction { [weak self] _ in - self?.didTapFailedDetailsButton() - }, - for: .touchUpInside, - ) - initializeConstraints() configureSubviewsForCurrentState() } @@ -380,9 +592,6 @@ private class BackupProgressView: UIView { case .complete: leadingAccessoryImageView.image = .checkCircle leadingAccessoryImageView.tintColor = .Signal.ultramarine - case .failed: - leadingAccessoryImageView.image = .errorCircle - leadingAccessoryImageView.tintColor = .Signal.orange } // Labels @@ -409,8 +618,6 @@ private class BackupProgressView: UIView { titleLabelText = "Backup paused" case .complete: titleLabelText = "Backup complete" - case .failed: - titleLabelText = "Backup failed" case nil: titleLabelText = "" } @@ -441,8 +648,6 @@ private class BackupProgressView: UIView { trailingAccessoryView = trailingAccessoryPausedLowPowerModeLabel case .complete: trailingAccessoryView = trailingAccessoryCompleteDismissButton - case .failed: - trailingAccessoryView = trailingAccessoryFailedDetailsButton case nil: trailingAccessoryView = nil } @@ -481,32 +686,6 @@ private class BackupProgressView: UIView { trailingAccessoryCompleteDismissButton.widthAnchor.constraint(equalToConstant: 24), ]) } - - // MARK: - - - private func didTapDismissButton() { - // TODO: Implement - print(#function) - } - - private func didTapPausedWifiResumeButton() { - // TODO: Implement - print(#function) - } - - private func didTapFailedDetailsButton() { - // TODO: Implement - print(#function) - } -} - -// MARK: - - -extension ChatListViewController { - func handleBackupProgressViewTapped() { - // TODO: Implement - print(#function) - } } // MARK: - @@ -573,11 +752,6 @@ private class BackupProgressViewPreviewViewController: UIViewController { return BackupProgressViewPreviewViewController(state: .complete) } -@available(iOS 17, *) -#Preview("Failed") { - return BackupProgressViewPreviewViewController(state: .failed) -} - @available(iOS 17, *) #Preview("Nil") { return BackupProgressViewPreviewViewController(state: nil) diff --git a/Signal/test/Backups/BackupAttachmentDownloadTrackerTest.swift b/Signal/test/Backups/BackupAttachmentDownloadTrackerTest.swift index 011c105376..1fc244cae8 100644 --- a/Signal/test/Backups/BackupAttachmentDownloadTrackerTest.swift +++ b/Signal/test/Backups/BackupAttachmentDownloadTrackerTest.swift @@ -19,10 +19,10 @@ final class BackupAttachmentDownloadTrackerTest: BackupAttachmentTrackerTest< @Test func testLaunchingWithQueuePopulated() async { let downloadProgress = MockAttachmentDownloadProgress(total: 4) - let downloadQueueStatusReporter = MockDownloadQueueStatusReporter(.running) + let downloadQueueStatusManager = MockDownloadQueueStatusManager(.running) let downloadTracker = BackupAttachmentDownloadTracker( - backupAttachmentDownloadQueueStatusReporter: downloadQueueStatusReporter, + backupAttachmentDownloadQueueStatusManager: downloadQueueStatusManager, backupAttachmentDownloadProgress: downloadProgress, ) @@ -42,7 +42,7 @@ final class BackupAttachmentDownloadTrackerTest: BackupAttachmentTrackerTest< ExpectedUpdate( update: DownloadUpdate(.running, downloaded: 4, total: 4), nextSteps: { - downloadQueueStatusReporter.currentStatusMock = .empty + downloadQueueStatusManager.currentStatusMock = .empty }, ), ExpectedUpdate( @@ -59,10 +59,10 @@ final class BackupAttachmentDownloadTrackerTest: BackupAttachmentTrackerTest< @Test func testQueueStartsSuspendedThenStartsRunning() async { let downloadProgress = MockAttachmentDownloadProgress(total: 4) - let downloadQueueStatusReporter = MockDownloadQueueStatusReporter(.suspended) + let downloadQueueStatusManager = MockDownloadQueueStatusManager(.suspended) let downloadTracker = BackupAttachmentDownloadTracker( - backupAttachmentDownloadQueueStatusReporter: downloadQueueStatusReporter, + backupAttachmentDownloadQueueStatusManager: downloadQueueStatusManager, backupAttachmentDownloadProgress: downloadProgress, ) @@ -70,7 +70,7 @@ final class BackupAttachmentDownloadTrackerTest: BackupAttachmentTrackerTest< ExpectedUpdate( update: DownloadUpdate(.suspended, downloaded: 0, total: 4), nextSteps: { - downloadQueueStatusReporter.currentStatusMock = .running + downloadQueueStatusManager.currentStatusMock = .running }, ), ExpectedUpdate( @@ -88,7 +88,7 @@ final class BackupAttachmentDownloadTrackerTest: BackupAttachmentTrackerTest< ExpectedUpdate( update: DownloadUpdate(.running, downloaded: 4, total: 4), nextSteps: { - downloadQueueStatusReporter.currentStatusMock = .empty + downloadQueueStatusManager.currentStatusMock = .empty }, ), ExpectedUpdate( @@ -107,10 +107,10 @@ final class BackupAttachmentDownloadTrackerTest: BackupAttachmentTrackerTest< @Test func testQueueRunsIntoLowStorage_remainingMoreThanMin() async { let downloadProgress = MockAttachmentDownloadProgress(precompleted: 50, total: 100) - let downloadQueueStatusReporter = MockDownloadQueueStatusReporter(.running, minimumRequiredDiskSpace: 10) + let downloadQueueStatusManager = MockDownloadQueueStatusManager(.running, minimumRequiredDiskSpace: 10) let downloadTracker = BackupAttachmentDownloadTracker( - backupAttachmentDownloadQueueStatusReporter: downloadQueueStatusReporter, + backupAttachmentDownloadQueueStatusManager: downloadQueueStatusManager, backupAttachmentDownloadProgress: downloadProgress, ) @@ -118,7 +118,7 @@ final class BackupAttachmentDownloadTrackerTest: BackupAttachmentTrackerTest< ExpectedUpdate( update: DownloadUpdate(.running, downloaded: 50, total: 100), nextSteps: { - downloadQueueStatusReporter.currentStatusMock = .lowDiskSpace + downloadQueueStatusManager.currentStatusMock = .lowDiskSpace }, ), ExpectedUpdate( @@ -137,10 +137,10 @@ final class BackupAttachmentDownloadTrackerTest: BackupAttachmentTrackerTest< @Test func testQueueRunsIntoLowStorage_remainingLessThanMin() async { let downloadProgress = MockAttachmentDownloadProgress(precompleted: 4, total: 12) - let downloadQueueStatusReporter = MockDownloadQueueStatusReporter(.running, minimumRequiredDiskSpace: 10) + let downloadQueueStatusManager = MockDownloadQueueStatusManager(.running, minimumRequiredDiskSpace: 10) let downloadTracker = BackupAttachmentDownloadTracker( - backupAttachmentDownloadQueueStatusReporter: downloadQueueStatusReporter, + backupAttachmentDownloadQueueStatusManager: downloadQueueStatusManager, backupAttachmentDownloadProgress: downloadProgress, ) @@ -148,7 +148,7 @@ final class BackupAttachmentDownloadTrackerTest: BackupAttachmentTrackerTest< ExpectedUpdate( update: DownloadUpdate(.running, downloaded: 4, total: 12), nextSteps: { - downloadQueueStatusReporter.currentStatusMock = .lowDiskSpace + downloadQueueStatusManager.currentStatusMock = .lowDiskSpace }, ), ExpectedUpdate( @@ -165,10 +165,10 @@ final class BackupAttachmentDownloadTrackerTest: BackupAttachmentTrackerTest< @Test func testTrackingStoppingAndReTracking() async { let downloadProgress = MockAttachmentDownloadProgress(total: 4) - let downloadQueueStatusReporter = MockDownloadQueueStatusReporter(.empty) + let downloadQueueStatusManager = MockDownloadQueueStatusManager(.empty) let downloadTracker = BackupAttachmentDownloadTracker( - backupAttachmentDownloadQueueStatusReporter: downloadQueueStatusReporter, + backupAttachmentDownloadQueueStatusManager: downloadQueueStatusManager, backupAttachmentDownloadProgress: downloadProgress, ) @@ -176,7 +176,7 @@ final class BackupAttachmentDownloadTrackerTest: BackupAttachmentTrackerTest< ExpectedUpdate( update: DownloadUpdate(.empty, downloaded: 0, total: 4), nextSteps: { - downloadQueueStatusReporter.currentStatusMock = .running + downloadQueueStatusManager.currentStatusMock = .running }, ), ExpectedUpdate( @@ -196,7 +196,7 @@ final class BackupAttachmentDownloadTrackerTest: BackupAttachmentTrackerTest< ExpectedUpdate( update: DownloadUpdate(.running, downloaded: 1, total: 1), nextSteps: { - downloadQueueStatusReporter.currentStatusMock = .empty + downloadQueueStatusManager.currentStatusMock = .empty }, ), ExpectedUpdate( @@ -210,10 +210,10 @@ final class BackupAttachmentDownloadTrackerTest: BackupAttachmentTrackerTest< @Test func testTrackingMultipleStreamInstances() async { let downloadProgress = MockAttachmentDownloadProgress(total: 1) - let downloadQueueStatusReporter = MockDownloadQueueStatusReporter(.empty) + let downloadQueueStatusManager = MockDownloadQueueStatusManager(.empty) let downloadTracker = BackupAttachmentDownloadTracker( - backupAttachmentDownloadQueueStatusReporter: downloadQueueStatusReporter, + backupAttachmentDownloadQueueStatusManager: downloadQueueStatusManager, backupAttachmentDownloadProgress: downloadProgress, ) @@ -221,7 +221,7 @@ final class BackupAttachmentDownloadTrackerTest: BackupAttachmentTrackerTest< ExpectedUpdate( update: DownloadUpdate(.empty, downloaded: 0, total: 1), nextSteps: { - downloadQueueStatusReporter.currentStatusMock = .running + downloadQueueStatusManager.currentStatusMock = .running }, ), ExpectedUpdate( @@ -233,7 +233,7 @@ final class BackupAttachmentDownloadTrackerTest: BackupAttachmentTrackerTest< ExpectedUpdate( update: DownloadUpdate(.running, downloaded: 1, total: 1), nextSteps: { - downloadQueueStatusReporter.currentStatusMock = .empty + downloadQueueStatusManager.currentStatusMock = .empty }, ), ExpectedUpdate( @@ -282,7 +282,7 @@ private class MockAttachmentDownloadProgress: BackupAttachmentDownloadProgressMo // MARK: - -private class MockDownloadQueueStatusReporter: BackupAttachmentDownloadQueueStatusReporter { +private class MockDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusManager { init( _ initialStatus: BackupAttachmentDownloadQueueStatus, minimumRequiredDiskSpace: UInt64 = 0, @@ -310,7 +310,7 @@ private class MockDownloadQueueStatusReporter: BackupAttachmentDownloadQueueStat return currentStatusMock } - func currentStatusAndToken(for mode: BackupAttachmentDownloadQueueMode) -> (SignalServiceKit.BackupAttachmentDownloadQueueStatus, any SignalServiceKit.BackupAttachmentDownloadQueueStatusToken) { + func currentStatusAndToken(for mode: BackupAttachmentDownloadQueueMode) -> (BackupAttachmentDownloadQueueStatus, BackupAttachmentDownloadQueueStatusToken) { switch mode { case .fullsize: break @@ -320,12 +320,32 @@ private class MockDownloadQueueStatusReporter: BackupAttachmentDownloadQueueStat return (currentStatusMock, MockBackupAttachmentDownloadQueueStatusManager.BackupAttachmentDownloadQueueStatusTokenMock()) } + func beginObservingIfNecessary(for mode: BackupAttachmentDownloadQueueMode) -> BackupAttachmentDownloadQueueStatus { + return currentStatusMock + } + + func jobDidExperienceError(_ error: any Error, token: any BackupAttachmentDownloadQueueStatusToken, mode: BackupAttachmentDownloadQueueMode) async -> BackupAttachmentDownloadQueueStatus? { + return nil + } + + func jobDidSucceed(token: any BackupAttachmentDownloadQueueStatusToken, mode: BackupAttachmentDownloadQueueMode) async { + // Do nothing + } + + func didEmptyQueue(for mode: BackupAttachmentDownloadQueueMode) { + // Do nothing + } + + func setIsMainAppAndActiveOverride(_ newValue: Bool) { + // Do nothing + } + nonisolated let minimumRequiredDiskSpaceMock: UInt64 func minimumRequiredDiskSpaceToCompleteDownloads() -> UInt64 { minimumRequiredDiskSpaceMock } func checkAvailableDiskSpace(clearPreviousOutOfSpaceErrors: Bool) { - // Nothing + // Do nothing } } diff --git a/Signal/test/Backups/BackupAttachmentUploadTrackerTest.swift b/Signal/test/Backups/BackupAttachmentUploadTrackerTest.swift index dff5930e68..cdfc5b3402 100644 --- a/Signal/test/Backups/BackupAttachmentUploadTrackerTest.swift +++ b/Signal/test/Backups/BackupAttachmentUploadTrackerTest.swift @@ -11,7 +11,7 @@ import Testing @MainActor @Suite(.serialized) final class BackupAttachmentUploadTrackerTest: BackupAttachmentTrackerTest< - BackupAttachmentUploadTracker.UploadUpdate?, + BackupAttachmentUploadTracker.UploadUpdate, > { typealias UploadUpdate = BackupAttachmentUploadTracker.UploadUpdate @@ -19,9 +19,9 @@ final class BackupAttachmentUploadTrackerTest: BackupAttachmentTrackerTest< @Test func testLaunchingWithQueuePopulated() async { let uploadProgress = MockAttachmentUploadProgress(total: 4) - let uploadQueueStatusReporter = MockUploadQueueStatusReporter(.running) + let uploadQueueStatusManager = MockUploadQueueStatusManager(.running) let uploadTracker = BackupAttachmentUploadTracker( - backupAttachmentUploadQueueStatusReporter: uploadQueueStatusReporter, + backupAttachmentUploadQueueStatusManager: uploadQueueStatusManager, backupAttachmentUploadProgress: uploadProgress, ) @@ -41,10 +41,13 @@ final class BackupAttachmentUploadTrackerTest: BackupAttachmentTrackerTest< ExpectedUpdate( update: UploadUpdate(.running, uploaded: 4, total: 4), nextSteps: { - uploadQueueStatusReporter.currentStatusMock = .empty + uploadQueueStatusManager.currentStatusMock = .empty }, ), - ExpectedUpdate(update: nil, nextSteps: {}), + ExpectedUpdate( + update: UploadUpdate(.empty, uploaded: 4, total: 4), + nextSteps: {}, + ), ] await runTest(updateStream: uploadTracker.updates(), expectedUpdates: expectedUpdates) @@ -55,9 +58,9 @@ final class BackupAttachmentUploadTrackerTest: BackupAttachmentTrackerTest< @Test func testTrackingStoppingAndReTracking() async { let uploadProgress = MockAttachmentUploadProgress(total: 4) - let uploadQueueStatusReporter = MockUploadQueueStatusReporter(.running) + let uploadQueueStatusManager = MockUploadQueueStatusManager(.running) let uploadTracker = BackupAttachmentUploadTracker( - backupAttachmentUploadQueueStatusReporter: uploadQueueStatusReporter, + backupAttachmentUploadQueueStatusManager: uploadQueueStatusManager, backupAttachmentUploadProgress: uploadProgress, ) @@ -79,11 +82,11 @@ final class BackupAttachmentUploadTrackerTest: BackupAttachmentTrackerTest< ExpectedUpdate( update: UploadUpdate(.running, uploaded: 1, total: 1), nextSteps: { - uploadQueueStatusReporter.currentStatusMock = .empty + uploadQueueStatusManager.currentStatusMock = .empty }, ), ExpectedUpdate( - update: nil, + update: UploadUpdate(.empty, uploaded: 1, total: 1), nextSteps: {}, ), ] @@ -93,9 +96,9 @@ final class BackupAttachmentUploadTrackerTest: BackupAttachmentTrackerTest< @Test func testTrackingMultipleStreamInstances() async { let uploadProgress = MockAttachmentUploadProgress(total: 1) - let uploadQueueStatusReporter = MockUploadQueueStatusReporter(.running) + let uploadQueueStatusManager = MockUploadQueueStatusManager(.running) let uploadTracker = BackupAttachmentUploadTracker( - backupAttachmentUploadQueueStatusReporter: uploadQueueStatusReporter, + backupAttachmentUploadQueueStatusManager: uploadQueueStatusManager, backupAttachmentUploadProgress: uploadProgress, ) @@ -109,11 +112,11 @@ final class BackupAttachmentUploadTrackerTest: BackupAttachmentTrackerTest< ExpectedUpdate( update: UploadUpdate(.running, uploaded: 1, total: 1), nextSteps: { - uploadQueueStatusReporter.currentStatusMock = .empty + uploadQueueStatusManager.currentStatusMock = .empty }, ), ExpectedUpdate( - update: nil, + update: UploadUpdate(.empty, uploaded: 1, total: 1), nextSteps: {}, ), ] @@ -157,7 +160,7 @@ private class MockAttachmentUploadProgress: BackupAttachmentUploadProgressMock { // MARK: - -private class MockUploadQueueStatusReporter: BackupAttachmentUploadQueueStatusReporter { +private class MockUploadQueueStatusManager: BackupAttachmentUploadQueueStatusManager { var currentStatusMock: BackupAttachmentUploadQueueStatus { didSet { NotificationCenter.default.postOnMainThread( @@ -172,12 +175,18 @@ private class MockUploadQueueStatusReporter: BackupAttachmentUploadQueueStatusRe } func currentStatus(for mode: BackupAttachmentUploadQueueMode) -> BackupAttachmentUploadQueueStatus { - switch mode { - case .fullsize: - break - case .thumbnail: - fatalError("Only use fullsize in these tests") - } return currentStatusMock } + + func beginObservingIfNecessary(for mode: BackupAttachmentUploadQueueMode) -> BackupAttachmentUploadQueueStatus { + return currentStatusMock + } + + func didEmptyQueue(for mode: BackupAttachmentUploadQueueMode) { + // Nothing + } + + func setIsMainAppAndActiveOverride(_ newValue: Bool) { + // Nothing + } } diff --git a/SignalServiceKit/Backups/Attachments/BackupAttachmentDownloadQueueStatusManager.swift b/SignalServiceKit/Backups/Attachments/BackupAttachmentDownloadQueueStatusManager.swift index 4f970ae36f..2804aa8adc 100644 --- a/SignalServiceKit/Backups/Attachments/BackupAttachmentDownloadQueueStatusManager.swift +++ b/SignalServiceKit/Backups/Attachments/BackupAttachmentDownloadQueueStatusManager.swift @@ -53,35 +53,6 @@ public extension Notification.Name { // MARK: - -/// Reports whether we are able to download Backup attachments, via various -/// consolidated inputs. -/// -/// `@MainActor`-isolated because most of the inputs are themselves isolated. -/// -/// - SeeAlso `BackupAttachmentDownloadTracker` -@MainActor -public protocol BackupAttachmentDownloadQueueStatusReporter { - func currentStatus(for mode: BackupAttachmentDownloadQueueMode) -> BackupAttachmentDownloadQueueStatus - - func currentStatusAndToken(for mode: BackupAttachmentDownloadQueueMode) -> (BackupAttachmentDownloadQueueStatus, BackupAttachmentDownloadQueueStatusToken) - - /// Synchronously returns the minimum required disk space for downloads. - nonisolated func minimumRequiredDiskSpaceToCompleteDownloads() -> UInt64 - - /// Check available disk space, optionally clearing in-memory state - /// regarding past "out of space" errors. - func checkAvailableDiskSpace(clearPreviousOutOfSpaceErrors: Bool) -} - -extension BackupAttachmentDownloadQueueStatusReporter { - fileprivate func notifyStatusDidChange(for mode: BackupAttachmentDownloadQueueMode) { - NotificationCenter.default.postOnMainThread( - name: .backupAttachmentDownloadQueueStatusDidChange(mode: mode), - object: nil, - ) - } -} - /// Grab one of these when starting a job; use it to mark success or failure /// This takes a (black box) snapshot of state when the download began so that /// when we respond to success or errors we apply them appropriately based @@ -90,10 +61,23 @@ public protocol BackupAttachmentDownloadQueueStatusToken {} // MARK: - -/// API for callers to manage the `StatusReporter` in response to relevant -/// external events. +/// Tracks and reports the status of the Backup attachment download queue. +/// +/// `@MainActor`-isolated because most of the inputs are themselves isolated. +/// +/// - SeeAlso `BackupAttachmentDownloadTracker` @MainActor -public protocol BackupAttachmentDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusReporter { +public protocol BackupAttachmentDownloadQueueStatusManager { + + /// The current status of the download queue. + /// - Important + /// Only returns meaningful values once `beginObservingIfNecessary` has been called. + func currentStatus(for mode: BackupAttachmentDownloadQueueMode) -> BackupAttachmentDownloadQueueStatus + + /// The current status of the download queue, and a token. + /// - Important + /// Only returns meaningful values once `beginObservingIfNecessary` has been called. + func currentStatusAndToken(for mode: BackupAttachmentDownloadQueueMode) -> (BackupAttachmentDownloadQueueStatus, BackupAttachmentDownloadQueueStatusToken) /// Begin observing status updates, if necessary. func beginObservingIfNecessary(for mode: BackupAttachmentDownloadQueueMode) -> BackupAttachmentDownloadQueueStatus @@ -112,6 +96,13 @@ public protocol BackupAttachmentDownloadQueueStatusManager: BackupAttachmentDown mode: BackupAttachmentDownloadQueueMode, ) async + /// Synchronously returns the minimum required disk space for downloads. + nonisolated func minimumRequiredDiskSpaceToCompleteDownloads() -> UInt64 + + /// Check available disk space, optionally clearing in-memory state + /// regarding past "out of space" errors. + func checkAvailableDiskSpace(clearPreviousOutOfSpaceErrors: Bool) + /// Call when the download queue is emptied. func didEmptyQueue(for mode: BackupAttachmentDownloadQueueMode) @@ -123,8 +114,6 @@ public protocol BackupAttachmentDownloadQueueStatusManager: BackupAttachmentDown @MainActor class BackupAttachmentDownloadQueueStatusManagerImpl: BackupAttachmentDownloadQueueStatusManager { - // MARK: - BackupAttachmentDownloadQueueStatusReporter - func currentStatus(for mode: BackupAttachmentDownloadQueueMode) -> BackupAttachmentDownloadQueueStatus { return state.asQueueStatus(mode: mode, dateProvider: dateProvider) } @@ -136,20 +125,6 @@ class BackupAttachmentDownloadQueueStatusManagerImpl: BackupAttachmentDownloadQu ) } - nonisolated func minimumRequiredDiskSpaceToCompleteDownloads() -> UInt64 { - return getRequiredDiskSpace() - } - - func checkAvailableDiskSpace(clearPreviousOutOfSpaceErrors: Bool) { - state.availableDiskSpace = getAvailableDiskSpace() - - if clearPreviousOutOfSpaceErrors { - state.downloadDidExperienceOutOfSpaceError = false - } - } - - // MARK: - BackupAttachmentDownloadQueueStatusManager - func beginObservingIfNecessary(for mode: BackupAttachmentDownloadQueueMode) -> BackupAttachmentDownloadQueueStatus { observeDeviceAndLocalStatesIfNecessary() return currentStatus(for: mode) @@ -209,6 +184,18 @@ class BackupAttachmentDownloadQueueStatusManagerImpl: BackupAttachmentDownloadQu } } + nonisolated func minimumRequiredDiskSpaceToCompleteDownloads() -> UInt64 { + return getRequiredDiskSpace() + } + + func checkAvailableDiskSpace(clearPreviousOutOfSpaceErrors: Bool) { + state.availableDiskSpace = getAvailableDiskSpace() + + if clearPreviousOutOfSpaceErrors { + state.downloadDidExperienceOutOfSpaceError = false + } + } + func setIsMainAppAndActiveOverride(_ newValue: Bool) { state.isMainAppAndActiveOverride = newValue } @@ -440,6 +427,13 @@ class BackupAttachmentDownloadQueueStatusManagerImpl: BackupAttachmentDownloadQu } } + private func notifyStatusDidChange(for mode: BackupAttachmentDownloadQueueMode) { + NotificationCenter.default.postOnMainThread( + name: .backupAttachmentDownloadQueueStatusDidChange(mode: mode), + object: nil, + ) + } + // MARK: State Observation private func observeDeviceAndLocalStatesIfNecessary() { diff --git a/SignalServiceKit/Backups/Attachments/BackupAttachmentUploadQueueStatusManager.swift b/SignalServiceKit/Backups/Attachments/BackupAttachmentUploadQueueStatusManager.swift index 0a9cd597be..b29b671cf8 100644 --- a/SignalServiceKit/Backups/Attachments/BackupAttachmentUploadQueueStatusManager.swift +++ b/SignalServiceKit/Backups/Attachments/BackupAttachmentUploadQueueStatusManager.swift @@ -47,32 +47,18 @@ public extension Notification.Name { // MARK: - -/// Reports whether we are able to upload Backup attachments, via various -/// consolidated inputs. +/// Tracks and reports the status of the Backup attachment upload queue. /// /// `@MainActor`-isolated because most of the inputs are themselves isolated. /// /// - SeeAlso `BackupAttachmentUploadTracker` @MainActor -public protocol BackupAttachmentUploadQueueStatusReporter { +public protocol BackupAttachmentUploadQueueStatusManager { + + /// The current status of the upload queue. + /// - Important + /// Only returns meaningful values once `beginObservingIfNecessary` has been called. func currentStatus(for mode: BackupAttachmentUploadQueueMode) -> BackupAttachmentUploadQueueStatus -} - -extension BackupAttachmentUploadQueueStatusReporter { - fileprivate func notifyStatusDidChange(for mode: BackupAttachmentUploadQueueMode) { - NotificationCenter.default.postOnMainThread( - name: .backupAttachmentUploadQueueStatusDidChange(for: mode), - object: nil, - ) - } -} - -// MARK: - - -/// API for callers to manage the `StatusReporter` in response to relevant -/// external events. -@MainActor -public protocol BackupAttachmentUploadQueueStatusManager: BackupAttachmentUploadQueueStatusReporter { /// Begin observing status updates, if necessary. func beginObservingIfNecessary(for mode: BackupAttachmentUploadQueueMode) -> BackupAttachmentUploadQueueStatus @@ -88,14 +74,10 @@ public protocol BackupAttachmentUploadQueueStatusManager: BackupAttachmentUpload @MainActor class BackupAttachmentUploadQueueStatusManagerImpl: BackupAttachmentUploadQueueStatusManager { - // MARK: - BackupAttachmentUploadQueueStatusReporter - func currentStatus(for mode: BackupAttachmentUploadQueueMode) -> BackupAttachmentUploadQueueStatus { return state.asQueueStatus(for: mode) } - // MARK: - BackupAttachmentUploadQueueStatusManager - func beginObservingIfNecessary(for mode: BackupAttachmentUploadQueueMode) -> BackupAttachmentUploadQueueStatus { observeDeviceAndLocalStatesIfNecessary() return currentStatus(for: mode) @@ -298,6 +280,13 @@ class BackupAttachmentUploadQueueStatusManagerImpl: BackupAttachmentUploadQueueS private var state: State { didSet { + func notifyStatusDidChange(for mode: BackupAttachmentUploadQueueMode) { + NotificationCenter.default.postOnMainThread( + name: .backupAttachmentUploadQueueStatusDidChange(for: mode), + object: nil, + ) + } + if oldValue.asQueueStatus(for: .fullsize) != state.asQueueStatus(for: .fullsize) { notifyStatusDidChange(for: .fullsize) } diff --git a/SignalServiceKit/Backups/BackupExportJob/BackupExportJobStore.swift b/SignalServiceKit/Backups/BackupExportJob/BackupExportJobStore.swift index fc3a9a5aa9..2ecea2d1f2 100644 --- a/SignalServiceKit/Backups/BackupExportJob/BackupExportJobStore.swift +++ b/SignalServiceKit/Backups/BackupExportJob/BackupExportJobStore.swift @@ -17,18 +17,24 @@ public struct BackupExportJobStore { // MARK: - + public func wipe(tx: DBWriteTransaction) { + kvStore.removeValue(forKey: Keys.resumptionPoint, tx: tx) + } + + // MARK: - + /// Represents a point at which an interrupted `BackupExportJob` can be /// resumed. public enum ResumptionPoint: Int64 { /// The job should be resumed from the beginning. - case beginning + case beginning = 0 /// The job should be resumed after Backup-file-related stages. - case postBackupFile + case postBackupFile = 1 } public func lastReachedResumptionPoint(tx: DBReadTransaction) -> ResumptionPoint? { return kvStore.fetchValue(Int64.self, forKey: Keys.resumptionPoint, tx: tx) - .flatMap { ResumptionPoint(rawValue: $0) } + .map { ResumptionPoint(rawValue: $0).owsFailUnwrap("Unexpected value: \($0)") } } public func setReachedResumptionPoint(_ point: ResumptionPoint?, tx: DBWriteTransaction) { diff --git a/SignalServiceKit/Environment/AppSetup.swift b/SignalServiceKit/Environment/AppSetup.swift index e89a9b59a9..cda93ac8f3 100644 --- a/SignalServiceKit/Environment/AppSetup.swift +++ b/SignalServiceKit/Environment/AppSetup.swift @@ -393,6 +393,7 @@ extension AppSetup.GlobalsContinuation { ) let backupNonceMetadataStore = BackupNonceMetadataStore() + let backupExportJobStore = BackupExportJobStore() let backupSettingsStore = BackupSettingsStore() let accountKeyStore = AccountKeyStore( backupSettingsStore: backupSettingsStore, @@ -1611,7 +1612,6 @@ extension AppSetup.GlobalsContinuation { receiptSender: receiptSender, ) - let backupExportJobStore = BackupExportJobStore() let backupExportJob = BackupExportJobImpl( accountKeyStore: accountKeyStore, backupArchiveManager: backupArchiveManager, diff --git a/SignalServiceKit/Util/NSNotificationCenter+OWS.swift b/SignalServiceKit/Util/NSNotificationCenter+OWS.swift index 2fe02a5531..8470db8afd 100644 --- a/SignalServiceKit/Util/NSNotificationCenter+OWS.swift +++ b/SignalServiceKit/Util/NSNotificationCenter+OWS.swift @@ -48,3 +48,20 @@ extension NotificationCenter { removeObserver(observer.wrapped) } } + +// MARK: - + +extension NotificationCenter { + public func startTaskTrackingNotifications( + named name: Notification.Name, + onNotification: @MainActor @escaping () -> Void, + ) -> Task { + return Task.detached { + for await _ in NotificationCenter.default.notifications(named: name) { + await MainActor.run { + onNotification() + } + } + } + } +}