feat: automatic backup on launch, foreground, and photo library change

AutoBackupCoordinator (new):
- @MainActor singleton that owns all auto-backup decisions
- Phase model: idle / checking / autoBackingUp / disconnected
  - idle:          nothing pending or destination not set
  - checking:      reconciliation in progress before decision
  - autoBackingUp: coordinator-triggered BackupEngine run active
  - disconnected:  NAS offline, pending count queued for retry
- Combine subscription on BackupStatusService.$isRefreshing fires
  handleReconciliationComplete() on every reconcile completion — the
  single entry point for all auto-backup decisions
- Combine subscription on LANMonitor.$nasReachable fires
  handleNASReachable() which retries only when phase == .disconnected
- Guards: autoBackupEnabled, quiet hours, chargingOnlyMode + battery state
- canAutoBackup also blocks when engine.job.status.isActive to prevent
  double-start from concurrent photo library change + foreground events

BackupView:
- .task + .onChange(of: scenePhase) now call coordinator.onActive() instead
  of statusService.refresh() directly — coordinator decides whether to also
  start a backup after reconciliation completes
- ringContentID includes coordinator phase suffix → ring animates on
  checking/disconnected transitions
- ringCenterContent case 0: when engine is idle, shows:
  - disconnectedRingView if phase == .disconnected and needBackup > 0
    (wifi.slash icon, "Waiting for NAS", queued count)
  - checkingRingView if phase == .checking (spinner, "Checking…")
  - existing ringMainView otherwise
- ringColor: dim orange (0.35 opacity) when disconnected with pending items
- Pull-to-refresh reconciliation triggers auto-backup via Combine chain

ConnectionStore:
- autoBackupEnabled: Bool, default true, UserDefaults-persisted

SettingsView:
- AUTO BACKUP section at top with autoBackupEnabled toggle

BackgroundTaskManager:
- BGProcessingTask: quiet-hours guard, requiresExternalPower mirrors
  chargingOnlyMode, expiration handler calls engine.cancel() for clean exit
- BGAppRefreshTask: lightweight refresh only (no upload in background refresh)
- Both tasks reschedule themselves before doing any work

AppDelegate:
- UIDevice.current.isBatteryMonitoringEnabled = true for battery checks

NASBackupApp:
- AutoBackupCoordinator.shared injected as @StateObject + environmentObject

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-17 17:28:11 +03:00
parent 0f62268af1
commit 5b68fde62f
8 changed files with 337 additions and 30 deletions

View File

@@ -10,6 +10,7 @@
01610BD782FA7F00787E8CB5 /* NASConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF503BCE3AEDC25F4D35083 /* NASConnection.swift */; };
0555485F0FD9D1D724B98B0D /* PhotoLibraryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35D2B8E1C4BBB260BC1EBA2B /* PhotoLibraryProtocol.swift */; };
06C876CCC3F66517F663B410 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6004FE7B107CC60894EDC434 /* BrowseView.swift */; };
0DAD4787CA9EA8E7EFA4E65E /* AutoBackupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 909C89562F7A0F4D4ECAFD7E /* AutoBackupCoordinator.swift */; };
0E446A91CD30C2905B8E215F /* BackupManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966927456571CE45600BEF75 /* BackupManifest.swift */; };
0E4948C8E3326DF859341942 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F052EE8B0A757532E4BCBCCD /* LoginView.swift */; };
0F80FDE64AF87518C6563CEC /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 354D31602CFEC56D0D219F41 /* SceneDelegate.swift */; };
@@ -106,10 +107,11 @@
84D7C31A6C73BBF5B8CBDCF2 /* LANTriggerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANTriggerSection.swift; sourceTree = "<group>"; };
86B66D4FB8A7E26BEE807C44 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
8AC6E57C1203C70B43C3A789 /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = "<group>"; };
909C89562F7A0F4D4ECAFD7E /* AutoBackupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoBackupCoordinator.swift; sourceTree = "<group>"; };
90F628AC04DC05FDCBAE52D9 /* SyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncView.swift; sourceTree = "<group>"; };
966927456571CE45600BEF75 /* BackupManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManifest.swift; sourceTree = "<group>"; };
9EF7C3E36ACFD6096DE0677A /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = "<group>"; };
A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
B3306187119851CE3E989408 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
B70F1C01E131F5F2798200E2 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
BBBDD83322981C31F4DE00FF /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = "<group>"; };
@@ -124,7 +126,7 @@
E309F9924060F6C3C2FE36D4 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
E956B8562EDB9259034CF8FF /* AppMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenuView.swift; sourceTree = "<group>"; };
E987653F4F7129568656EFDE /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = "<group>"; };
ED5F358675A200A5C0FF2289 /* NASBackup.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; };
ED5F358675A200A5C0FF2289 /* NASBackup.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; };
EF029B56DE72F2D5D7977D95 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = "<group>"; };
F052EE8B0A757532E4BCBCCD /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
F0987D44494F1385E8D4876F /* KisaniLogoMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniLogoMark.swift; sourceTree = "<group>"; };
@@ -246,6 +248,7 @@
5C5F3DAEDF1D5405A8D0FEA8 /* Services */ = {
isa = PBXGroup;
children = (
909C89562F7A0F4D4ECAFD7E /* AutoBackupCoordinator.swift */,
8AC6E57C1203C70B43C3A789 /* BackgroundTaskManager.swift */,
75FEBDDA82656772FE30609D /* BackupEngine.swift */,
FC516E4CBDFA09B6D04937D6 /* BackupStatusService.swift */,
@@ -439,6 +442,7 @@
LastUpgradeCheck = 1500;
TargetAttributes = {
009689A0ADEB4878A288991E = {
DevelopmentTeam = "";
ProvisioningStyle = Automatic;
};
3362521ADE965E6BA3383045 = {
@@ -490,6 +494,7 @@
A43FF80A5C7FC09E07294B60 /* AppDelegate.swift in Sources */,
17DBE498E2505814F5F40D6E /* AppMenuView.swift in Sources */,
97E28F8EF7418C7D7A4797B1 /* AppTheme.swift in Sources */,
0DAD4787CA9EA8E7EFA4E65E /* AutoBackupCoordinator.swift in Sources */,
957976696AD0F01A7BA054DE /* BackgroundTaskManager.swift in Sources */,
C0709000E6CAE98224DE4D98 /* BackupEngine.swift in Sources */,
67149DE0D19696D9327AEBAB /* BackupError.swift in Sources */,
@@ -567,7 +572,6 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
DEVELOPMENT_TEAM = K8BLMMR883;
INFOPLIST_FILE = App/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -747,7 +751,6 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
DEVELOPMENT_TEAM = K8BLMMR883;
INFOPLIST_FILE = App/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",