mirror of
https://github.com/immich-app/immich.git
synced 2026-02-15 13:28:24 +03:00
add schemas sync variants formatting initial implementation use existing db, wip move to separate folder fix table definitions wip wiring it up
405 lines
17 KiB
Swift
405 lines
17 KiB
Swift
//
|
|
// BackgroundServicePlugin.swift
|
|
// Runner
|
|
//
|
|
// Created by Marty Fuhry on 2/14/23.
|
|
//
|
|
|
|
import Flutter
|
|
import BackgroundTasks
|
|
import path_provider_foundation
|
|
import CryptoKit
|
|
import Network
|
|
|
|
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 background 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, strictly
|
|
// 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 ?? false
|
|
if (requireCharging) {
|
|
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
|
|
}
|
|
}
|
|
|
|
// If we have required Wi-Fi, we can check the isExpensive property
|
|
let requireWifi = defaults.value(forKey: "require_wifi") as? Bool ?? false
|
|
|
|
// The network is expensive and we have required Wi-Fi
|
|
// Therefore, we will simply complete the task without
|
|
// running it
|
|
if (requireWifi && NetworkMonitor.shared.isExpensive) {
|
|
return task.setTaskCompleted(success: true)
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
|
|
}
|