Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
fe2181f
use shared client in dart
mertalev Feb 2, 2026
a3273cd
websocket integration
mertalev Feb 2, 2026
4dc10fa
redundant logging
mertalev Feb 5, 2026
4a4a9e1
fix proguard
mertalev Feb 5, 2026
5c89364
formatting
mertalev Feb 5, 2026
4786fef
handle onProgress
mertalev Feb 5, 2026
8c6275c
support videos on ios
mertalev Feb 7, 2026
93426b0
inline return
mertalev Feb 9, 2026
aab8a04
improved ios impl
mertalev Feb 11, 2026
42cdb05
cleanup
mertalev Feb 11, 2026
c6a7b1f
sync stopForegroundBackup
mertalev Feb 12, 2026
a476c7d
voidify
mertalev Feb 12, 2026
a19d248
future already completed
mertalev Feb 16, 2026
d579522
stream request on android
mertalev Feb 16, 2026
6c69670
outdated ios ws code
mertalev Feb 16, 2026
304f797
use `choosePrivateKeyAlias`
mertalev Feb 16, 2026
33537c0
return result
mertalev Feb 16, 2026
a8cb6ac
formatting
mertalev Feb 16, 2026
d273890
update tests
mertalev Feb 16, 2026
d3d7f16
redundant check
mertalev Feb 16, 2026
057d58e
handle custom headers
mertalev Feb 17, 2026
2637615
move completer outside of state
mertalev Feb 18, 2026
1409262
persist auth
mertalev Feb 18, 2026
d4abb59
dispose old socket
mertalev Feb 18, 2026
d3b4ccc
use group id for cookies
mertalev Feb 20, 2026
a7e099f
redundant headers
mertalev Feb 20, 2026
13f7629
cache global ref
mertalev Feb 20, 2026
04cfb7f
handle network switching
mertalev Feb 21, 2026
a0009d1
handle basic auth
mertalev Feb 21, 2026
8b7b36a
apply custom headers immediately
mertalev Feb 21, 2026
3273ac4
video player update
mertalev Feb 22, 2026
63c087d
fix
mertalev Mar 2, 2026
765217a
persist url
mertalev Mar 2, 2026
18bd9ee
potential logout fix
mertalev Mar 2, 2026
1c2e726
Merge branch 'main' into feat/use-native-clients
alextran1502 Mar 5, 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
1 change: 1 addition & 0 deletions mobile/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ android {

release {
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
namespace 'app.alextran.immich'
Expand Down
10 changes: 9 additions & 1 deletion mobile/android/app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,12 @@
##---------------End: proguard configuration for Gson ----------

# Keep all widget model classes and their fields for Gson
-keep class app.alextran.immich.widget.model.** { *; }
-keep class app.alextran.immich.widget.model.** { *; }

##---------------Begin: proguard configuration for ok_http JNI ----------
# The ok_http Dart plugin accesses OkHttp and Okio classes via JNI
# string-based reflection (JClass.forName), which R8 cannot trace.
-keep class okhttp3.** { *; }
-keep class okio.** { *; }
-keep class com.example.ok_http.** { *; }
##---------------End: proguard configuration for ok_http JNI ----------
14 changes: 14 additions & 0 deletions mobile/android/app/src/main/cpp/native_buffer.c
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,17 @@ Java_app_alextran_immich_NativeBuffer_copy(
memcpy((void *) destAddress, (char *) src + offset, length);
}
}

/**
* Creates a JNI global reference to the given object and returns its address.
* The caller is responsible for deleting the global reference when it's no longer needed.
*/
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) {
if (obj == NULL) {
return 0;
}

jobject globalRef = (*env)->NewGlobalRef(env, obj);
return (jlong) globalRef;
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ object NativeBuffer {

@JvmStatic
external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int)

@JvmStatic
external fun createGlobalRef(obj: Any): Long
}

class NativeByteBuffer(initialCapacity: Int) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
package app.alextran.immich.core

