Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 11 additions & 3 deletions mobile/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,17 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

<application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true"
android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false">
<application
android:label="Immich"
android:name=".ImmichApp"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true"
android:largeHeap="true"
android:enableOnBackInvokedCallback="false"
android:allowBackup="false"
>

<profileable android:shell="true" />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package app.alextran.immich

import android.annotation.SuppressLint
import android.content.Context
import android.security.KeyChain
import android.security.KeyChainException
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
Expand All @@ -10,6 +12,8 @@ import java.io.ByteArrayInputStream
import java.net.InetSocketAddress
import java.net.Socket
import java.security.KeyStore
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.HttpsURLConnection
Expand All @@ -21,18 +25,21 @@ import javax.net.ssl.SSLSession
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedTrustManager
import javax.net.ssl.X509KeyManager

/**
* Android plugin for Dart `HttpSSLOptions`
*/
class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private var methodChannel: MethodChannel? = null
private var context: Context? = null

override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
}

private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) {
context = ctx
methodChannel = MethodChannel(messenger, "immich/httpSSLOptions")
methodChannel?.setMethodCallHandler(this)
}
Expand All @@ -44,6 +51,7 @@ class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private fun onDetachedFromEngine() {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
context = null
}

override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
Expand All @@ -57,26 +65,60 @@ class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
tm = arrayOf(AllowSelfSignedTrustManager(args[1] as? String))
}

var km: Array<KeyManager>? = null
if (args[2] != null) {
val cert = ByteArrayInputStream(args[2] as ByteArray)
val password = (args[3] as String).toCharArray()
val keyStore = KeyStore.getInstance("PKCS12")
keyStore.load(cert, password)
val keyManagerFactory =
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
keyManagerFactory.init(keyStore, null)
km = keyManagerFactory.keyManagers
}
// var km: Array<KeyManager>? = null
// if (args[2] != null) {
// val cert = ByteArrayInputStream(args[2] as ByteArray)
// val password = (args[3] as String).toCharArray()
// val keyStore = KeyStore.getInstance("PKCS12")
// keyStore.load(cert, password)
// val keyManagerFactory =
// KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
// keyManagerFactory.init(keyStore, null)
// km = keyManagerFactory.keyManagers
// }

val sslContext = SSLContext.getInstance("TLS")
sslContext.init(km, tm, null)
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
// val sslContext = SSLContext.getInstance("TLS")
// sslContext.init(km, tm, null)
// HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)

HttpsURLConnection.setDefaultHostnameVerifier(AllowSelfSignedHostnameVerifier(args[1] as? String))
// HttpsURLConnection.setDefaultHostnameVerifier(AllowSelfSignedHostnameVerifier(args[1] as? String))

result.success(true)
}

"applyWithUserCertificates" -> {
// val args = call.arguments<ArrayList<*>>()!!
// val serverHost = args[0] as? String
// val allowSelfSigned = args[1] as Boolean

// var tm: Array<TrustManager>? = null
// if (allowSelfSigned) {
// tm = arrayOf(AllowSelfSignedTrustManager(serverHost))
// } else {
// // Use system trust store with user certificates
// tm = createSystemTrustManagers()
// }

// // Create key managers that can access user certificates
// val km = createUserKeyManagers()

// val sslContext = SSLContext.getInstance("TLS")
// sslContext.init(km, tm, null)
// HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)

// HttpsURLConnection.setDefaultHostnameVerifier(AllowSelfSignedHostnameVerifier(serverHost))

result.success(true)
}

"getAvailableCertificates" -> {
try {
val certificates = getAvailableUserCertificates()
result.success(certificates)
} catch (e: Exception) {
result.error("CERT_ERROR", e.message, null)
}
}

else -> result.notImplemented()
}
Expand Down Expand Up @@ -143,4 +185,112 @@ class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
}
}
}

/**
* Creates trust managers that use the system trust store including user-installed certificates
*/
private fun createSystemTrustManagers(): Array<TrustManager> {
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())

// Use AndroidKeyStore which includes user-installed certificates
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)

trustManagerFactory.init(keyStore)
return trustManagerFactory.trustManagers
}

/**
* Creates key managers that can access user certificates from the Android KeyChain
*/
private fun createUserKeyManagers(): Array<KeyManager>? {
return try {
val ctx = context ?: return null
// Create a key manager that can access certificates from KeyChain
arrayOf(UserCertificateKeyManager(ctx))
} catch (e: Exception) {
null
}
}

/**
* Gets available user certificates from the Android KeyChain
*/
private fun getAvailableUserCertificates(): List<Map<String, String>> {
val certificates = mutableListOf<Map<String, String>>()

try {
// This would require implementing certificate enumeration
// For now, return empty list as KeyChain doesn't provide direct enumeration
// In a real implementation, you might need to use KeyChain.choosePrivateKeyAlias
// with a callback to let the user select certificates
} catch (e: Exception) {
// Log error but don't fail
}

return certificates
}

