From 4aa378ed3a973572c7fcc214141916a318c5428a Mon Sep 17 00:00:00 2001 From: Sebastian Jug Date: Wed, 29 Apr 2026 15:17:08 -0400 Subject: [PATCH 1/2] fix(storage): resolve MediaStore URIs before scoped requests --- .../commons/activities/BaseSimpleActivity.kt | 150 ++++++----- .../commons/asynctasks/CopyMoveTask.kt | 16 +- .../fossify/commons/extensions/Activity.kt | 227 +++++++++------- .../commons/extensions/Context-storage.kt | 244 ++++++++++++++++-- .../org/fossify/commons/models/FileDirItem.kt | 10 +- .../fossify/commons/views/RenamePatternTab.kt | 101 ++++---- .../fossify/commons/views/RenameSimpleTab.kt | 125 ++++----- 7 files changed, 584 insertions(+), 289 deletions(-) diff --git a/commons/src/main/kotlin/org/fossify/commons/activities/BaseSimpleActivity.kt b/commons/src/main/kotlin/org/fossify/commons/activities/BaseSimpleActivity.kt index b9f640ec42..ad73e44a14 100644 --- a/commons/src/main/kotlin/org/fossify/commons/activities/BaseSimpleActivity.kt +++ b/commons/src/main/kotlin/org/fossify/commons/activities/BaseSimpleActivity.kt @@ -63,7 +63,7 @@ import org.fossify.commons.extensions.getColoredDrawableWithColor import org.fossify.commons.extensions.getContrastColor import org.fossify.commons.extensions.getCurrentFormattedDateTime import org.fossify.commons.extensions.getDoesFilePathExist -import org.fossify.commons.extensions.getFileUrisFromFileDirItems +import org.fossify.commons.extensions.resolveMediaStoreUris import org.fossify.commons.extensions.getFirstParentLevel import org.fossify.commons.extensions.getFirstParentPath import org.fossify.commons.extensions.getPermissionString @@ -546,16 +546,22 @@ abstract class BaseSimpleActivity : EdgeToEdgeActivity() { val outputStream = contentResolver.openOutputStream(resultData.data!!) exportSettingsTo(outputStream, configItemsToExport) } else if (requestCode == DELETE_FILE_SDK_30_HANDLER) { - funAfterSdk30Action?.invoke(resultCode == RESULT_OK) + val funAfter = funAfterSdk30Action + funAfterSdk30Action = null + funAfter?.invoke(resultCode == RESULT_OK) } else if (requestCode == RECOVERABLE_SECURITY_HANDLER) { funRecoverableSecurity?.invoke(resultCode == RESULT_OK) funRecoverableSecurity = null } else if (requestCode == UPDATE_FILE_SDK_30_HANDLER) { - funAfterUpdate30File?.invoke(resultCode == RESULT_OK) + val funAfter = funAfterUpdate30File + funAfterUpdate30File = null + funAfter?.invoke(resultCode == RESULT_OK) } else if (requestCode == MANAGE_MEDIA_RC) { funAfterManageMediaPermission?.invoke() } else if (requestCode == TRASH_FILE_SDK_30_HANDLER) { - funAfterTrash30File?.invoke(resultCode == RESULT_OK) + val funAfter = funAfterTrash30File + funAfterTrash30File = null + funAfter?.invoke(resultCode == RESULT_OK) } } @@ -788,16 +794,19 @@ abstract class BaseSimpleActivity : EdgeToEdgeActivity() { @SuppressLint("NewApi") fun deleteSDK30Uris(uris: List, callback: (success: Boolean) -> Unit) { hideKeyboard() - if (isRPlus()) { - funAfterSdk30Action = callback - try { - val deleteRequest = - MediaStore.createDeleteRequest(contentResolver, uris).intentSender - startIntentSenderForResult(deleteRequest, DELETE_FILE_SDK_30_HANDLER, null, 0, 0, 0) - } catch (e: Exception) { - showErrorToast(e) - } - } else { + if (!isRPlus() || uris.isEmpty()) { + callback(false) + return + } + + funAfterSdk30Action = callback + try { + val deleteRequest = + MediaStore.createDeleteRequest(contentResolver, uris).intentSender + startIntentSenderForResult(deleteRequest, DELETE_FILE_SDK_30_HANDLER, null, 0, 0, 0) + } catch (e: Exception) { + funAfterSdk30Action = null + showErrorToast(e) callback(false) } } @@ -809,16 +818,19 @@ abstract class BaseSimpleActivity : EdgeToEdgeActivity() { callback: (success: Boolean) -> Unit ) { hideKeyboard() - if (isRPlus()) { - funAfterTrash30File = callback - try { - val trashRequest = - MediaStore.createTrashRequest(contentResolver, uris, toTrash).intentSender - startIntentSenderForResult(trashRequest, TRASH_FILE_SDK_30_HANDLER, null, 0, 0, 0) - } catch (e: Exception) { - showErrorToast(e) - } - } else { + if (!isRPlus() || uris.isEmpty()) { + callback(false) + return + } + + funAfterTrash30File = callback + try { + val trashRequest = + MediaStore.createTrashRequest(contentResolver, uris, toTrash).intentSender + startIntentSenderForResult(trashRequest, TRASH_FILE_SDK_30_HANDLER, null, 0, 0, 0) + } catch (e: Exception) { + funAfterTrash30File = null + showErrorToast(e) callback(false) } } @@ -829,15 +841,18 @@ abstract class BaseSimpleActivity : EdgeToEdgeActivity() { callback: (success: Boolean) -> Unit ) { hideKeyboard() - if (isRPlus()) { - funAfterUpdate30File = callback - try { - val writeRequest = MediaStore.createWriteRequest(contentResolver, uris).intentSender - startIntentSenderForResult(writeRequest, UPDATE_FILE_SDK_30_HANDLER, null, 0, 0, 0) - } catch (e: Exception) { - showErrorToast(e) - } - } else { + if (!isRPlus() || uris.isEmpty()) { + callback(false) + return + } + + funAfterUpdate30File = callback + try { + val writeRequest = MediaStore.createWriteRequest(contentResolver, uris).intentSender + startIntentSenderForResult(writeRequest, UPDATE_FILE_SDK_30_HANDLER, null, 0, 0, 0) + } catch (e: Exception) { + funAfterUpdate30File = null + showErrorToast(e) callback(false) } } @@ -912,29 +927,41 @@ abstract class BaseSimpleActivity : EdgeToEdgeActivity() { copyMoveCallback = callback var fileCountToCopy = fileDirItems.size - if (isCopyOperation) { - val recycleBinPath = fileDirItems.first().isRecycleBinPath(this) - if (canManageMedia() && !recycleBinPath) { - val fileUris = getFileUrisFromFileDirItems(fileDirItems) + + fun startCopyMoveNow() { + startCopyMove( + files = fileDirItems, + destinationPath = destination, + isCopyOperation = isCopyOperation, + copyPhotoVideoOnly = copyPhotoVideoOnly, + copyHidden = copyHidden + ) + } + + fun requestSdk30UpdateThenStart() { + resolveMediaStoreUris(fileDirItems) { resolution -> + val fileUris = resolution.uris + if (fileUris.isEmpty()) { + startCopyMoveNow() + return@resolveMediaStoreUris + } + updateSDK30Uris(fileUris) { sdk30UriSuccess -> if (sdk30UriSuccess) { - startCopyMove( - files = fileDirItems, - destinationPath = destination, - isCopyOperation = isCopyOperation, - copyPhotoVideoOnly = copyPhotoVideoOnly, - copyHidden = copyHidden - ) + startCopyMoveNow() + } else { + copyMoveListener.copyFailed() } } + } + } + + if (isCopyOperation) { + val recycleBinPath = fileDirItems.first().isRecycleBinPath(this) + if (canManageMedia() && !recycleBinPath) { + requestSdk30UpdateThenStart() } else { - startCopyMove( - files = fileDirItems, - destinationPath = destination, - isCopyOperation = isCopyOperation, - copyPhotoVideoOnly = copyPhotoVideoOnly, - copyHidden = copyHidden - ) + startCopyMoveNow() } } else { if (isPathOnOTG(source) || isPathOnOTG(destination) || isPathOnSD(source) || isPathOnSD( @@ -948,26 +975,9 @@ abstract class BaseSimpleActivity : EdgeToEdgeActivity() { if (safSuccess) { val recycleBinPath = fileDirItems.first().isRecycleBinPath(this) if (canManageMedia() && !recycleBinPath) { - val fileUris = getFileUrisFromFileDirItems(fileDirItems) - updateSDK30Uris(fileUris) { sdk30UriSuccess -> - if (sdk30UriSuccess) { - startCopyMove( - files = fileDirItems, - destinationPath = destination, - isCopyOperation = isCopyOperation, - copyPhotoVideoOnly = copyPhotoVideoOnly, - copyHidden = copyHidden - ) - } - } + requestSdk30UpdateThenStart() } else { - startCopyMove( - files = fileDirItems, - destinationPath = destination, - isCopyOperation = isCopyOperation, - copyPhotoVideoOnly = copyPhotoVideoOnly, - copyHidden = copyHidden - ) + startCopyMoveNow() } } } diff --git a/commons/src/main/kotlin/org/fossify/commons/asynctasks/CopyMoveTask.kt b/commons/src/main/kotlin/org/fossify/commons/asynctasks/CopyMoveTask.kt index 3ce4f6d688..3b3fb0986a 100644 --- a/commons/src/main/kotlin/org/fossify/commons/asynctasks/CopyMoveTask.kt +++ b/commons/src/main/kotlin/org/fossify/commons/asynctasks/CopyMoveTask.kt @@ -323,11 +323,17 @@ class CopyMoveTask( // if we delete multiple files from Downloads folder on Android 11 or 12 without being a Media Management app, show the confirmation dialog just once private fun deleteProtectedFiles() { if (mFileDirItemsToDelete.isNotEmpty()) { - val fileUris = activity.getFileUrisFromFileDirItems(mFileDirItemsToDelete) - activity.deleteSDK30Uris(fileUris) { success -> - if (success) { - mFileDirItemsToDelete.forEach { - activity.deleteFromMediaStore(it.path) + activity.resolveMediaStoreUris(mFileDirItemsToDelete) { resolution -> + val fileUris = resolution.uris + if (fileUris.isEmpty()) { + return@resolveMediaStoreUris + } + + activity.deleteSDK30Uris(fileUris) { success -> + if (success) { + resolution.resolved.forEach { + activity.deleteFromMediaStore(it.fileDirItem.path) + } } } } diff --git a/commons/src/main/kotlin/org/fossify/commons/extensions/Activity.kt b/commons/src/main/kotlin/org/fossify/commons/extensions/Activity.kt index 3acdac26a8..416afe8d76 100644 --- a/commons/src/main/kotlin/org/fossify/commons/extensions/Activity.kt +++ b/commons/src/main/kotlin/org/fossify/commons/extensions/Activity.kt @@ -833,11 +833,30 @@ fun BaseSimpleActivity.deleteFilesBg(files: List, allowDeleteFolder val recycleBinPath = firstFile.isRecycleBinPath(this) if (canManageMedia() && !recycleBinPath && !firstFilePath.doesThisOrParentHaveNoMedia(HashMap(), null)) { - val fileUris = getFileUrisFromFileDirItems(files) + resolveMediaStoreUris(files) { resolution -> + val fileUris = resolution.uris + if (fileUris.isEmpty()) { + ensureBackgroundThread { + deleteFilesCasual(resolution.unresolved, allowDeleteFolder, callback) + } + return@resolveMediaStoreUris + } - deleteSDK30Uris(fileUris) { success -> - runOnUiThread { - callback?.invoke(success) + deleteSDK30Uris(fileUris) { sdk30Success -> + if (!sdk30Success || resolution.unresolved.isEmpty()) { + runOnUiThread { + callback?.invoke(sdk30Success) + } + return@deleteSDK30Uris + } + + ensureBackgroundThread { + deleteFilesCasual(resolution.unresolved, allowDeleteFolder) { casualSuccess -> + runOnUiThread { + callback?.invoke(casualSuccess) + } + } + } } } } else { @@ -864,10 +883,19 @@ private fun BaseSimpleActivity.deleteFilesCasual( if (index == files.lastIndex) { if (isRPlus() && failedFileDirItems.isNotEmpty()) { - val fileUris = getFileUrisFromFileDirItems(failedFileDirItems) - deleteSDK30Uris(fileUris) { success -> - runOnUiThread { - callback?.invoke(success) + resolveMediaStoreUris(failedFileDirItems) { resolution -> + val fileUris = resolution.uris + if (fileUris.isEmpty()) { + runOnUiThread { + callback?.invoke(false) + } + return@resolveMediaStoreUris + } + + deleteSDK30Uris(fileUris) { success -> + runOnUiThread { + callback?.invoke(success) + } } } } else { @@ -955,10 +983,19 @@ fun BaseSimpleActivity.deleteFileBg( } private fun BaseSimpleActivity.deleteSdk30(fileDirItem: FileDirItem, callback: ((wasSuccess: Boolean) -> Unit)?) { - val fileUris = getFileUrisFromFileDirItems(arrayListOf(fileDirItem)) - deleteSDK30Uris(fileUris) { success -> - runOnUiThread { - callback?.invoke(success) + resolveMediaStoreUris(arrayListOf(fileDirItem)) { resolution -> + val fileUris = resolution.uris + if (fileUris.isEmpty()) { + runOnUiThread { + callback?.invoke(false) + } + return@resolveMediaStoreUris + } + + deleteSDK30Uris(fileUris) { success -> + runOnUiThread { + callback?.invoke(success) + } } } } @@ -1132,22 +1169,29 @@ private fun BaseSimpleActivity.renameCasually( if (isRenamingMultipleFiles) { callback?.invoke(false, Android30RenameFormat.CONTENT_RESOLVER) } else { - val fileUris = getFileUrisFromFileDirItems(arrayListOf(File(oldPath).toFileDirItem(this))) - updateSDK30Uris(fileUris) { success -> - if (success) { - val values = ContentValues().apply { - put(MediaStore.Images.Media.DISPLAY_NAME, newPath.getFilenameFromPath()) - } + resolveMediaStoreUris(arrayListOf(File(oldPath).toFileDirItem(this))) { resolution -> + val fileUri = resolution.uris.firstOrNull() + if (fileUri == null) { + callback?.invoke(false, Android30RenameFormat.NONE) + return@resolveMediaStoreUris + } - try { - contentResolver.update(fileUris.first(), values, null, null) - callback?.invoke(true, Android30RenameFormat.NONE) - } catch (e: Exception) { - showErrorToast(e) + updateSDK30Uris(arrayListOf(fileUri)) { success -> + if (success) { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, newPath.getFilenameFromPath()) + } + + try { + contentResolver.update(fileUri, values, null, null) + callback?.invoke(true, Android30RenameFormat.NONE) + } catch (e: Exception) { + showErrorToast(e) + callback?.invoke(false, Android30RenameFormat.NONE) + } + } else { callback?.invoke(false, Android30RenameFormat.NONE) } - } else { - callback?.invoke(false, Android30RenameFormat.NONE) } } } @@ -1198,70 +1242,77 @@ private fun BaseSimpleActivity.renameCasually( if (isRenamingMultipleFiles) { callback?.invoke(false, Android30RenameFormat.SAF) } else { - val fileUris = getFileUrisFromFileDirItems(arrayListOf(File(oldPath).toFileDirItem(this))) - updateSDK30Uris(fileUris) { success -> - if (!success) { - return@updateSDK30Uris + resolveMediaStoreUris(arrayListOf(File(oldPath).toFileDirItem(this))) { resolution -> + val sourceUri = resolution.uris.firstOrNull() + if (sourceUri == null) { + callback?.invoke(false, Android30RenameFormat.NONE) + return@resolveMediaStoreUris } - try { - val sourceUri = fileUris.first() - val sourceFile = File(oldPath).toFileDirItem(this) - - if (oldPath.equals(newPath, true)) { - val tempDestination = try { - createTempFile(File(sourceFile.path)) ?: return@updateSDK30Uris - } catch (exception: Exception) { - showErrorToast(exception) - callback?.invoke(false, Android30RenameFormat.NONE) - return@updateSDK30Uris - } - val copyTempSuccess = copySingleFileSdk30(sourceFile, tempDestination.toFileDirItem(this)) - if (copyTempSuccess) { - contentResolver.delete(sourceUri, null) - tempDestination.renameTo(File(newPath)) - if (!baseConfig.keepLastModified) { - newFile.setLastModified(System.currentTimeMillis()) + updateSDK30Uris(arrayListOf(sourceUri)) { success -> + if (!success) { + callback?.invoke(false, Android30RenameFormat.NONE) + return@updateSDK30Uris + } + + try { + val sourceFile = File(oldPath).toFileDirItem(this) + + if (oldPath.equals(newPath, true)) { + val tempDestination = try { + createTempFile(File(sourceFile.path)) ?: return@updateSDK30Uris + } catch (exception: Exception) { + showErrorToast(exception) + callback?.invoke(false, Android30RenameFormat.NONE) + return@updateSDK30Uris } - updateInMediaStore(oldPath, newPath) - scanPathsRecursively(arrayListOf(newPath)) { - runOnUiThread { - callback?.invoke(true, Android30RenameFormat.NONE) + + val copyTempSuccess = copySingleFileSdk30(sourceFile, tempDestination.toFileDirItem(this)) + if (copyTempSuccess) { + contentResolver.delete(sourceUri, null) + tempDestination.renameTo(File(newPath)) + if (!baseConfig.keepLastModified) { + newFile.setLastModified(System.currentTimeMillis()) + } + updateInMediaStore(oldPath, newPath) + scanPathsRecursively(arrayListOf(newPath)) { + runOnUiThread { + callback?.invoke(true, Android30RenameFormat.NONE) + } } + } else { + callback?.invoke(false, Android30RenameFormat.NONE) } } else { - callback?.invoke(false, Android30RenameFormat.NONE) - } - } else { - val destinationFile = FileDirItem( - newPath, - newPath.getFilenameFromPath(), - sourceFile.isDirectory, - sourceFile.children, - sourceFile.size, - sourceFile.modified - ) - val copySuccessful = copySingleFileSdk30(sourceFile, destinationFile) - if (copySuccessful) { - if (!baseConfig.keepLastModified) { - newFile.setLastModified(System.currentTimeMillis()) - } - contentResolver.delete(sourceUri, null) - updateInMediaStore(oldPath, newPath) - scanPathsRecursively(arrayListOf(newPath)) { - runOnUiThread { - callback?.invoke(true, Android30RenameFormat.NONE) + val destinationFile = FileDirItem( + newPath, + newPath.getFilenameFromPath(), + sourceFile.isDirectory, + sourceFile.children, + sourceFile.size, + sourceFile.modified + ) + val copySuccessful = copySingleFileSdk30(sourceFile, destinationFile) + if (copySuccessful) { + if (!baseConfig.keepLastModified) { + newFile.setLastModified(System.currentTimeMillis()) } + contentResolver.delete(sourceUri, null) + updateInMediaStore(oldPath, newPath) + scanPathsRecursively(arrayListOf(newPath)) { + runOnUiThread { + callback?.invoke(true, Android30RenameFormat.NONE) + } + } + } else { + toast(R.string.unknown_error_occurred) + callback?.invoke(false, Android30RenameFormat.NONE) } - } else { - toast(R.string.unknown_error_occurred) - callback?.invoke(false, Android30RenameFormat.NONE) } + } catch (e: Exception) { + showErrorToast(e) + callback?.invoke(false, Android30RenameFormat.NONE) } - - } catch (e: Exception) { - showErrorToast(e) - callback?.invoke(false, Android30RenameFormat.NONE) } } } @@ -1392,14 +1443,16 @@ fun BaseSimpleActivity.getFileOutputStream(fileDirItem: FileDirItem, allowCreati } isRestrictedWithSAFSdk30(fileDirItem.path) -> { - callback.invoke( - try { - val fileUri = getFileUrisFromFileDirItems(arrayListOf(fileDirItem)) - applicationContext.contentResolver.openOutputStream(fileUri.first(), "wt") - } catch (e: Exception) { - null - } ?: createCasualFileOutputStream(this, targetFile) - ) + resolveMediaStoreUris(arrayListOf(fileDirItem)) { resolution -> + callback.invoke( + try { + val fileUri = resolution.uris.firstOrNull() + fileUri?.let { applicationContext.contentResolver.openOutputStream(it, "wt") } + } catch (e: Exception) { + null + } ?: createCasualFileOutputStream(this, targetFile) + ) + } } else -> { diff --git a/commons/src/main/kotlin/org/fossify/commons/extensions/Context-storage.kt b/commons/src/main/kotlin/org/fossify/commons/extensions/Context-storage.kt index dca8fc2aa3..e32b12337e 100644 --- a/commons/src/main/kotlin/org/fossify/commons/extensions/Context-storage.kt +++ b/commons/src/main/kotlin/org/fossify/commons/extensions/Context-storage.kt @@ -26,13 +26,38 @@ import org.fossify.commons.models.FileDirItem import java.io.* import java.net.URLDecoder import java.util.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger import java.util.regex.Pattern private const val ANDROID_DATA_DIR = "/Android/data/" private const val ANDROID_OBB_DIR = "/Android/obb/" +private const val MEDIA_STORE_SCAN_TIMEOUT_MS = 10_000L +private const val MEDIA_STORE_QUERY_CHUNK_SIZE = 500 val DIRS_ACCESSIBLE_ONLY_WITH_SAF = listOf(ANDROID_DATA_DIR, ANDROID_OBB_DIR) val Context.recycleBinPath: String get() = filesDir.absolutePath +data class ResolvedMediaStoreUri( + val fileDirItem: FileDirItem, + val uri: Uri, +) + +data class MediaStoreUriResolution( + val resolved: List, + val unresolved: List, +) { + val uris: List + get() = resolved.map { it.uri } +} + +private data class MediaStoreUriCandidate( + val fileDirItem: FileDirItem, + val uri: Uri, + val collectionUri: Uri, + val id: Long, +) + // http://stackoverflow.com/a/40582634/1967672 fun Context.getSDCardPath(): String { val directories = getStorageDirectories().filter { @@ -936,28 +961,210 @@ private val physicalPaths = arrayListOf( "/storage/usbdisk2" ) +fun Context.resolveMediaStoreUris( + fileDirItems: List, + timeoutMs: Long = MEDIA_STORE_SCAN_TIMEOUT_MS, + callback: (MediaStoreUriResolution) -> Unit, +) { + val mainHandler = Handler(Looper.getMainLooper()) + if (fileDirItems.isEmpty()) { + mainHandler.post { + callback(MediaStoreUriResolution(emptyList(), emptyList())) + } + return + } + + ensureBackgroundThread { + val initiallyResolved = getVerifiedMediaStoreUrisFromKnownIds(fileDirItems) + val itemsToScan = fileDirItems.filter { + initiallyResolved[it.path.mediaStoreResolutionKey()] == null && it.canScanForMediaStoreUri() + } + val pathsToScan = itemsToScan.map { it.path }.distinctBy { it.mediaStoreResolutionKey() } + + if (pathsToScan.isEmpty()) { + postMediaStoreUriResolution(mainHandler, fileDirItems, initiallyResolved, callback) + return@ensureBackgroundThread + } + + val pathByKey = pathsToScan.associateBy { it.mediaStoreResolutionKey() } + val itemByKey = itemsToScan.distinctBy { it.path.mediaStoreResolutionKey() }.associateBy { it.path.mediaStoreResolutionKey() } + val scannedUris = ConcurrentHashMap() + val remaining = AtomicInteger(pathsToScan.size) + val finished = AtomicBoolean(false) + + lateinit var timeoutRunnable: Runnable + + fun finishScan() { + if (!finished.compareAndSet(false, true)) { + return + } + + mainHandler.removeCallbacks(timeoutRunnable) + ensureBackgroundThread { + val scannedCandidates = scannedUris.mapNotNull { (pathKey, scannedUri) -> + val fileDirItem = itemByKey[pathKey] ?: return@mapNotNull null + val scannedId = scannedUri.getPositiveMediaStoreId() ?: return@mapNotNull null + fileDirItem.toMediaStoreUriCandidate(scannedId) + } + + val resolved = LinkedHashMap(initiallyResolved) + resolved.putAll(verifyMediaStoreUriCandidates(scannedCandidates)) + postMediaStoreUriResolution(mainHandler, fileDirItems, resolved, callback) + } + } + + timeoutRunnable = Runnable { finishScan() } + mainHandler.postDelayed(timeoutRunnable, timeoutMs) + + try { + MediaScannerConnection.scanFile(applicationContext, pathsToScan.toTypedArray(), null) { path, uri -> + if (finished.get()) { + return@scanFile + } + + val pathKey = path?.mediaStoreResolutionKey() + if (pathKey != null && uri != null && pathByKey.containsKey(pathKey)) { + scannedUris[pathKey] = uri + } + + if (remaining.decrementAndGet() == 0) { + finishScan() + } + } + } catch (e: Exception) { + finishScan() + } + } +} + +private fun FileDirItem.canScanForMediaStoreUri(): Boolean { + return !isDirectory && File(path).isFile && path.getMediaStoreCollectionUri() != null +} + +private fun Context.getVerifiedMediaStoreUrisFromKnownIds(fileDirItems: List): LinkedHashMap { + val candidates = fileDirItems.mapNotNull { fileDirItem -> + if (fileDirItem.mediaStoreId > 0) { + fileDirItem.toMediaStoreUriCandidate(fileDirItem.mediaStoreId) + } else { + null + } + } + + return verifyMediaStoreUriCandidates(candidates) +} + +private fun FileDirItem.toMediaStoreUriCandidate(mediaStoreId: Long): MediaStoreUriCandidate? { + if (mediaStoreId <= 0) { + return null + } + + val collectionUri = path.getMediaStoreCollectionUri() ?: return null + val uri = ContentUris.withAppendedId(collectionUri, mediaStoreId) + return MediaStoreUriCandidate(this, uri, collectionUri, mediaStoreId) +} + +private fun String.getMediaStoreCollectionUri(): Uri? = when { + isImageFast() || isGif() || isRawFast() || isSvg() -> Images.Media.EXTERNAL_CONTENT_URI + isVideoFast() -> Video.Media.EXTERNAL_CONTENT_URI + isAudioFast() -> Audio.Media.EXTERNAL_CONTENT_URI + else -> null +} + +private fun Uri.getPositiveMediaStoreId(): Long? { + return try { + ContentUris.parseId(this).takeIf { it > 0 } + } catch (e: Exception) { + null + } +} + +private fun Context.verifyMediaStoreUriCandidates(candidates: List): LinkedHashMap { + val resolved = LinkedHashMap() + candidates.groupBy { it.collectionUri }.forEach { (collectionUri, collectionCandidates) -> + val existingIds = getExistingMediaStoreIds(collectionUri, collectionCandidates.map { it.id }.toSet()) + collectionCandidates.forEach { candidate -> + if (candidate.id in existingIds) { + resolved[candidate.fileDirItem.path.mediaStoreResolutionKey()] = candidate.uri + } + } + } + + return resolved +} + +private fun Context.getExistingMediaStoreIds(collectionUri: Uri, ids: Set): Set { + if (ids.isEmpty()) { + return emptySet() + } + + val existingIds = HashSet() + ids.chunked(MEDIA_STORE_QUERY_CHUNK_SIZE).forEach { chunk -> + val selection = "${MediaColumns._ID} IN (${chunk.joinToString(",") { "?" }})" + val selectionArgs = chunk.map { it.toString() }.toTypedArray() + try { + contentResolver.query(collectionUri, arrayOf(MediaColumns._ID), selection, selectionArgs, null)?.use { cursor -> + if (cursor.moveToFirst()) { + do { + val id = cursor.getLongValue(MediaColumns._ID) + if (id > 0) { + existingIds.add(id) + } + } while (cursor.moveToNext()) + } + } + } catch (e: Exception) { + } + } + + return existingIds +} + +private fun postMediaStoreUriResolution( + mainHandler: Handler, + fileDirItems: List, + resolvedByPath: Map, + callback: (MediaStoreUriResolution) -> Unit, +) { + val resolved = ArrayList() + val unresolved = ArrayList() + + fileDirItems.forEach { fileDirItem -> + val uri = resolvedByPath[fileDirItem.path.mediaStoreResolutionKey()] + if (uri != null) { + resolved.add(ResolvedMediaStoreUri(fileDirItem, uri)) + } else { + unresolved.add(fileDirItem) + } + } + + mainHandler.post { + callback(MediaStoreUriResolution(resolved, unresolved)) + } +} + +private fun String.mediaStoreResolutionKey() = this + // Convert paths like /storage/emulated/0/Pictures/Screenshots/first.jpg to content://media/external/images/media/131799 // so that we can refer to the file in the MediaStore. -// If we found no mediastore uri for a given file, do not return its path either to avoid some mismatching +// If we found no mediastore uri for a given file, do not return its path either to avoid some mismatching. +@Deprecated("Use resolveMediaStoreUris() and handle unresolved files explicitly.") fun Context.getUrisPathsFromFileDirItems(fileDirItems: List): Pair, ArrayList> { + val resolvedByPath = getVerifiedMediaStoreUrisFromKnownIds(fileDirItems) val fileUris = ArrayList() val successfulFilePaths = ArrayList() - val allIds = getMediaStoreIds(this) - val filePaths = fileDirItems.map { it.path } - filePaths.forEach { path -> - for ((filePath, mediaStoreId) in allIds) { - if (filePath.lowercase() == path.lowercase()) { - val baseUri = getFileUri(filePath) - val uri = ContentUris.withAppendedId(baseUri, mediaStoreId) - fileUris.add(uri) - successfulFilePaths.add(path) - } + + fileDirItems.forEach { fileDirItem -> + val uri = resolvedByPath[fileDirItem.path.mediaStoreResolutionKey()] + if (uri != null) { + fileUris.add(uri) + successfulFilePaths.add(fileDirItem.path) } } return Pair(successfulFilePaths, fileUris) } +@Deprecated("_DATA based MediaStore lookups are unreliable on scoped-storage Android versions. Use resolveMediaStoreUris().") fun getMediaStoreIds(context: Context): HashMap { val ids = HashMap() val projection = arrayOf( @@ -971,8 +1178,8 @@ fun getMediaStoreIds(context: Context): HashMap { context.queryCursor(uri, projection) { cursor -> try { val id = cursor.getLongValue(Images.Media._ID) - if (id != 0L) { - val path = cursor.getStringValue(Images.Media.DATA) + val path = cursor.getStringValueOrNull(Images.Media.DATA) + if (id > 0L && path != null) { ids[path] = id } } catch (e: Exception) { @@ -984,15 +1191,10 @@ fun getMediaStoreIds(context: Context): HashMap { return ids } +@Suppress("DEPRECATION") +@Deprecated("Use resolveMediaStoreUris() and handle unresolved files explicitly.") fun Context.getFileUrisFromFileDirItems(fileDirItems: List): List { - val fileUris = getUrisPathsFromFileDirItems(fileDirItems).second - if (fileUris.isEmpty()) { - fileDirItems.map { fileDirItem -> - fileUris.add(fileDirItem.assembleContentUri()) - } - } - - return fileUris + return getUrisPathsFromFileDirItems(fileDirItems).second } fun Context.getDefaultCopyDestinationPath(showHidden: Boolean, currentPath: String): String { diff --git a/commons/src/main/kotlin/org/fossify/commons/models/FileDirItem.kt b/commons/src/main/kotlin/org/fossify/commons/models/FileDirItem.kt index 0430f023ce..4be7c7035e 100644 --- a/commons/src/main/kotlin/org/fossify/commons/models/FileDirItem.kt +++ b/commons/src/main/kotlin/org/fossify/commons/models/FileDirItem.kt @@ -1,5 +1,6 @@ package org.fossify.commons.models +import android.content.ContentUris import android.content.Context import android.net.Uri import android.provider.MediaStore @@ -152,14 +153,19 @@ open class FileDirItem( fun getKey() = ObjectKey(getSignature()) - fun assembleContentUri(): Uri { + @Deprecated("Use Context.resolveMediaStoreUris() and handle unresolved files explicitly.") + fun assembleContentUri(): Uri? { + if (mediaStoreId <= 0) { + return null + } + val uri = when { path.isImageFast() -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI path.isVideoFast() -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI else -> MediaStore.Files.getContentUri("external") } - return Uri.withAppendedPath(uri, mediaStoreId.toString()) + return ContentUris.withAppendedId(uri, mediaStoreId) } } diff --git a/commons/src/main/kotlin/org/fossify/commons/views/RenamePatternTab.kt b/commons/src/main/kotlin/org/fossify/commons/views/RenamePatternTab.kt index fad1312bc2..0bcba9253e 100644 --- a/commons/src/main/kotlin/org/fossify/commons/views/RenamePatternTab.kt +++ b/commons/src/main/kotlin/org/fossify/commons/views/RenamePatternTab.kt @@ -188,61 +188,70 @@ class RenamePatternTab(context: Context, attrs: AttributeSet) : RelativeLayout(c callback: (success: Boolean) -> Unit, ) { val fileDirItems = paths.map { File(it).toFileDirItem(context) } - val uriPairs = context.getUrisPathsFromFileDirItems(fileDirItems) - val validPaths = uriPairs.first - val uris = uriPairs.second - val activity = activity - activity?.updateSDK30Uris(uris) { success -> - if (success) { - try { - uris.forEachIndexed { index, uri -> - val path = validPaths[index] - val newFileName = getNewPath(path, useMediaFileExtension)?.getFilenameFromPath() ?: return@forEachIndexed - when (android30Format) { - Android30RenameFormat.SAF -> { - val sourceFile = File(path).toFileDirItem(context) - val newPath = "${path.getParentPath()}/$newFileName" - val destinationFile = FileDirItem( - newPath, - newFileName, - sourceFile.isDirectory, - sourceFile.children, - sourceFile.size, - sourceFile.modified - ) - if (activity.copySingleFileSdk30(sourceFile, destinationFile)) { - if (!activity.baseConfig.keepLastModified) { - File(newPath).setLastModified(System.currentTimeMillis()) + val activity = activity ?: return + context.resolveMediaStoreUris(fileDirItems) { resolution -> + if (resolution.unresolved.isNotEmpty()) { + activity.toast(R.string.unknown_error_occurred) + callback(false) + return@resolveMediaStoreUris + } + + val resolved = resolution.resolved + activity.updateSDK30Uris(resolution.uris) { success -> + if (success) { + try { + resolved.forEach { resolvedUri -> + val path = resolvedUri.fileDirItem.path + val uri = resolvedUri.uri + val newFileName = getNewPath(path, useMediaFileExtension)?.getFilenameFromPath() ?: return@forEach + when (android30Format) { + Android30RenameFormat.SAF -> { + val sourceFile = File(path).toFileDirItem(context) + val newPath = "${path.getParentPath()}/$newFileName" + val destinationFile = FileDirItem( + newPath, + newFileName, + sourceFile.isDirectory, + sourceFile.children, + sourceFile.size, + sourceFile.modified + ) + if (activity.copySingleFileSdk30(sourceFile, destinationFile)) { + if (!activity.baseConfig.keepLastModified) { + File(newPath).setLastModified(System.currentTimeMillis()) + } + activity.contentResolver.delete(uri, null) + activity.updateInMediaStore(path, newPath) + activity.scanPathsRecursively(arrayListOf(newPath)) } - activity.contentResolver.delete(uri, null) - activity.updateInMediaStore(path, newPath) - activity.scanPathsRecursively(arrayListOf(newPath)) } - } - Android30RenameFormat.CONTENT_RESOLVER -> { - val values = ContentValues().apply { - put(MediaStore.Images.Media.DISPLAY_NAME, newFileName) + Android30RenameFormat.CONTENT_RESOLVER -> { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, newFileName) + } + context.contentResolver.update(uri, values, null, null) } - context.contentResolver.update(uri, values, null, null) - } - Android30RenameFormat.NONE -> { - activity.runOnUiThread { - callback(true) + Android30RenameFormat.NONE -> { + activity.runOnUiThread { + callback(true) + } + return@forEach } - return@forEachIndexed } } + activity.runOnUiThread { + callback(true) + } + } catch (e: Exception) { + activity.runOnUiThread { + activity.showErrorToast(e) + callback(false) + } } - activity.runOnUiThread { - callback(true) - } - } catch (e: Exception) { - activity.runOnUiThread { - activity.showErrorToast(e) - callback(false) - } + } else { + callback(false) } } } diff --git a/commons/src/main/kotlin/org/fossify/commons/views/RenameSimpleTab.kt b/commons/src/main/kotlin/org/fossify/commons/views/RenameSimpleTab.kt index 3b02f05b6c..bdfc9d5d3a 100644 --- a/commons/src/main/kotlin/org/fossify/commons/views/RenameSimpleTab.kt +++ b/commons/src/main/kotlin/org/fossify/commons/views/RenameSimpleTab.kt @@ -122,76 +122,85 @@ class RenameSimpleTab(context: Context, attrs: AttributeSet) : RelativeLayout(co callback: (success: Boolean) -> Unit ) { val fileDirItems = paths.map { File(it).toFileDirItem(context) } - val uriPairs = context.getUrisPathsFromFileDirItems(fileDirItems) - val validPaths = uriPairs.first - val uris = uriPairs.second - val activity = activity - activity?.updateSDK30Uris(uris) { success -> - if (success) { - try { - uris.forEachIndexed { index, uri -> - val path = validPaths[index] - - val fullName = path.getFilenameFromPath() - var dotAt = fullName.lastIndexOf(".") - if (dotAt == -1) { - dotAt = fullName.length - } + val activity = activity ?: return + context.resolveMediaStoreUris(fileDirItems) { resolution -> + if (resolution.unresolved.isNotEmpty()) { + activity.toast(R.string.unknown_error_occurred) + callback(false) + return@resolveMediaStoreUris + } - val name = fullName.substring(0, dotAt) - val extension = if (fullName.contains(".")) ".${fullName.getFilenameExtension()}" else "" + val resolved = resolution.resolved + activity.updateSDK30Uris(resolution.uris) { success -> + if (success) { + try { + resolved.forEach { resolvedUri -> + val path = resolvedUri.fileDirItem.path + val uri = resolvedUri.uri + + val fullName = path.getFilenameFromPath() + var dotAt = fullName.lastIndexOf(".") + if (dotAt == -1) { + dotAt = fullName.length + } - val newName = if (appendString) { - "$name$stringToAdd$extension" - } else { - "$stringToAdd$fullName" - } + val name = fullName.substring(0, dotAt) + val extension = if (fullName.contains(".")) ".${fullName.getFilenameExtension()}" else "" + + val newName = if (appendString) { + "$name$stringToAdd$extension" + } else { + "$stringToAdd$fullName" + } - when (android30Format) { - Android30RenameFormat.SAF -> { - val sourceFile = File(path).toFileDirItem(activity) - val newPath = "${path.getParentPath()}/$newName" - val destinationFile = FileDirItem( - newPath, - newName, - sourceFile.isDirectory, - sourceFile.children, - sourceFile.size, - sourceFile.modified - ) - if (activity.copySingleFileSdk30(sourceFile, destinationFile)) { - if (!activity.baseConfig.keepLastModified) { - File(newPath).setLastModified(System.currentTimeMillis()) + when (android30Format) { + Android30RenameFormat.SAF -> { + val sourceFile = File(path).toFileDirItem(activity) + val newPath = "${path.getParentPath()}/$newName" + val destinationFile = FileDirItem( + newPath, + newName, + sourceFile.isDirectory, + sourceFile.children, + sourceFile.size, + sourceFile.modified + ) + if (activity.copySingleFileSdk30(sourceFile, destinationFile)) { + if (!activity.baseConfig.keepLastModified) { + File(newPath).setLastModified(System.currentTimeMillis()) + } + activity.contentResolver.delete(uri, null) + activity.updateInMediaStore(path, newPath) + activity.scanPathsRecursively(arrayListOf(newPath)) } - activity.contentResolver.delete(uri, null) - activity.updateInMediaStore(path, newPath) - activity.scanPathsRecursively(arrayListOf(newPath)) } - } - Android30RenameFormat.CONTENT_RESOLVER -> { - val values = ContentValues().apply { - put(MediaStore.Images.Media.DISPLAY_NAME, newName) + Android30RenameFormat.CONTENT_RESOLVER -> { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, newName) + } + context.contentResolver.update(uri, values, null, null) } - context.contentResolver.update(uri, values, null, null) - } - Android30RenameFormat.NONE -> { - activity.runOnUiThread { - callback(true) + Android30RenameFormat.NONE -> { + activity.runOnUiThread { + callback(true) + } + return@forEach } - return@forEachIndexed } } + activity.runOnUiThread { + callback(true) + } + } catch (e: Exception) { + activity.runOnUiThread { + activity.showErrorToast(e) + callback(false) + } } - activity.runOnUiThread { - callback(true) - } - } catch (e: Exception) { - activity.runOnUiThread { - activity.showErrorToast(e) - callback(false) - } + } else { + callback(false) } } } From 7fcaa532f38514a8e6695ad16aa1c2cfc740d404 Mon Sep 17 00:00:00 2001 From: Sebastian Jug Date: Wed, 29 Apr 2026 15:37:49 -0400 Subject: [PATCH 2/2] style: satisfy detekt for scoped storage changes --- .../fossify/commons/extensions/Activity.kt | 5 +- .../commons/extensions/Context-storage.kt | 28 ++--- .../fossify/commons/views/RenamePatternTab.kt | 3 +- .../fossify/commons/views/RenameSimpleTab.kt | 118 +++++++++--------- 4 files changed, 79 insertions(+), 75 deletions(-) diff --git a/commons/src/main/kotlin/org/fossify/commons/extensions/Activity.kt b/commons/src/main/kotlin/org/fossify/commons/extensions/Activity.kt index 416afe8d76..9827842a15 100644 --- a/commons/src/main/kotlin/org/fossify/commons/extensions/Activity.kt +++ b/commons/src/main/kotlin/org/fossify/commons/extensions/Activity.kt @@ -1267,7 +1267,10 @@ private fun BaseSimpleActivity.renameCasually( return@updateSDK30Uris } - val copyTempSuccess = copySingleFileSdk30(sourceFile, tempDestination.toFileDirItem(this)) + val copyTempSuccess = copySingleFileSdk30( + sourceFile, + tempDestination.toFileDirItem(this) + ) if (copyTempSuccess) { contentResolver.delete(sourceUri, null) tempDestination.renameTo(File(newPath)) diff --git a/commons/src/main/kotlin/org/fossify/commons/extensions/Context-storage.kt b/commons/src/main/kotlin/org/fossify/commons/extensions/Context-storage.kt index e32b12337e..4274c6d1ce 100644 --- a/commons/src/main/kotlin/org/fossify/commons/extensions/Context-storage.kt +++ b/commons/src/main/kotlin/org/fossify/commons/extensions/Context-storage.kt @@ -987,7 +987,9 @@ fun Context.resolveMediaStoreUris( } val pathByKey = pathsToScan.associateBy { it.mediaStoreResolutionKey() } - val itemByKey = itemsToScan.distinctBy { it.path.mediaStoreResolutionKey() }.associateBy { it.path.mediaStoreResolutionKey() } + val itemByKey = itemsToScan + .distinctBy { it.path.mediaStoreResolutionKey() } + .associateBy { it.path.mediaStoreResolutionKey() } val scannedUris = ConcurrentHashMap() val remaining = AtomicInteger(pathsToScan.size) val finished = AtomicBoolean(false) @@ -1078,7 +1080,9 @@ private fun Uri.getPositiveMediaStoreId(): Long? { } } -private fun Context.verifyMediaStoreUriCandidates(candidates: List): LinkedHashMap { +private fun Context.verifyMediaStoreUriCandidates( + candidates: List, +): LinkedHashMap { val resolved = LinkedHashMap() candidates.groupBy { it.collectionUri }.forEach { (collectionUri, collectionCandidates) -> val existingIds = getExistingMediaStoreIds(collectionUri, collectionCandidates.map { it.id }.toSet()) @@ -1101,18 +1105,11 @@ private fun Context.getExistingMediaStoreIds(collectionUri: Uri, ids: Set) ids.chunked(MEDIA_STORE_QUERY_CHUNK_SIZE).forEach { chunk -> val selection = "${MediaColumns._ID} IN (${chunk.joinToString(",") { "?" }})" val selectionArgs = chunk.map { it.toString() }.toTypedArray() - try { - contentResolver.query(collectionUri, arrayOf(MediaColumns._ID), selection, selectionArgs, null)?.use { cursor -> - if (cursor.moveToFirst()) { - do { - val id = cursor.getLongValue(MediaColumns._ID) - if (id > 0) { - existingIds.add(id) - } - } while (cursor.moveToNext()) - } + queryCursor(collectionUri, arrayOf(MediaColumns._ID), selection, selectionArgs) { cursor -> + val id = cursor.getLongValue(MediaColumns._ID) + if (id > 0) { + existingIds.add(id) } - } catch (e: Exception) { } } @@ -1164,7 +1161,10 @@ fun Context.getUrisPathsFromFileDirItems(fileDirItems: List): Pair< return Pair(successfulFilePaths, fileUris) } -@Deprecated("_DATA based MediaStore lookups are unreliable on scoped-storage Android versions. Use resolveMediaStoreUris().") +@Deprecated( + "_DATA based MediaStore lookups are unreliable on scoped-storage Android versions. " + + "Use resolveMediaStoreUris()." +) fun getMediaStoreIds(context: Context): HashMap { val ids = HashMap() val projection = arrayOf( diff --git a/commons/src/main/kotlin/org/fossify/commons/views/RenamePatternTab.kt b/commons/src/main/kotlin/org/fossify/commons/views/RenamePatternTab.kt index 0bcba9253e..bb31d0f5ac 100644 --- a/commons/src/main/kotlin/org/fossify/commons/views/RenamePatternTab.kt +++ b/commons/src/main/kotlin/org/fossify/commons/views/RenamePatternTab.kt @@ -203,7 +203,8 @@ class RenamePatternTab(context: Context, attrs: AttributeSet) : RelativeLayout(c resolved.forEach { resolvedUri -> val path = resolvedUri.fileDirItem.path val uri = resolvedUri.uri - val newFileName = getNewPath(path, useMediaFileExtension)?.getFilenameFromPath() ?: return@forEach + val newFileName = getNewPath(path, useMediaFileExtension) + ?.getFilenameFromPath() ?: return@forEach when (android30Format) { Android30RenameFormat.SAF -> { val sourceFile = File(path).toFileDirItem(context) diff --git a/commons/src/main/kotlin/org/fossify/commons/views/RenameSimpleTab.kt b/commons/src/main/kotlin/org/fossify/commons/views/RenameSimpleTab.kt index bdfc9d5d3a..fd062dda03 100644 --- a/commons/src/main/kotlin/org/fossify/commons/views/RenameSimpleTab.kt +++ b/commons/src/main/kotlin/org/fossify/commons/views/RenameSimpleTab.kt @@ -114,6 +114,19 @@ class RenameSimpleTab(context: Context, attrs: AttributeSet) : RelativeLayout(co } } + private fun getNewFileName(path: String, appendString: Boolean, stringToAdd: String): String { + val fullName = path.getFilenameFromPath() + val dotAt = fullName.lastIndexOf(".").takeIf { it != -1 } ?: fullName.length + val name = fullName.substring(0, dotAt) + val extension = if (fullName.contains(".")) ".${fullName.getFilenameExtension()}" else "" + + return if (appendString) { + "$name$stringToAdd$extension" + } else { + "$stringToAdd$fullName" + } + } + private fun renameAllFiles( paths: List, appendString: Boolean, @@ -132,75 +145,62 @@ class RenameSimpleTab(context: Context, attrs: AttributeSet) : RelativeLayout(co val resolved = resolution.resolved activity.updateSDK30Uris(resolution.uris) { success -> - if (success) { - try { - resolved.forEach { resolvedUri -> - val path = resolvedUri.fileDirItem.path - val uri = resolvedUri.uri - - val fullName = path.getFilenameFromPath() - var dotAt = fullName.lastIndexOf(".") - if (dotAt == -1) { - dotAt = fullName.length - } - - val name = fullName.substring(0, dotAt) - val extension = if (fullName.contains(".")) ".${fullName.getFilenameExtension()}" else "" - - val newName = if (appendString) { - "$name$stringToAdd$extension" - } else { - "$stringToAdd$fullName" - } + if (!success) { + callback(false) + return@updateSDK30Uris + } - when (android30Format) { - Android30RenameFormat.SAF -> { - val sourceFile = File(path).toFileDirItem(activity) - val newPath = "${path.getParentPath()}/$newName" - val destinationFile = FileDirItem( - newPath, - newName, - sourceFile.isDirectory, - sourceFile.children, - sourceFile.size, - sourceFile.modified - ) - if (activity.copySingleFileSdk30(sourceFile, destinationFile)) { - if (!activity.baseConfig.keepLastModified) { - File(newPath).setLastModified(System.currentTimeMillis()) - } - activity.contentResolver.delete(uri, null) - activity.updateInMediaStore(path, newPath) - activity.scanPathsRecursively(arrayListOf(newPath)) + try { + resolved.forEach { resolvedUri -> + val path = resolvedUri.fileDirItem.path + val uri = resolvedUri.uri + val newName = getNewFileName(path, appendString, stringToAdd) + + when (android30Format) { + Android30RenameFormat.SAF -> { + val sourceFile = File(path).toFileDirItem(activity) + val newPath = "${path.getParentPath()}/$newName" + val destinationFile = FileDirItem( + newPath, + newName, + sourceFile.isDirectory, + sourceFile.children, + sourceFile.size, + sourceFile.modified + ) + if (activity.copySingleFileSdk30(sourceFile, destinationFile)) { + if (!activity.baseConfig.keepLastModified) { + File(newPath).setLastModified(System.currentTimeMillis()) } + activity.contentResolver.delete(uri, null) + activity.updateInMediaStore(path, newPath) + activity.scanPathsRecursively(arrayListOf(newPath)) } + } - Android30RenameFormat.CONTENT_RESOLVER -> { - val values = ContentValues().apply { - put(MediaStore.Images.Media.DISPLAY_NAME, newName) - } - context.contentResolver.update(uri, values, null, null) + Android30RenameFormat.CONTENT_RESOLVER -> { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, newName) } + context.contentResolver.update(uri, values, null, null) + } - Android30RenameFormat.NONE -> { - activity.runOnUiThread { - callback(true) - } - return@forEach + Android30RenameFormat.NONE -> { + activity.runOnUiThread { + callback(true) } + return@forEach } } - activity.runOnUiThread { - callback(true) - } - } catch (e: Exception) { - activity.runOnUiThread { - activity.showErrorToast(e) - callback(false) - } } - } else { - callback(false) + activity.runOnUiThread { + callback(true) + } + } catch (e: Exception) { + activity.runOnUiThread { + activity.showErrorToast(e) + callback(false) + } } } }