1
0
mirror of https://github.com/immich-app/immich.git synced 2025-01-11 06:10:28 +02:00

feat(mobile): iOS background sync (#1758)

* first run of getting background sync working in iOS

* got background sync calling into flutter

* added background task

* added necessary sync files

* fixed some names and added more implementations

* got as far as Hive.initFlutter

* brute force got to await Hive.initFlutter

* lots of print statements to figure out where execution is failing, and its failing at the root asset bundle in the localization.dart service

* first time working, got plugins registered

* removed broken cleanup code

* refactored

* linters

* now can pass user settings

* background service plugin uses app background processing instead of fetch

* renamed backgroundFetch to backgroundProcessing to make it clearer

* don't use max delay

* adds fetch back in

* fixes require charging default values and backup controller page

* fixes background fetch

* fixes ios not importing photos

* guarded path provider ios

* lint

* adds max tries for heartbeat to work in iOS

* fail after seconds

* timeout instead of fail after seconds

* removes release lock from system stop

* restores checkLockReleasedWithHeartbeat to Future<void>

* removes max tries from acquire lock

* fixes lock timeout with iOS

* restored for loop

* adds comments, made the AppRefresh task only run while not requiring network or charge

* fixed compile issue

* now both are registered and added better comments. also added ability for task to cancel itself

* added the podfile and pubspec

* added backup diagnostics to IOS and removed iOS ignored backup options and fixed network connectivity always required

* Added Alex's dev team

* styled debug list item, fixed refresh task not set bug, fixed enable / disable background service on platform channel

---------

Co-authored-by: Marty Fuhry <marty@fuhry.farm>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
martyfuhry 2023-02-20 00:59:50 -05:00 committed by GitHub
parent 2cf42e867c
commit 87fea29e32
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 926 additions and 284 deletions

1
mobile/.gitignore vendored
View File

@ -24,7 +24,6 @@
# Flutter/Dart/Pub related # Flutter/Dart/Pub related
**/doc/api/ **/doc/api/
**/ios/
.dart_tool/ .dart_tool/
.flutter-plugins .flutter-plugins
.flutter-plugins-dependencies .flutter-plugins-dependencies

View File

@ -24,6 +24,8 @@ PODS:
- path_provider_foundation (0.0.1): - path_provider_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- path_provider_ios (0.0.1):
- Flutter
- photo_manager (2.0.0): - photo_manager (2.0.0):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
@ -55,6 +57,7 @@ DEPENDENCIES:
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`) - isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/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`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/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: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation: path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/ios" :path: ".symlinks/plugins/path_provider_foundation/ios"
path_provider_ios:
:path: ".symlinks/plugins/path_provider_ios/ios"
photo_manager: photo_manager:
:path: ".symlinks/plugins/photo_manager/ios" :path: ".symlinks/plugins/photo_manager/ios"
share_plus: share_plus:
@ -117,6 +122,7 @@ SPEC CHECKSUMS:
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073 isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852 path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604 photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68

View File

@ -9,6 +9,8 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 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 */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
@ -80,6 +84,15 @@
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
65DD438629917FAD0047FFA8 /* BackgroundSync */ = {
isa = PBXGroup;
children = (
65F32F32299D349D00CE9261 /* BackgroundSyncWorker.swift */,
65F32F30299BD2F800CE9261 /* BackgroundServicePlugin.swift */,
);
path = BackgroundSync;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = { 9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -113,6 +126,7 @@
97C146F01CF9000F007C117D /* Runner */ = { 97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
65DD438629917FAD0047FFA8 /* BackgroundSync */,
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */, FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FD1CF9000F007C117D /* Assets.xcassets */,
@ -275,8 +289,10 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -363,7 +379,6 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 86; CURRENT_PROJECT_VERSION = 86;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -372,6 +387,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich; PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -499,7 +515,6 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 86; CURRENT_PROJECT_VERSION = 86;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -508,6 +523,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich; PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -527,7 +543,6 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 86; CURRENT_PROJECT_VERSION = 86;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -536,6 +551,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich; PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

View File

@ -43,11 +43,13 @@
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
disableMainThreadChecker = "YES"
launchStyle = "0" launchStyle = "0"
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO" ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES" debugDocumentVersioning = "YES"
debugServiceExtension = "internal" debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES"> allowLocationSimulation = "YES">
<BuildableProductRunnable <BuildableProductRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">

View File

@ -1,13 +1,33 @@
import UIKit import UIKit
import Flutter import Flutter
import BackgroundTasks
import path_provider_ios
import photo_manager
@UIApplicationMain @UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> 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)
} }
} }

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -2,6 +2,13 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <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> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@ -12,66 +19,6 @@
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <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> <key>CFBundleLocalizations</key>
<array> <array>
<string>cs</string> <string>cs</string>
@ -92,11 +39,74 @@
<string>sk</string> <string>sk</string>
<string>zh</string> <string>zh</string>
</array> </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/> <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> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <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/> <true/>
</dict> </dict>
</plist> </plist>

