diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 3c2125e24e..5eeacd65b9 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -31,7 +31,7 @@ if (keystorePropertiesFile.exists()) { android { compileSdkVersion 35 - ndkVersion = "28.1.13356709" + ndkVersion = "28.2.13676358" compileOptions { sourceCompatibility JavaVersion.VERSION_17 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 4383b3098d..a8230b04ae 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 @@ -10,8 +10,8 @@ import app.alextran.immich.background.BackgroundWorkerLockApi import app.alextran.immich.connectivity.ConnectivityApi import app.alextran.immich.connectivity.ConnectivityApiImpl import app.alextran.immich.core.ImmichPlugin -import app.alextran.immich.images.ThumbnailApi -import app.alextran.immich.images.ThumbnailsImpl +import app.alextran.immich.images.LocalImageApi +import app.alextran.immich.images.LocalImagesImpl import app.alextran.immich.sync.NativeSyncApi import app.alextran.immich.sync.NativeSyncApiImpl26 import app.alextran.immich.sync.NativeSyncApiImpl30 @@ -36,7 +36,8 @@ class MainActivity : FlutterFragmentActivity() { NativeSyncApiImpl30(ctx) } NativeSyncApi.setUp(messenger, nativeSyncApiImpl) - ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx)) + LocalImageApi.setUp(messenger, LocalImagesImpl(ctx)) + BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx)) ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx)) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt similarity index 78% rename from mobile/android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt rename to mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt index ae2cca4d7b..1a965ac1b3 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt @@ -13,7 +13,7 @@ import io.flutter.plugin.common.StandardMethodCodec import io.flutter.plugin.common.StandardMessageCodec import java.io.ByteArrayOutputStream import java.nio.ByteBuffer -private object ThumbnailsPigeonUtils { +private object LocalImagesPigeonUtils { fun wrapResult(result: Any?): List { return listOf(result) @@ -47,7 +47,7 @@ class FlutterError ( override val message: String? = null, val details: Any? = null ) : Throwable() -private open class ThumbnailsPigeonCodec : StandardMessageCodec() { +private open class LocalImagesPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return super.readValueOfType(type, buffer) } @@ -58,22 +58,22 @@ private open class ThumbnailsPigeonCodec : StandardMessageCodec() { /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ -interface ThumbnailApi { +interface LocalImageApi { fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, callback: (Result>) -> Unit) - fun cancelImageRequest(requestId: Long) + fun cancelRequest(requestId: Long) fun getThumbhash(thumbhash: String, callback: (Result>) -> Unit) companion object { - /** The codec used by ThumbnailApi. */ + /** The codec used by LocalImageApi. */ val codec: MessageCodec by lazy { - ThumbnailsPigeonCodec() + LocalImagesPigeonCodec() } - /** Sets up an instance of `ThumbnailApi` to handle messages through the `binaryMessenger`. */ + /** Sets up an instance of `LocalImageApi` to handle messages through the `binaryMessenger`. */ @JvmOverloads - fun setUp(binaryMessenger: BinaryMessenger, api: ThumbnailApi?, messageChannelSuffix: String = "") { + fun setUp(binaryMessenger: BinaryMessenger, api: LocalImageApi?, messageChannelSuffix: String = "") { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage$separatedMessageChannelSuffix", codec) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { message, reply -> val args = message as List @@ -85,10 +85,10 @@ interface ThumbnailApi { api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result> -> val error = result.exceptionOrNull() if (error != null) { - reply.reply(ThumbnailsPigeonUtils.wrapError(error)) + reply.reply(LocalImagesPigeonUtils.wrapError(error)) } else { val data = result.getOrNull() - reply.reply(ThumbnailsPigeonUtils.wrapResult(data)) + reply.reply(LocalImagesPigeonUtils.wrapResult(data)) } } } @@ -97,16 +97,16 @@ interface ThumbnailApi { } } run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.cancelImageRequest$separatedMessageChannelSuffix", codec) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { message, reply -> val args = message as List val requestIdArg = args[0] as Long val wrapped: List = try { - api.cancelImageRequest(requestIdArg) + api.cancelRequest(requestIdArg) listOf(null) } catch (exception: Throwable) { - ThumbnailsPigeonUtils.wrapError(exception) + LocalImagesPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -115,7 +115,7 @@ interface ThumbnailApi { } } run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.getThumbhash$separatedMessageChannelSuffix", codec) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { message, reply -> val args = message as List @@ -123,10 +123,10 @@ interface ThumbnailApi { api.getThumbhash(thumbhashArg) { result: Result> -> val error = result.exceptionOrNull() if (error != null) { - reply.reply(ThumbnailsPigeonUtils.wrapError(error)) + reply.reply(LocalImagesPigeonUtils.wrapError(error)) } else { val data = result.getOrNull() - reply.reply(ThumbnailsPigeonUtils.wrapResult(data)) + reply.reply(LocalImagesPigeonUtils.wrapResult(data)) } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt similarity index 98% rename from mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt rename to mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt index a9d602c19c..2dfb9ebdeb 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt @@ -29,7 +29,7 @@ data class Request( val callback: (Result>) -> Unit ) -class ThumbnailsImpl(context: Context) : ThumbnailApi { +class LocalImagesImpl(context: Context) : LocalImageApi { private val ctx: Context = context.applicationContext private val resolver: ContentResolver = ctx.contentResolver private val requestThread = Executors.newSingleThreadExecutor() @@ -98,7 +98,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi { requestMap[requestId] = request } - override fun cancelImageRequest(requestId: Long) { + override fun cancelRequest(requestId: Long) { val request = requestMap.remove(requestId) ?: return request.taskFuture.cancel(false) request.cancellationSignal.cancel() diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt new file mode 100644 index 0000000000..d98676ec67 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt @@ -0,0 +1,104 @@ +// Autogenerated from Pigeon (v26.0.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package app.alextran.immich.images + +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 RemoteImagesPigeonUtils { + + 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) + ) + } + } +} +private open class RemoteImagesPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return super.readValueOfType(type, buffer) + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + super.writeValue(stream, value) + } +} + + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface RemoteImageApi { + fun requestImage(url: String, headers: Map, requestId: Long, callback: (Result>) -> Unit) + fun cancelRequest(requestId: Long) + + companion object { + /** The codec used by RemoteImageApi. */ + val codec: MessageCodec by lazy { + RemoteImagesPigeonCodec() + } + /** Sets up an instance of `RemoteImageApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: RemoteImageApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val urlArg = args[0] as String + val headersArg = args[1] as Map + val requestIdArg = args[2] as Long + api.requestImage(urlArg, headersArg, requestIdArg) { result: Result> -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(RemoteImagesPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(RemoteImagesPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val requestIdArg = args[0] as Long + val wrapped: List = try { + api.cancelRequest(requestIdArg) + listOf(null) + } catch (exception: Throwable) { + RemoteImagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbHash.java b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbHash.java index 3af76b5763..fdf31f2c96 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbHash.java +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbHash.java @@ -56,8 +56,8 @@ public final class ThumbHash { int w = Math.round(ratio > 1.0f ? 32.0f : 32.0f * ratio); int h = Math.round(ratio > 1.0f ? 32.0f / ratio : 32.0f); int size = w * h * 4; - long pointer = ThumbnailsImpl.allocateNative(size); - ByteBuffer rgba = ThumbnailsImpl.wrapAsBuffer(pointer, size); + long pointer = LocalImagesImpl.allocateNative(size); + ByteBuffer rgba = LocalImagesImpl.wrapAsBuffer(pointer, size); int cx_stop = Math.max(lx, hasAlpha ? 5 : 3); int cy_stop = Math.max(ly, hasAlpha ? 5 : 3); float[] fx = new float[cx_stop]; diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 599e7990f4..8f6b39d850 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 */ @@ -29,9 +29,11 @@ FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; }; FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; }; + FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F12F1197D8006016CB /* LocalImages.g.swift */; }; + FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F22F1197D8006016CB /* RemoteImages.g.swift */; }; + FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F52F11980E006016CB /* LocalImagesImpl.swift */; }; + FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */; }; FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; }; - FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; }; - FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; }; FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; }; FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; }; FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; }; @@ -118,9 +120,11 @@ FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = ""; }; + FE5499F12F1197D8006016CB /* LocalImages.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalImages.g.swift; sourceTree = ""; }; + FE5499F22F1197D8006016CB /* RemoteImages.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImages.g.swift; sourceTree = ""; }; + FE5499F52F11980E006016CB /* LocalImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalImagesImpl.swift; sourceTree = ""; }; + FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImagesImpl.swift; sourceTree = ""; }; FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = ""; }; - FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = ""; }; - FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailsImpl.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -136,15 +140,11 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ B231F52D2E93A44A00BC45D1 /* Core */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = Core; sourceTree = ""; }; B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = Sync; sourceTree = ""; }; @@ -158,8 +158,6 @@ }; FEE084F22EC172080045228E /* Schemas */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = Schemas; sourceTree = ""; }; @@ -321,9 +319,11 @@ FED3B1952E253E9B0030FD97 /* Images */ = { isa = PBXGroup; children = ( + FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */, + FE5499F52F11980E006016CB /* LocalImagesImpl.swift */, + FE5499F12F1197D8006016CB /* LocalImages.g.swift */, + FE5499F22F1197D8006016CB /* RemoteImages.g.swift */, FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */, - FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */, - FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */, ); path = Images; sourceTree = ""; @@ -549,10 +549,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"; @@ -581,10 +585,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"; @@ -600,12 +608,14 @@ 65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */, + FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */, + FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */, + FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */, B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */, + FE5499F82F1198E2006016CB /* RemoteImagesImpl.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 */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */, 65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 108fb7e2aa..60f97b6645 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -53,7 +53,8 @@ import UIKit public static func registerPlugins(with engine: FlutterEngine) { NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!) - ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl()) + LocalImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: LocalImageApiImpl()) + RemoteImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: RemoteImageApiImpl()) BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl()) ConnectivityApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ConnectivityApiImpl()) } diff --git a/mobile/ios/Runner/Images/Thumbnails.g.swift b/mobile/ios/Runner/Images/LocalImages.g.swift similarity index 69% rename from mobile/ios/Runner/Images/Thumbnails.g.swift rename to mobile/ios/Runner/Images/LocalImages.g.swift index fbaef294d3..fd99f0e019 100644 --- a/mobile/ios/Runner/Images/Thumbnails.g.swift +++ b/mobile/ios/Runner/Images/LocalImages.g.swift @@ -47,41 +47,41 @@ private func nilOrValue(_ value: Any?) -> T? { } -private class ThumbnailsPigeonCodecReader: FlutterStandardReader { +private class LocalImagesPigeonCodecReader: FlutterStandardReader { } -private class ThumbnailsPigeonCodecWriter: FlutterStandardWriter { +private class LocalImagesPigeonCodecWriter: FlutterStandardWriter { } -private class ThumbnailsPigeonCodecReaderWriter: FlutterStandardReaderWriter { +private class LocalImagesPigeonCodecReaderWriter: FlutterStandardReaderWriter { override func reader(with data: Data) -> FlutterStandardReader { - return ThumbnailsPigeonCodecReader(data: data) + return LocalImagesPigeonCodecReader(data: data) } override func writer(with data: NSMutableData) -> FlutterStandardWriter { - return ThumbnailsPigeonCodecWriter(data: data) + return LocalImagesPigeonCodecWriter(data: data) } } -class ThumbnailsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { - static let shared = ThumbnailsPigeonCodec(readerWriter: ThumbnailsPigeonCodecReaderWriter()) +class LocalImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = LocalImagesPigeonCodec(readerWriter: LocalImagesPigeonCodecReaderWriter()) } /// Generated protocol from Pigeon that represents a handler of messages from Flutter. -protocol ThumbnailApi { +protocol LocalImageApi { func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], Error>) -> Void) - func cancelImageRequest(requestId: Int64) throws + func cancelRequest(requestId: Int64) throws func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. -class ThumbnailApiSetup { - static var codec: FlutterStandardMessageCodec { ThumbnailsPigeonCodec.shared } - /// Sets up an instance of `ThumbnailApi` to handle messages through the `binaryMessenger`. - static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ThumbnailApi?, messageChannelSuffix: String = "") { +class LocalImageApiSetup { + static var codec: FlutterStandardMessageCodec { LocalImagesPigeonCodec.shared } + /// Sets up an instance of `LocalImageApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: LocalImageApi?, messageChannelSuffix: String = "") { let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" - let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { requestImageChannel.setMessageHandler { message, reply in let args = message as! [Any?] @@ -102,22 +102,22 @@ class ThumbnailApiSetup { } else { requestImageChannel.setMessageHandler(nil) } - let cancelImageRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.cancelImageRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + let cancelRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { - cancelImageRequestChannel.setMessageHandler { message, reply in + cancelRequestChannel.setMessageHandler { message, reply in let args = message as! [Any?] let requestIdArg = args[0] as! Int64 do { - try api.cancelImageRequest(requestId: requestIdArg) + try api.cancelRequest(requestId: requestIdArg) reply(wrapResult(nil)) } catch { reply(wrapError(error)) } } } else { - cancelImageRequestChannel.setMessageHandler(nil) + cancelRequestChannel.setMessageHandler(nil) } - let getThumbhashChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.getThumbhash\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + let getThumbhashChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { getThumbhashChannel.setMessageHandler { message, reply in let args = message as! [Any?] diff --git a/mobile/ios/Runner/Images/ThumbnailsImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift similarity index 63% rename from mobile/ios/Runner/Images/ThumbnailsImpl.swift rename to mobile/ios/Runner/Images/LocalImagesImpl.swift index 452ca62377..0c2ffeb981 100644 --- a/mobile/ios/Runner/Images/ThumbnailsImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -1,9 +1,9 @@ -import CryptoKit +import Accelerate import Flutter import MobileCoreServices import Photos -class Request { +class LocalImageRequest { weak var workItem: DispatchWorkItem? var isCancelled = false let callback: (Result<[String: Int64], any Error>) -> Void @@ -13,7 +13,7 @@ class Request { } } -class ThumbnailApiImpl: ThumbnailApi { +class LocalImageApiImpl: LocalImageApi { private static let imageManager = PHImageManager.default() private static let fetchOptions = { let fetchOptions = PHFetchOptions() @@ -36,9 +36,14 @@ class ThumbnailApiImpl: ThumbnailApi { private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default) private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent) - private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB() - private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue - private static var requests = [Int64: Request]() + private static var rgbaFormat = vImage_CGImageFormat( + bitsPerComponent: 8, + bitsPerPixel: 32, + colorSpace: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), + renderingIntent: .defaultIntent + )! + private static var requests = [Int64: LocalImageRequest]() private static let cancelledResult = Result<[String: Int64], any Error>.success([:]) private static let concurrencySemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2) private static let assetCache = { @@ -46,37 +51,19 @@ class ThumbnailApiImpl: ThumbnailApi { assetCache.countLimit = 10000 return assetCache }() - private static let activitySemaphore = DispatchSemaphore(value: 1) - private static let willResignActiveObserver = NotificationCenter.default.addObserver( - forName: UIApplication.willResignActiveNotification, - object: nil, - queue: .main - ) { _ in - processingQueue.suspend() - activitySemaphore.wait() - } - private static let didBecomeActiveObserver = NotificationCenter.default.addObserver( - forName: UIApplication.didBecomeActiveNotification, - object: nil, - queue: .main - ) { _ in - processingQueue.resume() - activitySemaphore.signal() - } func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) { Self.processingQueue.async { guard let data = Data(base64Encoded: thumbhash) else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))} - + let (width, height, pointer) = thumbHashToRGBA(hash: data) - self.waitForActiveState() completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)])) } } func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], any Error>) -> Void) { - let request = Request(callback: completion) + let request = LocalImageRequest(callback: completion) let item = DispatchWorkItem { if request.isCancelled { return completion(Self.cancelledResult) @@ -93,7 +80,7 @@ class ThumbnailApiImpl: ThumbnailApi { guard let asset = Self.requestAsset(assetId: assetId) else { - Self.removeRequest(requestId: requestId) + Self.remove(requestId: requestId) completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))) return } @@ -119,70 +106,54 @@ class ThumbnailApiImpl: ThumbnailApi { guard let image = image, let cgImage = image.cgImage else { - Self.removeRequest(requestId: requestId) + Self.remove(requestId: requestId) return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) } - let pointer = UnsafeMutableRawPointer.allocate( - byteCount: Int(cgImage.width) * Int(cgImage.height) * 4, - alignment: MemoryLayout.alignment - ) - if request.isCancelled { - pointer.deallocate() return completion(Self.cancelledResult) } - guard let context = CGContext( - data: pointer, - width: cgImage.width, - height: cgImage.height, - bitsPerComponent: 8, - bytesPerRow: cgImage.width * 4, - space: Self.rgbColorSpace, - bitmapInfo: Self.bitmapInfo - ) else { - pointer.deallocate() - Self.removeRequest(requestId: requestId) - return completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil))) + do { + let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat) + + if request.isCancelled { + buffer.free() + return completion(Self.cancelledResult) + } + + request.callback(.success([ + "pointer": Int64(Int(bitPattern: buffer.data)), + "width": Int64(buffer.width), + "height": Int64(buffer.height), + "rowBytes": Int64(buffer.rowBytes) + ])) + print("Successful response for \(requestId)") + Self.remove(requestId: requestId) + } catch { + Self.remove(requestId: requestId) + return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil))) } - - if request.isCancelled { - pointer.deallocate() - return completion(Self.cancelledResult) - } - - context.interpolationQuality = .none - context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height)) - - if request.isCancelled { - pointer.deallocate() - return completion(Self.cancelledResult) - } - - self.waitForActiveState() - completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)])) - Self.removeRequest(requestId: requestId) } request.workItem = item - Self.addRequest(requestId: requestId, request: request) + Self.add(requestId: requestId, request: request) Self.processingQueue.async(execute: item) } - func cancelImageRequest(requestId: Int64) { - Self.cancelRequest(requestId: requestId) + func cancelRequest(requestId: Int64) { + Self.cancel(requestId: requestId) } - private static func addRequest(requestId: Int64, request: Request) -> Void { + private static func add(requestId: Int64, request: LocalImageRequest) -> Void { requestQueue.sync { requests[requestId] = request } } - private static func removeRequest(requestId: Int64) -> Void { + private static func remove(requestId: Int64) -> Void { requestQueue.sync { requests[requestId] = nil } } - private static func cancelRequest(requestId: Int64) -> Void { + private static func cancel(requestId: Int64) -> Void { requestQueue.async { guard let request = requests.removeValue(forKey: requestId) else { return } request.isCancelled = true @@ -203,9 +174,4 @@ class ThumbnailApiImpl: ThumbnailApi { assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) } return asset } - - func waitForActiveState() { - Self.activitySemaphore.wait() - Self.activitySemaphore.signal() - } } diff --git a/mobile/ios/Runner/Images/RemoteImages.g.swift b/mobile/ios/Runner/Images/RemoteImages.g.swift new file mode 100644 index 0000000000..c8af4d8772 --- /dev/null +++ b/mobile/ios/Runner/Images/RemoteImages.g.swift @@ -0,0 +1,118 @@ +// Autogenerated from Pigeon (v26.0.2), 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? +} + + +private class RemoteImagesPigeonCodecReader: FlutterStandardReader { +} + +private class RemoteImagesPigeonCodecWriter: FlutterStandardWriter { +} + +private class RemoteImagesPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return RemoteImagesPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return RemoteImagesPigeonCodecWriter(data: data) + } +} + +class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = RemoteImagesPigeonCodec(readerWriter: RemoteImagesPigeonCodecReaderWriter()) +} + + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol RemoteImageApi { + func requestImage(url: String, headers: [String: String], requestId: Int64, completion: @escaping (Result<[String: Int64], Error>) -> Void) + func cancelRequest(requestId: Int64) throws +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class RemoteImageApiSetup { + static var codec: FlutterStandardMessageCodec { RemoteImagesPigeonCodec.shared } + /// Sets up an instance of `RemoteImageApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: RemoteImageApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + requestImageChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let urlArg = args[0] as! String + let headersArg = args[1] as! [String: String] + let requestIdArg = args[2] as! Int64 + api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + requestImageChannel.setMessageHandler(nil) + } + let cancelRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + cancelRequestChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let requestIdArg = args[0] as! Int64 + do { + try api.cancelRequest(requestId: requestIdArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + cancelRequestChannel.setMessageHandler(nil) + } + } +} diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift new file mode 100644 index 0000000000..6e8476d036 --- /dev/null +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -0,0 +1,181 @@ +import Accelerate +import Flutter +import MobileCoreServices +import Photos + +class RemoteImageRequest { + weak var task: URLSessionDataTask? + var isCancelled = false + let imageSource: CGImageSource + var data: CFMutableData? + let completion: (Result<[String: Int64], any Error>) -> Void + + init(task: URLSessionDataTask, imageSource: CGImageSource, completion: @escaping (Result<[String: Int64], any Error>) -> Void) { + self.task = task + self.imageSource = imageSource + self.data = nil + self.completion = completion + } +} + +class RemoteImageApiImpl: NSObject, RemoteImageApi { + private static let delegate = RemoteImageApiDelegate() + static let session = { + let config = URLSessionConfiguration.default + let thumbnailPath = FileManager.default.temporaryDirectory.appendingPathComponent("thumbnails", isDirectory: true) + try! FileManager.default.createDirectory(at: thumbnailPath, withIntermediateDirectories: true) + config.urlCache = URLCache( + memoryCapacity: 0, + diskCapacity: 10 << 20, + directory: thumbnailPath + ) + config.httpMaximumConnectionsPerHost = 16 + return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + }() + + func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64], any Error>) -> Void) { + print("Got image request \(requestId) for \(url)") + var urlRequest = URLRequest(url: URL(string: url)!) + for (key, value) in headers { + urlRequest.setValue(value, forHTTPHeaderField: key) + } + let task = Self.session.dataTask(with: urlRequest) + task.taskDescription = String(requestId) + + let imageRequest = RemoteImageRequest( + task: task, + imageSource: CGImageSourceCreateIncremental( + [kCGImageSourceShouldCache: false] as CFDictionary), + completion: completion + ) + Self.delegate.add(requestId: requestId, request: imageRequest) + + task.resume() + print("Started task for \(requestId)") + } + + func cancelRequest(requestId: Int64) { + print("Cancelling task for \(requestId)") + Self.delegate.cancel(requestId: requestId) + } +} + +class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate { + private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated) + private static var rgbaFormat = vImage_CGImageFormat( + bitsPerComponent: 8, + bitsPerPixel: 32, + colorSpace: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), + renderingIntent: .perceptual + )! + private static var requests = [Int64: RemoteImageRequest]() + private static let cancelledResult = Result<[String: Int64], any Error>.success([:]) + + func urlSession( + _ session: URLSession, dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + guard let taskDescription = dataTask.taskDescription, + let requestId = Int64(taskDescription), + let request = (Self.requestQueue.sync { Self.requests[requestId] }) + else { + return completionHandler(.cancel) + } + + let capacity = max(Int(response.expectedContentLength), 0) + request.data = CFDataCreateMutable(nil, capacity) + + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive data: Data) { + print("Got data") + guard let taskDescription = dataTask.taskDescription, + let requestId = Int64(taskDescription), + let request = get(requestId: requestId), + let accumulatedData = request.data + else { return } + + data.withUnsafeBytes { bytes in + CFDataAppendBytes(request.data, bytes.bindMemory(to: UInt8.self).baseAddress, data.count) + } + CGImageSourceUpdateData(request.imageSource, accumulatedData, false) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, + didCompleteWithError error: Error?) { + print("Task ended") + guard let taskDescription = task.taskDescription, + let requestId = Int64(taskDescription), + let request = get(requestId: requestId), + let accumulatedData = request.data + else { return } + + if let error = error { + print("Task failed for \(requestId): \(error)") + remove(requestId: requestId) + return request.completion(.failure(error)) + } + + CGImageSourceUpdateData(request.imageSource, accumulatedData, true) + + let options = [kCGImageSourceShouldCacheImmediately: true] as CFDictionary + guard let cgImage = CGImageSourceCreateImageAtIndex(request.imageSource, 0, nil) else { + print("No image for \(requestId)") + remove(requestId: requestId) + return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request \(requestId)", details: nil))) + } + print("Got image for \(requestId)") + + if request.isCancelled { + remove(requestId: requestId) + return request.completion(Self.cancelledResult) + } + + do { + let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat) + + if request.isCancelled { + buffer.free() + remove(requestId: requestId) + return request.completion(Self.cancelledResult) + } + + request.completion( + .success([ + "pointer": Int64(Int(bitPattern: buffer.data)), + "width": Int64(buffer.width), + "height": Int64(buffer.height), + "rowBytes": Int64(buffer.rowBytes), + ])) + print("Successful response for \(requestId)") + + remove(requestId: requestId) + } catch { + print("vImage conversion failed for \(requestId): \(error)") + remove(requestId: requestId) + return request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for request \(requestId): \(error)", details: nil))) + } + } + + func get(requestId: Int64) -> RemoteImageRequest? { + Self.requestQueue.sync { Self.requests[requestId] } + } + + func add(requestId: Int64, request: RemoteImageRequest) -> Void { + Self.requestQueue.sync { Self.requests[requestId] = request } + } + + func remove(requestId: Int64) -> Void { + Self.requestQueue.sync { Self.requests[requestId] = nil } + } + + func cancel(requestId: Int64) -> Void { + guard let request = (Self.requestQueue.sync { Self.requests[requestId] }) else { return } + request.isCancelled = true + request.task?.cancel() + } +} diff --git a/mobile/lib/infrastructure/loaders/image_request.dart b/mobile/lib/infrastructure/loaders/image_request.dart index a1552e1aae..424a6f350b 100644 --- a/mobile/lib/infrastructure/loaders/image_request.dart +++ b/mobile/lib/infrastructure/loaders/image_request.dart @@ -2,15 +2,11 @@ import 'dart:async'; import 'dart:ffi'; import 'dart:ui' as ui; -import 'package:cronet_http/cronet_http.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:ffi/ffi.dart'; -import 'package:http/http.dart' as http; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; -import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; part 'local_image_request.dart'; part 'thumbhash_image_request.dart'; @@ -52,12 +48,14 @@ abstract class ImageRequest { final int actualWidth; final int actualHeight; + final int rowBytes; final int actualSize; final ui.ImmutableBuffer buffer; try { actualWidth = info['width']!; actualHeight = info['height']!; - actualSize = actualWidth * actualHeight * 4; + rowBytes = info['rowBytes'] ?? actualWidth * 4; + actualSize = rowBytes * actualHeight; buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(actualSize)); } finally { malloc.free(pointer); @@ -72,6 +70,7 @@ abstract class ImageRequest { buffer, width: actualWidth, height: actualHeight, + rowBytes: rowBytes, pixelFormat: ui.PixelFormat.rgba8888, ); final codec = await descriptor.instantiateCodec(); diff --git a/mobile/lib/infrastructure/loaders/local_image_request.dart b/mobile/lib/infrastructure/loaders/local_image_request.dart index 7a1b3d8957..ace83a8709 100644 --- a/mobile/lib/infrastructure/loaders/local_image_request.dart +++ b/mobile/lib/infrastructure/loaders/local_image_request.dart @@ -16,7 +16,7 @@ class LocalImageRequest extends ImageRequest { return null; } - final Map info = await thumbnailApi.requestImage( + final Map info = await localImageApi.requestImage( localId, requestId: requestId, width: width, @@ -30,6 +30,6 @@ class LocalImageRequest extends ImageRequest { @override Future _onCancelled() { - return thumbnailApi.cancelImageRequest(requestId); + return localImageApi.cancelRequest(requestId); } } diff --git a/mobile/lib/infrastructure/loaders/remote_image_request.dart b/mobile/lib/infrastructure/loaders/remote_image_request.dart index 415192830b..b3f4ec226c 100644 --- a/mobile/lib/infrastructure/loaders/remote_image_request.dart +++ b/mobile/lib/infrastructure/loaders/remote_image_request.dart @@ -1,16 +1,8 @@ part of 'image_request.dart'; class RemoteImageRequest extends ImageRequest { - static final _client = const NetworkRepository().getHttpClient( - 'thumbnails', - diskCapacity: kThumbnailDiskCacheSize, - memoryCapacity: 0, - maxConnections: 16, - cacheMode: CacheMode.disk, - ); final String uri; final Map headers; - final abortTrigger = Completer(); RemoteImageRequest({required this.uri, required this.headers}); @@ -20,106 +12,14 @@ class RemoteImageRequest extends ImageRequest { return null; } - try { - final buffer = await _downloadImage(); - if (buffer == null) { - return null; - } + final Map info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId); - return await _decodeBuffer(buffer, decode, scale); - } catch (e) { - if (_isCancelled) { - return null; - } - - rethrow; - } - } - - Future _downloadImage() async { - if (_isCancelled) { - return null; - } - - final req = http.AbortableRequest('GET', Uri.parse(uri), abortTrigger: abortTrigger.future); - req.headers.addAll(headers); - final res = await _client.send(req); - if (_isCancelled) { - _onCancelled(); - return null; - } - - if (res.statusCode != 200) { - throw Exception('Failed to download $uri: ${res.statusCode}'); - } - - final stream = res.stream.map((chunk) { - if (_isCancelled) { - throw StateError('Cancelled request'); - } - return chunk; - }); - - try { - final Uint8List bytes = await _downloadBytes(stream, res.contentLength ?? -1); - if (_isCancelled) { - return null; - } - return await ImmutableBuffer.fromUint8List(bytes); - } catch (e) { - if (_isCancelled) { - return null; - } - rethrow; - } - } - - Future _downloadBytes(Stream> stream, int length) async { - final Uint8List bytes; - int offset = 0; - if (length > 0) { - // Known content length - use pre-allocated buffer - bytes = Uint8List(length); - await stream.listen((chunk) { - bytes.setAll(offset, chunk); - offset += chunk.length; - }, cancelOnError: true).asFuture(); - } else { - // Unknown content length - collect chunks dynamically - final chunks = >[]; - int totalLength = 0; - await stream.listen((chunk) { - chunks.add(chunk); - totalLength += chunk.length; - }, cancelOnError: true).asFuture(); - - bytes = Uint8List(totalLength); - for (final chunk in chunks) { - bytes.setAll(offset, chunk); - offset += chunk.length; - } - } - - return bytes; - } - - Future _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async { - if (_isCancelled) { - buffer.dispose(); - return null; - } - final codec = await decode(buffer); - if (_isCancelled) { - buffer.dispose(); - codec.dispose(); - return null; - } - final frame = await codec.getNextFrame(); - return ImageInfo(image: frame.image, scale: scale); + final frame = await _fromPlatformImage(info); + return frame == null ? null : ImageInfo(image: frame.image, scale: scale); } @override - void _onCancelled() { - abortTrigger.complete(); + Future _onCancelled() { + return localImageApi.cancelRequest(requestId); } } diff --git a/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart b/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart index a876020984..1a184d1cb8 100644 --- a/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart +++ b/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart @@ -11,7 +11,7 @@ class ThumbhashImageRequest extends ImageRequest { return null; } - final Map info = await thumbnailApi.getThumbhash(thumbhash); + final Map info = await localImageApi.getThumbhash(thumbhash); final frame = await _fromPlatformImage(info); return frame == null ? null : ImageInfo(image: frame.image, scale: scale); } diff --git a/mobile/lib/platform/local_image_api.g.dart b/mobile/lib/platform/local_image_api.g.dart new file mode 100644 index 0000000000..e91dd87206 --- /dev/null +++ b/mobile/lib/platform/local_image_api.g.dart @@ -0,0 +1,142 @@ +// Autogenerated from Pigeon (v26.0.2), 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".', + ); +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + default: + return super.readValueOfType(type, buffer); + } + } +} + +class LocalImageApi { + /// Constructor for [LocalImageApi]. 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. + LocalImageApi({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> requestImage( + String assetId, { + required int requestId, + required int width, + required int height, + required bool isVideo, + }) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([ + assetId, + requestId, + width, + height, + isVideo, + ]); + 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 Map?)!.cast(); + } + } + + Future cancelRequest(int requestId) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([requestId]); + 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> getThumbhash(String thumbhash) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([thumbhash]); + 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 Map?)!.cast(); + } + } +} diff --git a/mobile/lib/platform/remote_image_api.g.dart b/mobile/lib/platform/remote_image_api.g.dart new file mode 100644 index 0000000000..f9788ea979 --- /dev/null +++ b/mobile/lib/platform/remote_image_api.g.dart @@ -0,0 +1,106 @@ +// Autogenerated from Pigeon (v26.0.2), 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".', + ); +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + default: + return super.readValueOfType(type, buffer); + } + } +} + +class RemoteImageApi { + /// Constructor for [RemoteImageApi]. 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. + RemoteImageApi({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> requestImage( + String url, { + required Map headers, + required int requestId, + }) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([url, headers, requestId]); + 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 Map?)!.cast(); + } + } + + Future cancelRequest(int requestId) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([requestId]); + 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; + } + } +} diff --git a/mobile/lib/providers/infrastructure/platform.provider.dart b/mobile/lib/providers/infrastructure/platform.provider.dart index 11c5280c02..60300e74df 100644 --- a/mobile/lib/providers/infrastructure/platform.provider.dart +++ b/mobile/lib/providers/infrastructure/platform.provider.dart @@ -4,7 +4,8 @@ import 'package:immich_mobile/platform/background_worker_api.g.dart'; import 'package:immich_mobile/platform/background_worker_lock_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'; +import 'package:immich_mobile/platform/local_image_api.g.dart'; +import 'package:immich_mobile/platform/remote_image_api.g.dart'; final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi())); @@ -16,4 +17,6 @@ final nativeSyncApiProvider = Provider((_) => NativeSyncApi()); final connectivityApiProvider = Provider((_) => ConnectivityApi()); -final thumbnailApi = ThumbnailApi(); +final localImageApi = LocalImageApi(); + +final remoteImageApi = RemoteImageApi(); diff --git a/mobile/makefile b/mobile/makefile index 3b211bcd09..79b263c079 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -7,12 +7,14 @@ build: pigeon: dart run pigeon --input pigeon/native_sync_api.dart - dart run pigeon --input pigeon/thumbnail_api.dart + dart run pigeon --input pigeon/local_image_api.dart + dart run pigeon --input pigeon/remote_image_api.dart dart run pigeon --input pigeon/background_worker_api.dart dart run pigeon --input pigeon/background_worker_lock_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/local_image_api.g.dart + dart format lib/platform/remote_image_api.g.dart dart format lib/platform/background_worker_api.g.dart dart format lib/platform/background_worker_lock_api.g.dart dart format lib/platform/connectivity_api.g.dart diff --git a/mobile/pigeon/thumbnail_api.dart b/mobile/pigeon/local_image_api.dart similarity index 75% rename from mobile/pigeon/thumbnail_api.dart rename to mobile/pigeon/local_image_api.dart index 0698e7cdc9..5c4ab50cc8 100644 --- a/mobile/pigeon/thumbnail_api.dart +++ b/mobile/pigeon/local_image_api.dart @@ -2,18 +2,18 @@ import 'package:pigeon/pigeon.dart'; @ConfigurePigeon( PigeonOptions( - dartOut: 'lib/platform/thumbnail_api.g.dart', - swiftOut: 'ios/Runner/Images/Thumbnails.g.swift', + dartOut: 'lib/platform/local_image_api.g.dart', + swiftOut: 'ios/Runner/Images/LocalImages.g.swift', swiftOptions: SwiftOptions(includeErrorClass: false), kotlinOut: - 'android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt', + 'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt', kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images'), dartOptions: DartOptions(), dartPackageName: 'immich_mobile', ), ) @HostApi() -abstract class ThumbnailApi { +abstract class LocalImageApi { @async Map requestImage( String assetId, { @@ -23,7 +23,7 @@ abstract class ThumbnailApi { required bool isVideo, }); - void cancelImageRequest(int requestId); + void cancelRequest(int requestId); @async Map getThumbhash(String thumbhash); diff --git a/mobile/pigeon/remote_image_api.dart b/mobile/pigeon/remote_image_api.dart new file mode 100644 index 0000000000..5ff73631f9 --- /dev/null +++ b/mobile/pigeon/remote_image_api.dart @@ -0,0 +1,25 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/remote_image_api.g.dart', + swiftOut: 'ios/Runner/Images/RemoteImages.g.swift', + swiftOptions: SwiftOptions(includeErrorClass: false), + kotlinOut: + 'android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images', includeErrorClass: false), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) +@HostApi() +abstract class RemoteImageApi { + @async + Map requestImage( + String url, { + required Map headers, + required int requestId, + }); + + void cancelRequest(int requestId); +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index dc5e13b92d..c2c41013ca 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1249,10 +1249,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1942,10 +1942,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" thumbhash: dependency: "direct main" description: