This commit is contained in:
Denys Vitali 2025-10-17 13:38:43 -04:00 committed by GitHub
commit 58c7c891a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 796 additions and 250 deletions

View file

@ -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" />

View file

@ -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
@ -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
@ -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)
}
@ -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) {
@ -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()
}
@ -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
}
}
}

View file

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

View file

@ -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>

View file

@ -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();
}

View file

@ -0,0 +1,39 @@
import 'package:package_info_plus/package_info_plus.dart';
class PackageInfoSingleton {
static PackageInfoSingleton? _instance;
static PackageInfoSingleton get instance {
_instance ??= PackageInfoSingleton._();
return _instance!;
}
PackageInfoSingleton._();
PackageInfo? _packageInfo;
/// Initializes the package info by calling PackageInfo.fromPlatform()
/// This should be called once during app initialization
Future<void> init() async {
_packageInfo ??= await PackageInfo.fromPlatform();
}
/// Returns the PackageInfo instance
/// Returns null if init() hasn't been called yet
PackageInfo? get packageInfo => _packageInfo;
/// Returns the app name
/// Returns null if init() hasn't been called yet
String? get appName => _packageInfo?.appName;
/// Returns the app version
/// Returns null if init() hasn't been called yet
String? get version => _packageInfo?.version;
/// Returns the build number
/// Returns null if init() hasn't been called yet
String? get buildNumber => _packageInfo?.buildNumber;
/// Returns the package name
/// Returns null if init() hasn't been called yet
String? get packageName => _packageInfo?.packageName;
}

View file

