Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
e098a82
platform clients
mertalev Aug 29, 2025
34f7e07
uppercase http method
mertalev Aug 31, 2025
9cae618
fix hot reload
mertalev Sep 1, 2025
66a0e04
custom user agent
mertalev Sep 1, 2025
1e0e0d8
init before app launch
mertalev Sep 2, 2025
6e956bf
set defaults
mertalev Sep 2, 2025
2d4ee03
move to bootstrap
mertalev Sep 2, 2025
6cba105
unrelated change
mertalev Sep 19, 2025
4ec0d6d
disable disk cache by default
mertalev Sep 19, 2025
89f9c13
optimized decoding
mertalev Jan 9, 2026
8ad93ab
remove incremental
mertalev Jan 16, 2026
957ee84
android impl
mertalev Jan 17, 2026
06467f2
memory optimization
mertalev Jan 17, 2026
d83e74d
lock approach is slower on ios
mertalev Jan 17, 2026
e739aa1
conditional cronet
mertalev Jan 17, 2026
f98b441
clarify parameter
mertalev Jan 17, 2026
495cd8a
enable disk cache
mertalev Jan 17, 2026
24ff08e
set user agent
mertalev Jan 17, 2026
00ea984
flutter-side decode
mertalev Jan 18, 2026
95fad97
optimized http
mertalev Jan 20, 2026
fd89607
fixed locking
mertalev Jan 20, 2026
d898cba
refactor
mertalev Jan 20, 2026
e51547c
potential race conditions
mertalev Jan 20, 2026
9980d06
embedded cronet
mertalev Jan 20, 2026
cef5a7c
refactor, fix capacity handling
mertalev Jan 21, 2026
87a1502
fast path for known content length
mertalev Jan 21, 2026
fe07cf6
ios optimizations
mertalev Jan 21, 2026
82dde1c
re-enable cache
mertalev Jan 21, 2026
c531b95
formatting
mertalev Jan 21, 2026
cf623a4
Merge branch 'main' into feat/mobile-platform-clients
alextran1502 Jan 22, 2026
15c9eb1
bump concurrency
mertalev Jan 22, 2026
3555021
clear cache button
mertalev Jan 23, 2026
f1b1f90
fix eviction race condition
mertalev Jan 23, 2026
a04338e
add extra cancellation check
mertalev Jan 23, 2026
538cc92
tighten dispose
mertalev Jan 23, 2026
40dd94d
better error handling
mertalev Jan 23, 2026
b98b09c
fix disposal
mertalev Jan 23, 2026
a662b35
merge main
alextran1502 Jan 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,9 @@
"admin_password": "Admin Password",
"administration": "Administration",
"advanced": "Advanced",
"advanced_settings_clear_image_cache": "Clear Image Cache",
"advanced_settings_clear_image_cache_error": "Failed to clear image cache",
"advanced_settings_clear_image_cache_success": "Successfully cleared {size}",
"advanced_settings_enable_alternate_media_filter_subtitle": "Use this option to filter media during sync based on alternate criteria. Only try this if you have issues with the app detecting all albums.",
"advanced_settings_enable_alternate_media_filter_title": "[EXPERIMENTAL] Use alternate device album sync filter",
"advanced_settings_log_level_title": "Log level: {level}",
Expand Down
2 changes: 2 additions & 0 deletions mobile/android/app/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ project(native_buffer LANGUAGES C)
add_library(native_buffer SHARED
src/main/cpp/native_buffer.c
)

target_link_libraries(native_buffer jnigraphics)
6 changes: 5 additions & 1 deletion mobile/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ if (keystorePropertiesFile.exists()) {

android {
compileSdkVersion 35
ndkVersion = "28.1.13356709"
ndkVersion = "28.2.13676358"

compileOptions {
sourceCompatibility JavaVersion.VERSION_17
Expand All @@ -48,6 +48,7 @@ android {
}

buildFeatures {
buildConfig true
compose true
}

Expand Down Expand Up @@ -105,8 +106,11 @@ 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.chromium.net:cronet-embedded:143.7445.0'
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"
Expand Down
34 changes: 16 additions & 18 deletions mobile/android/app/src/main/cpp/native_buffer.c
Original file line number Diff line number Diff line change
@@ -1,40 +1,38 @@
#include <jni.h>
#include <stdlib.h>
#include <string.h>

JNIEXPORT jlong JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_allocateNative(
JNIEnv *env, jclass clazz, jint size) {
void *ptr = malloc(size);
return (jlong) ptr;
}

JNIEXPORT jlong JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_allocateNative(
Java_app_alextran_immich_NativeBuffer_allocate(
JNIEnv *env, jclass clazz, jint size) {
void *ptr = malloc(size);
return (jlong) ptr;
}

JNIEXPORT void JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_freeNative(
Java_app_alextran_immich_NativeBuffer_free(
JNIEnv *env, jclass clazz, jlong address) {
free((void *) address);
}

JNIEXPORT void JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_freeNative(
JNIEnv *env, jclass clazz, jlong address) {
free((void *) address);
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeBuffer_realloc(
JNIEnv *env, jclass clazz, jlong address, jint size) {
void *ptr = realloc((void *) address, size);
return (jlong) ptr;
}

JNIEXPORT jobject JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_wrapAsBuffer(
Java_app_alextran_immich_NativeBuffer_wrap(
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
}

JNIEXPORT jobject JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_wrapAsBuffer(
JNIEnv *env, jclass clazz, jlong address, jint 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ 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.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
Expand All @@ -36,7 +38,9 @@ class MainActivity : FlutterFragmentActivity() {
NativeSyncApiImpl30(ctx)
}
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx))
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))

BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package app.alextran.immich

import java.nio.ByteBuffer

const val INITIAL_BUFFER_SIZE = 32 * 1024

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)
}

class NativeByteBuffer(initialCapacity: Int) {
var pointer = NativeBuffer.allocate(initialCapacity)
var capacity = initialCapacity
var offset = 0

inline fun ensureHeadroom() {
if (offset == capacity) {
capacity *= 2
pointer = NativeBuffer.realloc(pointer, capacity)
}
}

inline fun wrapRemaining() = NativeBuffer.wrap(pointer + offset, capacity - offset)

inline fun advance(bytesRead: Int) {
offset += bytesRead
}

inline fun free() {
if (pointer != 0L) {
NativeBuffer.free(pointer)
pointer = 0L
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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

var requiresCustomSSL: Boolean = false
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
) {
synchronized(this) {
val newHash = computeHash(allowSelfSigned, serverHost, clientCertHash)
val newRequiresCustomSSL = allowSelfSigned || keyManagers != null
if (newHash == configHash && sslSocketFactory != null && requiresCustomSSL == newRequiresCustomSSL) {
return // Config unchanged, skip
}

val sslContext = SSLContext.getInstance("TLS")
sslContext.init(keyManagers, trustManagers, null)
sslSocketFactory = sslContext.socketFactory
trustManager = trustManagers?.filterIsInstance<X509TrustManager>()?.firstOrNull()
?: getDefaultTrustManager()
requiresCustomSSL = newRequiresCustomSSL
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()
}
}
Loading
Loading