diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt index ae2ec22a71..90052e856c 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt @@ -24,6 +24,7 @@ import java.security.MessageDigest import java.io.FileInputStream import kotlinx.coroutines.* import androidx.core.net.toUri +import java.io.InputStream /** * Android plugin for Dart `BackgroundService` and file trash operations @@ -155,14 +156,15 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, "restoreFromTrash" -> { val fileName = call.argument("fileName") val type = call.argument("type") - if (fileName != null && type != null) { + val checksum = call.argument("checksum") + if (fileName != null && type != null && checksum != null) { if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) { - restoreFromTrash(fileName, type, result) + restoreFromTrash(fileName, type, checksum, result) } else { result.error("PERMISSION_DENIED", "Media permission required", null) } } else { - result.error("INVALID_NAME", "The file name is not specified.", null) + result.error("INVALID_NAME", "The file name or checksum is not specified.", null) } } @@ -212,14 +214,14 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, } @RequiresApi(Build.VERSION_CODES.R) - private fun restoreFromTrash(name: String, type: Int, result: Result) { - val uri = getTrashedFileUri(name, type) + private fun restoreFromTrash(name: String, type: Int, checksum: String, result: Result) { + val uri = getTrashedFileUri(name, type, checksum) if (uri == null) { - Log.e("TrashError", "Asset Uri cannot be found obtained") + Log.e(TAG, "TrashError, Asset Uri cannot be found obtained") result.error("TrashError", "Asset Uri cannot be found obtained", null) return } - Log.e("FILE_URI", uri.toString()) + Log.i(TAG, "trying to restore from trash FILE_URI - $uri") uri.let { toggleTrash(listOf(it), false, result) } } @@ -231,7 +233,6 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, result.error("TrashError", "Activity or ContentResolver not available", null) return } - try { val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed) pendingResult = result // Store for onActivityResult @@ -241,39 +242,68 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, null, 0, 0, 0 ) } catch (e: Exception) { - Log.e("TrashError", "Error creating or starting trash request", e) + Log.e(TAG, "TrashError, Error creating or starting trash request", e) result.error("TrashError", "Error creating or starting trash request", null) } } @RequiresApi(Build.VERSION_CODES.R) - private fun getTrashedFileUri(fileName: String, type: Int): Uri? { + private fun getTrashedFileUri(fileName: String, type: Int, checksum: String): Uri? { val contentResolver = context?.contentResolver ?: return null val queryUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) - val projection = arrayOf(MediaStore.Files.FileColumns._ID) + val projection = arrayOf( + MediaStore.Files.FileColumns._ID, + MediaStore.Files.FileColumns.DISPLAY_NAME + ) + + val dotIndex = fileName.lastIndexOf('.') + val baseName = if (dotIndex != -1) fileName.substring(0, dotIndex) else fileName + val extension = if (dotIndex != -1) fileName.substring(dotIndex) else "" + val nameLike = "%${baseName}%${extension}" val queryArgs = Bundle().apply { - putString( - ContentResolver.QUERY_ARG_SQL_SELECTION, - "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?" - ) - putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName)) + putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns.DISPLAY_NAME} LIKE ?") + putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(nameLike)) putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY) } + val expectedBytes = try { + android.util.Base64.decode(checksum, android.util.Base64.DEFAULT) + } catch (e: Exception) { + Log.e(TAG, "getTrashedFileUri, invalid Base64 checksum: $checksum, Exception: $e") + return null + } + contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)) - // same order as AssetType from dart - val contentUri = when (type) { - 1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI - 2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI - 3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI - else -> queryUri + val idxId = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID) + val idxName = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME) + + while (cursor.moveToNext()) { + val id = cursor.getLong(idxId) + val dn = cursor.getString(idxName) + + val candidateUri = ContentUris.withAppendedId(queryUri, id) + + val sha1Bytes = try { + contentResolver.openInputStream(candidateUri)?.use { ins -> sha1OfStream(ins) } + } catch (e: Exception) { + Log.e(TAG, "getTrashedFileUri, hash failed for $dn: ${e.message}") + null + } ?: continue + + if (sha1Bytes.contentEquals(expectedBytes)) { + // same order as AssetType from dart + val contentUri = when (type) { + 1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI + 2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI + 3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + else -> queryUri + } + return ContentUris.withAppendedId(contentUri, id) } - return ContentUris.withAppendedId(contentUri, id) } } + Log.w(TAG, "getTrashedFileUri, not found by checksum, nameLike=$nameLike, checksum: $checksum") return null } @@ -315,6 +345,18 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, } return false } + + private fun sha1OfStream(ins: InputStream): ByteArray { + val buf = ByteArray(BUFFER_SIZE) + val md = MessageDigest.getInstance("SHA-1") + var len: Int + while (true) { + len = ins.read(buf) + if (len <= 0) break + md.update(buf, 0, len) + } + return md.digest() + } } private const val TAG = "BackgroundServicePlugin" diff --git a/mobile/lib/domain/services/trash_sync.service.dart b/mobile/lib/domain/services/trash_sync.service.dart index d6fdc9c5e0..ddbbec7f4f 100644 --- a/mobile/lib/domain/services/trash_sync.service.dart +++ b/mobile/lib/domain/services/trash_sync.service.dart @@ -52,20 +52,16 @@ class TrashSyncService { if (trashedAssetsChecksums.isEmpty) { return Future.value(); } else { - final candidatesToTrash = await _localAssetRepository.getByChecksums(trashedAssetsChecksums); - if (candidatesToTrash.isNotEmpty) { - final groupedByChecksum = {}; - for (final localAsset in candidatesToTrash) { - groupedByChecksum[localAsset.checksum!] = localAsset; - } + final localAssetsToTrash = await _localAssetRepository.getByChecksums(trashedAssetsChecksums); + if (localAssetsToTrash.isNotEmpty) { final mediaUrls = await Future.wait( - groupedByChecksum.values.map( + localAssetsToTrash.map( (localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl()), ), ); _logger.info("Moving to trash ${mediaUrls.join(", ")} assets"); await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList()); - await _localAssetRepository.delete(candidatesToTrash.map((asset) => asset.id)); + await _localAssetRepository.delete(localAssetsToTrash.map((asset) => asset.id)); } } } @@ -79,9 +75,9 @@ class TrashSyncService { isTrashed: true, ); if (remoteAssetsToRestore.isNotEmpty) { - _logger.info("Restoring from trash ${remoteAssetsToRestore.map((e) => e.name).join(", ")} assets"); + _logger.info("Restoring from trash ${remoteAssetsToRestore.map((e) => "${e.name}/${e.checksum}").join(", ")} assets"); for (RemoteAsset asset in remoteAssetsToRestore) { - await _localFilesManager.restoreFromTrash(asset.name, asset.type.index); + await _localFilesManager.restoreFromTrash(asset.name, asset.type.index, asset.checksum!); } } } diff --git a/mobile/lib/repositories/local_files_manager.repository.dart b/mobile/lib/repositories/local_files_manager.repository.dart index 519d79b49b..fe2a5a2f04 100644 --- a/mobile/lib/repositories/local_files_manager.repository.dart +++ b/mobile/lib/repositories/local_files_manager.repository.dart @@ -14,8 +14,8 @@ class LocalFilesManagerRepository { return await _service.moveToTrash(mediaUrls); } - Future restoreFromTrash(String fileName, int type) async { - return await _service.restoreFromTrash(fileName, type); + Future restoreFromTrash(String fileName, int type, String checksum) async { + return await _service.restoreFromTrash(fileName, type, checksum); } Future requestManageMediaPermission() async { diff --git a/mobile/lib/services/local_files_manager.service.dart b/mobile/lib/services/local_files_manager.service.dart index 7cb3067342..cb05f44a85 100644 --- a/mobile/lib/services/local_files_manager.service.dart +++ b/mobile/lib/services/local_files_manager.service.dart @@ -18,9 +18,13 @@ class LocalFilesManagerService { } } - Future restoreFromTrash(String fileName, int type) async { + Future restoreFromTrash(String fileName, int type, String checksum) async { try { - return await _channel.invokeMethod('restoreFromTrash', {'fileName': fileName, 'type': type}); + return await _channel.invokeMethod('restoreFromTrash', { + 'fileName': fileName, + 'type': type, + 'checksum': checksum, + }); } catch (e, s) { _logger.warning('Error restore file from trash', e, s); return false; diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index 7b420413cf..af6892bac8 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -689,7 +689,7 @@ class SyncService { } trashMediaUrls.add(mediaUrl); } else { - await _localFilesManager.restoreFromTrash(asset.fileName, asset.type.index); + await _localFilesManager.restoreFromTrash(asset.fileName, asset.type.index, asset.checksum); } }