From f1db149ec56f42a2b96d990893726a685a21fbaf Mon Sep 17 00:00:00 2001 From: IgitBuh <703571+IgitBuh@users.noreply.github.com> Date: Tue, 12 May 2026 09:38:59 +0200 Subject: [PATCH] preserve original file name on API 33+ by switching to SAF picker The Photo Picker (ActivityResultContracts.PickVisualMedia) returns synthetic, numeric DISPLAY_NAME values like "1000040501" on Android 13+ for privacy reasons. The recently added "preserve original file name" feature then produced output filenames like "1000040501_Compressed.mp4" instead of e.g. "PXL_20260316_124917401~2_Compressed.mp4". Switch to ActivityResultContracts.OpenDocument(arrayOf("video/*")). SAF URIs expose the real DISPLAY_NAME, require no extra permissions and work uniformly across API levels. While here, harden the surrounding code: - Extract DISPLAY_NAME independently of MediaMetadataRetriever so a thrown setDataSource/extractMetadata call (HDR, exotic codecs) no longer silently discards the original name. - Use a specific projection and .use { } for the cursor instead of a null projection with manual close. - Reject obviously synthetic numeric IDs as a defense in depth against any other provider that may return one. - Sanitize filesystem-illegal characters from the base name. - Unify cache and gallery output naming through a single helper so the fallback never produces the previous "Compressed__Compressed.mp4" double marker. Co-Authored-By: Claude Opus 4.7 --- .../joshattic/us/CompressorViewModel.kt | 58 +++++++++++++------ .../compress/joshattic/us/MainActivity.kt | 11 +++- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/compress/joshattic/us/CompressorViewModel.kt b/app/src/main/java/compress/joshattic/us/CompressorViewModel.kt index 0116727..3165212 100644 --- a/app/src/main/java/compress/joshattic/us/CompressorViewModel.kt +++ b/app/src/main/java/compress/joshattic/us/CompressorViewModel.kt @@ -416,7 +416,43 @@ class CompressorViewModel(application: Application) : AndroidViewModel(applicati private var compressionJob: Job? = null private var activeTransformer: Transformer? = null + private fun queryDisplayName(context: Context, uri: Uri): String? { + val raw = try { + context.contentResolver.query( + uri, + arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), + null, null, null + )?.use { cursor -> + if (cursor.moveToFirst()) { + val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (idx >= 0) cursor.getString(idx) else null + } else null + } + } catch (e: Exception) { + e.printStackTrace() + null + } + // Reject Photo Picker synthetic IDs (e.g. "1000040501") — these are MediaStore IDs, + // not real filenames. The picker hides the actual name for privacy on Android 13+. + if (raw.isNullOrBlank() || raw.matches(Regex("""^\d{6,}$"""))) return null + return raw + } + + private fun sanitizeFilename(name: String): String = + name.replace(Regex("""[/\\:*?"<>|\x00-\x1F]"""), "_").trim().ifBlank { "Video" } + + private fun makeOutputName(originalName: String?): String { + val base = originalName?.substringBeforeLast(".")?.takeIf { it.isNotBlank() } + ?.let { sanitizeFilename(it) } + ?: "Video_${System.currentTimeMillis()}" + return "${base}_Compressed.mp4" + } + fun updateSelectedUri(context: Context, uri: Uri) { + // Resolve the display name independently — if metadata extraction throws below + // (HDR videos, unsupported codecs, slow URIs), we still keep the original name. + val originalName = queryDisplayName(context, uri) + var size = 0L var width = 0 var height = 0 @@ -425,8 +461,7 @@ class CompressorViewModel(application: Application) : AndroidViewModel(applicati var fps = 30f var videoMime: String? = null var duration = 0L - var originalName: String? = null - + try { audioBitrate = getAudioBitrate(context, uri) val videoInfo = getVideoTrackInfo(context, uri) @@ -460,15 +495,6 @@ class CompressorViewModel(application: Application) : AndroidViewModel(applicati fps = 30f } - val cursor = context.contentResolver.query(uri, null, null, null, null) - if (cursor != null && cursor.moveToFirst()) { - val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) - if (nameIndex != -1) { - originalName = cursor.getString(nameIndex) - } - cursor.close() - } - retriever.release() } catch (e: Exception) { e.printStackTrace() @@ -713,8 +739,7 @@ class CompressorViewModel(application: Application) : AndroidViewModel(applicati val outputDir = File(context.cacheDir, "compressed_videos") outputDir.mkdirs() - val baseName = currentState.originalName?.substringBeforeLast(".") ?: "Compressed_${System.currentTimeMillis()}" - val outputFile = File(outputDir, "${baseName}_Compressed.mp4") + val outputFile = File(outputDir, makeOutputName(currentState.originalName)) if (outputFile.exists()) { outputFile.delete() } @@ -1117,12 +1142,7 @@ class CompressorViewModel(application: Application) : AndroidViewModel(applicati return@launch } - val targetName = if (currentState.originalName != null) { - val nameWithoutExt = currentState.originalName.substringBeforeLast(".") - "${nameWithoutExt}_Compressed.mp4" - } else { - "Compressed_${System.currentTimeMillis()}.mp4" - } + val targetName = makeOutputName(currentState.originalName) val values = ContentValues().apply { put(MediaStore.Video.Media.DISPLAY_NAME, targetName) diff --git a/app/src/main/java/compress/joshattic/us/MainActivity.kt b/app/src/main/java/compress/joshattic/us/MainActivity.kt index d41201c..c60f01e 100644 --- a/app/src/main/java/compress/joshattic/us/MainActivity.kt +++ b/app/src/main/java/compress/joshattic/us/MainActivity.kt @@ -10,7 +10,6 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.animation.AnimatedContent @@ -160,8 +159,14 @@ fun CompressorApp(viewModel: CompressorViewModel) { } } - val pickMedia = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + // SAF-based picker — preserves the real file name (Photo Picker hides it for privacy on API 33+). + val pickMedia = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> if (uri != null) { + try { + context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (_: SecurityException) { + // Some providers don't grant persistable URIs; the URI is still readable for this session. + } viewModel.updateSelectedUri(context, uri) } } @@ -241,7 +246,7 @@ fun CompressorApp(viewModel: CompressorViewModel) { when(index) { 0 -> EmptyScreen( totalSaved = state.formattedTotalSaved, - onPick = { pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)) } + onPick = { pickMedia.launch(arrayOf("video/*")) } ) 2 -> { if (state.error != null) {