/**
* Custom KeyManager that can access user certificates from Android KeyChain
*/
private inner class UserCertificateKeyManager(private val context: Context) : X509KeyManager {
override fun chooseClientAlias(
keyTypes: Array<out String>?,
issuers: Array<out Principal>?,
socket: Socket?
): String? {
// This would need to be implemented to prompt user for certificate selection
// For now, return null to let the system handle it
return null
}

override fun chooseServerAlias(
keyType: String?,
issuers: Array<out Principal>?,
socket: Socket?
): String? {
return null
}

override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
return try {
// Retrieve certificate chain from KeyChain
if (alias != null) {
KeyChain.getCertificateChain(context, alias)
} else {
null
}
} catch (e: KeyChainException) {
null
}
}

override fun getPrivateKey(alias: String?): PrivateKey? {
return try {
// Retrieve private key from KeyChain
if (alias != null) {
KeyChain.getPrivateKey(context, alias)
} else {
null
}
} catch (e: KeyChainException) {
null
}
}

override fun getClientAliases(
keyType: String?,
issuers: Array<out Principal>?
): Array<String>? {
return null
}

override fun getServerAliases(
keyType: String?,
issuers: Array<out Principal>?
): Array<String>? {
return null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class MainActivity : FlutterFragmentActivity() {
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))

flutterEngine.plugins.add(BackgroundServicePlugin())
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
// flutterEngine.plugins.add(HttpSSLOptionsPlugin())
flutterEngine.plugins.add(backgroundEngineLockImpl)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config>
<trust-anchors>
<certificates src="system"/>
<certificates src="user"/>
</trust-anchors>
</base-config>
</network-security-config>
10 changes: 5 additions & 5 deletions mobile/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ PODS:
- Flutter
- integration_test (0.0.1):
- Flutter
- isar_flutter_libs (1.0.0):
- isar_community_flutter_libs (1.0.0):
- Flutter
- local_auth_darwin (0.0.1):
- Flutter
Expand Down Expand Up @@ -149,7 +149,7 @@ DEPENDENCIES:
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
- isar_community_flutter_libs (from `.symlinks/plugins/isar_community_flutter_libs/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
Expand Down Expand Up @@ -210,8 +210,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/image_picker_ios/ios"
integration_test:
:path: ".symlinks/plugins/integration_test/ios"
isar_flutter_libs:
:path: ".symlinks/plugins/isar_flutter_libs/ios"
isar_community_flutter_libs:
:path: ".symlinks/plugins/isar_community_flutter_libs/ios"
local_auth_darwin:
:path: ".symlinks/plugins/local_auth_darwin/darwin"
maplibre_gl:
Expand Down Expand Up @@ -264,7 +264,7 @@ SPEC CHECKSUMS:
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
isar_flutter_libs: bc909e72c3d756c2759f14c8776c13b5b0556e26
isar_community_flutter_libs: bede843185a61a05ff364a05c9b23209523f7e0d
local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
MapLibre: 69e572367f4ef6287e18246cfafc39c80cdcabcd
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
Expand Down
80 changes: 80 additions & 0 deletions mobile/lib/common/http.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import 'dart:io';
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart';
import 'package:http/io_client.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/user_agent.dart';
import 'package:ok_http/ok_http.dart';

/// Top-level function for compute isolate to load private key and certificate chain
/// This must be top-level to work with compute()
(PrivateKey?, List<X509Certificate>?) _loadPrivateKeyAndCertificateChainFromAliasCompute(String alias) {
PrivateKey? pkey;
List<X509Certificate>? certs;
(pkey, certs) = loadPrivateKeyAndCertificateChainFromAlias(alias);
return (pkey, certs);
}

class _ImmichHttpClientSingleton {
static _ImmichHttpClientSingleton? _instance;
Client? _client;

_ImmichHttpClientSingleton._();

static _ImmichHttpClientSingleton get instance {
_instance ??= _ImmichHttpClientSingleton._();
return _instance!;
}

Client getClient() {
if (_client == null) {
throw "Client is not initialized!";
}
return _client!;
}

/// Refreshes the HTTP client with proper async handling to avoid main thread deadlocks
Future<void> refreshClient() async {
String userAgent = getUserAgentString();
if (Platform.isAndroid) {
// Unfortunately cronet doesn't support mTLS - so we use OkHttpClient
String pKeyAlias = SSLClientCertStoreVal.load()?.privateKeyAlias ?? "";
PrivateKey? pKey;
List<X509Certificate>? certs;
if (pKeyAlias != "") {
// Run this in a compute isolate to avoid main thread deadlocks
(pKey, certs) = await compute(_loadPrivateKeyAndCertificateChainFromAliasCompute, pKeyAlias);
}
OkHttpClient okHttpClient = OkHttpClient(
configuration: OkHttpClientConfiguration(
clientPrivateKey: pKey,
clientCertificateChain: certs,
validateServerCertificates: true,
userAgent: userAgent,
),
);
_client = okHttpClient;
} else {
_client = IOClient(HttpClient()..userAgent = userAgent);
}
}

void dispose() {
_client?.close();
_client = null;
}
}

/// Creates an optimized HTTP client based on the platform (singleton pattern)
///
/// On Android, uses CronetEngine for better performance with memory caching
/// On other platforms, falls back to standard HTTP client
/// Returns the same client instance for all calls after first initialization
Client immichHttpClient() {
return _ImmichHttpClientSingleton.instance.getClient();
}

Future<void> refreshClient() async {
return _ImmichHttpClientSingleton.instance.refreshClient();
}
Loading
Loading