use checksum for asset restoration

refactro code
This commit is contained in:
Peter Ombodi 2025-09-08 14:29:59 +03:00
parent b8274c9ed4
commit 40ac65db46
5 changed files with 82 additions and 40 deletions

View file

@ -24,6 +24,7 @@ import java.security.MessageDigest
import java.io.FileInputStream import java.io.FileInputStream
import kotlinx.coroutines.* import kotlinx.coroutines.*
import androidx.core.net.toUri import androidx.core.net.toUri
import java.io.InputStream
/** /**
* Android plugin for Dart `BackgroundService` and file trash operations * Android plugin for Dart `BackgroundService` and file trash operations
@ -155,14 +156,15 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
"restoreFromTrash" -> { "restoreFromTrash" -> {
val fileName = call.argument<String>("fileName") val fileName = call.argument<String>("fileName")
val type = call.argument<Int>("type") val type = call.argument<Int>("type")
if (fileName != null && type != null) { val checksum = call.argument<String>("checksum")
if (fileName != null && type != null && checksum != null) {
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) { if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
restoreFromTrash(fileName, type, result) restoreFromTrash(fileName, type, checksum, result)
} else { } else {
result.error("PERMISSION_DENIED", "Media permission required", null) result.error("PERMISSION_DENIED", "Media permission required", null)
} }
} else { } 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) @RequiresApi(Build.VERSION_CODES.R)
private fun restoreFromTrash(name: String, type: Int, result: Result) { private fun restoreFromTrash(name: String, type: Int, checksum: String, result: Result) {
val uri = getTrashedFileUri(name, type) val uri = getTrashedFileUri(name, type, checksum)
if (uri == null) { 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) result.error("TrashError", "Asset Uri cannot be found obtained", null)
return 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) } 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) result.error("TrashError", "Activity or ContentResolver not available", null)
return return
} }
try { try {
val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed) val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed)
pendingResult = result // Store for onActivityResult pendingResult = result // Store for onActivityResult
@ -241,39 +242,68 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
null, 0, 0, 0 null, 0, 0, 0
) )
} catch (e: Exception) { } 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) result.error("TrashError", "Error creating or starting trash request", null)
} }
} }
@RequiresApi(Build.VERSION_CODES.R) @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 contentResolver = context?.contentResolver ?: return null
val queryUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) 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 { val queryArgs = Bundle().apply {
putString( putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns.DISPLAY_NAME} LIKE ?")
ContentResolver.QUERY_ARG_SQL_SELECTION, putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(nameLike))
"${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?"
)
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName))
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY) 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 -> contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor ->
if (cursor.moveToFirst()) { val idxId = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)) val idxName = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME)
// same order as AssetType from dart
val contentUri = when (type) { while (cursor.moveToNext()) {
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI val id = cursor.getLong(idxId)
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI val dn = cursor.getString(idxName)
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
else -> queryUri 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 return null
} }
@ -315,6 +345,18 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
} }
return false 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" private const val TAG = "BackgroundServicePlugin"

View file

@ -52,20 +52,16 @@ class TrashSyncService {
if (trashedAssetsChecksums.isEmpty) { if (trashedAssetsChecksums.isEmpty) {
return Future.value(); return Future.value();
} else { } else {
final candidatesToTrash = await _localAssetRepository.getByChecksums(trashedAssetsChecksums); final localAssetsToTrash = await _localAssetRepository.getByChecksums(trashedAssetsChecksums);
if (candidatesToTrash.isNotEmpty) { if (localAssetsToTrash.isNotEmpty) {
final groupedByChecksum = <String, LocalAsset>{};
for (final localAsset in candidatesToTrash) {
groupedByChecksum[localAsset.checksum!] = localAsset;
}
final mediaUrls = await Future.wait( final mediaUrls = await Future.wait(
groupedByChecksum.values.map( localAssetsToTrash.map(
(localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl()), (localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl()),
), ),
); );
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets"); _logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList()); 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, isTrashed: true,
); );
if (remoteAssetsToRestore.isNotEmpty) { 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) { for (RemoteAsset asset in remoteAssetsToRestore) {
await _localFilesManager.restoreFromTrash(asset.name, asset.type.index); await _localFilesManager.restoreFromTrash(asset.name, asset.type.index, asset.checksum!);
} }
} }
} }

View file

@ -14,8 +14,8 @@ class LocalFilesManagerRepository {
return await _service.moveToTrash(mediaUrls); return await _service.moveToTrash(mediaUrls);
} }
Future<bool> restoreFromTrash(String fileName, int type) async { Future<bool> restoreFromTrash(String fileName, int type, String checksum) async {
return await _service.restoreFromTrash(fileName, type); return await _service.restoreFromTrash(fileName, type, checksum);
} }
Future<bool> requestManageMediaPermission() async { Future<bool> requestManageMediaPermission() async {

View file

@ -18,9 +18,13 @@ class LocalFilesManagerService {
} }
} }
Future<bool> restoreFromTrash(String fileName, int type) async { Future<bool> restoreFromTrash(String fileName, int type, String checksum) async {
try { try {
return await _channel.invokeMethod('restoreFromTrash', {'fileName': fileName, 'type': type}); return await _channel.invokeMethod('restoreFromTrash', {
'fileName': fileName,
'type': type,
'checksum': checksum,
});
} catch (e, s) { } catch (e, s) {
_logger.warning('Error restore file from trash', e, s); _logger.warning('Error restore file from trash', e, s);
return false; return false;

View file

@ -689,7 +689,7 @@ class SyncService {
} }
trashMediaUrls.add(mediaUrl); trashMediaUrls.add(mediaUrl);
} else { } else {
await _localFilesManager.restoreFromTrash(asset.fileName, asset.type.index); await _localFilesManager.restoreFromTrash(asset.fileName, asset.type.index, asset.checksum);
} }
} }