Implement interactions, handle edge cases in CLVBackupProgressView

This commit is contained in:
Sasha Weiss 2026-02-17 09:50:31 -08:00 committed by GitHub
parent 696d33c750
commit 6ae49c28f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 633 additions and 347 deletions

View File

@ -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,
)

View File

@ -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<DownloadUpdate> {
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<DownloadUpdate>.Continuation
}
private let backupAttachmentDownloadQueueStatusReporter: BackupAttachmentDownloadQueueStatusReporter
private let backupAttachmentDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusManager
private let backupAttachmentDownloadProgress: BackupAttachmentDownloadProgress
private let state: SeriallyAccessedState<State>
init(
backupAttachmentDownloadQueueStatusReporter: BackupAttachmentDownloadQueueStatusReporter,
backupAttachmentDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusManager,
backupAttachmentDownloadProgress: BackupAttachmentDownloadProgress,
continuation: AsyncStream<DownloadUpdate>.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,

View File

@ -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<UploadUpdate?> {
func updates() -> AsyncStream<UploadUpdate> {
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<UploadUpdate?>.Continuation
let streamContinuation: AsyncStream<UploadUpdate>.Continuation
}
private let backupAttachmentUploadQueueStatusReporter: BackupAttachmentUploadQueueStatusReporter
private let backupAttachmentUploadQueueStatusManager: BackupAttachmentUploadQueueStatusManager
private let backupAttachmentUploadProgress: BackupAttachmentUploadProgress
private let state: SeriallyAccessedState<State>
init(
backupAttachmentUploadQueueStatusReporter: BackupAttachmentUploadQueueStatusReporter,
backupAttachmentUploadQueueStatusManager: BackupAttachmentUploadQueueStatusManager,
backupAttachmentUploadProgress: BackupAttachmentUploadProgress,
continuation: AsyncStream<UploadUpdate?>.Continuation,
continuation: AsyncStream<UploadUpdate>.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,
))
}
}

View File

@ -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.

View File

@ -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<BackupExportJobStage>?
@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<BackupExportJobStage>?,
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<BackupExportJobStage>
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<BackupExportJobStage>? = 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(

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -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"
}
}

Binary file not shown.

View File

@ -408,47 +408,45 @@ class CLVTableDataSource: NSObject, UITableViewDataSource, UITableViewDelegate {
contextMenuConfigurationForRowAt indexPath: IndexPath,
point: CGPoint,
) -> 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
}

View File

@ -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)

View File

@ -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<Void, Never>] = []
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<BackupExportJobStage>??
var lastUploadTrackerUpdate: BackupAttachmentUploadTracker.UploadUpdate?
var lastExportJobUpdate: BackupExportJobRunnerUpdate?
var updateStreamTasks: [Task<Void, Never>] = []
}
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)

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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() {

View File

@ -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)
}

View File

@ -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) {

View File

@ -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,

View File

@ -48,3 +48,20 @@ extension NotificationCenter {
removeObserver(observer.wrapped)
}
}
// MARK: -
extension NotificationCenter {
public func startTaskTrackingNotifications(
named name: Notification.Name,
onNotification: @MainActor @escaping () -> Void,
) -> Task<Void, Never> {
return Task.detached {
for await _ in NotificationCenter.default.notifications(named: name) {
await MainActor.run {
onNotification()
}
}
}
}
}