mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-30 10:24:58 -04:00 
			
		
		
		
	* ios: calculate hash using CryptoKit * chore: remove unused crypto dep --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
		
			
				
	
	
		
			394 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			394 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
| //
 | |
| //  BackgroundServicePlugin.swift
 | |
| //  Runner
 | |
| //
 | |
| //  Created by Marty Fuhry on 2/14/23.
 | |
| //
 | |
| 
 | |
| import Flutter
 | |
| import BackgroundTasks
 | |
| import path_provider_foundation
 | |
| import CryptoKit
 | |
| 
 | |
| 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)
 | |
|                 break
 | |
|             case "lastBackgroundProcessingTime":
 | |
|                 let defaults = UserDefaults.standard
 | |
|                 let lastRunTime = defaults.value(forKey: "last_background_processing_run_time")
 | |
|                 result(lastRunTime)
 | |
|                 break
 | |
|             case "numberOfBackgroundProcesses":
 | |
|                 handleNumberOfProcesses(call: call, result: result)
 | |
|                 break
 | |
|             case "backgroundAppRefreshEnabled":
 | |
|                 handleBackgroundRefreshStatus(call: call, result: result)
 | |
|                 break
 | |
|             case "digestFiles":
 | |
|                 handleDigestFiles(call: call, result: result)
 | |
|                 break
 | |
|             default:
 | |
|                 result(FlutterMethodNotImplemented)
 | |
|                 break
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     // Calculates the SHA-1 hash of each file from the list of paths provided
 | |
|     func handleDigestFiles(call: FlutterMethodCall, result: @escaping FlutterResult) {
 | |
|         
 | |
|         let bufsize = 2 * 1024 * 1024
 | |
|         // Private error to throw if file cannot be read
 | |
|         enum DigestError: String, LocalizedError {
 | |
|             case NoFileHandle = "Cannot Open File Handle"
 | |
| 
 | |
|             public var errorDescription: String? { self.rawValue }
 | |
|         }
 | |
|         
 | |
|         // Parse the arguments or else fail
 | |
|         guard let args = call.arguments as? Array<String> else {
 | |
|             print("Cannot parse args as array: \(String(describing: call.arguments))")
 | |
|             result(FlutterError(code: "Malformed",
 | |
|                                 message: "Received args is not an Array<String>",
 | |
|                                 details: nil))
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         // Compute hash in background thread
 | |
|         DispatchQueue.global(qos: .background).async {
 | |
|             var hashes: [FlutterStandardTypedData?] = Array(repeating: nil, count: args.count)
 | |
|             for i in (0 ..< args.count) {
 | |
|                 do {
 | |
|                     guard let file = FileHandle(forReadingAtPath: args[i]) else { throw DigestError.NoFileHandle }
 | |
|                     var hasher = Insecure.SHA1.init();
 | |
|                     while autoreleasepool(invoking: {
 | |
|                         let chunk = file.readData(ofLength: bufsize)
 | |
|                         guard !chunk.isEmpty else { return false } // EOF
 | |
|                         hasher.update(data: chunk)
 | |
|                         return true // continue
 | |
|                     }) { }
 | |
|                     let digest = hasher.finalize()
 | |
|                     hashes[i] = FlutterStandardTypedData(bytes: Data(Array(digest.makeIterator())))
 | |
|                 } catch {
 | |
|                     print("Cannot calculate the digest of the file \(args[i]) due to \(error.localizedDescription)")
 | |
|                 }
 | |
|             }
 | |
|             
 | |
|             // Return result in main thread
 | |
|             DispatchQueue.main.async {
 | |
|                 result(Array(hashes))
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     // 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
 | |
|         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)
 | |
|     }
 | |
|   
 | |
|     // Checks the status of the Background App Refresh from the system
 | |
|     // Returns true if the service is enabled for Immich, and false otherwise
 | |
|     func handleBackgroundRefreshStatus(call: FlutterMethodCall, result: FlutterResult) {
 | |
|         switch UIApplication.shared.backgroundRefreshStatus {
 | |
|         case .available:
 | |
|             result(true)
 | |
|             break
 | |
|         case .denied:
 | |
|             result(false)
 | |
|             break
 | |
|         case .restricted:
 | |
|             result(false)
 | |
|             break
 | |
|         default:
 | |
|             result(false)
 | |
|             break
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     
 | |
|     // Schedules a short-running background sync to sync only a few photos
 | |
|     static func scheduleBackgroundFetch() {
 | |
|         // We will schedule this task to run no matter the charging or wifi requirents from the end user
 | |
|         // 1. They can set Background App Refresh to Off / Wi-Fi / Wi-Fi & Cellular Data from Settings
 | |
|         // 2. We will check the battery connectivity when we begin running the background activity
 | |
|         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) {
 | |
|         // Schedule the next sync task so we can run this again later
 | |
|         scheduleBackgroundFetch()
 | |
|         
 | |
|         // Log the time of last background processing to now
 | |
|         let defaults = UserDefaults.standard
 | |
|         defaults.set(Date().timeIntervalSince1970, forKey: "last_background_fetch_run_time")
 | |
|         
 | |
|         // If we have required charging, we should check the charging status
 | |
|         let requireCharging = defaults.value(forKey: "require_charging") as? Bool
 | |
|         if (requireCharging ?? false) {
 | |
|             UIDevice.current.isBatteryMonitoringEnabled = true
 | |
|             if (UIDevice.current.batteryState == .unplugged) {
 | |
|                 // The device is unplugged and we have required charging
 | |
|                 // Therefore, we will simply complete the task without
 | |
|                 // running it.
 | |
|                 task.setTaskCompleted(success: true)
 | |
|                 return
 | |
|             }
 | |
|         }
 | |
|         
 | |
|         // 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) {
 | |
|         // Schedule the next sync task so we run this again later
 | |
|         scheduleBackgroundSync()
 | |
| 
 | |
|         // Log the time of last background processing to now
 | |
|         let defaults = UserDefaults.standard
 | |
|         defaults.set(Date().timeIntervalSince1970, forKey: "last_background_processing_run_time")
 | |
|         
 | |
|         // 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()
 | |
|     }
 | |
| 
 | |
| 
 | |
| }
 |