feat(mobile): use shared native client (#25942)

* use shared client in dart

fix android

* websocket integration

platform-side headers

update comment

consistent platform check

tweak websocket handling

support streaming

* redundant logging

* fix proguard

* formatting

* handle onProgress

* support videos on ios

* inline return

* improved ios impl

* cleanup

* sync stopForegroundBackup

* voidify

* future already completed

* stream request on android

* outdated ios ws code

* use `choosePrivateKeyAlias`

* return result

* formatting

* update tests

* redundant check

* handle custom headers

* move completer outside of state

* persist auth

* dispose old socket

* use group id for cookies

* redundant headers

* cache global ref

* handle network switching

* handle basic auth

* apply custom headers immediately

* video player update

* fix

* persist url

* potential logout fix

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Mert
2026-03-05 12:04:45 -05:00
committed by GitHub
parent 35a521c6ec
commit a05c8c6087
57 changed files with 880 additions and 855 deletions
+35 -19
View File
@@ -3,12 +3,11 @@ import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/utils/user_agent.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -49,9 +48,14 @@ class ApiService implements Authentication {
String? _accessToken;
final _log = Logger("ApiService");
Future<void> updateHeaders() async {
await NetworkRepository.setHeaders(getRequestHeaders(), getServerUrls());
_apiClient.client = NetworkRepository.client;
}
setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint, authentication: this);
_setUserAgentHeader();
_apiClient.client = NetworkRepository.client;
if (_accessToken != null) {
setAccessToken(_accessToken!);
}
@@ -78,11 +82,6 @@ class ApiService implements Authentication {
tagsApi = TagsApi(_apiClient);
}
Future<void> _setUserAgentHeader() async {
final userAgent = await getUserAgentString();
_apiClient.addDefaultHeader('User-Agent', userAgent);
}
Future<String> resolveAndSetEndpoint(String serverUrl) async {
final endpoint = await resolveEndpoint(serverUrl);
setEndpoint(endpoint);
@@ -136,14 +135,9 @@ class ApiService implements Authentication {
}
Future<String> _getWellKnownEndpoint(String baseUrl) async {
final Client client = Client();
try {
var headers = {"Accept": "application/json"};
headers.addAll(getRequestHeaders());
final res = await client
.get(Uri.parse("$baseUrl/.well-known/immich"), headers: headers)
final res = await NetworkRepository.client
.get(Uri.parse("$baseUrl/.well-known/immich"))
.timeout(const Duration(seconds: 5));
if (res.statusCode == 200) {
@@ -185,6 +179,31 @@ class ApiService implements Authentication {
}
}
static List<String> getServerUrls() {
final urls = <String>[];
final serverEndpoint = Store.tryGet(StoreKey.serverEndpoint);
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint);
}
final serverUrl = Store.tryGet(StoreKey.serverUrl);
if (serverUrl != null && serverUrl.isNotEmpty) {
urls.add(serverUrl);
}
final localEndpoint = Store.tryGet(StoreKey.localEndpoint);
if (localEndpoint != null && localEndpoint.isNotEmpty) {
urls.add(localEndpoint);
}
final externalJson = Store.tryGet(StoreKey.externalEndpointList);
if (externalJson != null) {
final List<dynamic> list = jsonDecode(externalJson);
for (final entry in list) {
final url = entry['url'] as String?;
if (url != null && url.isNotEmpty) urls.add(url);
}
}
return urls;
}
static Map<String, String> getRequestHeaders() {
var accessToken = Store.get(StoreKey.accessToken, "");
var customHeadersStr = Store.get(StoreKey.customHeaders, "");
@@ -207,10 +226,7 @@ class ApiService implements Authentication {
@override
Future<void> applyToParams(List<QueryParam> queryParams, Map<String, String> headerParams) {
return Future<void>(() {
var headers = ApiService.getRequestHeaders();
headerParams.addAll(headers);
});
return Future.value();
}
ApiClient get apiClient => _apiClient;
+2 -13
View File
@@ -1,10 +1,10 @@
import 'dart:async';
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.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';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
@@ -64,27 +64,16 @@ class AuthService {
}
Future<bool> validateAuxilaryServerUrl(String url) async {
final httpclient = HttpClient();
bool isValid = false;
try {
final uri = Uri.parse('$url/users/me');
final request = await httpclient.getUrl(uri);
// 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();
final response = await NetworkRepository.client.get(uri);
if (response.statusCode == 200) {
isValid = true;
}
} catch (error) {
_log.severe("Error validating auxiliary endpoint", error);
} finally {
httpclient.close();
}
return isValid;
+7 -8
View File
@@ -4,7 +4,6 @@ import 'dart:io';
import 'dart:isolate';
import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
@@ -30,7 +29,6 @@ import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:path_provider_foundation/path_provider_foundation.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
@@ -43,7 +41,7 @@ class BackgroundService {
static const MethodChannel _backgroundChannel = MethodChannel('immich/backgroundChannel');
static const notifyInterval = Duration(milliseconds: 400);
bool _isBackgroundInitialized = false;
CancellationToken? _cancellationToken;
Completer<void>? _cancellationToken;
bool _canceledBySystem = false;
int _wantsLockTime = 0;
bool _hasLock = false;
@@ -321,7 +319,8 @@ class BackgroundService {
}
case "systemStop":
_canceledBySystem = true;
_cancellationToken?.cancel();
_cancellationToken?.complete();
_cancellationToken = null;
return true;
default:
dPrint(() => "Unknown method ${call.method}");
@@ -341,7 +340,6 @@ class BackgroundService {
],
);
HttpSSLOptions.apply();
await ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken));
await ref.read(authServiceProvider).setOpenApiServiceEndpoint();
dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}");
@@ -441,7 +439,8 @@ class BackgroundService {
),
);
_cancellationToken = CancellationToken();
_cancellationToken?.complete();
_cancellationToken = Completer<void>();
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
final bool ok = await backupService.backupAsset(
@@ -455,7 +454,7 @@ class BackgroundService {
isBackground: true,
);
if (!ok && !_cancellationToken!.isCancelled) {
if (!ok && !_cancellationToken!.isCompleted) {
unawaited(
_showErrorNotification(
title: "backup_background_service_error_title".tr(),
@@ -467,7 +466,7 @@ class BackgroundService {
return ok;
}
void _onAssetUploaded({bool shouldNotify = false}) async {
void _onAssetUploaded({bool shouldNotify = false}) {
if (!shouldNotify) {
return;
}
+13 -40
View File
@@ -2,14 +2,16 @@ import 'dart:async';
import 'dart:convert';
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/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
@@ -43,7 +45,6 @@ final backupServiceProvider = Provider(
);
class BackupService {
final httpClient = http.Client();
final ApiService _apiService;
final Logger _log = Logger("BackupService");
final AppSettingsService _appSetting;
@@ -233,7 +234,7 @@ class BackupService {
Future<bool> backupAsset(
Iterable<BackupCandidate> assets,
http.CancellationToken cancelToken, {
Completer<void> cancelToken, {
bool isBackground = false,
PMProgressHandler? pmProgressHandler,
required void Function(SuccessUploadAsset result) onSuccess,
@@ -306,20 +307,20 @@ 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'),
abortTrigger: cancelToken.future,
onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)),
);
baseRequest.headers.addAll(ApiService.getRequestHeaders());
baseRequest.fields['deviceAssetId'] = asset.localId!;
baseRequest.fields['deviceId'] = deviceId;
baseRequest.fields['fileCreatedAt'] = asset.fileCreatedAt.toUtc().toIso8601String();
@@ -348,7 +349,7 @@ class BackupService {
baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId;
}
final response = await httpClient.send(baseRequest, cancellationToken: cancelToken);
final response = await NetworkRepository.client.send(baseRequest);
final responseBody = jsonDecode(await response.stream.bytesToString());
@@ -398,7 +399,7 @@ class BackupService {
await _albumService.syncUploadAlbums(candidate.albumNames, [responseBody['id'] as String]);
}
}
} on http.CancelledException {
} on RequestAbortedException {
dPrint(() => "Backup was cancelled by the user");
anyErrors = true;
break;
@@ -429,26 +430,26 @@ class BackupService {
String originalFileName,
File? livePhotoVideoFile,
MultipartRequest baseRequest,
http.CancellationToken cancelToken,
Completer cancelToken,
) async {
if (livePhotoVideoFile == null) {
return null;
}
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)
final livePhotoReq = ProgressMultipartRequest(baseRequest.method, baseRequest.url, abortTrigger: cancelToken.future)
..headers.addAll(baseRequest.headers)
..fields.addAll(baseRequest.fields);
livePhotoReq.files.add(livePhotoRawUploadData);
var response = await httpClient.send(livePhotoReq, cancellationToken: cancelToken);
var response = await NetworkRepository.client.send(livePhotoReq);
var responseBody = jsonDecode(await response.stream.bytesToString());
@@ -470,31 +471,3 @@ class BackupService {
AssetType.other => "OTHER",
};
}
class MultipartRequest extends http.MultipartRequest {
/// Creates a new [MultipartRequest].
MultipartRequest(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]
/// that will emit the request body.
@override
http.ByteStream finalize() {
final byteStream = super.finalize();
final total = contentLength;
var bytes = 0;
final t = StreamTransformer.fromHandlers(
handleData: (List<int> data, EventSink<List<int>> sink) {
bytes += data.length;
onProgress.call(bytes, total);
sink.add(data);
},
);
final stream = byteStream.transform(t);
return http.ByteStream(stream);
}
}
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -19,7 +18,6 @@ import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
@@ -82,7 +80,7 @@ class ForegroundUploadService {
/// Bulk upload of backup candidates from selected albums
Future<void> uploadCandidates(
String userId,
CancellationToken cancelToken, {
Completer<void> cancelToken, {
UploadCallbacks callbacks = const UploadCallbacks(),
bool useSequentialUpload = false,
}) async {
@@ -105,7 +103,7 @@ class ForegroundUploadService {
final requireWifi = _shouldRequireWiFi(asset);
return requireWifi && !hasWifi;
},
processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks),
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
);
}
}
@@ -113,37 +111,32 @@ class ForegroundUploadService {
/// Sequential upload - used for background isolate where concurrent HTTP clients may cause issues
Future<void> _uploadSequentially({
required List<LocalAsset> items,
required CancellationToken cancelToken,
required Completer<void> cancelToken,
required bool hasWifi,
required UploadCallbacks callbacks,
}) async {
final httpClient = Client();
await _storageRepository.clearCache();
shouldAbortUpload = false;
try {
for (final asset in items) {
if (shouldAbortUpload || cancelToken.isCancelled) {
break;
}
final requireWifi = _shouldRequireWiFi(asset);
if (requireWifi && !hasWifi) {
_logger.warning('Skipping upload for ${asset.id} because it requires WiFi');
continue;
}
await _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks);
for (final asset in items) {
if (shouldAbortUpload || cancelToken.isCompleted) {
break;
}
} finally {
httpClient.close();
final requireWifi = _shouldRequireWiFi(asset);
if (requireWifi && !hasWifi) {
_logger.warning('Skipping upload for ${asset.id} because it requires WiFi');
continue;
}
await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
}
}
/// Manually upload picked local assets
Future<void> uploadManual(
List<LocalAsset> localAssets,
CancellationToken cancelToken, {
List<LocalAsset> localAssets, {
Completer<void>? cancelToken,
UploadCallbacks callbacks = const UploadCallbacks(),
}) async {
if (localAssets.isEmpty) {
@@ -153,14 +146,14 @@ class ForegroundUploadService {
await _executeWithWorkerPool<LocalAsset>(
items: localAssets,
cancelToken: cancelToken,
processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks),
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
);
}
/// Upload files from shared intent
Future<void> uploadShareIntent(
List<File> files, {
CancellationToken? cancelToken,
Completer<void>? cancelToken,
void Function(String fileId, int bytes, int totalBytes)? onProgress,
void Function(String fileId)? onSuccess,
void Function(String fileId, String errorMessage)? onError,
@@ -168,20 +161,16 @@ class ForegroundUploadService {
if (files.isEmpty) {
return;
}
final effectiveCancelToken = cancelToken ?? CancellationToken();
await _executeWithWorkerPool<File>(
items: files,
cancelToken: effectiveCancelToken,
processItem: (file, httpClient) async {
cancelToken: cancelToken,
processItem: (file) async {
final fileId = p.hash(file.path).toString();
final result = await _uploadSingleFile(
file,
deviceAssetId: fileId,
httpClient: httpClient,
cancelToken: effectiveCancelToken,
cancelToken: cancelToken,
onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes),
);
@@ -207,58 +196,49 @@ class ForegroundUploadService {
/// [concurrentWorkers] - Number of concurrent workers (default: 3)
Future<void> _executeWithWorkerPool<T>({
required List<T> items,
required CancellationToken cancelToken,
required Future<void> Function(T item, Client httpClient) processItem,
required Completer<void>? cancelToken,
required Future<void> Function(T item) processItem,
bool Function(T item)? shouldSkip,
int concurrentWorkers = 3,
}) async {
final httpClients = List.generate(concurrentWorkers, (_) => Client());
await _storageRepository.clearCache();
shouldAbortUpload = false;
try {
int currentIndex = 0;
int currentIndex = 0;
Future<void> worker(Client httpClient) async {
while (true) {
if (shouldAbortUpload || cancelToken.isCancelled) {
break;
}
final index = currentIndex;
if (index >= items.length) {
break;
}
currentIndex++;
final item = items[index];
if (shouldSkip?.call(item) ?? false) {
continue;
}
await processItem(item, httpClient);
Future<void> worker() async {
while (true) {
if (shouldAbortUpload || (cancelToken != null && cancelToken.isCompleted)) {
break;
}
}
final workerFutures = <Future<void>>[];
for (int i = 0; i < concurrentWorkers; i++) {
workerFutures.add(worker(httpClients[i]));
}
final index = currentIndex;
if (index >= items.length) {
break;
}
currentIndex++;
await Future.wait(workerFutures);
} finally {
for (final client in httpClients) {
client.close();
final item = items[index];
if (shouldSkip?.call(item) ?? false) {
continue;
}
await processItem(item);
}
}
final workerFutures = <Future<void>>[];
for (int i = 0; i < concurrentWorkers; i++) {
workerFutures.add(worker());
}
await Future.wait(workerFutures);
}
Future<void> _uploadSingleAsset(
LocalAsset asset,
Client httpClient,
CancellationToken cancelToken, {
Completer<void>? cancelToken, {
required UploadCallbacks callbacks,
}) async {
File? file;
@@ -343,7 +323,6 @@ class ForegroundUploadService {
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final deviceId = Store.get(StoreKey.deviceId);
final headers = ApiService.getRequestHeaders();
final fields = {
'deviceAssetId': asset.localId!,
'deviceId': deviceId,
@@ -358,15 +337,15 @@ class ForegroundUploadService {
if (entity.isLivePhoto && livePhotoFile != null) {
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
final onProgress = callbacks.onProgress;
final livePhotoResult = await _uploadRepository.uploadFile(
file: livePhotoFile,
originalFileName: livePhotoTitle,
headers: headers,
fields: fields,
httpClient: httpClient,
cancelToken: cancelToken,
onProgress: (bytes, totalBytes) =>
callbacks.onProgress?.call(asset.localId!, livePhotoTitle, bytes, totalBytes),
onProgress: onProgress != null
? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes)
: null,
logContext: 'livePhotoVideo[${asset.localId}]',
);
@@ -395,15 +374,15 @@ class ForegroundUploadService {
]);
}
final onProgress = callbacks.onProgress;
final result = await _uploadRepository.uploadFile(
file: file,
originalFileName: originalFileName,
headers: headers,
fields: fields,
httpClient: httpClient,
cancelToken: cancelToken,
onProgress: (bytes, totalBytes) =>
callbacks.onProgress?.call(asset.localId!, originalFileName, bytes, totalBytes),
onProgress: onProgress != null
? (bytes, totalBytes) => onProgress(asset.localId!, originalFileName, bytes, totalBytes)
: null,
logContext: 'asset[${asset.localId}]',
);
@@ -442,8 +421,7 @@ class ForegroundUploadService {
Future<UploadResult> _uploadSingleFile(
File file, {
required String deviceAssetId,
required Client httpClient,
required CancellationToken cancelToken,
required Completer<void>? cancelToken,
void Function(int bytes, int totalBytes)? onProgress,
}) async {
try {
@@ -452,12 +430,9 @@ class ForegroundUploadService {
final fileModifiedAt = stats.modified;
final filename = p.basename(file.path);
final headers = ApiService.getRequestHeaders();
final deviceId = Store.get(StoreKey.deviceId);
final fields = {
'deviceAssetId': deviceAssetId,
'deviceId': deviceId,
'deviceId': Store.get(StoreKey.deviceId),
'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(),
'isFavorite': 'false',
@@ -467,11 +442,9 @@ class ForegroundUploadService {
return await _uploadRepository.uploadFile(
file: file,
originalFileName: filename,
headers: headers,
fields: fields,
httpClient: httpClient,
cancelToken: cancelToken,
onProgress: onProgress ?? (_, __) {},
onProgress: onProgress,
logContext: 'shareIntent[$deviceAssetId]',
);
} catch (e) {