2026-01-15 20:10:08 -06:00
import ' dart:async ' ;
import ' dart:convert ' ;
import ' dart:io ' ;
2026-07-14 01:20:06 +05:30
import ' package:flutter/foundation.dart ' ;
2026-01-15 20:10:08 -06:00
import ' package:hooks_riverpod/hooks_riverpod.dart ' ;
import ' package:immich_mobile/domain/models/asset/asset_metadata.model.dart ' ;
2026-07-07 19:57:39 +05:30
import ' package:immich_mobile/domain/models/asset/base_asset.model.dart ' hide AssetVisibility ;
2026-05-13 09:36:16 -05:00
import ' package:immich_mobile/domain/models/store.model.dart ' ;
import ' package:immich_mobile/entities/store.entity.dart ' ;
2026-01-15 20:10:08 -06:00
import ' package:immich_mobile/extensions/network_capability_extensions.dart ' ;
2026-05-19 00:40:10 +05:30
import ' package:immich_mobile/extensions/platform_extensions.dart ' ;
2026-08-11 14:41:11 +05:30
import ' package:immich_mobile/generated/translations.g.dart ' ;
2026-01-15 20:10:08 -06:00
import ' package:immich_mobile/infrastructure/repositories/backup.repository.dart ' ;
2026-05-30 20:57:55 +05:30
import ' package:immich_mobile/infrastructure/repositories/settings.repository.dart ' ;
2026-01-15 20:10:08 -06:00
import ' package:immich_mobile/infrastructure/repositories/storage.repository.dart ' ;
import ' package:immich_mobile/platform/connectivity_api.g.dart ' ;
import ' package:immich_mobile/providers/infrastructure/platform.provider.dart ' ;
import ' package:immich_mobile/providers/infrastructure/storage.provider.dart ' ;
2026-01-21 07:58:32 -06:00
import ' package:immich_mobile/repositories/asset_media.repository.dart ' ;
2026-01-15 20:10:08 -06:00
import ' package:immich_mobile/repositories/upload.repository.dart ' ;
import ' package:logging/logging.dart ' ;
2026-07-07 19:57:39 +05:30
import ' package:openapi/api.dart ' ;
2026-01-15 20:10:08 -06:00
import ' package:path/path.dart ' as p ;
import ' package:photo_manager/photo_manager.dart ' show PMProgressHandler ;
/// Callbacks for upload progress and status updates
class UploadCallbacks {
final void Function ( String id , String filename , int bytes , int totalBytes ) ? onProgress ;
final void Function ( String localId , String remoteId ) ? onSuccess ;
final void Function ( String id , String errorMessage ) ? onError ;
final void Function ( String id , double progress ) ? onICloudProgress ;
const UploadCallbacks ( { this . onProgress , this . onSuccess , this . onError , this . onICloudProgress } ) ;
}
final foregroundUploadServiceProvider = Provider ( ( ref ) {
2026-08-05 01:26:49 +05:30
// ignore: dispose-provided-instances
2026-01-15 20:10:08 -06:00
return ForegroundUploadService (
ref . watch ( uploadRepositoryProvider ) ,
ref . watch ( storageRepositoryProvider ) ,
ref . watch ( backupRepositoryProvider ) ,
ref . watch ( connectivityApiProvider ) ,
2026-01-21 07:58:32 -06:00
ref . watch ( assetMediaRepositoryProvider ) ,
2026-01-15 20:10:08 -06:00
) ;
} ) ;
/// Service for handling foreground HTTP uploads
///
/// This service handles synchronous uploads using HTTP client with
/// concurrent worker pools. Used for manual backups, auto backups
/// (foreground mode), and share intent uploads.
class ForegroundUploadService {
ForegroundUploadService (
this . _uploadRepository ,
this . _storageRepository ,
this . _backupRepository ,
this . _connectivityApi ,
2026-01-21 07:58:32 -06:00
this . _assetMediaRepository ,
2026-01-15 20:10:08 -06:00
) ;
final UploadRepository _uploadRepository ;
final StorageRepository _storageRepository ;
final DriftBackupRepository _backupRepository ;
final ConnectivityApi _connectivityApi ;
2026-01-21 07:58:32 -06:00
final AssetMediaRepository _assetMediaRepository ;
2026-01-15 20:10:08 -06:00
final Logger _logger = Logger ( ' ForegroundUploadService ' ) ;
bool shouldAbortUpload = false ;
Future < ( { int total , int remainder , int processing } ) > getBackupCounts ( String userId ) {
return _backupRepository . getAllCounts ( userId ) ;
}
Future < List < LocalAsset > > getBackupCandidates ( String userId , { bool onlyHashed = true } ) {
return _backupRepository . getCandidates ( userId , onlyHashed: onlyHashed ) ;
}
/// Bulk upload of backup candidates from selected albums
Future < void > uploadCandidates (
String userId ,
2026-03-05 12:04:45 -05:00
Completer < void > cancelToken , {
2026-01-15 20:10:08 -06:00
UploadCallbacks callbacks = const UploadCallbacks ( ) ,
bool useSequentialUpload = false ,
} ) async {
final candidates = await _backupRepository . getCandidates ( userId ) ;
if ( candidates . isEmpty ) {
return ;
}
final networkCapabilities = await _connectivityApi . getCapabilities ( ) ;
final hasWifi = networkCapabilities . isUnmetered ;
_logger . info ( ' Network capabilities: $ networkCapabilities , hasWifi/isUnmetered: $ hasWifi ' ) ;
if ( useSequentialUpload ) {
await _uploadSequentially ( items: candidates , cancelToken: cancelToken , hasWifi: hasWifi , callbacks: callbacks ) ;
} else {
await _executeWithWorkerPool < LocalAsset > (
items: candidates ,
cancelToken: cancelToken ,
shouldSkip: ( asset ) {
final requireWifi = _shouldRequireWiFi ( asset ) ;
return requireWifi & & ! hasWifi ;
} ,
2026-07-14 01:20:06 +05:30
processItem: ( asset ) = > uploadSingleAsset ( asset , cancelToken , callbacks: callbacks ) ,
2026-01-15 20:10:08 -06:00
) ;
}
}
/// Sequential upload - used for background isolate where concurrent HTTP clients may cause issues
Future < void > _uploadSequentially ( {
required List < LocalAsset > items ,
2026-03-05 12:04:45 -05:00
required Completer < void > cancelToken ,
2026-01-15 20:10:08 -06:00
required bool hasWifi ,
required UploadCallbacks callbacks ,
} ) async {
await _storageRepository . clearCache ( ) ;
shouldAbortUpload = false ;
2026-03-05 12:04:45 -05:00
for ( final asset in items ) {
if ( shouldAbortUpload | | cancelToken . isCompleted ) {
break ;
}
2026-01-15 20:10:08 -06:00
2026-03-05 12:04:45 -05:00
final requireWifi = _shouldRequireWiFi ( asset ) ;
if ( requireWifi & & ! hasWifi ) {
_logger . warning ( ' Skipping upload for ${ asset . id } because it requires WiFi ' ) ;
continue ;
2026-01-15 20:10:08 -06:00
}
2026-03-05 12:04:45 -05:00
2026-07-14 01:20:06 +05:30
await uploadSingleAsset ( asset , cancelToken , callbacks: callbacks ) ;
2026-01-15 20:10:08 -06:00
}
}
/// Manually upload picked local assets
Future < void > uploadManual (
2026-03-05 12:04:45 -05:00
List < LocalAsset > localAssets , {
Completer < void > ? cancelToken ,
2026-01-15 20:10:08 -06:00
UploadCallbacks callbacks = const UploadCallbacks ( ) ,
} ) async {
if ( localAssets . isEmpty ) {
return ;
}
await _executeWithWorkerPool < LocalAsset > (
items: localAssets ,
cancelToken: cancelToken ,
2026-07-14 01:20:06 +05:30
processItem: ( asset ) = > uploadSingleAsset ( asset , cancelToken , callbacks: callbacks ) ,
2026-01-15 20:10:08 -06:00
) ;
}
/// Upload files from shared intent
Future < void > uploadShareIntent (
List < File > files , {
2026-03-05 12:04:45 -05:00
Completer < void > ? cancelToken ,
2026-01-15 20:10:08 -06:00
void Function ( String fileId , int bytes , int totalBytes ) ? onProgress ,
2026-06-03 20:05:52 +03:00
void Function ( String fileId , String remoteAssetId ) ? onSuccess ,
2026-01-15 20:10:08 -06:00
void Function ( String fileId , String errorMessage ) ? onError ,
} ) async {
if ( files . isEmpty ) {
return ;
}
await _executeWithWorkerPool < File > (
items: files ,
2026-03-05 12:04:45 -05:00
cancelToken: cancelToken ,
processItem: ( file ) async {
2026-01-15 20:10:08 -06:00
final fileId = p . hash ( file . path ) . toString ( ) ;
final result = await _uploadSingleFile (
file ,
deviceAssetId: fileId ,
2026-03-05 12:04:45 -05:00
cancelToken: cancelToken ,
2026-01-15 20:10:08 -06:00
onProgress: ( bytes , totalBytes ) = > onProgress ? . call ( fileId , bytes , totalBytes ) ,
) ;
if ( result . isSuccess ) {
2026-06-03 20:05:52 +03:00
onSuccess ? . call ( fileId , result . remoteAssetId ! ) ;
2026-01-15 20:10:08 -06:00
} else if ( ! result . isCancelled & & result . errorMessage ! = null ) {
onError ? . call ( fileId , result . errorMessage ! ) ;
}
} ,
) ;
}
void cancel ( ) {
shouldAbortUpload = true ;
}
/// Generic worker pool for concurrent uploads
///
/// [items] - List of items to process
/// [cancelToken] - Token to cancel the operation
/// [processItem] - Function to process each item with an HTTP client
/// [shouldSkip] - Optional function to skip items (e.g., WiFi requirement check)
/// [concurrentWorkers] - Number of concurrent workers (default: 3)
Future < void > _executeWithWorkerPool < T > ( {
required List < T > items ,
2026-03-05 12:04:45 -05:00
required Completer < void > ? cancelToken ,
required Future < void > Function ( T item ) processItem ,
2026-01-15 20:10:08 -06:00
bool Function ( T item ) ? shouldSkip ,
int concurrentWorkers = 3 ,
} ) async {
await _storageRepository . clearCache ( ) ;
shouldAbortUpload = false ;
2026-03-05 12:04:45 -05:00
int currentIndex = 0 ;
2026-01-15 20:10:08 -06:00
2026-03-05 12:04:45 -05:00
Future < void > worker ( ) async {
while ( true ) {
if ( shouldAbortUpload | | ( cancelToken ! = null & & cancelToken . isCompleted ) ) {
break ;
}
2026-01-15 20:10:08 -06:00
2026-03-05 12:04:45 -05:00
final index = currentIndex ;
if ( index > = items . length ) {
break ;
}
currentIndex + + ;
2026-01-15 20:10:08 -06:00
2026-03-05 12:04:45 -05:00
final item = items [ index ] ;
2026-01-15 20:10:08 -06:00
2026-03-05 12:04:45 -05:00
if ( shouldSkip ? . call ( item ) ? ? false ) {
continue ;
2026-01-15 20:10:08 -06:00
}
2026-03-05 12:04:45 -05:00
await processItem ( item ) ;
2026-01-15 20:10:08 -06:00
}
2026-03-05 12:04:45 -05:00
}
2026-01-15 20:10:08 -06:00
2026-03-05 12:04:45 -05:00
final workerFutures = < Future < void > > [ ] ;
for ( int i = 0 ; i < concurrentWorkers ; i + + ) {
workerFutures . add ( worker ( ) ) ;
2026-01-15 20:10:08 -06:00
}
2026-03-05 12:04:45 -05:00
await Future . wait ( workerFutures ) ;
2026-01-15 20:10:08 -06:00
}
2026-07-14 01:20:06 +05:30
@ visibleForTesting
Future < void > uploadSingleAsset (
2026-01-15 20:10:08 -06:00
LocalAsset asset ,
2026-03-05 12:04:45 -05:00
Completer < void > ? cancelToken , {
2026-01-15 20:10:08 -06:00
required UploadCallbacks callbacks ,
} ) async {
2026-08-11 14:41:11 +05:30
final t = StaticTranslations . instance ;
final assetNotFoundOnDevice = CurrentPlatform . isAndroid
? t . asset_not_found_on_device_android
: t . asset_not_found_on_device_ios ;
2026-01-15 20:10:08 -06:00
File ? file ;
File ? livePhotoFile ;
try {
final entity = await _storageRepository . getAssetEntityForAsset ( asset ) ;
if ( entity = = null ) {
2026-08-11 14:41:11 +05:30
callbacks . onError ? . call ( asset . localId ! , assetNotFoundOnDevice ) ;
2026-01-15 20:10:08 -06:00
return ;
}
final isAvailableLocally = await _storageRepository . isAssetAvailableLocally ( asset . id ) ;
if ( ! isAvailableLocally & & CurrentPlatform . isIOS ) {
_logger . info ( " Loading iCloud asset ${ asset . id } - ${ asset . name } " ) ;
// Create progress handler for iCloud download
PMProgressHandler ? progressHandler ;
StreamSubscription ? progressSubscription ;
progressHandler = PMProgressHandler ( ) ;
progressSubscription = progressHandler . stream . listen ( ( event ) {
callbacks . onICloudProgress ? . call ( asset . localId ! , event . progress ) ;
} ) ;
try {
file = await _storageRepository . loadFileFromCloud ( asset . id , progressHandler: progressHandler ) ;
if ( entity . isLivePhoto ) {
livePhotoFile = await _storageRepository . loadMotionFileFromCloud (
asset . id ,
progressHandler: progressHandler ,
) ;
}
} finally {
await progressSubscription . cancel ( ) ;
}
} else {
// Get files locally
file = await _storageRepository . getFileForAsset ( asset . id ) ;
if ( file = = null ) {
2026-01-27 10:33:44 -06:00
_logger . warning ( " Failed to get file ${ asset . id } - ${ asset . name } " ) ;
2026-08-11 14:41:11 +05:30
callbacks . onError ? . call ( asset . localId ! , assetNotFoundOnDevice ) ;
2026-01-15 20:10:08 -06:00
return ;
}
// For live photos, get the motion video file
if ( entity . isLivePhoto ) {
livePhotoFile = await _storageRepository . getMotionFileForAsset ( asset ) ;
if ( livePhotoFile = = null ) {
_logger . warning ( " Failed to obtain motion part of the livePhoto - ${ asset . name } " ) ;
2026-08-11 14:41:11 +05:30
callbacks . onError ? . call ( asset . localId ! , assetNotFoundOnDevice ) ;
2026-01-15 20:10:08 -06:00
}
}
}
if ( file = = null ) {
2026-01-27 10:33:44 -06:00
_logger . warning ( " Failed to obtain file from iCloud for asset ${ asset . id } - ${ asset . name } " ) ;
2026-08-11 14:41:11 +05:30
callbacks . onError ? . call ( asset . localId ! , t . asset_not_found_on_icloud ) ;
2026-01-15 20:10:08 -06:00
return ;
}
2026-07-22 14:59:54 +02:00
final fileName = await _assetMediaRepository . getOriginalFilename ( asset . id ) ? ? asset . name ;
// Some apps (e.g. DJI/Fusion) return names without an extension; fall back to the asset name for those.
final extension = p . extension ( file . path ) . isNotEmpty ? p . extension ( file . path ) : p . extension ( asset . name ) ;
final originalFileName = p . setExtension ( fileName , extension ) ;
2026-05-13 09:36:16 -05:00
final deviceId = Store . get ( StoreKey . deviceId ) ;
2026-01-15 20:10:08 -06:00
final fields = {
2026-05-13 09:36:16 -05:00
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
' deviceAssetId ' : asset . localId ! ,
' deviceId ' : deviceId ,
2026-01-15 20:10:08 -06:00
' fileCreatedAt ' : asset . createdAt . toUtc ( ) . toIso8601String ( ) ,
' fileModifiedAt ' : asset . updatedAt . toUtc ( ) . toIso8601String ( ) ,
' isFavorite ' : asset . isFavorite . toString ( ) ,
2026-05-11 14:35:10 -07:00
' duration ' : ( asset . durationMs ? ? 0 ) . toString ( ) ,
2026-01-15 20:10:08 -06:00
} ;
// Upload live photo video first if available
String ? livePhotoVideoId ;
if ( entity . isLivePhoto & & livePhotoFile ! = null ) {
final livePhotoTitle = p . setExtension ( originalFileName , p . extension ( livePhotoFile . path ) ) ;
2026-03-05 12:04:45 -05:00
final onProgress = callbacks . onProgress ;
2026-01-15 20:10:08 -06:00
final livePhotoResult = await _uploadRepository . uploadFile (
file: livePhotoFile ,
originalFileName: livePhotoTitle ,
2026-07-07 19:57:39 +05:30
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
2026-07-21 02:06:23 +02:00
fields: { . . . fields , ' visibility ' : AssetVisibility . hidden . toString ( ) } ,
2026-01-15 20:10:08 -06:00
cancelToken: cancelToken ,
2026-03-05 12:04:45 -05:00
onProgress: onProgress ! = null
? ( bytes , totalBytes ) = > onProgress ( asset . localId ! , livePhotoTitle , bytes , totalBytes )
: null ,
2026-01-15 20:10:08 -06:00
logContext: ' livePhotoVideo[ ${ asset . localId } ] ' ,
) ;
if ( livePhotoResult . isSuccess & & livePhotoResult . remoteAssetId ! = null ) {
livePhotoVideoId = livePhotoResult . remoteAssetId ;
}
}
if ( livePhotoVideoId ! = null ) {
fields [ ' livePhotoVideoId ' ] = livePhotoVideoId ;
}
2026-01-21 07:58:32 -06:00
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
if ( CurrentPlatform . isIOS & & asset . cloudId ! = null ) {
fields [ ' metadata ' ] = jsonEncode ( [
RemoteAssetMetadataItem (
key: RemoteAssetMetadataKey . mobileApp ,
value: RemoteAssetMobileAppMetadata (
cloudId: asset . cloudId ,
createdAt: asset . createdAt . toIso8601String ( ) ,
adjustmentTime: asset . adjustmentTime ? . toIso8601String ( ) ,
latitude: asset . latitude ? . toString ( ) ,
longitude: asset . longitude ? . toString ( ) ,
) ,
) ,
] ) ;
}
2026-03-05 12:04:45 -05:00
final onProgress = callbacks . onProgress ;
2026-01-15 20:10:08 -06:00
final result = await _uploadRepository . uploadFile (
file: file ,
originalFileName: originalFileName ,
fields: fields ,
cancelToken: cancelToken ,
2026-03-05 12:04:45 -05:00
onProgress: onProgress ! = null
? ( bytes , totalBytes ) = > onProgress ( asset . localId ! , originalFileName , bytes , totalBytes )
: null ,
2026-01-15 20:10:08 -06:00
logContext: ' asset[ ${ asset . localId } ] ' ,
) ;
if ( result . isSuccess & & result . remoteAssetId ! = null ) {
callbacks . onSuccess ? . call ( asset . localId ! , result . remoteAssetId ! ) ;
} else if ( result . isCancelled ) {
shouldAbortUpload = true ;
} else if ( result . errorMessage ! = null ) {
_logger . severe (
( ) = >
" Error( ${ result . statusCode } ) uploading ${ asset . localId } | $ originalFileName | Created on ${ asset . createdAt } | ${ result . errorMessage } " ,
) ;
callbacks . onError ? . call ( asset . localId ! , result . errorMessage ! ) ;
if ( result . errorMessage = = " Quota has been exceeded! " ) {
shouldAbortUpload = true ;
}
}
} catch ( error , stackTrace ) {
2026-07-30 01:56:46 -07:00
_logger . severe ( ( ) = > " Error backup asset: $ error " , stackTrace ) ;
2026-01-15 20:10:08 -06:00
callbacks . onError ? . call ( asset . localId ! , error . toString ( ) ) ;
} finally {
if ( Platform . isIOS ) {
try {
await file ? . delete ( ) ;
await livePhotoFile ? . delete ( ) ;
} catch ( error , stackTrace ) {
2026-07-30 01:56:46 -07:00
_logger . severe ( ( ) = > " ERROR deleting file: $ error " , stackTrace ) ;
2026-01-15 20:10:08 -06:00
}
}
}
}
Future < UploadResult > _uploadSingleFile (
File file , {
required String deviceAssetId ,
2026-03-05 12:04:45 -05:00
required Completer < void > ? cancelToken ,
2026-01-15 20:10:08 -06:00
void Function ( int bytes , int totalBytes ) ? onProgress ,
} ) async {
try {
2026-07-30 12:15:35 -07:00
// ignore: avoid_slow_async_io
2026-01-15 20:10:08 -06:00
final stats = await file . stat ( ) ;
final fileCreatedAt = stats . changed ;
final fileModifiedAt = stats . modified ;
final filename = p . basename ( file . path ) ;
final fields = {
2026-05-13 09:36:16 -05:00
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
' deviceAssetId ' : deviceAssetId ,
' deviceId ' : Store . get ( StoreKey . deviceId ) ,
2026-01-15 20:10:08 -06:00
' fileCreatedAt ' : fileCreatedAt . toUtc ( ) . toIso8601String ( ) ,
' fileModifiedAt ' : fileModifiedAt . toUtc ( ) . toIso8601String ( ) ,
' isFavorite ' : ' false ' ,
' duration ' : ' 0 ' ,
} ;
return await _uploadRepository . uploadFile (
file: file ,
originalFileName: filename ,
fields: fields ,
cancelToken: cancelToken ,
2026-03-05 12:04:45 -05:00
onProgress: onProgress ,
2026-01-15 20:10:08 -06:00
logContext: ' shareIntent[ $ deviceAssetId ] ' ,
) ;
} catch ( e ) {
return UploadResult . error ( errorMessage: e . toString ( ) ) ;
}
}
bool _shouldRequireWiFi ( LocalAsset asset ) {
2026-05-30 20:57:55 +05:30
final backup = SettingsRepository . instance . appConfig . backup ;
2026-05-19 00:40:10 +05:30
if ( asset . isVideo & & backup . useCellularForVideos ) {
return false ;
2026-01-15 20:10:08 -06:00
}
2026-05-19 00:40:10 +05:30
if ( ! asset . isVideo & & backup . useCellularForPhotos ) {
return false ;
}
return true ;
2026-01-15 20:10:08 -06:00
}
}