361 lines
15 KiB
Swift
361 lines
15 KiB
Swift
//
|
|
// Copyright 2025 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
public import LibSignalClient
|
|
|
|
public protocol BackupPlanManager {
|
|
/// See ``BackupSettingsStore/backupPlan(tx:)``. API passed-through for
|
|
/// convenience of callers using this type.
|
|
func backupPlan(tx: DBReadTransaction) -> BackupPlan
|
|
|
|
/// Set the current `BackupPlan` via data from Storage Service.
|
|
///
|
|
/// - Important
|
|
/// Must only be called on linked devices!
|
|
///
|
|
/// - Important
|
|
/// Callers should use a `DB` method that rolls-back-if-throws to get the
|
|
/// `tx` for calling this API, to avoid state being partially set.
|
|
func setBackupPlan(
|
|
fromStorageService backupLevel: LibSignalClient.BackupLevel?,
|
|
tx: DBWriteTransaction
|
|
) throws
|
|
|
|
/// Set the current `BackupPlan`.
|
|
///
|
|
/// - Important
|
|
/// Callers should use a `DB` method that rolls-back-if-throws to get the
|
|
/// `tx` for calling this API, to avoid state being partially set.
|
|
func setBackupPlan(_ plan: BackupPlan, tx: DBWriteTransaction) throws
|
|
}
|
|
|
|
extension Notification.Name {
|
|
public static let backupPlanChanged = Notification.Name("BackupSettings.backupPlanChanged")
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
class BackupPlanManagerImpl: BackupPlanManager {
|
|
|
|
private let backupAttachmentDownloadStore: BackupAttachmentDownloadStore
|
|
private let backupAttachmentUploadProgress: BackupAttachmentUploadProgress
|
|
private let backupSettingsStore: BackupSettingsStore
|
|
private let dateProvider: DateProvider
|
|
private let logger: PrefixedLogger
|
|
private let tsAccountManager: TSAccountManager
|
|
|
|
init(
|
|
backupAttachmentDownloadStore: BackupAttachmentDownloadStore,
|
|
backupAttachmentUploadProgress: BackupAttachmentUploadProgress,
|
|
backupSettingsStore: BackupSettingsStore,
|
|
dateProvider: @escaping DateProvider,
|
|
tsAccountManager: TSAccountManager,
|
|
) {
|
|
self.backupAttachmentDownloadStore = backupAttachmentDownloadStore
|
|
self.backupAttachmentUploadProgress = backupAttachmentUploadProgress
|
|
self.backupSettingsStore = backupSettingsStore
|
|
self.dateProvider = dateProvider
|
|
self.logger = PrefixedLogger(prefix: "[Backups]")
|
|
self.tsAccountManager = tsAccountManager
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
func backupPlan(tx: DBReadTransaction) -> BackupPlan {
|
|
return backupSettingsStore.backupPlan(tx: tx)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
func setBackupPlan(fromStorageService backupLevel: BackupLevel?, tx: DBWriteTransaction) throws {
|
|
guard tsAccountManager.registrationState(tx: tx).isPrimaryDevice == false else {
|
|
owsFailDebug("Attempting to set backupPlan from Storage Service, but not a linked device!")
|
|
return
|
|
}
|
|
|
|
switch backupLevel {
|
|
case nil:
|
|
backupSettingsStore.setBackupPlan(.disabled, tx: tx)
|
|
try configureDownloadsForDisablingBackups(tx: tx)
|
|
case .free:
|
|
backupSettingsStore.setBackupPlan(.free, tx: tx)
|
|
case .paid:
|
|
// Linked devices don't support optimizeLocalStorage; default off.
|
|
backupSettingsStore.setBackupPlan(.paid(optimizeLocalStorage: false), tx: tx)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
func setBackupPlan(_ newBackupPlan: BackupPlan, tx: DBWriteTransaction) throws {
|
|
let oldBackupPlan = backupPlan(tx: tx)
|
|
|
|
logger.info("Setting BackupPlan! \(oldBackupPlan) -> \(newBackupPlan)")
|
|
|
|
// Bail early on unexpected state transitions, before we persist state
|
|
// we later regret.
|
|
try validateBackupPlanStateTransition(
|
|
oldBackupPlan: oldBackupPlan,
|
|
newBackupPlan: newBackupPlan
|
|
)
|
|
|
|
backupSettingsStore.setBackupPlan(newBackupPlan, tx: tx)
|
|
|
|
backupAttachmentUploadProgress.backupPlanDidChange(
|
|
oldBackupPlan: oldBackupPlan,
|
|
newBackupPlan: newBackupPlan,
|
|
tx: tx,
|
|
)
|
|
|
|
try configureDownloadsForBackupPlanChange(
|
|
oldPlan: oldBackupPlan,
|
|
newPlan: newBackupPlan,
|
|
tx: tx
|
|
)
|
|
|
|
switch newBackupPlan {
|
|
case .disabled, .disabling, .free:
|
|
// Media tier capacity is only a paid tier concept; reset our local
|
|
// knowledge of having run out of space when we become non-paid tier.
|
|
// If we become paid tier again, we will rediscover that we are out
|
|
// of space when we try and upload and get an error from the server.
|
|
backupSettingsStore.setHasConsumedMediaTierCapacity(false, tx: tx)
|
|
case .paid, .paidExpiringSoon, .paidAsTester:
|
|
break
|
|
}
|
|
|
|
if oldBackupPlan != newBackupPlan {
|
|
tx.addSyncCompletion {
|
|
NotificationCenter.default.post(name: .backupPlanChanged, object: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private func configureDownloadsForBackupPlanChange(
|
|
oldPlan: BackupPlan,
|
|
newPlan: BackupPlan,
|
|
tx: DBWriteTransaction,
|
|
) throws {
|
|
// Linked devices don't care about state changes; they keep downloading
|
|
// whatever got enqueued at link'n'sync time.
|
|
// (They also don't support storage optimization so that's moot.)
|
|
guard tsAccountManager.registrationState(tx: tx).isRegisteredPrimaryDevice else {
|
|
return
|
|
}
|
|
|
|
switch (oldPlan, newPlan) {
|
|
case
|
|
(.disabling, .disabling),
|
|
(.disabled, .disabled),
|
|
(.free, .free):
|
|
// No change.
|
|
return
|
|
case
|
|
(.disabling, .free),
|
|
(.disabling, .paid),
|
|
(.disabling, .paidExpiringSoon),
|
|
(.disabling, .paidAsTester),
|
|
(.disabled, .disabling):
|
|
throw OWSAssertionError("Unexpected BackupPlan transition: \(oldPlan) -> \(newPlan)")
|
|
case (.free, .disabling):
|
|
// While in free tier, we may have been continuing downloads
|
|
// from when you were previously paid tier. But that was nice
|
|
// to have; now that we're disabling backups cancel them all.
|
|
Logger.info("Configuring downloads for disabling free backups")
|
|
try backupAttachmentDownloadStore.markAllReadyIneligible(tx: tx)
|
|
try backupAttachmentDownloadStore.deleteAllDone(tx: tx)
|
|
case
|
|
let (.paid(optimizeLocalStorage), .disabling),
|
|
let (.paidExpiringSoon(optimizeLocalStorage), .disabling),
|
|
let (.paidAsTester(optimizeLocalStorage), .disabling):
|
|
Logger.info("Configuring downloads for disabling paid backups")
|
|
try backupAttachmentDownloadStore.deleteAllDone(tx: tx)
|
|
// Unsuspend; this is the user opt-in to trigger downloads.
|
|
backupSettingsStore.setIsBackupDownloadQueueSuspended(false, tx: tx)
|
|
if optimizeLocalStorage {
|
|
// If we had optimize enabled, make anything ineligible (offloaded
|
|
// attachments) now eligible.
|
|
try backupAttachmentDownloadStore.markAllIneligibleReady(tx: tx)
|
|
}
|
|
case (_, .disabled):
|
|
try configureDownloadsForDisablingBackups(tx: tx)
|
|
|
|
case (.disabled, .free):
|
|
try backupAttachmentDownloadStore.markAllIneligibleReady(tx: tx)
|
|
// Suspend the queue so the user has to explicitly opt-in to download.
|
|
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
|
|
|
|
case
|
|
let (.disabled, .paid(optimizeStorage)),
|
|
let (.disabled, .paidExpiringSoon(optimizeStorage)),
|
|
let (.disabled, .paidAsTester(optimizeStorage)):
|
|
try backupAttachmentDownloadStore.markAllIneligibleReady(tx: tx)
|
|
// Suspend the queue so the user has to explicitly opt-in to download.
|
|
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
|
|
if optimizeStorage {
|
|
// Unclear how you would go straight from disabled to optimize
|
|
// enabled, but just go through the motions of both state changes
|
|
// as if they'd happened independently.
|
|
try configureDownloadsForDidEnableOptimizeStorage(tx: tx)
|
|
}
|
|
|
|
case
|
|
let (.paid(wasOptimizeLocalStorageEnabled), .free),
|
|
let (.paidExpiringSoon(wasOptimizeLocalStorageEnabled), .free),
|
|
let (.paidAsTester(wasOptimizeLocalStorageEnabled), .free):
|
|
// We explicitly do nothing going from paid to free; we want to continue
|
|
// any downloads that were already running (so we take advantage of the
|
|
// media tier cdn TTL being longer than paid subscription lifetime) but
|
|
// also not schedule (or un-suspend) if we weren't already downloading.
|
|
// But if optimization was on, its now implicitly off, so handle that.
|
|
if wasOptimizeLocalStorageEnabled {
|
|
try configureDownloadsForDidDisableOptimizeStorage(tx: tx)
|
|
}
|
|
|
|
case
|
|
let (.free, .paid(optimizeStorage)),
|
|
let (.free, .paidExpiringSoon(optimizeStorage)),
|
|
let (.free, .paidAsTester(optimizeStorage)):
|
|
// We explicitly do nothing when going from free to paid; any state
|
|
// changes that will happen will be triggered by list media request
|
|
// handling which will always run at the start of a new upload era.
|
|
// But if we somehow went straight from free to optimize enabled,
|
|
// handle that state transition.
|
|
if optimizeStorage {
|
|
owsFailDebug("Going from free or disabled directly to optimize enabled shouldn't be allowed?")
|
|
try configureDownloadsForDidEnableOptimizeStorage(tx: tx)
|
|
}
|
|
|
|
case
|
|
// Downloads don't care if expiring soon or not
|
|
let (.paid(oldOptimize), .paid(newOptimize)),
|
|
let (.paid(oldOptimize), .paidExpiringSoon(newOptimize)),
|
|
let (.paid(oldOptimize), .paidAsTester(newOptimize)),
|
|
let (.paidExpiringSoon(oldOptimize), .paid(newOptimize)),
|
|
let (.paidExpiringSoon(oldOptimize), .paidExpiringSoon(newOptimize)),
|
|
let (.paidExpiringSoon(oldOptimize), .paidAsTester(newOptimize)),
|
|
let (.paidAsTester(oldOptimize), .paid(newOptimize)),
|
|
let (.paidAsTester(oldOptimize), .paidExpiringSoon(newOptimize)),
|
|
let (.paidAsTester(oldOptimize), .paidAsTester(newOptimize)):
|
|
if oldOptimize == newOptimize {
|
|
// Nothing changed.
|
|
break
|
|
} else if newOptimize {
|
|
try configureDownloadsForDidEnableOptimizeStorage(tx: tx)
|
|
} else {
|
|
try configureDownloadsForDidDisableOptimizeStorage(tx: tx)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func configureDownloadsForDisablingBackups(tx: DBWriteTransaction) throws {
|
|
Logger.info("Configuring downloads for disabled backups")
|
|
// When we disable, we mark everything ineligible and delete all
|
|
// done rows. If we ever re-enable, we will mark those rows
|
|
// ready again.
|
|
try backupAttachmentDownloadStore.deleteAllDone(tx: tx)
|
|
try backupAttachmentDownloadStore.markAllReadyIneligible(tx: tx)
|
|
// This doesn't _really_ do anything, since we don't run the queue
|
|
// when disabled anyway, but may as well suspend.
|
|
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
|
|
}
|
|
|
|
private func configureDownloadsForDidEnableOptimizeStorage(tx: DBWriteTransaction) throws {
|
|
Logger.info("Configuring downloads for optimize enabled")
|
|
// When we turn on optimization, make all media tier fullsize downloads
|
|
// from the queue that are past the optimization threshold ineligible.
|
|
// If we downloaded them we'd offload them immediately anyway.
|
|
// This isn't 100% necessary; after all something 29 days old today will be
|
|
// 30 days old tomorrow, so the queue runner will gracefully handle old
|
|
// downloads at run-time anyway. But its more efficient to do in bulk.
|
|
let threshold = dateProvider().ows_millisecondsSince1970 - Attachment.offloadingThresholdMs
|
|
try backupAttachmentDownloadStore.markAllMediaTierFullsizeDownloadsIneligible(
|
|
olderThan: threshold,
|
|
tx: tx
|
|
)
|
|
// Un-suspend; when optimization is enabled we always auto-download
|
|
// the stuff that is eligible (newer attachments).
|
|
backupSettingsStore.setIsBackupDownloadQueueSuspended(false, tx: tx)
|
|
// Reset the progress counter.
|
|
try backupAttachmentDownloadStore.deleteAllDone(tx: tx)
|
|
}
|
|
|
|
private func configureDownloadsForDidDisableOptimizeStorage(tx: DBWriteTransaction) throws {
|
|
Logger.info("Configuring downloads for optimize disabled")
|
|
// When we turn _off_ optimization, we want to make ready all the media tier downloads,
|
|
// but suspend the queue so we don't immediately start downloading.
|
|
try backupAttachmentDownloadStore.markAllIneligibleReady(tx: tx)
|
|
// Suspend the queue; the user has to explicitly opt in to downloads
|
|
// after optimization is disabled.
|
|
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
|
|
|
|
// Reset the download banner so we show it again if the user dismissed.
|
|
backupAttachmentDownloadStore.resetDidDismissDownloadCompleteBanner(tx: tx)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private func validateBackupPlanStateTransition(
|
|
oldBackupPlan: BackupPlan,
|
|
newBackupPlan: BackupPlan,
|
|
) throws {
|
|
var illegalStateTransition: Bool = false
|
|
|
|
switch oldBackupPlan {
|
|
case .disabled:
|
|
switch newBackupPlan {
|
|
case .disabled, .free, .paid, .paidExpiringSoon, .paidAsTester:
|
|
break
|
|
case .disabling:
|
|
// We're already disabled; how are we starting disabling again?
|
|
illegalStateTransition = true
|
|
}
|
|
case .disabling:
|
|
switch newBackupPlan {
|
|
case .disabled, .disabling:
|
|
break
|
|
case .free, .paid, .paidExpiringSoon, .paidAsTester:
|
|
// Shouldn't be able to "enable" while we're disabling!
|
|
illegalStateTransition = true
|
|
}
|
|
case .free, .paid, .paidExpiringSoon, .paidAsTester:
|
|
switch newBackupPlan {
|
|
case .disabling, .free, .paid, .paidExpiringSoon, .paidAsTester:
|
|
break
|
|
case .disabled:
|
|
// Should've moved through .disabling first!
|
|
illegalStateTransition = true
|
|
}
|
|
}
|
|
|
|
if illegalStateTransition {
|
|
throw OWSAssertionError("Unexpected illegal BackupPlan state transition: \(oldBackupPlan) -> \(newBackupPlan).")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
#if TESTABLE_BUILD
|
|
|
|
class MockBackupPlanManager: BackupPlanManager {
|
|
var backupPlanMock: BackupPlan?
|
|
func backupPlan(tx: DBReadTransaction) -> BackupPlan {
|
|
backupPlanMock ?? .disabled
|
|
}
|
|
|
|
func setBackupPlan(fromStorageService backupLevel: BackupLevel?, tx: DBWriteTransaction) {
|
|
owsFail("Not implemented!")
|
|
}
|
|
|
|
func setBackupPlan(_ plan: BackupPlan, tx: DBWriteTransaction) throws {
|
|
backupPlanMock = plan
|
|
}
|
|
}
|
|
|
|
#endif
|