android impl

This commit is contained in:
mertalev
2026-01-16 19:41:50 -05:00
parent ebca72d31f
commit ff08873aae
7 changed files with 276 additions and 35 deletions

View File

@@ -105,8 +105,10 @@ dependencies {
def serialization_version = '1.8.1'
def compose_version = '1.1.1'
def gson_version = '2.10.1'
def okhttp_version = '4.12.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"

View File

@@ -2,6 +2,7 @@ package app.alextran.immich
import android.annotation.SuppressLint
import android.content.Context
import app.alextran.immich.core.SSLConfig
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
@@ -51,15 +52,18 @@ class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
when (call.method) {
"apply" -> {
val args = call.arguments<ArrayList<*>>()!!
val allowSelfSigned = args[0] as Boolean
val serverHost = args[1] as? String
val clientCertHash = (args[2] as? ByteArray)
var tm: Array<TrustManager>? = null
if (args[0] as Boolean) {
tm = arrayOf(AllowSelfSignedTrustManager(args[1] as? String))
if (allowSelfSigned) {
tm = arrayOf(AllowSelfSignedTrustManager(serverHost))
}
var km: Array<KeyManager>? = null
if (args[2] != null) {
val cert = ByteArrayInputStream(args[2] as ByteArray)
if (clientCertHash != null) {
val cert = ByteArrayInputStream(clientCertHash)
val password = (args[3] as String).toCharArray()
val keyStore = KeyStore.getInstance("PKCS12")
keyStore.load(cert, password)
@@ -69,6 +73,9 @@ class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
km = keyManagerFactory.keyManagers
}
// Update shared SSL config for OkHttp and other HTTP clients
SSLConfig.apply(km, tm, allowSelfSigned, serverHost, clientCertHash?.contentHashCode() ?: 0)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(km, tm, null)
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)

View File

@@ -12,6 +12,8 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl
import app.alextran.immich.core.ImmichPlugin
import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi
import app.alextran.immich.images.RemoteImagesImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
@@ -37,6 +39,7 @@ class MainActivity : FlutterFragmentActivity() {
}
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))

View File

@@ -0,0 +1,66 @@
package app.alextran.immich.core
import java.security.KeyStore
import javax.net.ssl.KeyManager
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
/**
* Shared SSL configuration for OkHttp and HttpsURLConnection.
* Stores the SSLSocketFactory and X509TrustManager configured by HttpSSLOptionsPlugin.
*/
object SSLConfig {
var sslSocketFactory: SSLSocketFactory? = null
private set
var trustManager: X509TrustManager? = null
private set
private val listeners = mutableListOf<() -> Unit>()
private var configHash: Int = 0
fun addListener(listener: () -> Unit) {
listeners.add(listener)
}
fun apply(
keyManagers: Array<KeyManager>?,
trustManagers: Array<TrustManager>?,
allowSelfSigned: Boolean,
serverHost: String?,
clientCertHash: Int
) {
val newHash = computeHash(allowSelfSigned, serverHost, clientCertHash)
if (newHash == configHash && sslSocketFactory != null) {
return // Config unchanged, skip
}
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(keyManagers, trustManagers, null)
sslSocketFactory = sslContext.socketFactory
trustManager = trustManagers?.filterIsInstance<X509TrustManager>()?.firstOrNull()
?: getDefaultTrustManager()
configHash = newHash
notifyListeners()
}
private fun computeHash(allowSelfSigned: Boolean, serverHost: String?, clientCertHash: Int): Int {
var result = allowSelfSigned.hashCode()
result = 31 * result + (serverHost?.hashCode() ?: 0)
result = 31 * result + clientCertHash
return result
}
private fun notifyListeners() {
listeners.forEach { it() }
}
private fun getDefaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return factory.trustManagers.filterIsInstance<X509TrustManager>().first()
}
}

View File

@@ -11,6 +11,10 @@ import android.os.OperationCanceledException
import android.provider.MediaStore.Images
import android.provider.MediaStore.Video
import android.util.Size
import androidx.annotation.RequiresApi
import app.alextran.immich.images.LocalImagesImpl.Companion.allocateNative
import app.alextran.immich.images.LocalImagesImpl.Companion.freeNative
import app.alextran.immich.images.LocalImagesImpl.Companion.wrapAsBuffer
import java.nio.ByteBuffer
import kotlin.math.*
import java.util.concurrent.Executors
@@ -29,6 +33,37 @@ data class Request(
val callback: (Result<Map<String, Long>>) -> Unit
)
@RequiresApi(Build.VERSION_CODES.Q)
inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
return ImageDecoder.decodeBitmap(this) { decoder, info, _ ->
if (target.width > 0 && target.height > 0) {
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
decoder.setTargetSampleSize(sample)
}
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
}
}
fun Bitmap.toNativeBuffer(): Map<String, Long> {
val size = width * height * 4
val pointer = allocateNative(size)
try {
val buffer = wrapAsBuffer(pointer, size)
copyPixelsToBuffer(buffer)
recycle()
return mapOf(
"pointer" to pointer,
"width" to width.toLong(),
"height" to height.toLong()
)
} catch (e: Exception) {
freeNative(pointer)
recycle()
throw e
}
}
class LocalImagesImpl(context: Context) : LocalImageApi {
private val ctx: Context = context.applicationContext
private val resolver: ContentResolver = ctx.contentResolver
@@ -131,31 +166,12 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
decodeImage(id, size, signal)
}
processBitmap(bitmap, callback, signal)
}
private fun processBitmap(
bitmap: Bitmap, callback: (Result<Map<String, Long>>) -> Unit, signal: CancellationSignal
) {
signal.throwIfCanceled()
val actualWidth = bitmap.width
val actualHeight = bitmap.height
val size = actualWidth * actualHeight * 4
val pointer = allocateNative(size)
try {
signal.throwIfCanceled()
val buffer = wrapAsBuffer(pointer, size)
bitmap.copyPixelsToBuffer(buffer)
bitmap.recycle()
val res = bitmap.toNativeBuffer()
signal.throwIfCanceled()
val res = mapOf(
"pointer" to pointer, "width" to actualWidth.toLong(), "height" to actualHeight.toLong()
)
callback(Result.success(res))
} catch (e: Exception) {
freeNative(pointer)
callback(if (e is OperationCanceledException) CANCELLED else Result.failure(e))
}
}
@@ -191,16 +207,7 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
private fun decodeSource(uri: Uri, target: Size, signal: CancellationSignal): Bitmap {
signal.throwIfCanceled()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val source = ImageDecoder.createSource(resolver, uri)
signal.throwIfCanceled()
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
if (target.width > 0 && target.height > 0) {
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
decoder.setTargetSampleSize(sample)
}
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
}
ImageDecoder.createSource(resolver, uri).decodeBitmap(target)
} else {
val ref =
Glide.with(ctx).asBitmap().priority(Priority.IMMEDIATE).load(uri).disallowHardwareConfig()

View File

@@ -0,0 +1,156 @@
package app.alextran.immich.images
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ColorSpace
import android.graphics.ImageDecoder
import android.os.Build
import android.os.CancellationSignal
import android.util.Size
import app.alextran.immich.core.SSLConfig
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.Cache
import okhttp3.ConnectionPool
import okhttp3.Dispatcher
import java.io.File
import java.io.IOException
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
data class RemoteRequest(
val callback: (Result<Map<String, Long>>) -> Unit,
val cancellationSignal: CancellationSignal,
)
class RemoteImagesImpl(context: Context) : RemoteImageApi {
private val requestMap = ConcurrentHashMap<Long, RemoteRequest>()
init {
System.loadLibrary("native_buffer")
cacheDir = context.cacheDir
client = buildClient()
}
companion object {
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
val CANCELLED = Result.success<Map<String, Long>>(emptyMap())
private val decodePool = Executors.newFixedThreadPool(
(Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(2)
)
private var cacheDir: File? = null
private var client: OkHttpClient? = null
init {
SSLConfig.addListener(::invalidateClient)
}
private fun invalidateClient() {
client?.let {
it.dispatcher.cancelAll()
it.connectionPool.evictAll()
it.cache?.close()
}
client = buildClient()
}
private fun buildClient(): OkHttpClient {
val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
keepAliveDuration = KEEP_ALIVE_DURATION_MINUTES,
timeUnit = TimeUnit.MINUTES
)
val builder = OkHttpClient.Builder()
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
.connectionPool(connectionPool)
cacheDir?.let { dir ->
val cacheSubdir = File(dir, "thumbnails")
builder.cache(Cache(cacheSubdir, CACHE_SIZE_BYTES))
}
val sslSocketFactory = SSLConfig.sslSocketFactory
val trustManager = SSLConfig.trustManager
if (sslSocketFactory != null && trustManager != null) {
builder.sslSocketFactory(sslSocketFactory, trustManager)
}
return builder.build()
}
}
override fun requestImage(
url: String,
headers: Map<String, String>,
requestId: Long,
callback: (Result<Map<String, Long>>) -> Unit
) {
val client = client ?: return callback(Result.failure(RuntimeException("No client")))
val signal = CancellationSignal()
val requestBuilder = Request.Builder().url(url)
headers.forEach(requestBuilder::addHeader)
val call = client.newCall(requestBuilder.build())
signal.setOnCancelListener(call::cancel)
val request = RemoteRequest(callback, signal)
requestMap[requestId] = request
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
requestMap.remove(requestId)
val result = if (signal.isCanceled) CANCELLED else Result.failure(e)
callback(result)
}
override fun onResponse(call: Call, response: Response) {
decodePool.execute {
try {
signal.throwIfCanceled()
val bytes = response.takeIf { it.isSuccessful }?.body?.bytes()
?: return@execute callback(Result.failure(IOException(response.toString())))
signal.throwIfCanceled()
val bitmap = decodeImage(bytes)
signal.throwIfCanceled()
val res = bitmap.toNativeBuffer()
callback(Result.success(res))
} catch (e: Exception) {
val result = if (signal.isCanceled) CANCELLED else Result.failure(e)
callback(result)
} finally {
requestMap.remove(requestId)
response.close()
}
}
}
})
}
override fun cancelRequest(requestId: Long) {
val request = requestMap.remove(requestId) ?: return
request.cancellationSignal.cancel()
}
private fun decodeImage(bytes: ByteArray): Bitmap {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ImageDecoder.createSource(ByteBuffer.wrap(bytes)).decodeBitmap()
} else {
val options = BitmapFactory.Options().apply {
inPreferredConfig = Bitmap.Config.ARGB_8888
inPreferredColorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
}
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
}
}
}

View File

@@ -24,7 +24,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
try! FileManager.default.createDirectory(at: thumbnailPath, withIntermediateDirectories: true)
config.urlCache = URLCache(
memoryCapacity: 0,
diskCapacity: 10 << 30,
diskCapacity: 1 << 30,
directory: thumbnailPath
)
config.httpMaximumConnectionsPerHost = 16