@ -17,8 +17,8 @@ enum StoreKey<T> {
serverEndpoint<String>._(12),
autoBackup<bool>._(13),
backgroundBackup<bool>._(14),
sslClientCertData<String>._(15),
sslClientPasswd<String>._(16),
sslClientCertData<String>._(15), // deprecated!
sslClientPasswd<String>._(16), // deprecated!
// user settings from [AppSettingsEnum] below:
loadPreview<bool>._(100),
loadOriginal<bool>._(101),
@ -42,33 +42,34 @@ enum StoreKey<T> {
mapShowFavoriteOnly<bool>._(118),
mapRelativeDate<int>._(119),
selfSignedCert<bool>._(120),
mapIncludeArchived<bool>._(121),
ignoreIcloudAssets<bool>._(122),
selectedAlbumSortReverse<bool>._(123),
mapThemeMode<int>._(124),
mapwithPartners<bool>._(125),
enableHapticFeedback<bool>._(126),
customHeaders<String>._(127),
useUserCertificates<bool>._(121),
mapIncludeArchived<bool>._(122),
ignoreIcloudAssets<bool>._(123),
selectedAlbumSortReverse<bool>._(124),
mapThemeMode<int>._(125),
mapwithPartners<bool>._(126),
enableHapticFeedback<bool>._(127),
customHeaders<String>._(128),
// theme settings
primaryColor<String>._(128),
dynamicTheme<bool>._(129),
colorfulInterface<bool>._(130),
primaryColor<String>._(129),
dynamicTheme<bool>._(130),
colorfulInterface<bool>._(131),
syncAlbums<bool>._(131),
syncAlbums<bool>._(132),
// Auto endpoint switching
autoEndpointSwitching<bool>._(132),
preferredWifiName<String>._(133),
localEndpoint<String>._(134),
externalEndpointList<String>._(135),
autoEndpointSwitching<bool>._(133),
preferredWifiName<String>._(134),
localEndpoint<String>._(135),
externalEndpointList<String>._(136),
// Video settings
loadOriginalVideo<bool>._(136),
manageLocalMediaAndroid<bool>._(137),
loadOriginalVideo<bool>._(137),
manageLocalMediaAndroid<bool>._(138),
// Read-only Mode settings
readonlyModeEnabled<bool>._(138),
readonlyModeEnabled<bool>._(139),
autoPlayVideo<bool>._(139),
@ -81,7 +82,10 @@ enum StoreKey<T> {
useWifiForUploadPhotos<bool>._(1005),
needBetaMigration<bool>._(1006),
// TODO: Remove this after patching open-api
shouldResetSync<bool>._(1007);
shouldResetSync<bool>._(1007),
// mTLS
mTlsSelectedPrivateKey<String>._(1008);
const StoreKey._(this.id);
final int id;

View file

@ -1,6 +1,3 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
@ -8,31 +5,19 @@ import 'package:immich_mobile/domain/services/store.service.dart';
final Store = StoreService.I;
class SSLClientCertStoreVal {
final Uint8List data;
final String? password;
const SSLClientCertStoreVal(this.data, this.password);
final String privateKeyAlias;
const SSLClientCertStoreVal(this.privateKeyAlias);
Future<void> save() async {
final b64Str = base64Encode(data);
await Store.put(StoreKey.sslClientCertData, b64Str);
if (password != null) {
await Store.put(StoreKey.sslClientPasswd, password!);
}
await Store.put(StoreKey.mTlsSelectedPrivateKey, privateKeyAlias);
}
static SSLClientCertStoreVal? load() {
final b64Str = Store.tryGet<String>(StoreKey.sslClientCertData);
if (b64Str == null) {
return null;
}
final Uint8List certData = base64Decode(b64Str);
final passwd = Store.tryGet<String>(StoreKey.sslClientPasswd);
return SSLClientCertStoreVal(certData, passwd);
final privateKeyAlias = Store.tryGet<String>(StoreKey.mTlsSelectedPrivateKey) ?? "";
return SSLClientCertStoreVal(privateKeyAlias);
}
static Future<void> delete() async {
await Store.delete(StoreKey.sslClientCertData);
await Store.delete(StoreKey.sslClientPasswd);
await Store.delete(StoreKey.mTlsSelectedPrivateKey);
}
}

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:immich_mobile/common/http.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
@ -13,7 +14,8 @@ import 'package:openapi/api.dart';
class SyncApiRepository {
final Logger _logger = Logger('SyncApiRepository');
final ApiService _api;
SyncApiRepository(this._api);
final http.Client httpClient;
SyncApiRepository(this._api, {http.Client? httpClient}) : httpClient = httpClient ?? immichHttpClient();
Future<void> ack(List<String> data) {
return _api.syncApi.sendSyncAck(SyncAckSetDto(acks: data));
@ -23,10 +25,8 @@ class SyncApiRepository {
Future<void> Function(List<SyncEvent>, Function() abort, Function() reset) onData, {
Function()? onReset,
int batchSize = kSyncEventBatchSize,
http.Client? httpClient,
}) async {
final stopwatch = Stopwatch()..start();
final client = httpClient ?? http.Client();
final endpoint = "${_api.apiClient.basePath}/sync/stream";
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
@ -78,7 +78,7 @@ class SyncApiRepository {
final reset = onReset ?? () {};
try {
final response = await client.send(request);
final response = await httpClient.send(request);
if (response.statusCode != 200) {
final errorBody = await response.stream.bytesToString();
@ -111,8 +111,6 @@ class SyncApiRepository {
}
} catch (error, stack) {
return Future.error(error, stack);
} finally {
client.close();
}
stopwatch.stop();
_logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms");

View file

@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/common/http.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/domain/services/background_worker.service.dart';
@ -40,32 +41,51 @@ import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/utils/licenses.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:immich_mobile/pages/common/error_display_screen.dart';
import 'package:immich_mobile/common/package_info.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:logging/logging.dart';
import 'package:timezone/data/latest.dart';
import 'package:worker_manager/worker_manager.dart';
void main() async {
ImmichWidgetsBinding();
unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock());
final (isar, drift, logDb) = await Bootstrap.initDB();
await Bootstrap.initDomain(isar, drift, logDb);
await initApp();
// Warm-up isolate pool for worker manager
await workerManager.init(dynamicSpawning: true);
await migrateDatabaseIfNeeded(isar, drift);
HttpSSLOptions.apply();
try {
ImmichWidgetsBinding();
unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock());
final (isar, drift, logDb) = await Bootstrap.initDB();
await Bootstrap.initDomain(isar, drift, logDb);
await initApp();
await setPackageInfo();
await refreshClient();
// Warm-up isolate pool for worker manager
await workerManager.init(dynamicSpawning: true);
await migrateDatabaseIfNeeded(isar, drift);
HttpSSLOptions.apply();
runApp(
ProviderScope(
overrides: [
dbProvider.overrideWithValue(isar),
isarProvider.overrideWithValue(isar),
driftProvider.overrideWith(driftOverride(drift)),
],
child: const MainWidget(),
),
);
runApp(
ProviderScope(
overrides: [
dbProvider.overrideWithValue(isar),
isarProvider.overrideWithValue(isar),
driftProvider.overrideWith(driftOverride(drift)),
],
child: const MainWidget(),
),
);
} catch (e, stackTrace) {
// Log the error for debugging
debugPrint('Fatal initialization error: $e');
debugPrint('Stack trace: $stackTrace');
// Display a prominent error message to the user
runApp(
MaterialApp(
title: 'Immich - Initialization Error',
home: ErrorDisplayScreen(error: e.toString(), stackTrace: stackTrace.toString()),
debugShowCheckedModeBanner: false,
),
);
}
}
Future<void> initApp() async {
@ -120,6 +140,10 @@ Future<void> initApp() async {
});
}
Future<void> setPackageInfo() async {
await PackageInfoSingleton.instance.init();
}
class ImmichApp extends ConsumerStatefulWidget {
const ImmichApp({super.key});

View file

@ -0,0 +1,203 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
class ErrorDisplayScreen extends StatelessWidget {
final String error;
final String stackTrace;
const ErrorDisplayScreen({
super.key,
required this.error,
required this.stackTrace,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// App logo with error indicator
Stack(
children: [
Image.asset(
'assets/immich-logo.png',
width: 80,
height: 80,
),
Positioned(
bottom: 0,
right: 0,
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: theme.colorScheme.error,
shape: BoxShape.circle,
),
child: Icon(
Icons.error,
color: theme.colorScheme.onError,
size: 16,
),
),
),
],
),
const SizedBox(height: 24),
// Error title
Text(
'Initialization Failed',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Error message
Text(
'Failed to start due to an error during initialization.',
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 16,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Expandable error details
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.error),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Error Details:',
style: TextStyle(
color: theme.colorScheme.error,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
IconButton(
onPressed: () async {
String errorDetails;
try {
final packageInfo = await PackageInfo.fromPlatform();
final appVersion = '${packageInfo.version} build.${packageInfo.buildNumber}';
errorDetails = 'App Version: $appVersion\n\nError: $error\n\nStack Trace:\n$stackTrace';
} catch (e) {
// Fallback if package info fails
errorDetails = 'Error: $error\n\nStack Trace:\n$stackTrace';
}
Clipboard.setData(ClipboardData(text: errorDetails)).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Error details copied to clipboard'),
backgroundColor: theme.colorScheme.primary,
),
);
});
},
icon: Icon(
Icons.copy,
color: theme.colorScheme.onSurfaceVariant,
size: 18,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: 8),
Text(
error,
style: TextStyle(
color: theme.colorScheme.onSurface,
fontSize: 12,
fontFamily: 'monospace',
),
),
const SizedBox(height: 16),
ExpansionTile(
title: Text(
'Stack Trace',
style: TextStyle(
color: theme.colorScheme.tertiary,
fontSize: 12,
),
),
tilePadding: EdgeInsets.zero,
iconColor: theme.colorScheme.onSurfaceVariant,
collapsedIconColor: theme.colorScheme.onSurfaceVariant,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
child: Text(
stackTrace,
style: TextStyle(
color: theme.colorScheme.onSurface,
fontSize: 10,
fontFamily: 'monospace',
),
),
),
),
],
),
],
),
),
const SizedBox(height: 24),
// Restart button
ElevatedButton.icon(
onPressed: () {
// Attempt to restart the app
if (Platform.isAndroid || Platform.isIOS) {
exit(0);
}
},
icon: const Icon(Icons.close),
label: const Text('Close App'),
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.error,
foregroundColor: theme.colorScheme.onError,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
],
),
),
),
);
}
}

