diff --git a/mobile/.gitignore b/mobile/.gitignore index c27f5ca3f1..1b035f8f3d 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -24,7 +24,6 @@ # Flutter/Dart/Pub related **/doc/api/ -**/ios/ .dart_tool/ .flutter-plugins .flutter-plugins-dependencies diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 34d58a03fc..fb08564767 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -24,6 +24,8 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - path_provider_ios (0.0.1): + - Flutter - photo_manager (2.0.0): - Flutter - FlutterMacOS @@ -55,6 +57,7 @@ DEPENDENCIES: - isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`) + - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`) @@ -90,6 +93,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/ios" + path_provider_ios: + :path: ".symlinks/plugins/path_provider_ios/ios" photo_manager: :path: ".symlinks/plugins/photo_manager/ios" share_plus: @@ -117,6 +122,7 @@ SPEC CHECKSUMS: isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073 package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852 + path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604 SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index b89713af80..8d4a8b3f85 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F32F30299BD2F800CE9261 /* BackgroundServicePlugin.swift */; }; + 65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F32F32299D349D00CE9261 /* BackgroundSyncWorker.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -34,6 +36,8 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; 2E3441B73560D0F6FD25E04F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; + 65F32F30299BD2F800CE9261 /* BackgroundServicePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundServicePlugin.swift; sourceTree = "<group>"; }; + 65F32F32299D349D00CE9261 /* BackgroundSyncWorker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundSyncWorker.swift; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; @@ -80,6 +84,15 @@ name = Frameworks; sourceTree = "<group>"; }; + 65DD438629917FAD0047FFA8 /* BackgroundSync */ = { + isa = PBXGroup; + children = ( + 65F32F32299D349D00CE9261 /* BackgroundSyncWorker.swift */, + 65F32F30299BD2F800CE9261 /* BackgroundServicePlugin.swift */, + ); + path = BackgroundSync; + sourceTree = "<group>"; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -113,6 +126,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + 65DD438629917FAD0047FFA8 /* BackgroundSync */, FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, @@ -275,8 +289,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -363,7 +379,6 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 86; - MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -372,6 +387,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -499,7 +515,6 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 86; - MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -508,6 +523,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -527,7 +543,6 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 86; - MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -536,6 +551,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c87d15a335..83d495ca8a 100644 --- a/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -43,11 +43,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + disableMainThreadChecker = "YES" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> <BuildableProductRunnable runnableDebuggingMode = "0"> diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 70693e4a8c..c9331a780b 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,13 +1,33 @@ import UIKit import Flutter +import BackgroundTasks +import path_provider_ios +import photo_manager @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + + GeneratedPluginRegistrant.register(with: self) + BackgroundServicePlugin.registerBackgroundProcessing() + + BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!) + + BackgroundServicePlugin.setPluginRegistrantCallback { registry in + if !registry.hasPlugin("org.cocoapods.path-provider-ios") { + FLTPathProviderPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.path-provider-ios")!) + } + + if !registry.hasPlugin("org.cocoapods.photo-manager") { + PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!) + } + } + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + } diff --git a/mobile/ios/Runner/Assets.xcassets/Contents.json b/mobile/ios/Runner/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift b/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift new file mode 100644 index 0000000000..1dcf39d1e2 --- /dev/null +++ b/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift @@ -0,0 +1,305 @@ +// +// BackgroundServicePlugin.swift +// Runner +// +// Created by Marty Fuhry on 2/14/23. +// + +import Flutter +import BackgroundTasks +import path_provider_foundation + +class BackgroundServicePlugin: NSObject, FlutterPlugin { + + public static var flutterPluginRegistrantCallback: FlutterPluginRegistrantCallback? + + public static func setPluginRegistrantCallback(_ callback: FlutterPluginRegistrantCallback) { + flutterPluginRegistrantCallback = callback + } + + // Pause the application in XCode, then enter + // e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.backgroundFetch"] + // or + // e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.backgroundProcessing"] + // Then resume the application see the background code run + // Tested on a physical device, not a simulator + // This will submit either the Fetch or Processing command to the BGTaskScheduler for immediate processing. + // In my tests, I can only get app.alextran.immich.backgroundProcessing simulated by running the above command + + // This is the task ID in Info.plist to register as our background task ID + public static let backgroundFetchTaskID = "app.alextran.immich.backgroundFetch" + public static let backgroundProcessingTaskID = "app.alextran.immich.backgroundProcessing" + + // Establish communication with the main isolate and set up the channel call + // to this BackgroundServicePlugion() + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "immich/foregroundChannel", + binaryMessenger: registrar.messenger() + ) + + let instance = BackgroundServicePlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + registrar.addApplicationDelegate(instance) + } + + // Registers the Flutter engine with the plugins, used by the other Background Flutter engine + public static func register(engine: FlutterEngine) { + GeneratedPluginRegistrant.register(with: engine) + } + + // Registers the task IDs from the system so that we can process them here in this class + public static func registerBackgroundProcessing() { + + let processingRegisterd = BGTaskScheduler.shared.register( + forTaskWithIdentifier: backgroundProcessingTaskID, + using: nil) { task in + if task is BGProcessingTask { + handleBackgroundProcessing(task: task as! BGProcessingTask) + } + } + + let fetchRegisterd = BGTaskScheduler.shared.register( + forTaskWithIdentifier: backgroundFetchTaskID, + using: nil) { task in + if task is BGAppRefreshTask { + handleBackgroundFetch(task: task as! BGAppRefreshTask) + } + } + } + + // Handles the channel methods from Flutter + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "enable": + handleBackgroundEnable(call: call, result: result) + break + case "configure": + handleConfigure(call: call, result: result) + break + case "disable": + handleDisable(call: call, result: result) + break + case "isEnabled": + handleIsEnabled(call: call, result: result) + break + case "isIgnoringBatteryOptimizations": + result(FlutterMethodNotImplemented) + break + case "lastBackgroundFetchTime": + let defaults = UserDefaults.standard + let lastRunTime = defaults.value(forKey: "last_background_fetch_run_time") + result(lastRunTime) + case "lastBackgroundProcessingTime": + let defaults = UserDefaults.standard + let lastRunTime = defaults.value(forKey: "last_background_processing_run_time") + result(lastRunTime) + case "numberOfBackgroundProcesses": + handleNumberOfProcesses(call: call, result: result) + default: + result(FlutterMethodNotImplemented) + break + } + } + + // Called by the flutter code when enabled so that we can turn on the backround services + // and save the callback information to communicate on this method channel + public func handleBackgroundEnable(call: FlutterMethodCall, result: FlutterResult) { + + // Needs to parse the arguments from the method call + guard let args = call.arguments as? Array<Any> else { + print("Cannot parse args as array: \(call.arguments)") + result(FlutterMethodNotImplemented) + return + } + + // Requires 3 arguments in the array + guard args.count == 3 else { + print("Requires 3 arguments and received \(args.count)") + result(FlutterMethodNotImplemented) + return + } + + // Parses the arguments + let callbackHandle = args[0] as? Int64 + let notificationTitle = args[1] as? String + let instant = args[2] as? Bool + + // Write enabled to settings + let defaults = UserDefaults.standard + + // We are now enabled, so store this + defaults.set(true, forKey: "background_service_enabled") + + // The callback handle is an int64 address to communicate with the main isolate's + // entry function + defaults.set(callbackHandle, forKey: "callback_handle") + + // This is not used yet and will need to be implemented + defaults.set(notificationTitle, forKey: "notification_title") + + // Schedule the background services if instant + if (instant ?? true) { + BackgroundServicePlugin.scheduleBackgroundSync() + BackgroundServicePlugin.scheduleBackgroundFetch() + } + result(true) + } + + // Called by the flutter code at launch to see if the background service is enabled or not + func handleIsEnabled(call: FlutterMethodCall, result: FlutterResult) { + let defaults = UserDefaults.standard + let enabled = defaults.value(forKey: "background_service_enabled") as? Bool + + // False by default + result(enabled ?? false) + } + + // Called by the Flutter code whenever a change in configuration is set + func handleConfigure(call: FlutterMethodCall, result: FlutterResult) { + + // Needs to be able to parse the arguments or else fail + guard let args = call.arguments as? Array<Any> else { + print("Cannot parse args as array: \(call.arguments)") + result(FlutterError()) + return + } + + // Needs to have 4 arguments in the call or else fail + guard args.count == 4 else { + print("Not enough arguments, 4 required: \(args.count) given") + result(FlutterError()) + return + } + + // Parse the arguments from the method call + let requireUnmeteredNetwork = args[0] as? Bool + let requireCharging = args[1] as? Bool + let triggerUpdateDelay = args[2] as? Int + let triggerMaxDelay = args[3] as? Int + + // Store the values from the call in the defaults + let defaults = UserDefaults.standard + defaults.set(requireUnmeteredNetwork, forKey: "require_unmetered_network") + defaults.set(requireCharging, forKey: "require_charging") + defaults.set(triggerUpdateDelay, forKey: "trigger_update_delay") + defaults.set(triggerMaxDelay, forKey: "trigger_max_delay") + + // Cancel the background services and reschedule them + BGTaskScheduler.shared.cancelAllTaskRequests() + BackgroundServicePlugin.scheduleBackgroundSync() + BackgroundServicePlugin.scheduleBackgroundFetch() + result(true) + } + + // Returns the number of currently scheduled background processes to Flutter, striclty + // for debugging + func handleNumberOfProcesses(call: FlutterMethodCall, result: @escaping FlutterResult) { + BGTaskScheduler.shared.getPendingTaskRequests { requests in + result(requests.count) + } + } + + // Disables the service, cancels all the task requests + func handleDisable(call: FlutterMethodCall, result: FlutterResult) { + let defaults = UserDefaults.standard + defaults.set(false, forKey: "background_service_enabled") + + BGTaskScheduler.shared.cancelAllTaskRequests() + result(true) + } + + // Schedules a short-running background sync to sync only a few photos + static func scheduleBackgroundFetch() { + // We will only schedule this task to run if the user has explicitely allowed us to backup while + // not connected to power + let defaults = UserDefaults.standard + if defaults.value(forKey: "require_charging") as? Bool == true { + return + } + + let backgroundFetch = BGAppRefreshTaskRequest(identifier: BackgroundServicePlugin.backgroundFetchTaskID) + + // Use 5 minutes from now as earliest begin date + backgroundFetch.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) + + do { + try BGTaskScheduler.shared.submit(backgroundFetch) + } catch { + print("Could not schedule the background task \(error.localizedDescription)") + } + } + + // Schedules a long-running background sync for syncing all of the photos + static func scheduleBackgroundSync() { + let backgroundProcessing = BGProcessingTaskRequest(identifier: BackgroundServicePlugin.backgroundProcessingTaskID) + + // We need the values for requiring charging + let defaults = UserDefaults.standard + let requireCharging = defaults.value(forKey: "require_charging") as? Bool + + // Always require network connectivity, and set the require charging from the above + backgroundProcessing.requiresNetworkConnectivity = true + backgroundProcessing.requiresExternalPower = requireCharging ?? true + + // Use 15 minutes from now as earliest begin date + backgroundProcessing.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + + do { + // Submit the task to the scheduler + try BGTaskScheduler.shared.submit(backgroundProcessing) + } catch { + print("Could not schedule the background task \(error.localizedDescription)") + } + } + + // This function runs when the system kicks off the BGAppRefreshTask from the Background Task Scheduler + static func handleBackgroundFetch(task: BGAppRefreshTask) { + // Log the time of last background processing to now + let defaults = UserDefaults.standard + defaults.set(Date().timeIntervalSince1970, forKey: "last_background_fetch_run_time") + + // Schedule the next sync task so we can run this again later + scheduleBackgroundFetch() + + // The background sync task should only run for 20 seconds at most + BackgroundServicePlugin.runBackgroundSync(task, maxSeconds: 20) + } + + // This function runs when the system kicks off the BGProcessingTask from the Background Task Scheduler + static func handleBackgroundProcessing(task: BGProcessingTask) { + // Log the time of last background processing to now + let defaults = UserDefaults.standard + defaults.set(Date().timeIntervalSince1970, forKey: "last_background_processing_run_time") + + // Schedule the next sync task so we run this again later + scheduleBackgroundSync() + + // We won't specify a max time for the background sync service, so this can run for longer + BackgroundServicePlugin.runBackgroundSync(task, maxSeconds: nil) + } + + // This is a synchronous function which uses a semaphore to run the background sync worker's run + // function, which will create a background Isolate and communicate with the Flutter code to back + // up the assets. When it completes, we signal the semaphore and complete the execution allowing the + // control to pass back to the caller synchronously + static func runBackgroundSync(_ task: BGTask, maxSeconds: Int?) { + + let semaphore = DispatchSemaphore(value: 0) + DispatchQueue.main.async { + let backgroundWorker = BackgroundSyncWorker { _ in + semaphore.signal() + } + task.expirationHandler = { + backgroundWorker.cancel() + task.setTaskCompleted(success: true) + } + + backgroundWorker.run(maxSeconds: maxSeconds) + task.setTaskCompleted(success: true) + } + semaphore.wait() + } + + +} diff --git a/mobile/ios/Runner/BackgroundSync/BackgroundSyncWorker.swift b/mobile/ios/Runner/BackgroundSync/BackgroundSyncWorker.swift new file mode 100644 index 0000000000..10f1bb24a1 --- /dev/null +++ b/mobile/ios/Runner/BackgroundSync/BackgroundSyncWorker.swift @@ -0,0 +1,188 @@ +// +// BackgroundSyncProcessing.swift +// Runner +// +// Created by Marty Fuhry on 2/6/23. +// +// Credit to https://github.com/fluttercommunity/flutter_workmanager/blob/main/ios/Classes/BackgroundWorker.swift + +import Foundation +import Flutter +import BackgroundTasks + +// The background worker which creates a new Flutter VM, communicates with it +// to run the backup job, and then finishes execution and calls back to its callback +// handler +class BackgroundSyncWorker { + + // The Flutter engine we create for background execution. + // This is not the main Flutter engine which shows the UI, + // this is a brand new isolate created and managed in this code + // here. It does not share memory with the main + // Flutter engine which shows the UI. + // It needs to be started up, registered, and torn down here + let engine: FlutterEngine? = FlutterEngine( + name: "BackgroundImmich" + ) + + // The background message passing channel + var channel: FlutterMethodChannel? + + var completionHandler: (UIBackgroundFetchResult) -> Void + let taskSessionStart = Date() + + // We need the completion handler to tell the system when we are done running + init(_ completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + + // This is the background message passing channel to be used with the background engine + // created here in this platform code + self.channel = FlutterMethodChannel( + name: "immich/backgroundChannel", + binaryMessenger: engine!.binaryMessenger + ) + self.completionHandler = completionHandler + } + + // Handles all of the messages from the Flutter VM called into this platform code + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "initialized": + // Initialize tells us that we can now call into the Flutter VM to tell it to begin the update + self.channel?.invokeMethod( + "backgroundProcessing", + arguments: nil, + result: { flutterResult in + + // This is the result we send back to the BGTaskScheduler to let it know whether we'll need more time later or + // if this execution failed + let result: UIBackgroundFetchResult = (flutterResult as? Bool ?? false) ? .newData : .failed + + // Show the task duration + let taskSessionCompleter = Date() + let taskDuration = taskSessionCompleter.timeIntervalSince(self.taskSessionStart) + print("[\(String(describing: self))] \(#function) -> performBackgroundRequest.\(result) (finished in \(taskDuration) seconds)") + + // Complete the execution + self.complete(result) + }) + break + case "updateNotification": + // TODO: implement update notification + result(true) + break + case "showError": + // TODO: implement show error + result(true) + break + case "clearErrorNotifications": + // TODO: implement clear error notifications + result(true) + break + case "hasContentChanged": + // This is only called for Android, but we provide an implementation here + // telling Flutter that we don't have any information about whether the gallery + // contents have changed or not, so we can just say "no, they've not changed" + result(false) + break + default: + result(FlutterError()) + self.complete(UIBackgroundFetchResult.failed) + } + } + + // Runs the background sync by starting up a new isolate and handling the calls + // until it completes + public func run(maxSeconds: Int?) { + // We need the callback handle to start up the Flutter VM from the entry point + let defaults = UserDefaults.standard + guard let callbackHandle = defaults.value(forKey: "callback_handle") as? Int64 else { + // Can't find the callback handle, this is fatal + complete(UIBackgroundFetchResult.failed) + return + + } + + // Use the provided callbackHandle to get the callback function + guard let callback = FlutterCallbackCache.lookupCallbackInformation(callbackHandle) else { + // We need this callback or else this is fatal + complete(UIBackgroundFetchResult.failed) + return + } + + // Sanity check for the engine existing + if engine == nil { + complete(UIBackgroundFetchResult.failed) + return + } + + // Run the engine + let isRunning = engine!.run( + withEntrypoint: callback.callbackName, + libraryURI: callback.callbackLibraryPath + ) + + // If this engine isn't running, this is fatal + if !isRunning { + complete(UIBackgroundFetchResult.failed) + return + } + + // If we have a timer, we need to start the timer to cancel ourselves + // so that we don't run longer than the provided maxSeconds + // After maxSeconds has elapsed, we will invoke "systemStop" + if maxSeconds != nil { + // Schedule a non-repeating timer to run after maxSeconds + let timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(maxSeconds!), + repeats: false) { timer in + // The callback invalidates the timer and stops execution + timer.invalidate() + + // If the channel is already deallocated, we don't need to do anything + if self.channel == nil { + return + } + + // Tell the Flutter VM to stop backing up now + self.channel?.invokeMethod( + "systemStop", + arguments: nil, + result: nil) + + // Complete the execution + self.complete(UIBackgroundFetchResult.newData) + } + } + + // Set the handle function to the channel message handler + self.channel?.setMethodCallHandler(handle) + + // Register this to get access to the plugins on the platform channel + BackgroundServicePlugin.flutterPluginRegistrantCallback?(engine!) + } + + // Cancels execution of this task, used by the system's task expiration handler + // which is called shortly before execution is about to expire + public func cancel() { + // If the channel is already deallocated, we don't need to do anything + if self.channel == nil { + return + } + + // Tell the Flutter VM to stop backing up now + self.channel?.invokeMethod( + "systemStop", + arguments: nil, + result: nil) + + // Complete the execution + self.complete(UIBackgroundFetchResult.newData) + } + + // Completes the execution, destroys the engine, and sends a completion to our callback completionHandler + private func complete(_ fetchResult: UIBackgroundFetchResult) { + engine?.destroyContext() + channel = nil + completionHandler(fetchResult) + } +} + diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index a2bd73c438..ae11697bd5 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -2,6 +2,13 @@ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> + <key>BGTaskSchedulerPermittedIdentifiers</key> + <array> + <string>app.alextran.immich.backgroundFetch</string> + <string>app.alextran.immich.backgroundProcessing</string> + </array> + <key>CADisableMinimumFrameDurationOnPhone</key> + <true/> <key>CFBundleDevelopmentRegion</key> <string>$(DEVELOPMENT_LANGUAGE)</string> <key>CFBundleDisplayName</key> @@ -12,66 +19,6 @@ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <key>CFBundleInfoDictionaryVersion</key> <string>6.0</string> - <key>CFBundleName</key> - <string>immich_mobile</string> - <key>CFBundlePackageType</key> - <string>APPL</string> - <key>CFBundleShortVersionString</key> - <string>1.47.0</string> - <key>CFBundleSignature</key> - <string>????</string> - <key>CFBundleVersion</key> - <string>86</string> - <key>LSRequiresIPhoneOS</key> - <true/> - <key>MGLMapboxMetricsEnabledSettingShownInApp</key> - <true/> - <key>NSAppTransportSecurity</key> - <dict> - <key>NSAllowsArbitraryLoads</key> - <true/> - </dict> - <key>NSLocationAlwaysUsageDescription</key> - <string>Enable location setting to show position of assets on map</string> - <key>NSLocationWhenInUseUsageDescription</key> - <string>Enable location setting to show position of assets on map</string> - <key>NSPhotoLibraryUsageDescription</key> - <string>We need to manage backup your photos album</string> - <key>NSPhotoLibraryAddUsageDescription</key> - <string>We need to manage backup your photos album</string> - <key>NSCameraUsageDescription</key> - <string>We need to access the camera to let you take beautiful video using this app</string> - <key>NSMicrophoneUsageDescription</key> - <string>We need to access the microphone to let you take beautiful video using this app</string> - <key>UILaunchStoryboardName</key> - <string>LaunchScreen</string> - <key>UIMainStoryboardFile</key> - <string>Main</string> - <key>UISupportedInterfaceOrientations</key> - <array> - <string>UIInterfaceOrientationPortrait</string> - <string>UIInterfaceOrientationLandscapeLeft</string> - <string>UIInterfaceOrientationLandscapeRight</string> - </array> - <key>UISupportedInterfaceOrientations~ipad</key> - <array> - <string>UIInterfaceOrientationPortrait</string> - <string>UIInterfaceOrientationPortraitUpsideDown</string> - <string>UIInterfaceOrientationLandscapeLeft</string> - <string>UIInterfaceOrientationLandscapeRight</string> - </array> - <key>UIViewControllerBasedStatusBarAppearance</key> - <true/> - <key>io.flutter.embedded_views_preview</key> - <true/> - <key>ITSAppUsesNonExemptEncryption</key> - <false/> - <key>CADisableMinimumFrameDurationOnPhone</key> - <true/> - <key>LSApplicationQueriesSchemes</key> - <array> - <string>https</string> - </array> <key>CFBundleLocalizations</key> <array> <string>cs</string> @@ -92,11 +39,74 @@ <string>sk</string> <string>zh</string> </array> - <key>UIStatusBarHidden</key> + <key>CFBundleName</key> + <string>immich_mobile</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleShortVersionString</key> + <string>1.47.0</string> + <key>CFBundleSignature</key> + <string>????</string> + <key>CFBundleVersion</key> + <string>86</string> + <key>FLTEnableImpeller</key> + <true/> + <key>ITSAppUsesNonExemptEncryption</key> <false/> + <key>LSApplicationQueriesSchemes</key> + <array> + <string>https</string> + </array> + <key>LSRequiresIPhoneOS</key> + <true/> + <key>MGLMapboxMetricsEnabledSettingShownInApp</key> + <true/> + <key>NSAppTransportSecurity</key> + <dict> + <key>NSAllowsArbitraryLoads</key> + <true/> + </dict> + <key>NSCameraUsageDescription</key> + <string>We need to access the camera to let you take beautiful video using this app</string> + <key>NSLocationAlwaysUsageDescription</key> + <string>Enable location setting to show position of assets on map</string> + <key>NSLocationWhenInUseUsageDescription</key> + <string>Enable location setting to show position of assets on map</string> + <key>NSMicrophoneUsageDescription</key> + <string>We need to access the microphone to let you take beautiful video using this app</string> + <key>NSPhotoLibraryAddUsageDescription</key> + <string>We need to manage backup your photos album</string> + <key>NSPhotoLibraryUsageDescription</key> + <string>We need to manage backup your photos album</string> <key>UIApplicationSupportsIndirectInputEvents</key> <true/> - <key>FLTEnableImpeller</key> + <key>UIBackgroundModes</key> + <array> + <string>fetch</string> + <string>processing</string> + </array> + <key>UILaunchStoryboardName</key> + <string>LaunchScreen</string> + <key>UIMainStoryboardFile</key> + <string>Main</string> + <key>UIStatusBarHidden</key> + <false/> + <key>UISupportedInterfaceOrientations</key> + <array> + <string>UIInterfaceOrientationPortrait</string> + <string>UIInterfaceOrientationLandscapeLeft</string> + <string>UIInterfaceOrientationLandscapeRight</string> + </array> + <key>UISupportedInterfaceOrientations~ipad</key> + <array> + <string>UIInterfaceOrientationPortrait</string> + <string>UIInterfaceOrientationPortraitUpsideDown</string> + <string>UIInterfaceOrientationLandscapeLeft</string> + <string>UIInterfaceOrientationLandscapeRight</string> + </array> + <key>UIViewControllerBasedStatusBarAppearance</key> + <true/> + <key>io.flutter.embedded_views_preview</key> <true/> </dict> </plist> diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index fe96a5d274..f85189358f 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -47,10 +47,6 @@ Future<void> openBoxes() async { Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox), Hive.openBox(hiveGithubReleaseInfoBox), Hive.openBox(userSettingInfoBox), - if (!Platform.isAndroid) Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox), - if (!Platform.isAndroid) - Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox), - if (!Platform.isAndroid) Hive.openBox(backgroundBackupInfoBox), EasyLocalization.ensureInitialized(), ]); } diff --git a/mobile/lib/modules/backup/background_service/background.service.dart b/mobile/lib/modules/backup/background_service/background.service.dart index 1e3e120851..59ad4dc604 100644 --- a/mobile/lib/modules/backup/background_service/background.service.dart +++ b/mobile/lib/modules/backup/background_service/background.service.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:developer'; import 'dart:io'; import 'dart:isolate'; -import 'dart:ui' show IsolateNameServer, PluginUtilities; +import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities; import 'package:cancellation_token_http/http.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; @@ -10,7 +10,6 @@ import 'package:flutter/widgets.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/hive_box.dart'; -import 'package:immich_mobile/modules/backup/background_service/localization.dart'; import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart'; import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart'; import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; @@ -19,6 +18,7 @@ import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; +import 'package:path_provider_ios/path_provider_ios.dart'; import 'package:photo_manager/photo_manager.dart'; final backgroundServiceProvider = Provider( @@ -51,8 +51,7 @@ class BackgroundService { late final _Throttle _throttledDetailNotify = _Throttle(_updateDetailProgress, notifyInterval); Completer<bool> _hasAccessCompleter = Completer(); - late Future<bool> _hasAccess = - Platform.isAndroid ? _hasAccessCompleter.future : Future.value(true); + late Future<bool> _hasAccess = _hasAccessCompleter.future; Future<bool> get hasAccess => _hasAccess; @@ -67,9 +66,6 @@ class BackgroundService { /// Enqueues the background service Future<bool> enableService({bool immediate = false}) async { - if (!Platform.isAndroid) { - return true; - } try { final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!; final String title = @@ -89,9 +85,6 @@ class BackgroundService { int triggerUpdateDelay = 5000, int triggerMaxDelay = 50000, }) async { - if (!Platform.isAndroid) { - return true; - } try { final bool ok = await _foregroundChannel.invokeMethod( 'configure', @@ -110,9 +103,6 @@ class BackgroundService { /// Cancels the background service (if currently running) and removes it from work queue Future<bool> disableService() async { - if (!Platform.isAndroid) { - return true; - } try { final ok = await _foregroundChannel.invokeMethod('disable'); return ok; @@ -123,9 +113,6 @@ class BackgroundService { /// Returns `true` if the background service is enabled Future<bool> isBackgroundBackupEnabled() async { - if (!Platform.isAndroid) { - return false; - } try { return await _foregroundChannel.invokeMethod("isEnabled"); } catch (error) { @@ -135,7 +122,8 @@ class BackgroundService { /// Returns `true` if battery optimizations are disabled Future<bool> isIgnoringBatteryOptimizations() async { - if (!Platform.isAndroid) { + // iOS does not need battery optimizations enabled + if (Platform.isIOS) { return true; } try { @@ -156,9 +144,6 @@ class BackgroundService { bool isDetail = false, bool onlyIfFG = false, }) async { - if (!Platform.isAndroid) { - return true; - } try { if (_isBackgroundInitialized) { return _backgroundChannel.invokeMethod<bool>( @@ -178,9 +163,6 @@ class BackgroundService { String? content, String? individualTag, }) async { - if (!Platform.isAndroid) { - return true; - } try { if (_isBackgroundInitialized && _errorGracePeriodExceeded) { return await _backgroundChannel @@ -193,9 +175,6 @@ class BackgroundService { } Future<bool> _clearErrorNotifications() async { - if (!Platform.isAndroid) { - return true; - } try { if (_isBackgroundInitialized) { return await _backgroundChannel.invokeMethod('clearErrorNotifications'); @@ -210,9 +189,6 @@ class BackgroundService { /// await to ensure this thread (foreground or background) has exclusive access Future<bool> acquireLock() async { - if (!Platform.isAndroid) { - return true; - } if (_hasLock) { debugPrint("WARNING: [acquireLock] called more than once"); return true; @@ -253,7 +229,7 @@ class BackgroundService { while (_wantsLockTime == lockTime) { other.send(tempSp); final dynamic answer = await bs.first - .timeout(const Duration(seconds: 5), onTimeout: () => null); + .timeout(const Duration(seconds: 3), onTimeout: () => null); if (_wantsLockTime != lockTime) { break; } @@ -270,7 +246,7 @@ class BackgroundService { // other isolate is still active } final dynamic isFinished = await bs.first - .timeout(const Duration(seconds: 5), onTimeout: () => false); + .timeout(const Duration(seconds: 3), onTimeout: () => false); if (isFinished == true) { break; } @@ -288,9 +264,6 @@ class BackgroundService { /// releases the exclusive access lock void releaseLock() { - if (!Platform.isAndroid) { - return; - } _wantsLockTime = 0; if (_hasLock) { _hasAccessCompleter = Completer(); @@ -311,17 +284,35 @@ class BackgroundService { } Future<bool> _callHandler(MethodCall call) async { + DartPluginRegistrant.ensureInitialized(); + if (Platform.isIOS) { + // NOTE: I'm not sure this is strictly necessary anymore, but + // out of an abundance of caution, we will keep it in until someone + // can say for sure + PathProviderIOS.registerWith(); + } switch (call.method) { + case "backgroundProcessing": case "onAssetsChanged": - final Future<bool> translationsLoaded = loadTranslations(); try { _clearErrorNotifications(); - final bool hasAccess = await acquireLock(); + + // iOS should time out after some threshhold so it doesn't wait + // indefinitely and can run later + // Android is fine to wait here until the lock releases + final waitForLock = Platform.isIOS + ? acquireLock() + .timeout( + const Duration(seconds: 5), + onTimeout: () => false, + ) + : acquireLock(); + + final bool hasAccess = await waitForLock; if (!hasAccess) { debugPrint("[_callHandler] could not acquire lock, exiting"); return false; } - await translationsLoaded; final bool ok = await _onAssetsChanged(); return ok; } catch (error) { @@ -388,9 +379,9 @@ class BackgroundService { .put(backupFailedSince, DateTime.now()); return false; } - // check for new assets added while performing backup - } while (true == - await _backgroundChannel.invokeMethod<bool>("hasContentChanged")); + // Android should check for new assets added while performing backup + } while (Platform.isAndroid && + true == await _backgroundChannel.invokeMethod<bool>("hasContentChanged")); return true; } @@ -560,6 +551,28 @@ class BackgroundService { final String total = numberFormat.format(bytesTotal / 1024.0); return "$percent% ($done/$total$unit)"; } + + Future<DateTime?> getIOSBackupLastRun(IosBackgroundTask task) async { + // Seconds since last run + final double? lastRun = task == IosBackgroundTask.fetch + ? await _foregroundChannel.invokeMethod('lastBackgroundFetchTime') + : await _foregroundChannel.invokeMethod('lastBackgroundProcessingTime'); + if (lastRun == null) { + return null; + } + final time = DateTime.fromMillisecondsSinceEpoch(lastRun.toInt() * 1000); + return time; + } + + Future<int> getIOSBackupNumberOfProcesses() async { + return await _foregroundChannel + .invokeMethod('numberOfBackgroundProcesses'); + } +} + +enum IosBackgroundTask { + fetch, + processing } class _Throttle { @@ -603,6 +616,7 @@ class _Throttle { @pragma('vm:entry-point') void _nativeEntry() { WidgetsFlutterBinding.ensureInitialized(); + DartPluginRegistrant.ensureInitialized(); BackgroundService backgroundService = BackgroundService(); backgroundService._setupBackgroundCallHandler(); } diff --git a/mobile/lib/modules/backup/providers/backup.provider.dart b/mobile/lib/modules/backup/providers/backup.provider.dart index 38b4670b23..2e57250a06 100644 --- a/mobile/lib/modules/backup/providers/backup.provider.dart +++ b/mobile/lib/modules/backup/providers/backup.provider.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:cancellation_token_http/http.dart'; import 'package:flutter/widgets.dart'; import 'package:hive_flutter/hive_flutter.dart'; @@ -130,7 +128,6 @@ class BackupNotifier extends StateNotifier<BackUpState> { requireCharging != null || triggerDelay != null, ); - if (Platform.isAndroid) { final bool wasEnabled = state.backgroundBackup; final bool wasWifi = state.backupRequireWifi; final bool wasCharging = state.backupRequireCharging; @@ -180,7 +177,6 @@ class BackupNotifier extends StateNotifier<BackUpState> { onError("backup_controller_page_background_configure_error"); } } - } } /// @@ -577,48 +573,47 @@ class BackupNotifier extends StateNotifier<BackUpState> { } Future<void> resumeBackup() async { - if (Platform.isAndroid) { - // assumes the background service is currently running - // if true, waits until it has stopped to update the app state from HiveDB - // before actually resuming backup by calling the internal `_resumeBackup` - final BackUpProgressEnum previous = state.backupProgress; - state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground); - final bool hasLock = await _backgroundService.acquireLock(); - if (!hasLock) { - log.warning("WARNING [resumeBackup] failed to acquireLock"); - return; - } - await Future.wait([ - Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox), - Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox), - Hive.openBox(backgroundBackupInfoBox), - ]); - final HiveBackupAlbums? albums = - Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).get(backupInfoKey); - Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums; - Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums; - if (albums != null) { - selectedAlbums = _updateAlbumsBackupTime( - selectedAlbums, - albums.selectedAlbumIds, - albums.lastSelectedBackupTime, - ); - excludedAlbums = _updateAlbumsBackupTime( - excludedAlbums, - albums.excludedAlbumsIds, - albums.lastExcludedBackupTime, - ); - } - final Box backgroundBox = Hive.box(backgroundBackupInfoBox); - state = state.copyWith( - backupProgress: previous, - selectedBackupAlbums: selectedAlbums, - excludedBackupAlbums: excludedAlbums, - backupRequireWifi: backgroundBox.get(backupRequireWifi), - backupRequireCharging: backgroundBox.get(backupRequireCharging), - backupTriggerDelay: backgroundBox.get(backupTriggerDelay), + + // assumes the background service is currently running + // if true, waits until it has stopped to update the app state from HiveDB + // before actually resuming backup by calling the internal `_resumeBackup` + final BackUpProgressEnum previous = state.backupProgress; + state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground); + final bool hasLock = await _backgroundService.acquireLock(); + if (!hasLock) { + log.warning("WARNING [resumeBackup] failed to acquireLock"); + return; + } + await Future.wait([ + Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox), + Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox), + Hive.openBox(backgroundBackupInfoBox), + ]); + final HiveBackupAlbums? albums = + Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).get(backupInfoKey); + Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums; + Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums; + if (albums != null) { + selectedAlbums = _updateAlbumsBackupTime( + selectedAlbums, + albums.selectedAlbumIds, + albums.lastSelectedBackupTime, + ); + excludedAlbums = _updateAlbumsBackupTime( + excludedAlbums, + albums.excludedAlbumsIds, + albums.lastExcludedBackupTime, ); } + final Box backgroundBox = Hive.box(backgroundBackupInfoBox); + state = state.copyWith( + backupProgress: previous, + selectedBackupAlbums: selectedAlbums, + excludedBackupAlbums: excludedAlbums, + backupRequireWifi: backgroundBox.get(backupRequireWifi), + backupRequireCharging: backgroundBox.get(backupRequireCharging), + backupTriggerDelay: backgroundBox.get(backupTriggerDelay), + ); return _resumeBackup(); } @@ -649,8 +644,7 @@ class BackupNotifier extends StateNotifier<BackUpState> { AppStateEnum.paused, AppStateEnum.detached, ]; - if (Platform.isAndroid && - allowedStates.contains(ref.read(appStateProvider.notifier).state)) { + if (allowedStates.contains(ref.read(appStateProvider.notifier).state)) { try { if (Hive.isBoxOpen(hiveBackupInfoBox)) { await Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).close(); @@ -683,6 +677,7 @@ class BackupNotifier extends StateNotifier<BackUpState> { _backgroundService.releaseLock(); } } + } final backupProvider = diff --git a/mobile/lib/modules/backup/ui/ios_debug_info_tile.dart b/mobile/lib/modules/backup/ui/ios_debug_info_tile.dart new file mode 100644 index 0000000000..5f796622a1 --- /dev/null +++ b/mobile/lib/modules/backup/ui/ios_debug_info_tile.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; +import 'package:intl/intl.dart'; + +/// This is a simple debug widget which should be removed later on when we are +/// more confident about background sync +class IosDebugInfoTile extends HookConsumerWidget { + const IosDebugInfoTile({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final futures = [ + ref.read(backgroundServiceProvider) + .getIOSBackupLastRun(IosBackgroundTask.fetch), + ref.read(backgroundServiceProvider) + .getIOSBackupLastRun(IosBackgroundTask.processing), + ref.read(backgroundServiceProvider) + .getIOSBackupNumberOfProcesses(), + ]; + return FutureBuilder<List<dynamic>>( + future: Future.wait(futures), + builder: (context, snapshot) { + String? title; + String? subtitle; + if (snapshot.hasData) { + final results = snapshot.data as List<dynamic>; + final fetch = results[0] as DateTime?; + final processing = results[1] as DateTime?; + final processes = results[2] as int; + + final processOrProcesses = processes == 1 ? 'process' : 'processes'; + final numberOrZero = processes == 0 ? 'No' : processes.toString(); + title = '$numberOrZero background $processOrProcesses queued'; + + final df = DateFormat.yMd().add_jm(); + if (fetch == null && processing == null) { + subtitle = 'No background sync job has run yet'; + } else if (fetch != null && processing == null) { + subtitle = 'Fetch ran ${df.format(fetch)}'; + } else if (processing != null && fetch == null) { + subtitle = 'Processing ran ${df.format(processing)}'; + } else { + final fetchOrProcessing = fetch!.isAfter(processing!) + ? fetch + : processing; + subtitle = 'Last sync ${df.format(fetchOrProcessing)}'; + } + } + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: ListTile( + key: ValueKey(title), + title: Text(title ?? ''), + subtitle: Text(subtitle ?? ''), + leading: Icon( + Icons.bug_report, + color: Theme.of(context).primaryColor, + ), + ), + ); + }, + ); + } +} + diff --git a/mobile/lib/modules/backup/views/backup_controller_page.dart b/mobile/lib/modules/backup/views/backup_controller_page.dart index 8fc9b035e5..6a950eb476 100644 --- a/mobile/lib/modules/backup/views/backup_controller_page.dart +++ b/mobile/lib/modules/backup/views/backup_controller_page.dart @@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart'; import 'package:immich_mobile/modules/backup/ui/current_backup_asset_info_box.dart'; +import 'package:immich_mobile/modules/backup/ui/ios_debug_info_tile.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/backup/models/backup_state.model.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; @@ -195,7 +196,7 @@ class BackupControllerPage extends HookConsumerWidget { ); } - ListTile buildBackgroundBackupController() { + Widget buildBackgroundBackupController() { final bool isBackgroundEnabled = backupState.backgroundBackup; final bool isWifiRequired = backupState.backupRequireWifi; final bool isChargingRequired = backupState.backupRequireCharging; @@ -240,118 +241,126 @@ class BackupControllerPage extends HookConsumerWidget { final triggerDelay = useState(backupDelayToSliderValue(backupState.backupTriggerDelay)); - return ListTile( - isThreeLine: true, - leading: isBackgroundEnabled - ? Icon( - Icons.cloud_sync_rounded, - color: activeColor, - ) - : const Icon(Icons.cloud_sync_rounded), - title: Text( - isBackgroundEnabled - ? "backup_controller_page_background_is_on" - : "backup_controller_page_background_is_off", - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), - ).tr(), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isBackgroundEnabled) - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: - const Text("backup_controller_page_background_description") - .tr(), - ), - if (isBackgroundEnabled) - SwitchListTile( - title: - const Text("backup_controller_page_background_wifi").tr(), - secondary: Icon( - Icons.wifi, - color: isWifiRequired ? activeColor : null, - ), - dense: true, - activeColor: activeColor, - value: isWifiRequired, - onChanged: hasExclusiveAccess - ? (isChecked) => ref - .read(backupProvider.notifier) - .configureBackgroundBackup( - requireWifi: isChecked, - onError: showErrorToUser, - onBatteryInfo: showBatteryOptimizationInfoToUser, - ) - : null, - ), - if (isBackgroundEnabled) - SwitchListTile( - title: const Text("backup_controller_page_background_charging") - .tr(), - secondary: Icon( - Icons.charging_station, - color: isChargingRequired ? activeColor : null, - ), - dense: true, - activeColor: activeColor, - value: isChargingRequired, - onChanged: hasExclusiveAccess - ? (isChecked) => ref - .read(backupProvider.notifier) - .configureBackgroundBackup( - requireCharging: isChecked, - onError: showErrorToUser, - onBatteryInfo: showBatteryOptimizationInfoToUser, - ) - : null, - ), - if (isBackgroundEnabled) - ListTile( - isThreeLine: false, - dense: true, - enabled: hasExclusiveAccess, - title: const Text( - 'backup_controller_page_background_delay', - style: TextStyle( - fontWeight: FontWeight.bold, + return Column( + children: [ + ListTile( + isThreeLine: true, + leading: isBackgroundEnabled + ? Icon( + Icons.cloud_sync_rounded, + color: activeColor, + ) + : const Icon(Icons.cloud_sync_rounded), + title: Text( + isBackgroundEnabled + ? "backup_controller_page_background_is_on" + : "backup_controller_page_background_is_off", + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ).tr(), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isBackgroundEnabled) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: + const Text("backup_controller_page_background_description") + .tr(), ), - ).tr(args: [formatBackupDelaySliderValue(triggerDelay.value)]), - subtitle: Slider( - value: triggerDelay.value, - onChanged: hasExclusiveAccess - ? (double v) => triggerDelay.value = v - : null, - onChangeEnd: (double v) => ref - .read(backupProvider.notifier) - .configureBackgroundBackup( - triggerDelay: backupDelayToMilliseconds(v), - onError: showErrorToUser, - onBatteryInfo: showBatteryOptimizationInfoToUser, + if (isBackgroundEnabled && Platform.isAndroid) + SwitchListTile( + title: + const Text("backup_controller_page_background_wifi").tr(), + secondary: Icon( + Icons.wifi, + color: isWifiRequired ? activeColor : null, + ), + dense: true, + activeColor: activeColor, + value: isWifiRequired, + onChanged: hasExclusiveAccess + ? (isChecked) => ref + .read(backupProvider.notifier) + .configureBackgroundBackup( + requireWifi: isChecked, + onError: showErrorToUser, + onBatteryInfo: showBatteryOptimizationInfoToUser, + ) + : null, + ), + if (isBackgroundEnabled) + SwitchListTile( + title: const Text("backup_controller_page_background_charging") + .tr(), + secondary: Icon( + Icons.charging_station, + color: isChargingRequired ? activeColor : null, + ), + dense: true, + activeColor: activeColor, + value: isChargingRequired, + onChanged: hasExclusiveAccess + ? (isChecked) => ref + .read(backupProvider.notifier) + .configureBackgroundBackup( + requireCharging: isChecked, + onError: showErrorToUser, + onBatteryInfo: showBatteryOptimizationInfoToUser, + ) + : null, + ), + if (isBackgroundEnabled && Platform.isAndroid) + ListTile( + isThreeLine: false, + dense: true, + enabled: hasExclusiveAccess, + title: const Text( + 'backup_controller_page_background_delay', + style: TextStyle( + fontWeight: FontWeight.bold, ), - max: 3.0, - divisions: 3, - label: formatBackupDelaySliderValue(triggerDelay.value), - activeColor: Theme.of(context).primaryColor, + ).tr(args: [formatBackupDelaySliderValue(triggerDelay.value)]), + subtitle: Slider( + value: triggerDelay.value, + onChanged: hasExclusiveAccess + ? (double v) => triggerDelay.value = v + : null, + onChangeEnd: (double v) => ref + .read(backupProvider.notifier) + .configureBackgroundBackup( + triggerDelay: backupDelayToMilliseconds(v), + onError: showErrorToUser, + onBatteryInfo: showBatteryOptimizationInfoToUser, + ), + max: 3.0, + divisions: 3, + label: formatBackupDelaySliderValue(triggerDelay.value), + activeColor: Theme.of(context).primaryColor, + ), + ), + ElevatedButton( + onPressed: () => + ref.read(backupProvider.notifier).configureBackgroundBackup( + enabled: !isBackgroundEnabled, + onError: showErrorToUser, + onBatteryInfo: showBatteryOptimizationInfoToUser, + ), + child: Text( + isBackgroundEnabled + ? "backup_controller_page_background_turn_off" + : "backup_controller_page_background_turn_on", + style: + const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ).tr(), ), - ), - ElevatedButton( - onPressed: () => - ref.read(backupProvider.notifier).configureBackgroundBackup( - enabled: !isBackgroundEnabled, - onError: showErrorToUser, - onBatteryInfo: showBatteryOptimizationInfoToUser, - ), - child: Text( - isBackgroundEnabled - ? "backup_controller_page_background_turn_off" - : "backup_controller_page_background_turn_on", - style: - const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), - ).tr(), + ], ), - ], - ), + ), + if (isBackgroundEnabled) + IosDebugInfoTile( + key: ValueKey(isChargingRequired), + ), + ], ); } @@ -592,8 +601,8 @@ class BackupControllerPage extends HookConsumerWidget { ), const Divider(), buildAutoBackupController(), - if (Platform.isAndroid) const Divider(), - if (Platform.isAndroid) buildBackgroundBackupController(), + const Divider(), + buildBackgroundBackupController(), const Divider(), buildStorageInformation(), const Divider(), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 9bf3e466e3..f2a9e074a8 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -165,10 +165,10 @@ packages: dependency: transitive description: name: cancellation_token - sha256: "44891ef71d605bc59ef7974c403630d8e8506fcd897a29c3e38466ef69e5c4eb" + sha256: e40ac742c7faac52e1719ce249934e20975e5772d40112e1e01cfc5abf24185a url: "https://pub.dev" source: hosted - version: "1.6.1" + version: "1.5.0" cancellation_token_http: dependency: "direct main" description: @@ -333,10 +333,10 @@ packages: dependency: transitive description: name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + sha256: "04be3e934c52e082558cc9ee21f42f5c1cd7a1262f4c63cd0357c08d5bba81ec" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.0.1" flutter: dependency: "direct main" description: flutter @@ -428,10 +428,10 @@ packages: dependency: transitive description: name: flutter_riverpod - sha256: "0c997763ce06359ee4686553b74def84062e9d6929ac63f61fa02465c1f8e32c" + sha256: "46a27b7a11dc13738054093076f2dc65692ddcd463979b15092accf5681aea20" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" flutter_test: dependency: "direct dev" description: flutter @@ -483,10 +483,10 @@ packages: dependency: transitive description: name: glob - sha256: "4515b5b6ddb505ebdd242a5f2cc5d22d3d6a80013789debfbda7777f47ea308c" + sha256: c51b4fdfee4d281f49b8c957f1add91b815473597f76bcf07377987f66a55729 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.0" graphs: dependency: transitive description: @@ -523,10 +523,10 @@ packages: dependency: "direct main" description: name: hooks_riverpod - sha256: "71695b2e1dfc22a39f1f9c67b798f8f8f1521f2d0349817d13ccdd5c4cd7acba" + sha256: a596bcb1eaf48eae6da1ce8b9e60ec9538ef7d15725e941c3626f29dfcc01d96 url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" html: dependency: transitive description: @@ -851,6 +851,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + path_provider_ios: + dependency: "direct main" + description: + name: path_provider_ios + sha256: "03d639406f5343478352433f00d3c4394d52dac8df3d847869c5e2333e0bbce8" + url: "https://pub.dev" + source: hosted + version: "2.0.11" path_provider_linux: dependency: transitive description: @@ -975,10 +983,10 @@ packages: dependency: transitive description: name: riverpod - sha256: "0f43c64f1f79c2112c843305a879a746587fb7c1e388f1d4717737796756e2c4" + sha256: "59a48de9c757aa61aa28e9fd625ffb360d43b6b54606f12536622c55be9e8c4b" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" rxdart: dependency: transitive description: @@ -1492,10 +1500,10 @@ packages: dependency: transitive description: name: xml - sha256: "979ee37d622dec6365e2efa4d906c37470995871fe9ae080d967e192d88286b5" + sha256: ac0e3f4bf00ba2708c33fbabbbe766300e509f8c82dbd4ab6525039813f7e2fb url: "https://pub.dev" source: hosted - version: "6.2.2" + version: "6.1.0" xxh3: dependency: transitive description: @@ -1513,5 +1521,5 @@ packages: source: hosted version: "3.1.1" sdks: - dart: ">=2.19.0 <4.0.0" + dart: ">=2.18.0 <3.0.0" flutter: ">=3.3.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 5380b194b3..648c53b1b3 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: flutter: sdk: flutter + path_provider_ios: photo_manager: ^2.5.0 flutter_hooks: ^0.18.0 hooks_riverpod: ^2.0.0-dev.0