View File

@ -47,10 +47,6 @@ Future<void> openBoxes() async {
Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox), Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox),
Hive.openBox(hiveGithubReleaseInfoBox), Hive.openBox(hiveGithubReleaseInfoBox),
Hive.openBox(userSettingInfoBox), 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(), EasyLocalization.ensureInitialized(),
]); ]);
} }

View File

@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'dart:isolate'; 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:cancellation_token_http/http.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -10,7 +10,6 @@ import 'package:flutter/widgets.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.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/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_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'; 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/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/services/api.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'; import 'package:photo_manager/photo_manager.dart';
final backgroundServiceProvider = Provider( final backgroundServiceProvider = Provider(
@ -51,8 +51,7 @@ class BackgroundService {
late final _Throttle _throttledDetailNotify = late final _Throttle _throttledDetailNotify =
_Throttle(_updateDetailProgress, notifyInterval); _Throttle(_updateDetailProgress, notifyInterval);
Completer<bool> _hasAccessCompleter = Completer(); Completer<bool> _hasAccessCompleter = Completer();
late Future<bool> _hasAccess = late Future<bool> _hasAccess = _hasAccessCompleter.future;
Platform.isAndroid ? _hasAccessCompleter.future : Future.value(true);
Future<bool> get hasAccess => _hasAccess; Future<bool> get hasAccess => _hasAccess;
@ -67,9 +66,6 @@ class BackgroundService {
/// Enqueues the background service /// Enqueues the background service
Future<bool> enableService({bool immediate = false}) async { Future<bool> enableService({bool immediate = false}) async {
if (!Platform.isAndroid) {
return true;
}
try { try {
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!; final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
final String title = final String title =
@ -89,9 +85,6 @@ class BackgroundService {
int triggerUpdateDelay = 5000, int triggerUpdateDelay = 5000,
int triggerMaxDelay = 50000, int triggerMaxDelay = 50000,
}) async { }) async {
if (!Platform.isAndroid) {
return true;
}
try { try {
final bool ok = await _foregroundChannel.invokeMethod( final bool ok = await _foregroundChannel.invokeMethod(
'configure', 'configure',
@ -110,9 +103,6 @@ class BackgroundService {
/// Cancels the background service (if currently running) and removes it from work queue /// Cancels the background service (if currently running) and removes it from work queue
Future<bool> disableService() async { Future<bool> disableService() async {
if (!Platform.isAndroid) {
return true;
}
try { try {
final ok = await _foregroundChannel.invokeMethod('disable'); final ok = await _foregroundChannel.invokeMethod('disable');
return ok; return ok;
@ -123,9 +113,6 @@ class BackgroundService {
/// Returns `true` if the background service is enabled /// Returns `true` if the background service is enabled
Future<bool> isBackgroundBackupEnabled() async { Future<bool> isBackgroundBackupEnabled() async {
if (!Platform.isAndroid) {
return false;
}
try { try {
return await _foregroundChannel.invokeMethod("isEnabled"); return await _foregroundChannel.invokeMethod("isEnabled");
} catch (error) { } catch (error) {
@ -135,7 +122,8 @@ class BackgroundService {
/// Returns `true` if battery optimizations are disabled /// Returns `true` if battery optimizations are disabled
Future<bool> isIgnoringBatteryOptimizations() async { Future<bool> isIgnoringBatteryOptimizations() async {
if (!Platform.isAndroid) { // iOS does not need battery optimizations enabled
if (Platform.isIOS) {
return true; return true;
} }
try { try {
@ -156,9 +144,6 @@ class BackgroundService {
bool isDetail = false, bool isDetail = false,
bool onlyIfFG = false, bool onlyIfFG = false,
}) async { }) async {
if (!Platform.isAndroid) {
return true;
}
try { try {
if (_isBackgroundInitialized) { if (_isBackgroundInitialized) {
return _backgroundChannel.invokeMethod<bool>( return _backgroundChannel.invokeMethod<bool>(
@ -178,9 +163,6 @@ class BackgroundService {
String? content, String? content,
String? individualTag, String? individualTag,
}) async { }) async {
if (!Platform.isAndroid) {
return true;
}
try { try {
if (_isBackgroundInitialized && _errorGracePeriodExceeded) { if (_isBackgroundInitialized && _errorGracePeriodExceeded) {
return await _backgroundChannel return await _backgroundChannel
@ -193,9 +175,6 @@ class BackgroundService {
} }
Future<bool> _clearErrorNotifications() async { Future<bool> _clearErrorNotifications() async {
if (!Platform.isAndroid) {
return true;
}
try { try {
if (_isBackgroundInitialized) { if (_isBackgroundInitialized) {
return await _backgroundChannel.invokeMethod('clearErrorNotifications'); return await _backgroundChannel.invokeMethod('clearErrorNotifications');
@ -210,9 +189,6 @@ class BackgroundService {
/// await to ensure this thread (foreground or background) has exclusive access /// await to ensure this thread (foreground or background) has exclusive access
Future<bool> acquireLock() async { Future<bool> acquireLock() async {
if (!Platform.isAndroid) {
return true;
}
if (_hasLock) { if (_hasLock) {
debugPrint("WARNING: [acquireLock] called more than once"); debugPrint("WARNING: [acquireLock] called more than once");
return true; return true;
@ -253,7 +229,7 @@ class BackgroundService {
while (_wantsLockTime == lockTime) { while (_wantsLockTime == lockTime) {
other.send(tempSp); other.send(tempSp);
final dynamic answer = await bs.first final dynamic answer = await bs.first
.timeout(const Duration(seconds: 5), onTimeout: () => null); .timeout(const Duration(seconds: 3), onTimeout: () => null);
if (_wantsLockTime != lockTime) { if (_wantsLockTime != lockTime) {
break; break;
} }
@ -270,7 +246,7 @@ class BackgroundService {
// other isolate is still active // other isolate is still active
} }
final dynamic isFinished = await bs.first final dynamic isFinished = await bs.first
.timeout(const Duration(seconds: 5), onTimeout: () => false); .timeout(const Duration(seconds: 3), onTimeout: () => false);
if (isFinished == true) { if (isFinished == true) {
break; break;
} }
@ -288,9 +264,6 @@ class BackgroundService {
/// releases the exclusive access lock /// releases the exclusive access lock
void releaseLock() { void releaseLock() {
if (!Platform.isAndroid) {
return;
}
_wantsLockTime = 0; _wantsLockTime = 0;
if (_hasLock) { if (_hasLock) {
_hasAccessCompleter = Completer(); _hasAccessCompleter = Completer();
@ -311,17 +284,35 @@ class BackgroundService {
} }
Future<bool> _callHandler(MethodCall call) async { 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) { switch (call.method) {
case "backgroundProcessing":
case "onAssetsChanged": case "onAssetsChanged":
final Future<bool> translationsLoaded = loadTranslations();
try { try {
_clearErrorNotifications(); _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) { if (!hasAccess) {
debugPrint("[_callHandler] could not acquire lock, exiting"); debugPrint("[_callHandler] could not acquire lock, exiting");
return false; return false;
} }
await translationsLoaded;
final bool ok = await _onAssetsChanged(); final bool ok = await _onAssetsChanged();
return ok; return ok;
} catch (error) { } catch (error) {
@ -388,9 +379,9 @@ class BackgroundService {
.put(backupFailedSince, DateTime.now()); .put(backupFailedSince, DateTime.now());
return false; return false;
} }
// check for new assets added while performing backup // Android should check for new assets added while performing backup
} while (true == } while (Platform.isAndroid &&
await _backgroundChannel.invokeMethod<bool>("hasContentChanged")); true == await _backgroundChannel.invokeMethod<bool>("hasContentChanged"));
return true; return true;
} }
@ -560,6 +551,28 @@ class BackgroundService {
final String total = numberFormat.format(bytesTotal / 1024.0); final String total = numberFormat.format(bytesTotal / 1024.0);
return "$percent% ($done/$total$unit)"; 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 { class _Throttle {
@ -603,6 +616,7 @@ class _Throttle {
@pragma('vm:entry-point') @pragma('vm:entry-point')
void _nativeEntry() { void _nativeEntry() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
DartPluginRegistrant.ensureInitialized();
BackgroundService backgroundService = BackgroundService(); BackgroundService backgroundService = BackgroundService();
backgroundService._setupBackgroundCallHandler(); backgroundService._setupBackgroundCallHandler();
} }

View File

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:cancellation_token_http/http.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
@ -130,7 +128,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
requireCharging != null || requireCharging != null ||
triggerDelay != null, triggerDelay != null,
); );
if (Platform.isAndroid) {
final bool wasEnabled = state.backgroundBackup; final bool wasEnabled = state.backgroundBackup;
final bool wasWifi = state.backupRequireWifi; final bool wasWifi = state.backupRequireWifi;
final bool wasCharging = state.backupRequireCharging; final bool wasCharging = state.backupRequireCharging;
@ -180,7 +177,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
onError("backup_controller_page_background_configure_error"); onError("backup_controller_page_background_configure_error");
} }
} }
}
} }
/// ///
@ -577,48 +573,47 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} }
Future<void> resumeBackup() async { Future<void> resumeBackup() async {
if (Platform.isAndroid) {
// assumes the background service is currently running // assumes the background service is currently running
// if true, waits until it has stopped to update the app state from HiveDB // if true, waits until it has stopped to update the app state from HiveDB
// before actually resuming backup by calling the internal `_resumeBackup` // before actually resuming backup by calling the internal `_resumeBackup`
final BackUpProgressEnum previous = state.backupProgress; final BackUpProgressEnum previous = state.backupProgress;
state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground); state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
final bool hasLock = await _backgroundService.acquireLock(); final bool hasLock = await _backgroundService.acquireLock();
if (!hasLock) { if (!hasLock) {
log.warning("WARNING [resumeBackup] failed to acquireLock"); log.warning("WARNING [resumeBackup] failed to acquireLock");
return; return;
} }
await Future.wait([ await Future.wait([
Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox), Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox), Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
Hive.openBox(backgroundBackupInfoBox), Hive.openBox(backgroundBackupInfoBox),
]); ]);
final HiveBackupAlbums? albums = final HiveBackupAlbums? albums =
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).get(backupInfoKey); Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).get(backupInfoKey);
Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums; Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums; Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
if (albums != null) { if (albums != null) {
selectedAlbums = _updateAlbumsBackupTime( selectedAlbums = _updateAlbumsBackupTime(
selectedAlbums, selectedAlbums,
albums.selectedAlbumIds, albums.selectedAlbumIds,
albums.lastSelectedBackupTime, albums.lastSelectedBackupTime,
); );
excludedAlbums = _updateAlbumsBackupTime( excludedAlbums = _updateAlbumsBackupTime(
excludedAlbums, excludedAlbums,
albums.excludedAlbumsIds, albums.excludedAlbumsIds,
albums.lastExcludedBackupTime, 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),
); );
} }
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(); return _resumeBackup();
} }
@ -649,8 +644,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
AppStateEnum.paused, AppStateEnum.paused,
AppStateEnum.detached, AppStateEnum.detached,
]; ];
if (Platform.isAndroid && if (allowedStates.contains(ref.read(appStateProvider.notifier).state)) {
allowedStates.contains(ref.read(appStateProvider.notifier).state)) {
try { try {
if (Hive.isBoxOpen(hiveBackupInfoBox)) { if (Hive.isBoxOpen(hiveBackupInfoBox)) {
await Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).close(); await Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).close();
@ -683,6 +677,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
_backgroundService.releaseLock(); _backgroundService.releaseLock();
} }
} }
} }
final backupProvider = final backupProvider =

View File

@ -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,
),
),
);
},
);
}
}

View File

@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/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/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart'; import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.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 isBackgroundEnabled = backupState.backgroundBackup;
final bool isWifiRequired = backupState.backupRequireWifi; final bool isWifiRequired = backupState.backupRequireWifi;
final bool isChargingRequired = backupState.backupRequireCharging; final bool isChargingRequired = backupState.backupRequireCharging;
@ -240,118 +241,126 @@ class BackupControllerPage extends HookConsumerWidget {
final triggerDelay = final triggerDelay =
useState(backupDelayToSliderValue(backupState.backupTriggerDelay)); useState(backupDelayToSliderValue(backupState.backupTriggerDelay));
return ListTile( return Column(
isThreeLine: true, children: [
leading: isBackgroundEnabled ListTile(
? Icon( isThreeLine: true,
Icons.cloud_sync_rounded, leading: isBackgroundEnabled
color: activeColor, ? Icon(
) Icons.cloud_sync_rounded,
: const Icon(Icons.cloud_sync_rounded), color: activeColor,
title: Text( )
isBackgroundEnabled : const Icon(Icons.cloud_sync_rounded),
? "backup_controller_page_background_is_on" title: Text(
: "backup_controller_page_background_is_off", isBackgroundEnabled
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), ? "backup_controller_page_background_is_on"
).tr(), : "backup_controller_page_background_is_off",
subtitle: Column( style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
crossAxisAlignment: CrossAxisAlignment.start, ).tr(),
children: [ subtitle: Column(
if (!isBackgroundEnabled) crossAxisAlignment: CrossAxisAlignment.start,
Padding( children: [
padding: const EdgeInsets.symmetric(vertical: 8.0), if (!isBackgroundEnabled)
child: Padding(
const Text("backup_controller_page_background_description") padding: const EdgeInsets.symmetric(vertical: 8.0),
.tr(), child:
), const Text("backup_controller_page_background_description")
if (isBackgroundEnabled) .tr(),
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,
), ),
).tr(args: [formatBackupDelaySliderValue(triggerDelay.value)]), if (isBackgroundEnabled && Platform.isAndroid)
subtitle: Slider( SwitchListTile(
value: triggerDelay.value, title:
onChanged: hasExclusiveAccess const Text("backup_controller_page_background_wifi").tr(),
? (double v) => triggerDelay.value = v secondary: Icon(
: null, Icons.wifi,
onChangeEnd: (double v) => ref color: isWifiRequired ? activeColor : null,
.read(backupProvider.notifier) ),
.configureBackgroundBackup( dense: true,
triggerDelay: backupDelayToMilliseconds(v), activeColor: activeColor,
onError: showErrorToUser, value: isWifiRequired,
onBatteryInfo: showBatteryOptimizationInfoToUser, 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, ).tr(args: [formatBackupDelaySliderValue(triggerDelay.value)]),
divisions: 3, subtitle: Slider(
label: formatBackupDelaySliderValue(triggerDelay.value), value: triggerDelay.value,
activeColor: Theme.of(context).primaryColor, 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(), const Divider(),
buildAutoBackupController(), buildAutoBackupController(),
if (Platform.isAndroid) const Divider(), const Divider(),
if (Platform.isAndroid) buildBackgroundBackupController(), buildBackgroundBackupController(),
const Divider(), const Divider(),
buildStorageInformation(), buildStorageInformation(),
const Divider(), const Divider(),

View File

@ -165,10 +165,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: cancellation_token name: cancellation_token
sha256: "44891ef71d605bc59ef7974c403630d8e8506fcd897a29c3e38466ef69e5c4eb" sha256: e40ac742c7faac52e1719ce249934e20975e5772d40112e1e01cfc5abf24185a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.6.1" version: "1.5.0"
cancellation_token_http: cancellation_token_http:
dependency: "direct main" dependency: "direct main"
description: description:
@ -333,10 +333,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: fixnum name: fixnum
sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" sha256: "04be3e934c52e082558cc9ee21f42f5c1cd7a1262f4c63cd0357c08d5bba81ec"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.0.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -428,10 +428,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_riverpod name: flutter_riverpod
sha256: "0c997763ce06359ee4686553b74def84062e9d6929ac63f61fa02465c1f8e32c" sha256: "46a27b7a11dc13738054093076f2dc65692ddcd463979b15092accf5681aea20"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.2.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -483,10 +483,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: glob name: glob
sha256: "4515b5b6ddb505ebdd242a5f2cc5d22d3d6a80013789debfbda7777f47ea308c" sha256: c51b4fdfee4d281f49b8c957f1add91b815473597f76bcf07377987f66a55729
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "2.1.0"
graphs: graphs:
dependency: transitive dependency: transitive
description: description:
@ -523,10 +523,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: hooks_riverpod name: hooks_riverpod
sha256: "71695b2e1dfc22a39f1f9c67b798f8f8f1521f2d0349817d13ccdd5c4cd7acba" sha256: a596bcb1eaf48eae6da1ce8b9e60ec9538ef7d15725e941c3626f29dfcc01d96
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.2.0"
html: html:
dependency: transitive dependency: transitive
description: description:
@ -851,6 +851,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" 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: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@ -975,10 +983,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: riverpod name: riverpod
sha256: "0f43c64f1f79c2112c843305a879a746587fb7c1e388f1d4717737796756e2c4" sha256: "59a48de9c757aa61aa28e9fd625ffb360d43b6b54606f12536622c55be9e8c4b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.2.0"
rxdart: rxdart:
dependency: transitive dependency: transitive
description: description:
@ -1492,10 +1500,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: xml name: xml
sha256: "979ee37d622dec6365e2efa4d906c37470995871fe9ae080d967e192d88286b5" sha256: ac0e3f4bf00ba2708c33fbabbbe766300e509f8c82dbd4ab6525039813f7e2fb
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.2.2" version: "6.1.0"
xxh3: xxh3:
dependency: transitive dependency: transitive
description: description:
@ -1513,5 +1521,5 @@ packages:
source: hosted source: hosted
version: "3.1.1" version: "3.1.1"
sdks: sdks:
dart: ">=2.19.0 <4.0.0" dart: ">=2.18.0 <3.0.0"
flutter: ">=3.3.0" flutter: ">=3.3.0"

View File

@ -12,6 +12,7 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
path_provider_ios:
photo_manager: ^2.5.0 photo_manager: ^2.5.0
flutter_hooks: ^0.18.0 flutter_hooks: ^0.18.0
hooks_riverpod: ^2.0.0-dev.0 hooks_riverpod: ^2.0.0-dev.0