import android.content.Context
import android.content.SharedPreferences
import android.security.KeyChain
import androidx.core.content.edit
import app.alextran.immich.BuildConfig
import app.alextran.immich.NativeBuffer
import okhttp3.Cache
import okhttp3.ConnectionPool
import okhttp3.Dispatcher
import okhttp3.Headers
import okhttp3.Credentials
import okhttp3.OkHttpClient
import org.json.JSONObject
import java.io.ByteArrayInputStream
import java.io.File
import java.net.Socket
Expand All @@ -20,8 +27,12 @@ import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509KeyManager
import javax.net.ssl.X509TrustManager

const val CERT_ALIAS = "client_cert"
const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}"
private const val CERT_ALIAS = "client_cert"
private const val PREFS_NAME = "immich.ssl"
private const val PREFS_CERT_ALIAS = "immich.client_cert"
private const val PREFS_HEADERS = "immich.request_headers"
private const val PREFS_SERVER_URL = "immich.server_url"

/**
* Manages a shared OkHttpClient with SSL configuration support.
Expand All @@ -36,22 +47,56 @@ object HttpClientManager {
private val clientChangedListeners = mutableListOf<() -> Unit>()

private lateinit var client: OkHttpClient
private lateinit var appContext: Context
private lateinit var prefs: SharedPreferences

private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }

val isMtls: Boolean get() = keyStore.containsAlias(CERT_ALIAS)
var keyChainAlias: String? = null
private set

var headers: Headers = Headers.headersOf()
private set

val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS)

fun initialize(context: Context) {
if (initialized) return
synchronized(this) {
if (initialized) return

appContext = context.applicationContext
prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)

val savedHeaders = prefs.getString(PREFS_HEADERS, null)
if (savedHeaders != null) {
val json = JSONObject(savedHeaders)
val builder = Headers.Builder()
for (key in json.keys()) {
builder.add(key, json.getString(key))
}
headers = builder.build()
}

val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
client = build(cacheDir)
initialized = true
}
}

fun setKeyChainAlias(alias: String) {
synchronized(this) {
val wasMtls = isMtls
keyChainAlias = alias
prefs.edit { putString(PREFS_CERT_ALIAS, alias) }

if (wasMtls != isMtls) {
clientChangedListeners.forEach { it() }
}
}
}

fun setKeyEntry(clientData: ByteArray, password: CharArray) {
synchronized(this) {
val wasMtls = isMtls
Expand All @@ -63,7 +108,7 @@ object HttpClientManager {
val key = tmpKeyStore.getKey(tmpAlias, password)
val chain = tmpKeyStore.getCertificateChain(tmpAlias)

if (wasMtls) {
if (keyStore.containsAlias(CERT_ALIAS)) {
keyStore.deleteEntry(CERT_ALIAS)
}
keyStore.setKeyEntry(CERT_ALIAS, key, null, chain)
Expand All @@ -75,24 +120,58 @@ object HttpClientManager {

fun deleteKeyEntry() {
synchronized(this) {
if (!isMtls) {
return
val wasMtls = isMtls

if (keyChainAlias != null) {
keyChainAlias = null
prefs.edit { remove(PREFS_CERT_ALIAS) }
}

keyStore.deleteEntry(CERT_ALIAS)
clientChangedListeners.forEach { it() }

if (wasMtls) {
clientChangedListeners.forEach { it() }
}
}
}

private var clientGlobalRef: Long = 0L

@JvmStatic
fun getClient(): OkHttpClient {
return client
}

fun getClientPointer(): Long {
if (clientGlobalRef == 0L) {
clientGlobalRef = NativeBuffer.createGlobalRef(client)
}
return clientGlobalRef
}

fun addClientChangedListener(listener: () -> Unit) {
synchronized(this) { clientChangedListeners.add(listener) }
}

fun setRequestHeaders(headerMap: Map<String, String>, serverUrls: List<String>) {
synchronized(this) {
val builder = Headers.Builder()
headerMap.forEach { (key, value) -> builder[key] = value }
val newHeaders = builder.build()
val headersChanged = headers != newHeaders
val newUrl = serverUrls.firstOrNull()
val urlChanged = newUrl != prefs.getString(PREFS_SERVER_URL, null)
if (!headersChanged && !urlChanged) return
headers = newHeaders
prefs.edit {
if (headersChanged) putString(PREFS_HEADERS, JSONObject(headerMap).toString())
if (urlChanged) {
if (newUrl != null) putString(PREFS_SERVER_URL, newUrl) else remove(PREFS_SERVER_URL)
}
}
}
}

private fun build(cacheDir: File): OkHttpClient {
val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
Expand All @@ -109,8 +188,16 @@ object HttpClientManager {
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)

return OkHttpClient.Builder()
.addInterceptor { chain ->
chain.proceed(chain.request().newBuilder().header("User-Agent", USER_AGENT).build())
.addInterceptor {
val request = it.request()
val builder = request.newBuilder()
builder.header("User-Agent", USER_AGENT)
headers.forEach { (key, value) -> builder.header(key, value) }
val url = request.url
if (url.username.isNotEmpty()) {
builder.header("Authorization", Credentials.basic(url.username, url.password))
}
it.proceed(builder.build())
}
.connectionPool(connectionPool)
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
Expand All @@ -119,23 +206,39 @@ object HttpClientManager {
.build()
}

// Reads from the key store rather than taking a snapshot at initialization time
/**
* Resolves client certificates dynamically at TLS handshake time.
* Checks the system KeyChain alias first, then falls back to the app's private KeyStore.
*/
private class DynamicKeyManager : X509KeyManager {
override fun getClientAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
if (isMtls) arrayOf(CERT_ALIAS) else null
override fun getClientAliases(keyType: String, issuers: Array<Principal>?): Array<String>? {
val alias = chooseClientAlias(arrayOf(keyType), issuers, null) ?: return null
return arrayOf(alias)
}

override fun chooseClientAlias(
keyTypes: Array<String>,
issuers: Array<Principal>?,
socket: Socket?
): String? =
if (isMtls) CERT_ALIAS else null
): String? {
keyChainAlias?.let { return it }
if (keyStore.containsAlias(CERT_ALIAS)) return CERT_ALIAS
return null
}

