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 4188822e7..b677247de 100644 --- a/commons/src/main/kotlin/org/fossify/commons/extensions/Activity.kt +++ b/commons/src/main/kotlin/org/fossify/commons/extensions/Activity.kt @@ -12,6 +12,7 @@ import android.content.Intent import android.content.Intent.EXTRA_STREAM import android.content.pm.ApplicationInfo import android.content.pm.PackageManager +import android.media.MediaScannerConnection import android.media.RingtoneManager import android.net.Uri import android.os.Build @@ -89,6 +90,7 @@ import java.io.FileOutputStream import java.io.IOException import java.io.OutputStream import java.util.TreeSet +import java.util.concurrent.atomic.AtomicInteger fun Activity.appLaunched(appId: String) { baseConfig.internalStoragePath = getInternalStoragePath() @@ -979,6 +981,56 @@ fun Activity.rescanPaths(paths: List, callback: (() -> Unit)? = null) { applicationContext.rescanPaths(paths, callback) } +private fun BaseSimpleActivity.readRenameMediaEntries(path: String): List> { + if (isRPlus()) return emptyList() + val oldPath = File(path).canonicalPath + val prefix = "$oldPath/" + val entries = ArrayList>() + contentResolver.query( + MediaStore.Files.getContentUri("external"), + arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA), + "${MediaStore.MediaColumns.DATA} = ? OR substr(${MediaStore.MediaColumns.DATA}, 1, ?) = ?", + arrayOf(oldPath, prefix.length.toString(), prefix), null + )?.use { cursor -> + while (cursor.moveToNext()) entries.add(cursor.getLong(0) to cursor.getString(1)) + } ?: throw IOException("Could not read media entries before renaming") + return entries +} + +private fun BaseSimpleActivity.refreshRenamedPath( + oldPath: String, + newPath: String, + entries: List>, + callback: ((Boolean, Android30RenameFormat) -> Unit)? +) { + try { + val oldMediaPath = File(oldPath).canonicalPath + val newMediaPath = File(newPath).canonicalPath + // Older providers scan descendants when a directory entry moves, so move the child entries first. + for ((id, path) in entries.sortedByDescending { it.second.length }) { + val renamedPath = newMediaPath + path.removePrefix(oldMediaPath) + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DATA, renamedPath) + put(MediaStore.MediaColumns.DISPLAY_NAME, renamedPath.getFilenameFromPath()) + put(MediaStore.MediaColumns.TITLE, renamedPath.getFilenameFromPath()) + } + contentResolver.update(MediaStore.Files.getContentUri("external"), values, + "_id = ? AND ${MediaStore.MediaColumns.DATA} = ?", arrayOf(id.toString(), path)) + } + val paths = getPaths(File(newMediaPath)) + val remaining = AtomicInteger(paths.size) + // Scan the new paths once. A missing old path can match a sibling on older Android versions. + MediaScannerConnection.scanFile(applicationContext, paths.toTypedArray(), null) { _, _ -> + if (remaining.decrementAndGet() == 0) { + runOnUiThread { callback?.invoke(true, Android30RenameFormat.NONE) } + } + } + } catch (e: Exception) { + showErrorToast(e) + runOnUiThread { callback?.invoke(false, Android30RenameFormat.NONE) } + } +} + fun BaseSimpleActivity.renameFile( oldPath: String, newPath: String, @@ -1019,18 +1071,10 @@ fun BaseSimpleActivity.renameFile( try { ensureBackgroundThread { + val mediaEntries = readRenameMediaEntries(oldPath) val success = renameDocumentSdk30(oldPath, newPath) if (success) { - updateInMediaStore(oldPath, newPath) - rescanPath(newPath) { - runOnUiThread { - callback?.invoke(true, Android30RenameFormat.NONE) - } - if (!oldPath.equals(newPath, true)) { - deleteFromMediaStore(oldPath) - } - scanPathRecursively(newPath) - } + refreshRenamedPath(oldPath, newPath, mediaEntries, callback) } else { runOnUiThread { callback?.invoke(false, Android30RenameFormat.NONE) @@ -1062,6 +1106,13 @@ fun BaseSimpleActivity.renameFile( try { ensureBackgroundThread { + val mediaEntries = try { + readRenameMediaEntries(oldPath) + } catch (e: Exception) { + showErrorToast(e) + runOnUiThread { callback?.invoke(false, Android30RenameFormat.NONE) } + return@ensureBackgroundThread + } try { DocumentsContract.renameDocument(applicationContext.contentResolver, document.uri, newPath.getFilenameFromPath()) } catch (ignored: FileNotFoundException) { @@ -1072,16 +1123,10 @@ fun BaseSimpleActivity.renameFile( return@ensureBackgroundThread } - updateInMediaStore(oldPath, newPath) - rescanPaths(arrayListOf(oldPath, newPath)) { - if (!baseConfig.keepLastModified) { - updateLastModified(newPath, System.currentTimeMillis()) - } - deleteFromMediaStore(oldPath) - runOnUiThread { - callback?.invoke(true, Android30RenameFormat.NONE) - } + if (!baseConfig.keepLastModified) { + updateLastModified(newPath, System.currentTimeMillis()) } + refreshRenamedPath(oldPath, newPath, mediaEntries, callback) } } catch (e: Exception) { showErrorToast(e) @@ -1101,7 +1146,9 @@ private fun BaseSimpleActivity.renameCasually( ) { val oldFile = File(oldPath) val newFile = File(newPath) + val mediaEntries: List> val tempFile = try { + mediaEntries = readRenameMediaEntries(oldPath) createTempFile(oldFile) ?: return } catch (exception: Exception) { if (isRPlus() && exception is java.nio.file.FileSystemException) { @@ -1142,31 +1189,10 @@ private fun BaseSimpleActivity.renameCasually( val oldToTempSucceeds = oldFile.renameTo(tempFile) val tempToNewSucceeds = tempFile.renameTo(newFile) if (oldToTempSucceeds && tempToNewSucceeds) { - if (newFile.isDirectory) { - updateInMediaStore(oldPath, newPath) - rescanPath(newPath) { - runOnUiThread { - callback?.invoke(true, Android30RenameFormat.NONE) - } - if (!oldPath.equals(newPath, true)) { - deleteFromMediaStore(oldPath) - } - scanPathRecursively(newPath) - } - } else { - if (!baseConfig.keepLastModified) { - newFile.setLastModified(System.currentTimeMillis()) - } - updateInMediaStore(oldPath, newPath) - scanPathsRecursively(arrayListOf(newPath)) { - if (!oldPath.equals(newPath, true)) { - deleteFromMediaStore(oldPath) - } - runOnUiThread { - callback?.invoke(true, Android30RenameFormat.NONE) - } - } + if (!newFile.isDirectory && !baseConfig.keepLastModified) { + newFile.setLastModified(System.currentTimeMillis()) } + refreshRenamedPath(oldPath, newPath, mediaEntries, callback) } else { tempFile.delete() newFile.delete() 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 fad1312bc..6b5b778d5 100644 --- a/commons/src/main/kotlin/org/fossify/commons/views/RenamePatternTab.kt +++ b/commons/src/main/kotlin/org/fossify/commons/views/RenamePatternTab.kt @@ -22,7 +22,6 @@ import java.util.Locale class RenamePatternTab(context: Context, attrs: AttributeSet) : RelativeLayout(context, attrs), RenameTab { var ignoreClicks = false - var stopLooping = false // we should request the permission on Android 30+ for all uris at once, not one by one var currentIncrementalNumber = 1 var numbersCnt = 0 var activity: BaseSimpleActivity? = null @@ -43,7 +42,6 @@ class RenamePatternTab(context: Context, attrs: AttributeSet) : RelativeLayout(c } override fun dialogConfirmed(useMediaFileExtension: Boolean, callback: (success: Boolean) -> Unit) { - stopLooping = false if (ignoreClicks) { return } @@ -79,40 +77,53 @@ class RenamePatternTab(context: Context, attrs: AttributeSet) : RelativeLayout(c } ignoreClicks = true - var pathsCnt = validPaths.size - numbersCnt = pathsCnt.toString().length - for (path in validPaths) { - if (stopLooping) { - return@checkManageMediaOrHandleSAFDialogSdk30 + numbersCnt = validPaths.size.toString().length + fun renameNext(index: Int) { + if (index == validPaths.size) { + ignoreClicks = false + callback(true) + return } - + val path = validPaths[index] + val incrementalNumber = currentIncrementalNumber try { - val newPath = getNewPath(path, useMediaFileExtension) ?: continue + val newPath = getNewPath(path, useMediaFileExtension, newNameRaw) + if (newPath == null) { + ignoreClicks = false + callback(false) + return + } activity?.renameFile(path, newPath, true) { success, android30Format -> if (success) { - pathsCnt-- - if (pathsCnt == 0) { - callback(true) - } + renameNext(index + 1) } else { ignoreClicks = false if (android30Format != Android30RenameFormat.NONE) { - currentIncrementalNumber = 1 - stopLooping = true - renameAllFiles(validPaths, useMediaFileExtension, android30Format, callback) + currentIncrementalNumber = incrementalNumber + renameAllFiles( + validPaths.drop(index), + useMediaFileExtension, + android30Format, + newNameRaw, + callback + ) + } else { + callback(false) } } } } catch (e: Exception) { activity?.showErrorToast(e) + ignoreClicks = false + callback(false) } } - stopLooping = false + renameNext(0) } } } - private fun getNewPath(path: String, useMediaFileExtension: Boolean): String? { + private fun getNewPath(path: String, useMediaFileExtension: Boolean, renamePattern: String): String? { try { val exif = ExifInterface(path) var dateTime = if (isNougatPlus()) { @@ -140,7 +151,7 @@ class RenamePatternTab(context: Context, attrs: AttributeSet) : RelativeLayout(c val minutes = (cal.get(Calendar.MINUTE)).ensureTwoDigits() val seconds = (cal.get(Calendar.SECOND)).ensureTwoDigits() - var newName = binding.renameItemsValue.value + var newName = renamePattern .replace("%Y", year, false) .replace("%M", month, false) .replace("%D", day, false) @@ -185,6 +196,7 @@ class RenamePatternTab(context: Context, attrs: AttributeSet) : RelativeLayout(c paths: List, useMediaFileExtension: Boolean, android30Format: Android30RenameFormat, + renamePattern: String, callback: (success: Boolean) -> Unit, ) { val fileDirItems = paths.map { File(it).toFileDirItem(context) } @@ -197,7 +209,8 @@ class RenamePatternTab(context: Context, attrs: AttributeSet) : RelativeLayout(c try { uris.forEachIndexed { index, uri -> val path = validPaths[index] - val newFileName = getNewPath(path, useMediaFileExtension)?.getFilenameFromPath() ?: return@forEachIndexed + val newFileName = getNewPath(path, useMediaFileExtension, renamePattern) + ?.getFilenameFromPath() ?: return@forEachIndexed when (android30Format) { Android30RenameFormat.SAF -> { val sourceFile = File(path).toFileDirItem(context)