Path: blob/main/SignalServiceKit/Backups/Attachments/BackupAttachmentUploadQueueStatusManager.swift
1 views
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public enum BackupAttachmentUploadQueueMode {
case fullsize
case thumbnail
}
public enum BackupAttachmentUploadQueueStatus {
/// The queue is running, and attachment are uploading.
case running
/// The queue was paused by the user.
case suspended
/// There's nothing to upload.
case empty
/// Must be registered and the app ready to upload.
case notRegisteredAndReady
/// Wifi is required for uploads, but not available.
case noWifiReachability
/// Internet access is required for uploads, but not available.
case noReachability
/// The device has low battery.
case lowBattery
/// The device is in low power mode.
case lowPowerMode
/// The app is running in the background.
case appBackgrounded
/// Out of space on media tier; uploads suspended until we can free space.
case hasConsumedMediaTierCapacity
}
public extension Notification.Name {
static func backupAttachmentUploadQueueStatusDidChange(for mode: BackupAttachmentUploadQueueMode) -> Notification.Name {
switch mode {
case .fullsize:
return Notification.Name(rawValue: "BackupAttachmentUploadQueueStatusDidChange_fullsize")
case .thumbnail:
return Notification.Name(rawValue: "BackupAttachmentUploadQueueStatusDidChange_thumbnail")
}
}
}
// MARK: -
/// 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 BackupAttachmentUploadQueueStatusManager {
/// The current status of the upload queue.
/// - Important
/// Only returns meaningful values once `beginObservingIfNecessary` has been called.
func currentStatus(for mode: BackupAttachmentUploadQueueMode) -> BackupAttachmentUploadQueueStatus
/// Begin observing status updates, if necessary.
func beginObservingIfNecessary(for mode: BackupAttachmentUploadQueueMode) -> BackupAttachmentUploadQueueStatus
/// Notifies the status manager that the upload queue was emptied.
func didEmptyQueue(for mode: BackupAttachmentUploadQueueMode)
func setIsMainAppAndActiveOverride(_ newValue: Bool)
}
// MARK: -
@MainActor
class BackupAttachmentUploadQueueStatusManagerImpl: BackupAttachmentUploadQueueStatusManager {
func currentStatus(for mode: BackupAttachmentUploadQueueMode) -> BackupAttachmentUploadQueueStatus {
return state.asQueueStatus(for: mode)
}
func beginObservingIfNecessary(for mode: BackupAttachmentUploadQueueMode) -> BackupAttachmentUploadQueueStatus {
observeDeviceAndLocalStatesIfNecessary()
return currentStatus(for: mode)
}
func didEmptyQueue(for mode: BackupAttachmentUploadQueueMode) {
switch mode {
case .fullsize:
state.isFullsizeQueueEmpty = true
case .thumbnail:
state.isThumbnailQueueEmpty = true
}
if state.isFullsizeQueueEmpty == true, state.isThumbnailQueueEmpty == true {
stopObservingDeviceAndLocalStates()
}
}
func setIsMainAppAndActiveOverride(_ newValue: Bool) {
state.isMainAppAndActiveOverride = newValue
}
// MARK: - Init
private let appContext: AppContext
private let appReadiness: AppReadiness
private let backupAttachmentUploadStore: BackupAttachmentUploadStore
private let backupSettingsStore: BackupSettingsStore
private var batteryLevelMonitor: DeviceBatteryLevelMonitor?
private let dateProvider: DateProvider
private let db: DB
private let deviceBatteryLevelManager: (any DeviceBatteryLevelManager)?
private let reachabilityManager: SSKReachabilityManager
private nonisolated let remoteConfigManager: RemoteConfigManager
private let tsAccountManager: TSAccountManager
init(
appContext: AppContext,
appReadiness: AppReadiness,
backupAttachmentUploadStore: BackupAttachmentUploadStore,
backupSettingsStore: BackupSettingsStore,
dateProvider: @escaping DateProvider,
db: DB,
deviceBatteryLevelManager: (any DeviceBatteryLevelManager)?,
reachabilityManager: SSKReachabilityManager,
remoteConfigManager: RemoteConfigManager,
tsAccountManager: TSAccountManager,
) {
self.appContext = appContext
self.appReadiness = appReadiness
self.backupAttachmentUploadStore = backupAttachmentUploadStore
self.backupSettingsStore = backupSettingsStore
self.dateProvider = dateProvider
self.db = db
self.deviceBatteryLevelManager = deviceBatteryLevelManager
self.reachabilityManager = reachabilityManager
self.remoteConfigManager = remoteConfigManager
self.tsAccountManager = tsAccountManager
self.state = State(
isFullsizeQueueEmpty: nil,
isThumbnailQueueEmpty: nil,
isMainApp: appContext.isMainApp,
isAppReady: false,
isRegistered: nil,
backupPlan: nil,
hasConsumedMediaTierCapacity: nil,
shouldAllowBackupUploadsOnCellular: nil,
isWifiReachable: nil,
isReachable: nil,
batteryLevel: nil,
isLowPowerMode: nil,
isMainAppAndActive: appContext.isMainAppAndActive,
areUploadsSuspended: false,
)
appReadiness.runNowOrWhenMainAppDidBecomeReadyAsync { [weak self] in
self?.appReadinessDidChange()
}
}
// MARK: - Private
private struct State {
var isFullsizeQueueEmpty: Bool?
var isThumbnailQueueEmpty: Bool?
var isMainApp: Bool
var isAppReady: Bool
var isRegistered: Bool?
var backupPlan: BackupPlan?
var hasConsumedMediaTierCapacity: Bool?
var shouldAllowBackupUploadsOnCellular: Bool?
var isWifiReachable: Bool?
var isReachable: Bool?
var areUploadsSuspended: Bool?
// Value from 0 to 1
var batteryLevel: Float?
var isLowPowerMode: Bool?
var isMainAppAndActive: Bool
var isMainAppAndActiveOverride: Bool = false
init(
isFullsizeQueueEmpty: Bool?,
isThumbnailQueueEmpty: Bool?,
isMainApp: Bool,
isAppReady: Bool,
isRegistered: Bool?,
backupPlan: BackupPlan?,
hasConsumedMediaTierCapacity: Bool?,
shouldAllowBackupUploadsOnCellular: Bool?,
isWifiReachable: Bool?,
isReachable: Bool?,
batteryLevel: Float?,
isLowPowerMode: Bool?,
isMainAppAndActive: Bool,
areUploadsSuspended: Bool?,
) {
self.isFullsizeQueueEmpty = isFullsizeQueueEmpty
self.isThumbnailQueueEmpty = isThumbnailQueueEmpty
self.isMainApp = isMainApp
self.isAppReady = isAppReady
self.isRegistered = isRegistered
self.backupPlan = backupPlan
self.hasConsumedMediaTierCapacity = hasConsumedMediaTierCapacity
self.shouldAllowBackupUploadsOnCellular = shouldAllowBackupUploadsOnCellular
self.isWifiReachable = isWifiReachable
self.isReachable = isReachable
self.batteryLevel = batteryLevel
self.isLowPowerMode = isLowPowerMode
self.isMainAppAndActive = isMainAppAndActive
self.areUploadsSuspended = areUploadsSuspended
}
func asQueueStatus(for mode: BackupAttachmentUploadQueueMode) -> BackupAttachmentUploadQueueStatus {
switch mode {
case .fullsize:
if isFullsizeQueueEmpty == true {
return .empty
}
case .thumbnail:
if isThumbnailQueueEmpty == true {
return .empty
}
}
switch backupPlan {
case nil, .disabled, .disabling, .free:
return .empty
case .paid, .paidExpiringSoon, .paidAsTester:
break
}
guard
isMainApp,
isAppReady,
isRegistered == true
else {
return .notRegisteredAndReady
}
if hasConsumedMediaTierCapacity == true {
return .hasConsumedMediaTierCapacity
}
if areUploadsSuspended == true {
return .suspended
}
if
shouldAllowBackupUploadsOnCellular != true,
isWifiReachable != true
{
return .noWifiReachability
}
if isReachable != true {
return .noReachability
}
if (batteryLevel ?? 0) < 0.1 {
return .lowBattery
}
if isLowPowerMode == true {
return .lowPowerMode
}
if !isMainAppAndActive, !isMainAppAndActiveOverride {
return .appBackgrounded
}
return .running
}
}
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)
}
if oldValue.asQueueStatus(for: .thumbnail) != state.asQueueStatus(for: .thumbnail) {
notifyStatusDidChange(for: .thumbnail)
}
}
}
// MARK: State Observation
private func observeDeviceAndLocalStatesIfNecessary() {
// For change logic, treat nil as empty (if nil, observation is unstarted)
let wasQueueEmpty: Bool
if
let wasFullsizeQueueEmpty = state.isFullsizeQueueEmpty,
let wasThumbnailQueueEmpty = state.isThumbnailQueueEmpty
{
wasQueueEmpty = wasFullsizeQueueEmpty && wasThumbnailQueueEmpty
} else {
wasQueueEmpty = true
}
let (isFullsizeQueueEmpty, isThumbnailQueueEmpty) = db.read { tx in
return (
backupAttachmentUploadStore.fetchNextUploads(count: 1, isFullsize: true, tx: tx).isEmpty,
backupAttachmentUploadStore.fetchNextUploads(count: 1, isFullsize: false, tx: tx).isEmpty,
)
}
state.isFullsizeQueueEmpty = isFullsizeQueueEmpty
state.isThumbnailQueueEmpty = isThumbnailQueueEmpty
let isQueueEmpty = isFullsizeQueueEmpty && isThumbnailQueueEmpty
// Only observe if the queue is non-empty, so as to not waste resources;
// for example, by telling the OS we want battery level updates.
if isQueueEmpty, !wasQueueEmpty {
stopObservingDeviceAndLocalStates()
} else if !isQueueEmpty, wasQueueEmpty {
observeDeviceAndLocalStates()
}
}
private func observeDeviceAndLocalStates() {
let (
backupPlan,
hasConsumedMediaTierCapacity,
shouldAllowBackupUploadsOnCellular,
areUploadsSuspended,
) = db.read { tx in
(
backupSettingsStore.backupPlan(tx: tx),
backupSettingsStore.hasConsumedMediaTierCapacity(tx: tx),
backupSettingsStore.shouldAllowBackupUploadsOnCellular(tx: tx),
backupSettingsStore.isBackupAttachmentUploadQueueSuspended(tx: tx),
)
}
let notificationsToObserve: [(Notification.Name, Selector)] = [
(.registrationStateDidChange, #selector(registrationStateDidChange)),
(.backupPlanChanged, #selector(backupPlanDidChange)),
(.hasConsumedMediaTierCapacityStatusDidChange, #selector(hasConsumedMediaTierCapacityDidChange)),
(.shouldAllowBackupUploadsOnCellularChanged, #selector(shouldAllowBackupUploadsOnCellularDidChange)),
(SSKReachability.owsReachabilityDidChange, #selector(reachabilityDidChange)),
(.backupAttachmentUploadQueueSuspensionStatusDidChange, #selector(suspensionStatusDidChange)),
(.batteryLevelChanged, #selector(batteryLevelDidChange)),
(.batteryLowPowerModeChanged, #selector(lowPowerModeDidChange)),
(.OWSApplicationDidEnterBackground, #selector(isMainAppAndActiveDidChange)),
(.OWSApplicationDidBecomeActive, #selector(isMainAppAndActiveDidChange)),
]
for (name, selector) in notificationsToObserve {
NotificationCenter.default.addObserver(
self,
selector: selector,
name: name,
object: nil,
)
}
self.batteryLevelMonitor = deviceBatteryLevelManager?.beginMonitoring(reason: "BackupUploadQueue")
self.state = State(
isFullsizeQueueEmpty: state.isFullsizeQueueEmpty,
isThumbnailQueueEmpty: state.isThumbnailQueueEmpty,
isMainApp: appContext.isMainApp,
isAppReady: appReadiness.isAppReady,
isRegistered: tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered,
backupPlan: backupPlan,
hasConsumedMediaTierCapacity: hasConsumedMediaTierCapacity,
shouldAllowBackupUploadsOnCellular: shouldAllowBackupUploadsOnCellular,
isWifiReachable: reachabilityManager.isReachable(via: .wifi),
isReachable: reachabilityManager.isReachable(via: .any),
batteryLevel: batteryLevelMonitor?.batteryLevel,
isLowPowerMode: deviceBatteryLevelManager?.isLowPowerModeEnabled,
isMainAppAndActive: appContext.isMainAppAndActive,
areUploadsSuspended: areUploadsSuspended,
)
}
private func stopObservingDeviceAndLocalStates() {
NotificationCenter.default.removeObserver(self)
batteryLevelMonitor.map { deviceBatteryLevelManager?.endMonitoring($0) }
}
// MARK: Per state changes
private func appReadinessDidChange() {
state.isAppReady = appReadiness.isAppReady
}
@objc
private func registrationStateDidChange() {
state.isRegistered = db.read { tx in
tsAccountManager.registrationState(tx: tx).isRegistered
}
}
@objc
private func backupPlanDidChange() {
state.backupPlan = db.read { tx in
backupSettingsStore.backupPlan(tx: tx)
}
}
@objc
private func hasConsumedMediaTierCapacityDidChange() {
state.hasConsumedMediaTierCapacity = db.read { tx in
backupSettingsStore.hasConsumedMediaTierCapacity(tx: tx)
}
}
@objc
private func shouldAllowBackupUploadsOnCellularDidChange() {
state.shouldAllowBackupUploadsOnCellular = db.read { tx in
backupSettingsStore.shouldAllowBackupUploadsOnCellular(tx: tx)
}
}
@objc
private func reachabilityDidChange() {
state.isWifiReachable = reachabilityManager.isReachable(via: .wifi)
state.isReachable = reachabilityManager.isReachable(via: .any)
}
@objc
private func batteryLevelDidChange() {
state.batteryLevel = batteryLevelMonitor?.batteryLevel
}
@objc
private func lowPowerModeDidChange() {
self.state.isLowPowerMode = deviceBatteryLevelManager?.isLowPowerModeEnabled
}
@objc
private func isMainAppAndActiveDidChange() {
self.state.isMainAppAndActive = appContext.isMainAppAndActive
}
@objc
func suspensionStatusDidChange() {
self.state.areUploadsSuspended = db.read { tx in
backupSettingsStore.isBackupAttachmentUploadQueueSuspended(tx: tx)
}
}
}