diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt index fc29c4a1a2..9afd254709 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt @@ -2,10 +2,8 @@ package app.alextran.immich import java.nio.ByteBuffer -/** - * JNI interface for native memory operations. - * Used by HTTP responses and image processing to avoid copies. - */ +const val INITIAL_BUFFER_SIZE = 32 * 1024 + object NativeBuffer { init { System.loadLibrary("native_buffer") @@ -26,3 +24,29 @@ object NativeBuffer { @JvmStatic external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int) } + +class NativeByteBuffer(initialCapacity: Int) { + var pointer = NativeBuffer.allocate(initialCapacity) + var capacity = initialCapacity + var offset = 0 + + fun ensureHeadroom(needed: Int = INITIAL_BUFFER_SIZE) { + if (offset + needed > capacity) { + capacity = (capacity * 2).coerceAtLeast(offset + needed) + pointer = NativeBuffer.realloc(pointer, capacity) + } + } + + fun wrapRemaining() = NativeBuffer.wrap(pointer + offset, capacity - offset) + + fun advance(bytesRead: Int) { + offset += bytesRead + } + + fun free() { + if (pointer != 0L) { + NativeBuffer.free(pointer) + pointer = 0L + } + } +} 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 index 716169ea6f..d98676ec67 100644 --- 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 @@ -49,7 +49,6 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() { interface RemoteImageApi { fun requestImage(url: String, headers: Map, requestId: Long, callback: (Result>) -> Unit) fun cancelRequest(requestId: Long) - fun releaseImage(requestId: Long) companion object { /** The codec used by RemoteImageApi. */ @@ -100,24 +99,6 @@ interface RemoteImageApi { channel.setMessageHandler(null) } } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.releaseImage$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.releaseImage(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/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index 1923adc10a..2f5daff37d 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -4,7 +4,9 @@ import android.content.Context import android.os.CancellationSignal import android.os.OperationCanceledException import app.alextran.immich.BuildConfig +import app.alextran.immich.INITIAL_BUFFER_SIZE import app.alextran.immich.NativeBuffer +import app.alextran.immich.NativeByteBuffer import app.alextran.immich.core.SSLConfig import okhttp3.Cache import okhttp3.Call @@ -33,82 +35,18 @@ private const val MAX_REQUESTS_PER_HOST = 16 private const val KEEP_ALIVE_CONNECTIONS = 10 private const val KEEP_ALIVE_DURATION_MINUTES = 5L private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024 -private const val INITIAL_BUFFER_SIZE = 64 * 1024 -private class RemoteRequest( - val cancellationSignal: CancellationSignal, -) - -private class NativeByteBuffer(initialCapacity: Int) { - var pointer = NativeBuffer.allocate(initialCapacity) - var capacity = initialCapacity - var offset = 0 - - fun ensureHeadroom(needed: Int = INITIAL_BUFFER_SIZE) { - if (offset + needed > capacity) { - capacity = (capacity * 2).coerceAtLeast(offset + needed) - pointer = NativeBuffer.realloc(pointer, capacity) - } - } - - fun wrapRemaining() = NativeBuffer.wrap(pointer + offset, capacity - offset) - - fun advance(bytesRead: Int) { offset += bytesRead } - - fun free() { - if (pointer != 0L) { - NativeBuffer.free(pointer) - pointer = 0L - } - } -} +private class RemoteRequest(val cancellationSignal: CancellationSignal) class RemoteImagesImpl(context: Context) : RemoteImageApi { private val requestMap = ConcurrentHashMap() init { - appContext = context.applicationContext - cacheDir = context.cacheDir - fetcher = buildFetcher() + ImageFetcherManager.initialize(context) } companion object { val CANCELLED = Result.success>(emptyMap()) - - private var appContext: Context? = null - private var cacheDir: File? = null - private var fetcher: ImageFetcher? = null - - init { - SSLConfig.addListener(::invalidateFetcher) - } - - private fun invalidateFetcher() { - val oldFetcher = fetcher - val needsOkHttp = SSLConfig.requiresCustomSSL - - fetcher = when { - // OkHttp → OkHttp: reconfigure, sharing cache/dispatcher - oldFetcher is OkHttpImageFetcher && needsOkHttp -> { - oldFetcher.reconfigure(SSLConfig.sslSocketFactory, SSLConfig.trustManager) - } - // Any other transition: graceful drain, create new - else -> { - oldFetcher?.drain() - buildFetcher() - } - } - } - - private fun buildFetcher(): ImageFetcher { - val ctx = appContext ?: throw IllegalStateException("Context not set") - val dir = cacheDir ?: throw IllegalStateException("Cache dir not set") - return if (SSLConfig.requiresCustomSSL) { - OkHttpImageFetcher.create(dir, SSLConfig.sslSocketFactory, SSLConfig.trustManager) - } else { - CronetImageFetcher(ctx, dir) - } - } } override fun requestImage( @@ -117,11 +55,10 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { requestId: Long, callback: (Result>) -> Unit ) { - val fetcher = fetcher ?: return callback(Result.failure(RuntimeException("No fetcher"))) val signal = CancellationSignal() - val request = RemoteRequest(signal) - requestMap[requestId] = request - fetcher.fetch( + requestMap[requestId] = RemoteRequest(signal) + + ImageFetcherManager.fetch( url, headers, signal, @@ -132,10 +69,14 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { return@fetch callback(CANCELLED) } - callback(Result.success(mapOf( - "pointer" to buffer.pointer, - "length" to buffer.offset.toLong() - ))) + callback( + Result.success( + mapOf( + "pointer" to buffer.pointer, + "length" to buffer.offset.toLong() + ) + ) + ) }, onFailure = { e -> requestMap.remove(requestId) @@ -148,8 +89,53 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { override fun cancelRequest(requestId: Long) { requestMap.remove(requestId)?.cancellationSignal?.cancel() } +} - override fun releaseImage(requestId: Long) {} +private object ImageFetcherManager { + private lateinit var appContext: Context + private lateinit var cacheDir: File + private lateinit var fetcher: ImageFetcher + private var initialized = false + + fun initialize(context: Context) { + if (initialized) return + synchronized(this) { + if (initialized) return + appContext = context.applicationContext + cacheDir = context.cacheDir + fetcher = build() + SSLConfig.addListener(::invalidate) + initialized = true + } + } + + fun fetch( + url: String, + headers: Map, + signal: CancellationSignal, + onSuccess: (NativeByteBuffer) -> Unit, + onFailure: (Exception) -> Unit, + ) { + fetcher.fetch(url, headers, signal, onSuccess, onFailure) + } + + private fun invalidate() { + val oldFetcher = fetcher + if (oldFetcher is OkHttpImageFetcher && SSLConfig.requiresCustomSSL) { + fetcher = oldFetcher.reconfigure(SSLConfig.sslSocketFactory, SSLConfig.trustManager) + return + } + fetcher = build() + oldFetcher.drain() + } + + private fun build(): ImageFetcher { + return if (SSLConfig.requiresCustomSSL) { + OkHttpImageFetcher.create(cacheDir, SSLConfig.sslSocketFactory, SSLConfig.trustManager) + } else { + CronetImageFetcher(appContext, cacheDir) + } + } } private sealed interface ImageFetcher { @@ -164,10 +150,7 @@ private sealed interface ImageFetcher { fun drain() } -private class CronetImageFetcher( - context: Context, - cacheDir: File, -) : ImageFetcher { +private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher { private val engine: CronetEngine private val executor = Executors.newSingleThreadExecutor() private val stateLock = Any() @@ -256,7 +239,11 @@ private class CronetImageFetcher( request.read(buffer!!.wrapRemaining()) } - override fun onReadCompleted(request: UrlRequest, info: UrlResponseInfo, byteBuffer: ByteBuffer) { + override fun onReadCompleted( + request: UrlRequest, + info: UrlResponseInfo, + byteBuffer: ByteBuffer + ) { buffer!!.apply { advance(byteBuffer.remaining()) ensureHeadroom() diff --git a/mobile/ios/Runner/Images/RemoteImages.g.swift b/mobile/ios/Runner/Images/RemoteImages.g.swift index a88b13bb42..c8af4d8772 100644 --- a/mobile/ios/Runner/Images/RemoteImages.g.swift +++ b/mobile/ios/Runner/Images/RemoteImages.g.swift @@ -72,7 +72,6 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable protocol RemoteImageApi { func requestImage(url: String, headers: [String: String], requestId: Int64, completion: @escaping (Result<[String: Int64], Error>) -> Void) func cancelRequest(requestId: Int64) throws - func releaseImage(requestId: Int64) throws } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -115,20 +114,5 @@ class RemoteImageApiSetup { } else { cancelRequestChannel.setMessageHandler(nil) } - let releaseImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.releaseImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - releaseImageChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let requestIdArg = args[0] as! Int64 - do { - try api.releaseImage(requestId: requestIdArg) - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - releaseImageChannel.setMessageHandler(nil) - } } } diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index f3433bd385..78c7e5765f 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -50,8 +50,6 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { func cancelRequest(requestId: Int64) { Self.delegate.cancel(requestId: requestId) } - - func releaseImage(requestId: Int64) throws {} } class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate { diff --git a/mobile/lib/platform/remote_image_api.g.dart b/mobile/lib/platform/remote_image_api.g.dart index 63970979da..f9788ea979 100644 --- a/mobile/lib/platform/remote_image_api.g.dart +++ b/mobile/lib/platform/remote_image_api.g.dart @@ -103,27 +103,4 @@ class RemoteImageApi { return; } } - - Future releaseImage(int requestId) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.releaseImage$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/pigeon/remote_image_api.dart b/mobile/pigeon/remote_image_api.dart index e43013ac6a..5ff73631f9 100644 --- a/mobile/pigeon/remote_image_api.dart +++ b/mobile/pigeon/remote_image_api.dart @@ -22,6 +22,4 @@ abstract class RemoteImageApi { }); void cancelRequest(int requestId); - - void releaseImage(int requestId); }