View file

@ -2,8 +2,10 @@ import 'dart:convert';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:cancellation_token_http/http.dart' as http;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/common/http.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@ -92,8 +94,8 @@ class UploadRepository {
);
}
Future<void> backupWithDartClient(Iterable<UploadTaskWithFile> tasks, CancellationToken cancelToken) async {
final httpClient = Client();
Future<void> backupWithDartClient(Iterable<UploadTaskWithFile> tasks, http.CancellationToken cancelToken) async {
final httpClient = immichHttpClient();
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
Logger logger = Logger('UploadRepository');
@ -118,7 +120,7 @@ class UploadRepository {
baseRequest.fields.addAll(candidate.task.fields);
baseRequest.files.add(assetRawUploadData);
final response = await httpClient.send(baseRequest, cancellationToken: cancelToken);
final response = await httpClient.send(baseRequest);
final responseBody = jsonDecode(await response.stream.bytesToString());
@ -131,7 +133,7 @@ class UploadRepository {
continue;
}
} on CancelledException {
} on http.CancelledException {
logger.warning("Backup was cancelled by the user");
break;
} catch (error, stackTrace) {

View file

@ -4,6 +4,7 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/common/http.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/url_helper.dart';
@ -50,6 +51,7 @@ class ApiService implements Authentication {
setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint, authentication: this);
_apiClient.client = immichHttpClient();
_setUserAgentHeader();
if (_accessToken != null) {
setAccessToken(_accessToken!);
@ -77,7 +79,7 @@ class ApiService implements Authentication {
}
Future<void> _setUserAgentHeader() async {
final userAgent = await getUserAgentString();
final userAgent = getUserAgentString();
_apiClient.addDefaultHeader('User-Agent', userAgent);
}

View file

@ -41,6 +41,7 @@ enum AppSettingsEnum<T> {
mapwithPartners<bool>(StoreKey.mapwithPartners, null, false),
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
useUserCertificates<bool>(StoreKey.useUserCertificates, null, false),
ignoreIcloudAssets<bool>(StoreKey.ignoreIcloudAssets, null, false),
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, false),
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),

View file

@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/common/http.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@ -64,27 +63,18 @@ class AuthService {
}
Future<bool> validateAuxilaryServerUrl(String url) async {
final httpclient = HttpClient();
final httpclient = await immichHttpClient();
bool isValid = false;
try {
final uri = Uri.parse('$url/users/me');
final request = await httpclient.getUrl(uri);
final response = await httpclient.get(uri, headers: ApiService.getRequestHeaders());
// add auth token + any configured custom headers
final customHeaders = ApiService.getRequestHeaders();
customHeaders.forEach((key, value) {
request.headers.add(key, value);
});
final response = await request.close();
if (response.statusCode == 200) {
isValid = true;
}
} catch (error) {
_log.severe("Error validating auxiliary endpoint", error);
} finally {
httpclient.close();
}
return isValid;

View file

@ -5,6 +5,8 @@ import 'dart:io';
import 'package:cancellation_token_http/http.dart' as http;
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/common/http.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
@ -43,8 +45,7 @@ final backupServiceProvider = Provider(
);
class BackupService {
final httpClient = http.Client();
final ApiService _apiService;
final dynamic _apiService;
final Logger _log = Logger("BackupService");
final AppSettingsService _appSetting;
final AlbumService _albumService;
@ -52,6 +53,7 @@ class BackupService {
final FileMediaRepository _fileMediaRepository;
final AssetRepository _assetRepository;
final AssetMediaRepository _assetMediaRepository;
late final Client client;
BackupService(
this._apiService,
@ -60,8 +62,9 @@ class BackupService {
this._albumMediaRepository,
this._fileMediaRepository,
this._assetRepository,
this._assetMediaRepository,
);
this._assetMediaRepository, {
Client? client,
}) : client = client ?? immichHttpClient();
Future<List<String>?> getDeviceBackupAsset() async {
final String deviceId = Store.get(StoreKey.deviceId);
@ -306,14 +309,14 @@ class BackupService {
}
final fileStream = file.openRead();
final assetRawUploadData = http.MultipartFile(
final assetRawUploadData = MultipartFile(
"assetData",
fileStream,
file.lengthSync(),
filename: originalFileName,
);
final baseRequest = MultipartRequest(
final baseRequest = ProgressMultipartRequest(
'POST',
Uri.parse('$savedEndpoint/assets'),
onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)),
@ -348,7 +351,8 @@ class BackupService {
baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId;
}
final response = await httpClient.send(baseRequest, cancellationToken: cancelToken);
// TODO: Re-add cancellation token
final response = await client.send(baseRequest);
final responseBody = jsonDecode(await response.stream.bytesToString());
@ -428,7 +432,7 @@ class BackupService {
Future<String?> uploadLivePhotoVideo(
String originalFileName,
File? livePhotoVideoFile,
MultipartRequest baseRequest,
ProgressMultipartRequest baseRequest,
http.CancellationToken cancelToken,
) async {
if (livePhotoVideoFile == null) {
@ -436,19 +440,21 @@ class BackupService {
}
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoVideoFile.path));
final fileStream = livePhotoVideoFile.openRead();
final livePhotoRawUploadData = http.MultipartFile(
final livePhotoRawUploadData = MultipartFile(
"assetData",
fileStream,
livePhotoVideoFile.lengthSync(),
filename: livePhotoTitle,
);
final livePhotoReq = MultipartRequest(baseRequest.method, baseRequest.url, onProgress: baseRequest.onProgress)
..headers.addAll(baseRequest.headers)
..fields.addAll(baseRequest.fields);
final livePhotoReq =
ProgressMultipartRequest(baseRequest.method, baseRequest.url, onProgress: baseRequest.onProgress)
..headers.addAll(baseRequest.headers)
..fields.addAll(baseRequest.fields);
livePhotoReq.files.add(livePhotoRawUploadData);
var response = await httpClient.send(livePhotoReq, cancellationToken: cancelToken);
final client = immichHttpClient();
var response = await client.send(livePhotoReq);
var responseBody = jsonDecode(await response.stream.bytesToString());
@ -471,17 +477,17 @@ class BackupService {
};
}
class MultipartRequest extends http.MultipartRequest {
/// Creates a new [MultipartRequest].
MultipartRequest(super.method, super.url, {required this.onProgress});
class ProgressMultipartRequest extends MultipartRequest {
/// Creates a new [ProgressMultipartRequest].
ProgressMultipartRequest(super.method, super.url, {required this.onProgress});
final void Function(int bytes, int totalBytes) onProgress;
/// Freezes all mutable fields and returns a
/// single-subscription [http.ByteStream]
/// single-subscription [ByteStream]
/// that will emit the request body.
@override
http.ByteStream finalize() {
ByteStream finalize() {
final byteStream = super.finalize();
final total = contentLength;
@ -495,6 +501,6 @@ class MultipartRequest extends http.MultipartRequest {
},
);
final stream = byteStream.transform(t);
return http.ByteStream(stream);
return ByteStream(stream);
}
}

View file

@ -1,49 +1,18 @@
import 'dart:io';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:logging/logging.dart';
class HttpSSLCertOverride extends HttpOverrides {
static final Logger _log = Logger("HttpSSLCertOverride");
final bool _allowSelfSignedSSLCert;
final String? _serverHost;
final SSLClientCertStoreVal? _clientCert;
late final SecurityContext? _ctxWithCert;
HttpSSLCertOverride(this._allowSelfSignedSSLCert, this._serverHost, this._clientCert) {
if (_clientCert != null) {
_ctxWithCert = SecurityContext(withTrustedRoots: true);
if (_ctxWithCert != null) {
setClientCert(_ctxWithCert, _clientCert);
} else {
_log.severe("Failed to create security context with client cert!");
}
} else {
_ctxWithCert = null;
}
}
static bool setClientCert(SecurityContext ctx, SSLClientCertStoreVal cert) {
try {
_log.info("Setting client certificate");
ctx.usePrivateKeyBytes(cert.data, password: cert.password);
ctx.useCertificateChainBytes(cert.data, password: cert.password);
} catch (e) {
_log.severe("Failed to set SSL client cert: $e");
return false;
}
return true;
}
HttpSSLCertOverride(this._allowSelfSignedSSLCert, this._serverHost);
@override
HttpClient createHttpClient(SecurityContext? context) {
if (context != null) {
if (_clientCert != null) {
setClientCert(context, _clientCert);
}
} else {
context = _ctxWithCert;
}
// Use system trust store with trusted roots if no client certificate is provided
context = SecurityContext(withTrustedRoots: true);
return super.createHttpClient(context)
..badCertificateCallback = (X509Certificate cert, String host, int port) {

View file

@ -1,10 +1,7 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:logging/logging.dart';
class HttpSSLOptions {
@ -13,11 +10,38 @@ class HttpSSLOptions {
static void apply({bool applyNative = true}) {
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue);
_apply(allowSelfSignedSSLCert, applyNative: applyNative);
// Check if user certificates are enabled
bool useUserCerts = Store.get(
AppSettingsEnum.useUserCertificates.storeKey,
AppSettingsEnum.useUserCertificates.defaultValue,
);
if (useUserCerts) {
_applyWithUserCertificates(allowSelfSignedSSLCert, applyNative: applyNative);
} else {
_apply(allowSelfSignedSSLCert, applyNative: applyNative);
}
}
static void applyFromSettings(bool newValue) {
_apply(newValue);
// Check if user certificates are enabled
bool useUserCerts = Store.get(
AppSettingsEnum.useUserCertificates.storeKey,
AppSettingsEnum.useUserCertificates.defaultValue,
);
if (useUserCerts) {
_applyWithUserCertificates(newValue);
} else {
_apply(newValue);
}
}
static void applyWithUserCertificates({bool applyNative = true}) {
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue);
_applyWithUserCertificates(allowSelfSignedSSLCert, applyNative: applyNative);
}
static void _apply(bool allowSelfSignedSSLCert, {bool applyNative = true}) {
@ -26,17 +50,45 @@ class HttpSSLOptions {
serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
}
SSLClientCertStoreVal? clientCert = SSLClientCertStoreVal.load();
// HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert);
HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert);
// if (applyNative && Platform.isAndroid) {
// _channel
// .invokeMethod("apply", [allowSelfSignedSSLCert, serverHost, clientCert?.data, clientCert?.password])
// .onError<PlatformException>((e, _) {
// final log = Logger("HttpSSLOptions");
// log.severe('Failed to set SSL options', e.message);
// });
// }
}
if (applyNative && Platform.isAndroid) {
_channel
.invokeMethod("apply", [allowSelfSignedSSLCert, serverHost, clientCert?.data, clientCert?.password])
.onError<PlatformException>((e, _) {
final log = Logger("HttpSSLOptions");
log.severe('Failed to set SSL options', e.message);
});
static void _applyWithUserCertificates(bool allowSelfSignedSSLCert, {bool applyNative = true}) {
String? serverHost;
if (Store.tryGet(StoreKey.currentUser) != null) {
serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
}
// Create SSL override that uses user certificates from system store
// HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, null);
// if (applyNative) {
// _channel
// .invokeMethod("applyWithUserCertificates", [serverHost, allowSelfSignedSSLCert])
// .onError<PlatformException>((e, _) {
// final log = Logger("HttpSSLOptions");
// log.severe('Failed to set SSL options with user certificates', e.message);
// });
// }
}
static Future<List<Map<String, String>>> getAvailableUserCertificates() async {
try {
final List<dynamic> certificates = await _channel.invokeMethod("getAvailableCertificates");
return certificates.map((cert) => Map<String, String>.from(cert)).toList();
} catch (e) {
final log = Logger("HttpSSLOptions");
log.severe('Failed to get available user certificates: $e');
return [];
}
}
}

View file

@ -1,8 +1,8 @@
import 'dart:io' show Platform;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:immich_mobile/common/package_info.dart';
Future<String> getUserAgentString() async {
final packageInfo = await PackageInfo.fromPlatform();
String getUserAgentString() {
final packageInfo = PackageInfoSingleton.instance;
String platform;
if (Platform.isAndroid) {
platform = 'Android';
@ -11,5 +11,6 @@ Future<String> getUserAgentString() async {
} else {
platform = 'Unknown';
}
return 'Immich_${platform}_${packageInfo.version}';
final version = packageInfo.version ?? 'unknown';
return 'Immich_${platform}_$version';
}

View file

@ -1,14 +1,10 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:immich_mobile/common/http.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:ok_http/ok_http.dart';
class SslClientCertSettings extends StatefulWidget {
const SslClientCertSettings({super.key, required this.isLoggedIn});
@ -20,9 +16,9 @@ class SslClientCertSettings extends StatefulWidget {
}
class _SslClientCertSettingsState extends State<SslClientCertSettings> {
_SslClientCertSettingsState() : isCertExist = SSLClientCertStoreVal.load() != null;
_SslClientCertSettingsState() : pKeyAlias = SSLClientCertStoreVal.load()?.privateKeyAlias ?? "";
bool isCertExist;
String pKeyAlias = "";
@override
Widget build(BuildContext context) {
@ -39,21 +35,57 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
const SizedBox(height: 6),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ElevatedButton(
onPressed: widget.isLoggedIn ? null : () => importCert(context),
child: Text("client_cert_import".tr()),
if (pKeyAlias != "")
Center(
child: Container(
margin: const EdgeInsets.fromLTRB(0, 6, 0, 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: context.colorScheme.primary.withValues(alpha: 0.3), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.lock_outline, size: 16, color: context.colorScheme.primary),
const SizedBox(width: 8),
Flexible(
child: Text(
pKeyAlias,
style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.primary,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const SizedBox(width: 15),
ElevatedButton(
onPressed: widget.isLoggedIn || !isCertExist ? null : () async => await removeCert(context),
child: Text("remove".tr()),
),
if (pKeyAlias == "")
Center(
child: Container(
margin: const EdgeInsets.fromLTRB(0, 6, 0, 6),
child: Text("no_certificate_selected".tr(), style: const TextStyle(fontStyle: FontStyle.italic)),
),
],
),
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 6,
children: [
ElevatedButton(
onPressed: widget.isLoggedIn ? null : () async => await selectCert(context),
child: Text("select".tr()),
),
ElevatedButton(
onPressed: widget.isLoggedIn || pKeyAlias == "" ? null : () async => await removeCert(context),
child: Text("remove".tr()),
),
],
),
),
],
),
@ -70,61 +102,19 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
);
}
Future<void> storeCert(BuildContext context, Uint8List data, String? password) async {
if (password != null && password.isEmpty) {
password = null;
}
final cert = SSLClientCertStoreVal(data, password);
// Test whether the certificate is valid
final isCertValid = HttpSSLCertOverride.setClientCert(SecurityContext(withTrustedRoots: true), cert);
if (!isCertValid) {
showMessage(context, "client_cert_invalid_msg".tr());
Future<void> selectCert(BuildContext context) async {
String? chosenAlias = await choosePrivateKeyAlias();
if (chosenAlias == null) {
return;
}
await cert.save();
HttpSSLOptions.apply();
setState(() => isCertExist = true);
showMessage(context, "client_cert_import_success_msg".tr());
}
void setPassword(BuildContext context, Uint8List data) {
final password = TextEditingController();
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
content: TextField(
controller: password,
obscureText: true,
obscuringCharacter: "*",
decoration: InputDecoration(hintText: "client_cert_enter_password".tr()),
),
actions: [
TextButton(
onPressed: () async => {ctx.pop(), await storeCert(context, data, password.text)},
child: Text("client_cert_dialog_msg_confirm".tr()),
),
],
),
);
}
Future<void> importCert(BuildContext ctx) async {
FilePickerResult? res = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['p12', 'pfx'],
);
if (res != null) {
File file = File(res.files.single.path!);
final bytes = await file.readAsBytes();
setPassword(ctx, bytes);
}
setState(() => pKeyAlias = chosenAlias);
await SSLClientCertStoreVal(chosenAlias).save();
await refreshClient();
}
Future<void> removeCert(BuildContext context) async {
await SSLClientCertStoreVal.delete();
HttpSSLOptions.apply();
setState(() => isCertExist = false);
showMessage(context, "client_cert_remove_msg".tr());
setState(() => pKeyAlias = "");
await const SSLClientCertStoreVal("").save();
await refreshClient();
}
}

View file

@ -919,6 +919,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
http_profile:
dependency: transitive
description:
name: http_profile
sha256: "7e679e355b09aaee2ab5010915c932cce3f2d1c11c3b2dc177891687014ffa78"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
image:
dependency: transitive
description:
@ -1053,6 +1061,14 @@ packages:
url: "https://github.com/immich-app/isar"
source: git
version: "3.1.8"
jni:
dependency: transitive
description:
name: jni
sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1
url: "https://pub.dev"
source: hosted
version: "0.14.2"
js:
dependency: transitive
description:
@ -1254,6 +1270,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
ok_http:
dependency: "direct main"
description:
path: "pkgs/ok_http"
ref: "feature/ok-http-0.1.2"
resolved-ref: c285421e3d908f209930edb7535628bb63a7037a
url: "https://github.com/denysvitali/dart_lang_http"
source: git
version: "0.1.1-wip"
openapi:
dependency: "direct main"
description:

View file

@ -71,6 +71,14 @@ dependencies:
worker_manager: ^7.2.3
scroll_date_picker: ^3.8.0
ffi: ^2.1.4
ok_http:
# TODO: Replace with 0.1.2 when
# - https://github.com/dart-lang/http/pull/1830 and
# - https://github.com/dart-lang/http/pull/1831 are merged
git:
url: https://github.com/denysvitali/dart_lang_http
ref: feature/ok-http-0.1.2
path: pkgs/ok_http
native_video_player:
git:

View file

@ -61,7 +61,7 @@ void main() {
when(() => mockStreamedResponse.stream).thenAnswer((_) => http.ByteStream(responseStreamController.stream));
when(() => mockHttpClient.close()).thenAnswer((_) => {});
sut = SyncApiRepository(mockApiService);
sut = SyncApiRepository(mockApiService, httpClient: mockHttpClient);
});
tearDown(() async {
@ -73,7 +73,7 @@ void main() {
Future<void> streamChanges(
Future<void> Function(List<SyncEvent>, Function() abort, Function() reset) onDataCallback,
) {
return sut.streamChanges(onDataCallback, batchSize: testBatchSize, httpClient: mockHttpClient);
return sut.streamChanges(onDataCallback, batchSize: testBatchSize);
}
test('streamChanges stops processing stream when abort is called', () async {