mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
optimized http
This commit is contained in:
@@ -1,28 +1,38 @@
|
|||||||
#include <jni.h>
|
#include <jni.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
JNIEXPORT jlong JNICALL
|
JNIEXPORT jlong JNICALL
|
||||||
Java_app_alextran_immich_images_LocalImagesImpl_allocateNative(
|
Java_app_alextran_immich_NativeBuffer_allocate(
|
||||||
JNIEnv *env, jclass clazz, jint size) {
|
JNIEnv *env, jclass clazz, jint size) {
|
||||||
void *ptr = malloc(size);
|
void *ptr = malloc(size);
|
||||||
return (jlong) ptr;
|
return (jlong) ptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
JNIEXPORT void JNICALL
|
JNIEXPORT void JNICALL
|
||||||
Java_app_alextran_immich_images_LocalImagesImpl_freeNative(
|
Java_app_alextran_immich_NativeBuffer_free(
|
||||||
JNIEnv *env, jclass clazz, jlong address) {
|
JNIEnv *env, jclass clazz, jlong address) {
|
||||||
free((void *) address);
|
free((void *) address);
|
||||||
}
|
}
|
||||||
|
|
||||||
JNIEXPORT jlong JNICALL
|
JNIEXPORT jlong JNICALL
|
||||||
Java_app_alextran_immich_images_LocalImagesImpl_reallocNative(
|
Java_app_alextran_immich_NativeBuffer_realloc(
|
||||||
JNIEnv *env, jclass clazz, jlong address, jint size) {
|
JNIEnv *env, jclass clazz, jlong address, jint size) {
|
||||||
void *ptr = realloc((void *) address, size);
|
void *ptr = realloc((void *) address, size);
|
||||||
return (jlong) ptr;
|
return (jlong) ptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
JNIEXPORT jobject JNICALL
|
JNIEXPORT jobject JNICALL
|
||||||
Java_app_alextran_immich_images_LocalImagesImpl_wrapAsBuffer(
|
Java_app_alextran_immich_NativeBuffer_wrap(
|
||||||
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
|
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
|
||||||
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
|
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_app_alextran_immich_NativeBuffer_copy(
|
||||||
|
JNIEnv *env, jclass clazz, jobject buffer, jlong destAddress, jint offset, jint length) {
|
||||||
|
void *src = (*env)->GetDirectBufferAddress(env, buffer);
|
||||||
|
if (src != NULL) {
|
||||||
|
memcpy((void *) destAddress, (char *) src + offset, length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package app.alextran.immich
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JNI interface for native memory operations.
|
||||||
|
* Used by HTTP responses and image processing to avoid copies.
|
||||||
|
*/
|
||||||
|
object NativeBuffer {
|
||||||
|
init {
|
||||||
|
System.loadLibrary("native_buffer")
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
external fun allocate(size: Int): Long
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
external fun free(address: Long)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
external fun realloc(address: Long, size: Int): Long
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
external fun wrap(address: Long, capacity: Int): ByteBuffer
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int)
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import android.provider.MediaStore.Images
|
|||||||
import android.provider.MediaStore.Video
|
import android.provider.MediaStore.Video
|
||||||
import android.util.Size
|
import android.util.Size
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import java.nio.ByteBuffer
|
import app.alextran.immich.NativeBuffer
|
||||||
import kotlin.math.*
|
import kotlin.math.*
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
@@ -44,9 +44,9 @@ inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
|
|||||||
|
|
||||||
fun Bitmap.toNativeBuffer(): Map<String, Long> {
|
fun Bitmap.toNativeBuffer(): Map<String, Long> {
|
||||||
val size = width * height * 4
|
val size = width * height * 4
|
||||||
val pointer = LocalImagesImpl.allocateNative(size)
|
val pointer = NativeBuffer.allocate(size)
|
||||||
try {
|
try {
|
||||||
val buffer = LocalImagesImpl.wrapAsBuffer(pointer, size)
|
val buffer = NativeBuffer.wrap(pointer, size)
|
||||||
copyPixelsToBuffer(buffer)
|
copyPixelsToBuffer(buffer)
|
||||||
recycle()
|
recycle()
|
||||||
return mapOf(
|
return mapOf(
|
||||||
@@ -56,7 +56,7 @@ fun Bitmap.toNativeBuffer(): Map<String, Long> {
|
|||||||
"rowBytes" to (width * 4).toLong()
|
"rowBytes" to (width * 4).toLong()
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LocalImagesImpl.freeNative(pointer)
|
NativeBuffer.free(pointer)
|
||||||
recycle()
|
recycle()
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
@@ -73,22 +73,6 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
|
|||||||
companion object {
|
companion object {
|
||||||
val CANCELLED = Result.success<Map<String, Long>>(mapOf())
|
val CANCELLED = Result.success<Map<String, Long>>(mapOf())
|
||||||
val OPTIONS = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 }
|
val OPTIONS = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 }
|
||||||
|
|
||||||
init {
|
|
||||||
System.loadLibrary("native_buffer")
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
external fun allocateNative(size: Int): Long
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
external fun freeNative(pointer: Long)
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
external fun reallocNative(pointer: Long, size: Int): Long
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
external fun wrapAsBuffer(address: Long, capacity: Int): ByteBuffer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit) {
|
override fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit) {
|
||||||
|
|||||||
+337
-129
@@ -2,41 +2,66 @@ package app.alextran.immich.images
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.CancellationSignal
|
import android.os.CancellationSignal
|
||||||
|
import android.os.OperationCanceledException
|
||||||
import app.alextran.immich.BuildConfig
|
import app.alextran.immich.BuildConfig
|
||||||
|
import app.alextran.immich.NativeBuffer
|
||||||
import app.alextran.immich.core.SSLConfig
|
import app.alextran.immich.core.SSLConfig
|
||||||
import com.google.net.cronet.okhttptransport.CronetCallFactory
|
import okhttp3.Cache
|
||||||
import okhttp3.Call
|
import okhttp3.Call
|
||||||
import okhttp3.Callback
|
import okhttp3.Callback
|
||||||
|
import okhttp3.ConnectionPool
|
||||||
|
import okhttp3.Dispatcher
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.Cache
|
|
||||||
import okhttp3.ConnectionPool
|
|
||||||
import okhttp3.Dispatcher
|
|
||||||
import org.chromium.net.CronetEngine
|
import org.chromium.net.CronetEngine
|
||||||
|
import org.chromium.net.CronetException
|
||||||
|
import org.chromium.net.UrlRequest
|
||||||
|
import org.chromium.net.UrlResponseInfo
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.nio.ByteBuffer
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import okhttp3.Interceptor
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import javax.net.ssl.SSLSocketFactory
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
import kotlin.concurrent.Volatile
|
||||||
|
|
||||||
data class RemoteRequest(
|
|
||||||
val callback: (Result<Map<String, Long>>) -> Unit,
|
private const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}"
|
||||||
|
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,
|
val cancellationSignal: CancellationSignal,
|
||||||
var pointer: Long = 0L,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class UserAgentInterceptor : Interceptor {
|
private class NativeByteBuffer(initialCapacity: Int) {
|
||||||
companion object {
|
var pointer = NativeBuffer.allocate(initialCapacity)
|
||||||
const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}"
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
fun wrapRemaining() = NativeBuffer.wrap(pointer + offset, capacity - offset)
|
||||||
return chain.proceed(
|
|
||||||
chain.request().newBuilder()
|
fun advance(bytesRead: Int) { offset += bytesRead }
|
||||||
.header("User-Agent", USER_AGENT)
|
|
||||||
.build()
|
fun free() {
|
||||||
)
|
if (pointer != 0L) {
|
||||||
|
NativeBuffer.free(pointer)
|
||||||
|
pointer = 0L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,89 +71,46 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||||||
init {
|
init {
|
||||||
appContext = context.applicationContext
|
appContext = context.applicationContext
|
||||||
cacheDir = context.cacheDir
|
cacheDir = context.cacheDir
|
||||||
client = buildClient()
|
fetcher = buildFetcher()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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
|
|
||||||
|
|
||||||
private const val INITIAL_BUFFER_SIZE = 64 * 1024
|
|
||||||
|
|
||||||
val CANCELLED = Result.success<Map<String, Long>>(emptyMap())
|
val CANCELLED = Result.success<Map<String, Long>>(emptyMap())
|
||||||
|
|
||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
private var cacheDir: File? = null
|
private var cacheDir: File? = null
|
||||||
private var client: Call.Factory? = null
|
private var fetcher: ImageFetcher? = null
|
||||||
private var cronetEngine: CronetEngine? = null
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("native_buffer")
|
SSLConfig.addListener(::invalidateFetcher)
|
||||||
SSLConfig.addListener(::invalidateClient)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun invalidateClient() {
|
private fun invalidateFetcher() {
|
||||||
(client as? OkHttpClient)?.let {
|
val oldFetcher = fetcher
|
||||||
it.dispatcher.cancelAll()
|
val needsOkHttp = SSLConfig.requiresCustomSSL
|
||||||
it.connectionPool.evictAll()
|
|
||||||
it.cache?.close()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cronetEngine?.shutdown()
|
|
||||||
cronetEngine = null
|
|
||||||
|
|
||||||
client = buildClient()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildClient(): Call.Factory {
|
private fun buildFetcher(): ImageFetcher {
|
||||||
|
val ctx = appContext ?: throw IllegalStateException("Context not set")
|
||||||
val dir = cacheDir ?: throw IllegalStateException("Cache dir not set")
|
val dir = cacheDir ?: throw IllegalStateException("Cache dir not set")
|
||||||
return if (SSLConfig.requiresCustomSSL) {
|
return if (SSLConfig.requiresCustomSSL) {
|
||||||
buildOkHttpClient(dir)
|
OkHttpImageFetcher.create(dir, SSLConfig.sslSocketFactory, SSLConfig.trustManager)
|
||||||
} else {
|
} else {
|
||||||
buildCronetClient(dir)
|
CronetImageFetcher(ctx, dir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildCronetClient(cacheDir: File): Call.Factory {
|
|
||||||
val ctx = appContext ?: throw IllegalStateException("Context not set")
|
|
||||||
val storageDir = File(cacheDir, "cronet").apply { mkdirs() }
|
|
||||||
val engine = CronetEngine.Builder(ctx)
|
|
||||||
.enableHttp2(true)
|
|
||||||
.enableQuic(true)
|
|
||||||
.enableBrotli(true)
|
|
||||||
.setStoragePath(storageDir.absolutePath)
|
|
||||||
// .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES)
|
|
||||||
.setUserAgent(UserAgentInterceptor.USER_AGENT)
|
|
||||||
.build()
|
|
||||||
.also { cronetEngine = it }
|
|
||||||
|
|
||||||
return CronetCallFactory.newBuilder(engine).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildOkHttpClient(cacheDir: File): OkHttpClient {
|
|
||||||
val dir = File(cacheDir, "okhttp")
|
|
||||||
val connectionPool = ConnectionPool(
|
|
||||||
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
|
|
||||||
keepAliveDuration = KEEP_ALIVE_DURATION_MINUTES,
|
|
||||||
timeUnit = TimeUnit.MINUTES
|
|
||||||
)
|
|
||||||
|
|
||||||
val builder = OkHttpClient.Builder()
|
|
||||||
.addInterceptor(UserAgentInterceptor())
|
|
||||||
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
|
|
||||||
.connectionPool(connectionPool)
|
|
||||||
|
|
||||||
builder.cache(Cache((File(dir, "thumbnails")), 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(
|
override fun requestImage(
|
||||||
@@ -137,74 +119,300 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||||||
requestId: Long,
|
requestId: Long,
|
||||||
callback: (Result<Map<String, Long>>) -> Unit
|
callback: (Result<Map<String, Long>>) -> Unit
|
||||||
) {
|
) {
|
||||||
val client = client ?: return callback(Result.failure(RuntimeException("No client")))
|
val fetcher = fetcher ?: return callback(Result.failure(RuntimeException("No fetcher")))
|
||||||
val signal = CancellationSignal()
|
val signal = CancellationSignal()
|
||||||
val requestBuilder = Request.Builder().url(url)
|
val request = RemoteRequest(signal)
|
||||||
headers.forEach(requestBuilder::addHeader)
|
|
||||||
|
|
||||||
val call = client.newCall(requestBuilder.build())
|
|
||||||
signal.setOnCancelListener(call::cancel)
|
|
||||||
val request = RemoteRequest(callback, signal)
|
|
||||||
requestMap[requestId] = request
|
requestMap[requestId] = request
|
||||||
|
fetcher.fetch(
|
||||||
|
url,
|
||||||
|
headers,
|
||||||
|
signal,
|
||||||
|
onSuccess = { buffer ->
|
||||||
|
requestMap.remove(requestId)
|
||||||
|
if (signal.isCanceled) {
|
||||||
|
NativeBuffer.free(buffer.pointer)
|
||||||
|
return@fetch callback(CANCELLED)
|
||||||
|
}
|
||||||
|
|
||||||
call.enqueue(object : Callback {
|
callback(Result.success(mapOf(
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
"pointer" to buffer.pointer,
|
||||||
|
"length" to buffer.offset.toLong()
|
||||||
|
)))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
requestMap.remove(requestId)
|
requestMap.remove(requestId)
|
||||||
val result = if (signal.isCanceled) CANCELLED else Result.failure(e)
|
val result = if (signal.isCanceled) CANCELLED else Result.failure(e)
|
||||||
callback(result)
|
callback(result)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancelRequest(requestId: Long) {
|
||||||
|
requestMap.remove(requestId)?.cancellationSignal?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun releaseImage(requestId: Long) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed interface ImageFetcher {
|
||||||
|
fun fetch(
|
||||||
|
url: String,
|
||||||
|
headers: Map<String, String>,
|
||||||
|
signal: CancellationSignal,
|
||||||
|
onSuccess: (NativeByteBuffer) -> Unit,
|
||||||
|
onFailure: (Exception) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun drain()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CronetImageFetcher(
|
||||||
|
context: Context,
|
||||||
|
cacheDir: File,
|
||||||
|
) : ImageFetcher {
|
||||||
|
private val engine: CronetEngine
|
||||||
|
private val executor = Executors.newSingleThreadExecutor()
|
||||||
|
val active = AtomicInteger(0)
|
||||||
|
@Volatile
|
||||||
|
private var draining = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
val storageDir = File(cacheDir, "cronet").apply { mkdirs() }
|
||||||
|
engine = CronetEngine.Builder(context)
|
||||||
|
.enableHttp2(true)
|
||||||
|
.enableQuic(true)
|
||||||
|
.enableBrotli(true)
|
||||||
|
.setStoragePath(storageDir.absolutePath)
|
||||||
|
.setUserAgent(USER_AGENT)
|
||||||
|
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetch(
|
||||||
|
url: String,
|
||||||
|
headers: Map<String, String>,
|
||||||
|
signal: CancellationSignal,
|
||||||
|
onSuccess: (NativeByteBuffer) -> Unit,
|
||||||
|
onFailure: (Exception) -> Unit,
|
||||||
|
) {
|
||||||
|
active.incrementAndGet()
|
||||||
|
if (draining) {
|
||||||
|
active.decrementAndGet()
|
||||||
|
throw IllegalStateException("Engine is draining")
|
||||||
|
}
|
||||||
|
|
||||||
|
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
|
||||||
|
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
|
||||||
|
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
|
||||||
|
val request = requestBuilder.build()
|
||||||
|
signal.setOnCancelListener { request.cancel() }
|
||||||
|
request.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onComplete() {
|
||||||
|
// Runs on executor thread
|
||||||
|
active.decrementAndGet()
|
||||||
|
tryShutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryShutdown() {
|
||||||
|
// Also on executor thread—no race with onRequestDone
|
||||||
|
if (draining && active.get() == 0) {
|
||||||
|
engine.shutdown()
|
||||||
|
executor.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun drain() {
|
||||||
|
draining = true
|
||||||
|
// Post check to executor—runs after any already-queued callbacks
|
||||||
|
executor.execute(this::tryShutdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FetchCallback(
|
||||||
|
private val onSuccess: (NativeByteBuffer) -> Unit,
|
||||||
|
private val onFailure: (Exception) -> Unit,
|
||||||
|
private val onComplete: () -> Unit,
|
||||||
|
) : UrlRequest.Callback() {
|
||||||
|
private var buffer: NativeByteBuffer? = null
|
||||||
|
private var httpError: IOException? = null
|
||||||
|
|
||||||
|
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) {
|
||||||
|
request.followRedirect()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) {
|
||||||
|
if (info.httpStatusCode !in 200..299) {
|
||||||
|
httpError = IOException("HTTP ${info.httpStatusCode}: ${info.httpStatusText}")
|
||||||
|
return request.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-size from Content-Length when available, otherwise use reasonable default
|
||||||
|
val capacity = info.allHeaders["content-length"]?.firstOrNull()?.toIntOrNull()
|
||||||
|
?.takeIf { it > 0 } ?: INITIAL_BUFFER_SIZE
|
||||||
|
buffer = NativeByteBuffer(capacity)
|
||||||
|
request.read(buffer!!.wrapRemaining())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReadCompleted(request: UrlRequest, info: UrlResponseInfo, byteBuffer: ByteBuffer) {
|
||||||
|
buffer!!.apply {
|
||||||
|
advance(byteBuffer.remaining())
|
||||||
|
ensureHeadroom()
|
||||||
|
}
|
||||||
|
request.read(buffer!!.wrapRemaining())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
|
||||||
|
onSuccess(buffer!!)
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) {
|
||||||
|
buffer?.free()
|
||||||
|
onFailure(error)
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCanceled(request: UrlRequest, info: UrlResponseInfo?) {
|
||||||
|
buffer?.free()
|
||||||
|
onFailure(httpError ?: OperationCanceledException())
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class OkHttpImageFetcher private constructor(
|
||||||
|
private val client: OkHttpClient,
|
||||||
|
) : ImageFetcher {
|
||||||
|
private val active = AtomicInteger(0)
|
||||||
|
@Volatile private var draining = false
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun create(
|
||||||
|
cacheDir: File,
|
||||||
|
sslSocketFactory: SSLSocketFactory?,
|
||||||
|
trustManager: X509TrustManager?,
|
||||||
|
): OkHttpImageFetcher {
|
||||||
|
val dir = File(cacheDir, "okhttp")
|
||||||
|
val connectionPool = ConnectionPool(
|
||||||
|
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
|
||||||
|
keepAliveDuration = KEEP_ALIVE_DURATION_MINUTES,
|
||||||
|
timeUnit = TimeUnit.MINUTES
|
||||||
|
)
|
||||||
|
|
||||||
|
val builder = OkHttpClient.Builder()
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
chain.proceed(
|
||||||
|
chain.request().newBuilder()
|
||||||
|
.header("User-Agent", USER_AGENT)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
|
||||||
|
.connectionPool(connectionPool)
|
||||||
|
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
|
||||||
|
|
||||||
|
if (sslSocketFactory != null && trustManager != null) {
|
||||||
|
builder.sslSocketFactory(sslSocketFactory, trustManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
return OkHttpImageFetcher(builder.build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reconfigure(
|
||||||
|
sslSocketFactory: SSLSocketFactory?,
|
||||||
|
trustManager: X509TrustManager?,
|
||||||
|
): OkHttpImageFetcher {
|
||||||
|
val builder = client.newBuilder()
|
||||||
|
if (sslSocketFactory != null && trustManager != null) {
|
||||||
|
builder.sslSocketFactory(sslSocketFactory, trustManager)
|
||||||
|
}
|
||||||
|
// Evict idle connections using old SSL config
|
||||||
|
client.connectionPool.evictAll()
|
||||||
|
return OkHttpImageFetcher(builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onComplete() {
|
||||||
|
if (active.decrementAndGet() == 0 && draining) {
|
||||||
|
client.cache?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetch(
|
||||||
|
url: String,
|
||||||
|
headers: Map<String, String>,
|
||||||
|
signal: CancellationSignal,
|
||||||
|
onSuccess: (NativeByteBuffer) -> Unit,
|
||||||
|
onFailure: (Exception) -> Unit,
|
||||||
|
) {
|
||||||
|
active.incrementAndGet()
|
||||||
|
if (draining) {
|
||||||
|
active.decrementAndGet()
|
||||||
|
throw IllegalStateException("Client is draining")
|
||||||
|
}
|
||||||
|
|
||||||
|
val requestBuilder = Request.Builder().url(url)
|
||||||
|
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
|
||||||
|
val call = client.newCall(requestBuilder.build())
|
||||||
|
signal.setOnCancelListener { call.cancel() }
|
||||||
|
|
||||||
|
call.enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
onFailure(e)
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
override fun onResponse(call: Call, response: Response) {
|
||||||
var pointer = 0L
|
|
||||||
var capacity: Int
|
|
||||||
try {
|
try {
|
||||||
signal.throwIfCanceled()
|
response.use {
|
||||||
val body = response.takeIf { it.isSuccessful }?.body
|
if (call.isCanceled()) {
|
||||||
?: return callback(Result.failure(IOException(response.toString())))
|
return onFailure(OperationCanceledException())
|
||||||
|
}
|
||||||
|
|
||||||
val contentLength = body.contentLength()
|
if (!response.isSuccessful) {
|
||||||
capacity = if (contentLength > 0) contentLength.toInt() else INITIAL_BUFFER_SIZE
|
return onFailure(IOException("HTTP ${response.code}: ${response.message}"))
|
||||||
pointer = LocalImagesImpl.allocateNative(capacity)
|
}
|
||||||
request.pointer = pointer
|
|
||||||
|
|
||||||
var position = 0
|
val body = response.body ?: return onFailure(IOException("Empty response body"))
|
||||||
body.source().use { source ->
|
|
||||||
while (!source.exhausted()) {
|
val contentLength = body.contentLength()
|
||||||
signal.throwIfCanceled()
|
val capacity = if (contentLength > 0 && contentLength <= Int.MAX_VALUE) {
|
||||||
if (position >= capacity) {
|
contentLength.toInt()
|
||||||
capacity = maxOf(capacity * 2, position + 8192)
|
} else {
|
||||||
pointer = LocalImagesImpl.reallocNative(pointer, capacity)
|
INITIAL_BUFFER_SIZE
|
||||||
request.pointer = pointer
|
}
|
||||||
|
val buffer = NativeByteBuffer(capacity)
|
||||||
|
|
||||||
|
try {
|
||||||
|
body.source().use { source ->
|
||||||
|
while (true) {
|
||||||
|
signal.throwIfCanceled()
|
||||||
|
buffer.ensureHeadroom()
|
||||||
|
val bytesRead = source.read(buffer.wrapRemaining())
|
||||||
|
if (bytesRead == -1) break
|
||||||
|
buffer.advance(bytesRead)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val buffer = LocalImagesImpl.wrapAsBuffer(pointer + position, capacity - position)
|
|
||||||
val read = source.read(buffer)
|
onSuccess(buffer)
|
||||||
if (read == -1) break
|
} catch (e: Exception) {
|
||||||
position += read
|
buffer.free()
|
||||||
|
onFailure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signal.throwIfCanceled()
|
|
||||||
request.pointer = 0L // Transfer ownership to Dart before callback
|
|
||||||
callback(Result.success(mapOf(
|
|
||||||
"pointer" to pointer,
|
|
||||||
"length" to position.toLong()
|
|
||||||
)))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (pointer != 0L) LocalImagesImpl.freeNative(pointer)
|
|
||||||
val result = if (signal.isCanceled) CANCELLED else Result.failure(e)
|
|
||||||
callback(result)
|
|
||||||
} finally {
|
} finally {
|
||||||
requestMap.remove(requestId)
|
onComplete()
|
||||||
response.close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun cancelRequest(requestId: Long) {
|
override fun drain() {
|
||||||
// Just cancel the signal - memory cleanup happens in onResponse/onFailure
|
draining = true
|
||||||
requestMap[requestId]?.cancellationSignal?.cancel()
|
client.connectionPool.evictAll()
|
||||||
|
if (active.get() == 0) {
|
||||||
|
client.cache?.close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun releaseImage(requestId: Long) {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ package app.alextran.immich.images;
|
|||||||
|
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
|
|
||||||
|
import app.alextran.immich.NativeBuffer;
|
||||||
|
|
||||||
// modified to use native allocations
|
// modified to use native allocations
|
||||||
public final class ThumbHash {
|
public final class ThumbHash {
|
||||||
/**
|
/**
|
||||||
@@ -56,8 +58,8 @@ public final class ThumbHash {
|
|||||||
int w = Math.round(ratio > 1.0f ? 32.0f : 32.0f * ratio);
|
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 h = Math.round(ratio > 1.0f ? 32.0f / ratio : 32.0f);
|
||||||
int size = w * h * 4;
|
int size = w * h * 4;
|
||||||
long pointer = LocalImagesImpl.allocateNative(size);
|
long pointer = NativeBuffer.allocate(size);
|
||||||
ByteBuffer rgba = LocalImagesImpl.wrapAsBuffer(pointer, size);
|
ByteBuffer rgba = NativeBuffer.wrap(pointer, size);
|
||||||
int cx_stop = Math.max(lx, hasAlpha ? 5 : 3);
|
int cx_stop = Math.max(lx, hasAlpha ? 5 : 3);
|
||||||
int cy_stop = Math.max(ly, hasAlpha ? 5 : 3);
|
int cy_stop = Math.max(ly, hasAlpha ? 5 : 3);
|
||||||
float[] fx = new float[cx_stop];
|
float[] fx = new float[cx_stop];
|
||||||
|
|||||||
Reference in New Issue
Block a user