override fun getCertificateChain(alias: String): Array<X509Certificate>? =
keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray()
override fun getCertificateChain(alias: String): Array<X509Certificate>? {
if (alias == keyChainAlias) {
return KeyChain.getCertificateChain(appContext, alias)
}
return keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray()
}

override fun getPrivateKey(alias: String): PrivateKey? =
keyStore.getKey(alias, null) as? PrivateKey
override fun getPrivateKey(alias: String): PrivateKey? {
if (alias == keyChainAlias) {
return KeyChain.getPrivateKey(appContext, alias)
}
return keyStore.getKey(alias, null) as? PrivateKey
}

override fun getServerAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,11 @@ private open class NetworkPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface NetworkApi {
fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit)
fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<ClientCertData>) -> Unit)
fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<Unit>) -> Unit)
fun removeCertificate(callback: (Result<Unit>) -> Unit)
fun hasCertificate(): Boolean
fun getClientPointer(): Long
fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>)

companion object {
/** The codec used by NetworkApi. */
Expand Down Expand Up @@ -217,13 +220,12 @@ interface NetworkApi {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val promptTextArg = args[0] as ClientCertPrompt
api.selectCertificate(promptTextArg) { result: Result<ClientCertData> ->
api.selectCertificate(promptTextArg) { result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(NetworkPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(NetworkPigeonUtils.wrapResult(data))
reply.reply(NetworkPigeonUtils.wrapResult(null))
}
}
}
Expand All @@ -248,6 +250,55 @@ interface NetworkApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.hasCertificate())
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getClientPointer())
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val headersArg = args[0] as Map<String, String>
val serverUrlsArg = args[1] as List<String>
val wrapped: List<Any?> = try {
api.setRequestHeaders(headersArg, serverUrlsArg)
listOf(null)
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
Loading
Loading