diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index a87feddd1a..d395cc2243 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -5,6 +5,8 @@ import android.os.Build import android.os.ext.SdkExtensions import app.alextran.immich.background.BackgroundWorkerApiImpl import app.alextran.immich.background.BackgroundWorkerFgHostApi +import app.alextran.immich.connectivity.ConnectivityApi +import app.alextran.immich.connectivity.ConnectivityApiImpl import app.alextran.immich.images.ThumbnailApi import app.alextran.immich.images.ThumbnailsImpl import app.alextran.immich.sync.NativeSyncApi @@ -34,6 +36,7 @@ class MainActivity : FlutterFragmentActivity() { NativeSyncApi.setUp(messenger, nativeSyncApiImpl) ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx)) BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx)) + ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx)) } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt index b9826f80e9..4d9e2c0caf 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt @@ -111,6 +111,7 @@ interface BackgroundWorkerFgHostApi { /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface BackgroundWorkerBgHostApi { fun onInitialized() + fun showNotification(title: String, content: String) fun close() companion object { @@ -138,6 +139,25 @@ interface BackgroundWorkerBgHostApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.showNotification$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val titleArg = args[0] as String + val contentArg = args[1] as String + val wrapped: List = try { + api.showNotification(titleArg, contentArg) + listOf(null) + } catch (exception: Throwable) { + BackgroundWorkerPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close$separatedMessageChannelSuffix", codec) if (api != null) { diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt index 43124a957e..b69730018b 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt @@ -1,18 +1,27 @@ package app.alextran.immich.background +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.Context +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.os.Build import android.os.Handler import android.os.Looper +import android.os.PowerManager import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.work.ForegroundInfo import androidx.work.ListenableWorker import androidx.work.WorkerParameters import app.alextran.immich.MainActivity +import app.alextran.immich.R import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.SettableFuture import io.flutter.FlutterInjector import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.dart.DartExecutor import io.flutter.embedding.engine.loader.FlutterLoader +import java.util.concurrent.TimeUnit private const val TAG = "BackgroundWorker" @@ -40,15 +49,32 @@ class BackgroundWorker(context: Context, params: WorkerParameters) : /// Flag to track whether the background task has completed to prevent duplicate completions private var isComplete = false + private val notificationManager = + ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + private var foregroundFuture: ListenableFuture? = null + init { if (!loader.initialized()) { loader.startInitialization(ctx) } } + companion object { + private const val NOTIFICATION_CHANNEL_ID = "immich::background_worker::notif" + private const val NOTIFICATION_ID = 100 + } + override fun startWork(): ListenableFuture { Log.i(TAG, "Starting background upload worker") + val notificationChannel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + NOTIFICATION_CHANNEL_ID, + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(notificationChannel) + loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { engine = FlutterEngine(ctx) @@ -82,6 +108,34 @@ class BackgroundWorker(context: Context, params: WorkerParameters) : flutterApi?.onAndroidUpload { handleHostResult(it) } } + // TODO: Move this to a separate NotificationManager class + override fun showNotification(title: String, content: String) { + val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.notification_icon) + .setOnlyAlertOnce(true) + .setOngoing(true) + .setTicker(title) + .setContentTitle(title) + .setContentText(content) + .build() + + if (isIgnoringBatteryOptimizations()) { + foregroundFuture = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + setForegroundAsync( + ForegroundInfo( + NOTIFICATION_ID, + notification, + FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + ) + } else { + setForegroundAsync(ForegroundInfo(NOTIFICATION_ID, notification)) + } + } else { + notificationManager.notify(NOTIFICATION_ID, notification) + } + } + override fun close() { if (isComplete) { return @@ -95,6 +149,8 @@ class BackgroundWorker(context: Context, params: WorkerParameters) : } } + waitForForegroundPromotion() + Handler(Looper.getMainLooper()).postDelayed({ complete(Result.failure()) }, 5000) @@ -135,6 +191,32 @@ class BackgroundWorker(context: Context, params: WorkerParameters) : engine?.destroy() engine = null flutterApi = null + notificationManager.cancel(NOTIFICATION_ID) + waitForForegroundPromotion() completionHandler.set(success) } + + /** + * Returns `true` if the app is ignoring battery optimizations + */ + private fun isIgnoringBatteryOptimizations(): Boolean { + val powerManager = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager + return powerManager.isIgnoringBatteryOptimizations(ctx.packageName) + } + + /** + * Calls to setForegroundAsync() that do not complete before completion of a ListenableWorker will signal an IllegalStateException + * https://android-review.googlesource.com/c/platform/frameworks/support/+/1262743 + * Wait for a short period of time for the foreground promotion to complete before completing the worker + */ + private fun waitForForegroundPromotion() { + val foregroundFuture = this.foregroundFuture + if (foregroundFuture != null && !foregroundFuture.isCancelled && !foregroundFuture.isDone) { + try { + foregroundFuture.get(500, TimeUnit.MILLISECONDS) + } catch (e: Exception) { + // ignored, there is nothing to be done + } + } + } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/connectivity/Connectivity.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/connectivity/Connectivity.g.kt new file mode 100644 index 0000000000..434ba47ca1 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/connectivity/Connectivity.g.kt @@ -0,0 +1,116 @@ +// Autogenerated from Pigeon (v26.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package app.alextran.immich.connectivity + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object ConnectivityPigeonUtils { + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : Throwable() + +enum class NetworkCapability(val raw: Int) { + CELLULAR(0), + WIFI(1), + VPN(2), + UNMETERED(3); + + companion object { + fun ofRaw(raw: Int): NetworkCapability? { + return values().firstOrNull { it.raw == raw } + } + } +} +private open class ConnectivityPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as Long?)?.let { + NetworkCapability.ofRaw(it.toInt()) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is NetworkCapability -> { + stream.write(129) + writeValue(stream, value.raw) + } + else -> super.writeValue(stream, value) + } + } +} + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface ConnectivityApi { + fun getCapabilities(): List + + companion object { + /** The codec used by ConnectivityApi. */ + val codec: MessageCodec by lazy { + ConnectivityPigeonCodec() + } + /** Sets up an instance of `ConnectivityApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: ConnectivityApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val taskQueue = binaryMessenger.makeBackgroundTaskQueue() + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities$separatedMessageChannelSuffix", codec, taskQueue) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getCapabilities()) + } catch (exception: Throwable) { + ConnectivityPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/connectivity/ConnectivityApiImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/connectivity/ConnectivityApiImpl.kt new file mode 100644 index 0000000000..e8554dd63a --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/connectivity/ConnectivityApiImpl.kt @@ -0,0 +1,39 @@ +package app.alextran.immich.connectivity + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager + +class ConnectivityApiImpl(context: Context) : ConnectivityApi { + private val connectivityManager = + context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + private val wifiManager = + context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + + override fun getCapabilities(): List { + val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + ?: return emptyList() + + val hasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE) + val hasCellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) + val hasVpn = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) + val isUnmetered = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + + return buildList { + if (hasWifi) add(NetworkCapability.WIFI) + if (hasCellular) add(NetworkCapability.CELLULAR) + if (hasVpn) { + add(NetworkCapability.VPN) + if (!hasWifi && !hasCellular) { + if (wifiManager.isWifiEnabled) add(NetworkCapability.WIFI) + // If VPN is active, but neither WIFI nor CELLULAR is reported as active, + // assume CELLULAR if WIFI is not enabled + else add(NetworkCapability.CELLULAR) + } + } + if (isUnmetered) add(NetworkCapability.UNMETERED) + } + } +} diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 4e68390113..524778d89d 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -18,6 +18,8 @@ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */; }; B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; }; + B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; }; + B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; }; B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; }; D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; }; F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -97,6 +99,8 @@ B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = ""; }; B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkerApiImpl.swift; sourceTree = ""; }; B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = ""; }; + B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = ""; }; + B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = ""; }; B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = ""; }; E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -129,8 +133,6 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = Sync; sourceTree = ""; }; @@ -243,6 +245,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + B25D37792E72CA15008B6CA7 /* Connectivity */, B21E34A62E5AF9760031FDB9 /* Background */, B2CF7F8C2DDE4EBB00744BF6 /* Sync */, FA9973382CF6DF4B000EF859 /* Runner.entitlements */, @@ -271,6 +274,15 @@ path = Background; sourceTree = ""; }; + B25D37792E72CA15008B6CA7 /* Connectivity */ = { + isa = PBXGroup; + children = ( + B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */, + B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */, + ); + path = Connectivity; + sourceTree = ""; + }; FAC6F8B62D287F120078CB2F /* ShareExtension */ = { isa = PBXGroup; children = ( @@ -507,10 +519,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -539,10 +555,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -558,7 +578,9 @@ 65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */, + B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */, FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */, + B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */, FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */, B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */, FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */, diff --git a/mobile/ios/Runner/Background/BackgroundWorker.g.swift b/mobile/ios/Runner/Background/BackgroundWorker.g.swift index bfc0b26d9b..45a6402fe8 100644 --- a/mobile/ios/Runner/Background/BackgroundWorker.g.swift +++ b/mobile/ios/Runner/Background/BackgroundWorker.g.swift @@ -114,6 +114,7 @@ class BackgroundWorkerFgHostApiSetup { /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol BackgroundWorkerBgHostApi { func onInitialized() throws + func showNotification(title: String, content: String) throws func close() throws } @@ -136,6 +137,22 @@ class BackgroundWorkerBgHostApiSetup { } else { onInitializedChannel.setMessageHandler(nil) } + let showNotificationChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.showNotification\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + showNotificationChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let titleArg = args[0] as! String + let contentArg = args[1] as! String + do { + try api.showNotification(title: titleArg, content: contentArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + showNotificationChannel.setMessageHandler(nil) + } let closeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { closeChannel.setMessageHandler { _, reply in diff --git a/mobile/ios/Runner/Background/BackgroundWorker.swift b/mobile/ios/Runner/Background/BackgroundWorker.swift index 835632a5d0..9a965bd360 100644 --- a/mobile/ios/Runner/Background/BackgroundWorker.swift +++ b/mobile/ios/Runner/Background/BackgroundWorker.swift @@ -119,6 +119,10 @@ class BackgroundWorker: BackgroundWorkerBgHostApi { }) } + func showNotification(title: String, content: String) throws { + // No-op on iOS for the time being + } + /** * Cancels the currently running background task, either due to timeout or external request. * Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure diff --git a/mobile/ios/Runner/Connectivity/Connectivity.g.swift b/mobile/ios/Runner/Connectivity/Connectivity.g.swift new file mode 100644 index 0000000000..45333f03d8 --- /dev/null +++ b/mobile/ios/Runner/Connectivity/Connectivity.g.swift @@ -0,0 +1,129 @@ +// Autogenerated from Pigeon (v26.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + + +enum NetworkCapability: Int { + case cellular = 0 + case wifi = 1 + case vpn = 2 + case unmetered = 3 +} + +private class ConnectivityPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return NetworkCapability(rawValue: enumResultAsInt) + } + return nil + default: + return super.readValue(ofType: type) + } + } +} + +private class ConnectivityPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? NetworkCapability { + super.writeByte(129) + super.writeValue(value.rawValue) + } else { + super.writeValue(value) + } + } +} + +private class ConnectivityPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return ConnectivityPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return ConnectivityPigeonCodecWriter(data: data) + } +} + +class ConnectivityPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = ConnectivityPigeonCodec(readerWriter: ConnectivityPigeonCodecReaderWriter()) +} + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol ConnectivityApi { + func getCapabilities() throws -> [NetworkCapability] +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class ConnectivityApiSetup { + static var codec: FlutterStandardMessageCodec { ConnectivityPigeonCodec.shared } + /// Sets up an instance of `ConnectivityApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ConnectivityApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + #if os(iOS) + let taskQueue = binaryMessenger.makeBackgroundTaskQueue?() + #else + let taskQueue: FlutterTaskQueue? = nil + #endif + let getCapabilitiesChannel = taskQueue == nil + ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) + if let api = api { + getCapabilitiesChannel.setMessageHandler { _, reply in + do { + let result = try api.getCapabilities() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getCapabilitiesChannel.setMessageHandler(nil) + } + } +} diff --git a/mobile/ios/Runner/Connectivity/ConnectivityApiImpl.swift b/mobile/ios/Runner/Connectivity/ConnectivityApiImpl.swift new file mode 100644 index 0000000000..0261cb26fb --- /dev/null +++ b/mobile/ios/Runner/Connectivity/ConnectivityApiImpl.swift @@ -0,0 +1,6 @@ + +class ConnectivityApiImpl: ConnectivityApi { + func getCapabilities() throws -> [NetworkCapability] { + [] + } +} diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 18f07dc021..3bb0d7980f 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -1,10 +1,15 @@ import 'dart:async'; +import 'dart:io'; import 'dart:ui'; import 'package:background_downloader/background_downloader.dart'; +import 'package:cancellation_token_http/http.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/extensions/network_capability_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/intl_keys.g.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart'; import 'package:immich_mobile/platform/background_worker_api.g.dart'; @@ -13,11 +18,13 @@ import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; import 'package:immich_mobile/services/localization.service.dart'; +import 'package:immich_mobile/services/server_info.service.dart'; import 'package:immich_mobile/services/upload.service.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart'; @@ -42,6 +49,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { final Drift _drift; final DriftLogger _driftLogger; final BackgroundWorkerBgHostApi _backgroundHostApi; + final CancellationToken _cancellationToken = CancellationToken(); final Logger _logger = Logger('BackgroundWorkerBgService'); bool _isCleanedUp = false; @@ -87,6 +95,13 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { configureFileDownloaderNotifications(); + if (Platform.isAndroid) { + await _backgroundHostApi.showNotification( + IntlKeys.uploading_media.t(), + IntlKeys.backup_background_service_in_progress_notification.t(), + ); + } + // Notify the host that the background worker service has been initialized and is ready to use _backgroundHostApi.onInitialized(); } catch (error, stack) { @@ -102,7 +117,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { final sw = Stopwatch()..start(); await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6)); - await _handleBackup(processBulk: false); + await _handleBackup(); sw.stop(); _logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s"); @@ -155,9 +170,13 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { try { _isCleanedUp = true; + _cancellationToken.cancel(); _logger.info("Cleaning up background worker"); final cleanupFutures = [ - workerManager.dispose(), + workerManager.dispose().catchError((_) async { + // Discard any errors on the dispose call + return; + }), _drift.close(), _driftLogger.close(), _ref.read(backgroundSyncProvider).cancel(), @@ -175,7 +194,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { } } - Future _handleBackup({bool processBulk = true}) async { + Future _handleBackup() async { if (!_isBackupEnabled || _isCleanedUp) { _logger.info("[_handleBackup 1] Backup is disabled. Skipping backup routine"); return; @@ -189,19 +208,22 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { return; } - if (processBulk) { - _logger.info("[_handleBackup 4] Resume backup from background"); + _logger.info("[_handleBackup 4] Resume backup from background"); + if (Platform.isIOS) { return _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); } - final activeTask = await _ref.read(uploadServiceProvider).getActiveTasks(currentUser.id); - if (activeTask.isNotEmpty) { - _logger.info("[_handleBackup 5] Resuming backup for active tasks from background"); - await _ref.read(uploadServiceProvider).resumeBackup(); - } else { - _logger.info("[_handleBackup 6] Starting serial backup for new tasks from background"); - await _ref.read(uploadServiceProvider).startBackupSerial(currentUser.id); + final canPing = await _ref.read(serverInfoServiceProvider).ping(); + if (!canPing) { + _logger.warning("[_handleBackup 5] Server is not reachable. Skipping backup from background"); + return; } + + final networkCapabilities = await _ref.read(connectivityApiProvider).getCapabilities(); + + return _ref + .read(uploadServiceProvider) + .startBackupWithHttpClient(currentUser.id, networkCapabilities.hasWifi, _cancellationToken); } Future _syncAssets({Duration? hashTimeout}) async { diff --git a/mobile/lib/extensions/network_capability_extensions.dart b/mobile/lib/extensions/network_capability_extensions.dart new file mode 100644 index 0000000000..aeefc11e39 --- /dev/null +++ b/mobile/lib/extensions/network_capability_extensions.dart @@ -0,0 +1,8 @@ +import 'package:immich_mobile/platform/connectivity_api.g.dart'; + +extension NetworkCapabilitiesGetters on List { + bool get hasCellular => contains(NetworkCapability.cellular); + bool get hasWifi => contains(NetworkCapability.wifi); + bool get hasVpn => contains(NetworkCapability.vpn); + bool get isUnmetered => contains(NetworkCapability.unmetered); +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 4f74c30e3b..7e3d6152c9 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -46,7 +46,7 @@ void main() async { await Bootstrap.initDomain(isar, drift, logDb); await initApp(); // Warm-up isolate pool for worker manager - await workerManager.init(dynamicSpawning: true); + await workerManager.init(dynamicSpawning: false); await migrateDatabaseIfNeeded(isar, drift); HttpSSLOptions.apply(); diff --git a/mobile/lib/platform/background_worker_api.g.dart b/mobile/lib/platform/background_worker_api.g.dart index 4b5689f4df..9398b0a15b 100644 --- a/mobile/lib/platform/background_worker_api.g.dart +++ b/mobile/lib/platform/background_worker_api.g.dart @@ -142,6 +142,29 @@ class BackgroundWorkerBgHostApi { } } + Future showNotification(String title, String content) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.showNotification$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([title, content]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + Future close() async { final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close$pigeonVar_messageChannelSuffix'; diff --git a/mobile/lib/platform/connectivity_api.g.dart b/mobile/lib/platform/connectivity_api.g.dart new file mode 100644 index 0000000000..c348356f81 --- /dev/null +++ b/mobile/lib/platform/connectivity_api.g.dart @@ -0,0 +1,87 @@ +// Autogenerated from Pigeon (v26.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +enum NetworkCapability { cellular, wifi, vpn, unmetered } + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is NetworkCapability) { + buffer.putUint8(129); + writeValue(buffer, value.index); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + final int? value = readValue(buffer) as int?; + return value == null ? null : NetworkCapability.values[value]; + default: + return super.readValueOfType(type, buffer); + } + } +} + +class ConnectivityApi { + /// Constructor for [ConnectivityApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + ConnectivityApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future> getCapabilities() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as List?)!.cast(); + } + } +} diff --git a/mobile/lib/providers/infrastructure/platform.provider.dart b/mobile/lib/providers/infrastructure/platform.provider.dart index 05901a4fec..dec5c6905e 100644 --- a/mobile/lib/providers/infrastructure/platform.provider.dart +++ b/mobile/lib/providers/infrastructure/platform.provider.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/background_worker.service.dart'; import 'package:immich_mobile/platform/background_worker_api.g.dart'; +import 'package:immich_mobile/platform/connectivity_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/thumbnail_api.g.dart'; @@ -8,4 +9,6 @@ final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgServ final nativeSyncApiProvider = Provider((_) => NativeSyncApi()); +final connectivityApiProvider = Provider((_) => ConnectivityApi()); + final thumbnailApi = ThumbnailApi(); diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index c8b06ae102..2d99631d51 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -1,7 +1,21 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:background_downloader/background_downloader.dart'; +import 'package:cancellation_token_http/http.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:logging/logging.dart'; + +class UploadTaskWithFile { + final File file; + final UploadTask task; + + const UploadTaskWithFile({required this.file, required this.task}); +} final uploadRepositoryProvider = Provider((ref) => UploadRepository()); @@ -31,7 +45,7 @@ class UploadRepository { return FileDownloader().enqueue(task); } - Future enqueueBackgroundAll(List tasks) { + Future> enqueueBackgroundAll(List tasks) { return FileDownloader().enqueueAll(tasks); } @@ -74,4 +88,53 @@ class UploadRepository { Paused: ${pausedTasks.length} """); } + + Future backupWithDartClient(Iterable tasks, CancellationToken cancelToken) async { + final httpClient = Client(); + final String savedEndpoint = Store.get(StoreKey.serverEndpoint); + + Logger logger = Logger('UploadRepository'); + for (final candidate in tasks) { + if (cancelToken.isCancelled) { + logger.warning("Backup was cancelled by the user"); + break; + } + + try { + final fileStream = candidate.file.openRead(); + final assetRawUploadData = MultipartFile( + "assetData", + fileStream, + candidate.file.lengthSync(), + filename: candidate.task.filename, + ); + + final baseRequest = MultipartRequest('POST', Uri.parse('$savedEndpoint/assets')); + + baseRequest.headers.addAll(candidate.task.headers); + baseRequest.fields.addAll(candidate.task.fields); + baseRequest.files.add(assetRawUploadData); + + final response = await httpClient.send(baseRequest, cancellationToken: cancelToken); + + final responseBody = jsonDecode(await response.stream.bytesToString()); + + if (![200, 201].contains(response.statusCode)) { + final error = responseBody; + + logger.warning( + "Error(${error['statusCode']}) uploading ${candidate.task.filename} | Created on ${candidate.task.fields["fileCreatedAt"]} | ${error['error']}", + ); + + continue; + } + } on CancelledException { + logger.warning("Backup was cancelled by the user"); + break; + } catch (error, stackTrace) { + logger.warning("Error backup asset: ${error.toString()}: $stackTrace"); + continue; + } + } + } } diff --git a/mobile/lib/services/server_info.service.dart b/mobile/lib/services/server_info.service.dart index 4319d9dbae..e1238999d7 100644 --- a/mobile/lib/services/server_info.service.dart +++ b/mobile/lib/services/server_info.service.dart @@ -14,6 +14,15 @@ class ServerInfoService { const ServerInfoService(this._apiService); + Future ping() async { + try { + await _apiService.serverInfoApi.pingServer().timeout(const Duration(seconds: 5)); + return true; + } catch (e) { + return false; + } + } + Future getDiskInfo() async { try { final dto = await _apiService.serverInfoApi.getStorage(); diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart index a711608e7f..e41d730ff8 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; +import 'package:cancellation_token_http/http.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; @@ -19,6 +20,7 @@ import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; final uploadServiceProvider = Provider((ref) { @@ -51,6 +53,7 @@ class UploadService { final StorageRepository _storageRepository; final DriftLocalAssetRepository _localAssetRepository; final AppSettingsService _appSettingsService; + final Logger _logger = Logger('UploadService'); final StreamController _taskStatusController = StreamController.broadcast(); final StreamController _taskProgressController = StreamController.broadcast(); @@ -78,7 +81,7 @@ class UploadService { _taskProgressController.close(); } - Future enqueueTasks(List tasks) { + Future> enqueueTasks(List tasks) { return _uploadRepository.enqueueBackgroundAll(tasks); } @@ -138,7 +141,6 @@ class UploadService { } final batch = candidates.skip(i).take(batchSize).toList(); - List tasks = []; for (final asset in batch) { final task = await _getUploadTask(asset); @@ -156,9 +158,7 @@ class UploadService { } } - // Enqueue All does not work from the background on Android yet. This method is a temporary workaround - // that enqueues tasks one by one. - Future startBackupSerial(String userId) async { + Future startBackupWithHttpClient(String userId, bool hasWifi, CancellationToken token) async { await _storageRepository.clearCache(); shouldAbortQueuingTasks = false; @@ -168,14 +168,29 @@ class UploadService { return; } - for (final asset in candidates) { - if (shouldAbortQueuingTasks) { + const batchSize = 100; + for (int i = 0; i < candidates.length; i += batchSize) { + if (shouldAbortQueuingTasks || token.isCancelled) { break; } - final task = await _getUploadTask(asset); - if (task != null) { - await _uploadRepository.enqueueBackground(task); + final batch = candidates.skip(i).take(batchSize).toList(); + List tasks = []; + for (final asset in batch) { + final requireWifi = _shouldRequireWiFi(asset); + if (requireWifi && !hasWifi) { + _logger.warning('Skipping upload for ${asset.id} because it requires WiFi'); + continue; + } + + final task = await _getUploadTaskWithFile(asset); + if (task != null) { + tasks.add(task); + } + } + + if (tasks.isNotEmpty && !shouldAbortQueuingTasks) { + await _uploadRepository.backupWithDartClient(tasks, token); } } } @@ -242,6 +257,42 @@ class UploadService { } } + Future _getUploadTaskWithFile(LocalAsset asset) async { + final entity = await _storageRepository.getAssetEntityForAsset(asset); + if (entity == null) { + return null; + } + + final file = await _storageRepository.getFileForAsset(asset.id); + if (file == null) { + return null; + } + + final originalFileName = entity.isLivePhoto ? p.setExtension(asset.name, p.extension(file.path)) : asset.name; + + String metadata = UploadTaskMetadata( + localAssetId: asset.id, + isLivePhotos: entity.isLivePhoto, + livePhotoVideoId: '', + ).toJson(); + + return UploadTaskWithFile( + file: file, + task: await buildUploadTask( + file, + createdAt: asset.createdAt, + modifiedAt: asset.updatedAt, + originalFileName: originalFileName, + deviceAssetId: asset.id, + metadata: metadata, + group: "group", + priority: 0, + isFavorite: asset.isFavorite, + requiresWiFi: false, + ), + ); + } + Future _getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async { final entity = await _storageRepository.getAssetEntityForAsset(asset); if (entity == null) { diff --git a/mobile/makefile b/mobile/makefile index 1a20e769ef..61854830d9 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -9,9 +9,11 @@ pigeon: dart run pigeon --input pigeon/native_sync_api.dart dart run pigeon --input pigeon/thumbnail_api.dart dart run pigeon --input pigeon/background_worker_api.dart + dart run pigeon --input pigeon/connectivity_api.dart dart format lib/platform/native_sync_api.g.dart dart format lib/platform/thumbnail_api.g.dart dart format lib/platform/background_worker_api.g.dart + dart format lib/platform/connectivity_api.g.dart watch: dart run build_runner watch --delete-conflicting-outputs diff --git a/mobile/pigeon/background_worker_api.dart b/mobile/pigeon/background_worker_api.dart index 193bbc5832..8aa0f5f5ee 100644 --- a/mobile/pigeon/background_worker_api.dart +++ b/mobile/pigeon/background_worker_api.dart @@ -24,6 +24,8 @@ abstract class BackgroundWorkerBgHostApi { // required platform channels to notify the native side to start the background upload void onInitialized(); + void showNotification(String title, String content); + // Called from the background flutter engine to request the native side to cleanup void close(); } diff --git a/mobile/pigeon/connectivity_api.dart b/mobile/pigeon/connectivity_api.dart new file mode 100644 index 0000000000..c5677ee20e --- /dev/null +++ b/mobile/pigeon/connectivity_api.dart @@ -0,0 +1,20 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/connectivity_api.g.dart', + swiftOut: 'ios/Runner/Connectivity/Connectivity.g.swift', + swiftOptions: SwiftOptions(includeErrorClass: false), + kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/connectivity/Connectivity.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.connectivity'), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) +enum NetworkCapability { cellular, wifi, vpn, unmetered } + +@HostApi() +abstract class ConnectivityApi { + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + List getCapabilities(); +}