fixed file extension loss on move via tree doc

This commit is contained in:
Thibault Deckers 2025-05-31 20:20:07 +02:00
parent 43cb2cd101
commit 8c3d0f1b83
5 changed files with 54 additions and 12 deletions

View file

@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
### Fixed ### Fixed
- moved file losing its extension and no longer being detected as media in some cases
- opening home when launching app as media picker - opening home when launching app as media picker
- removing groups with obsolete albums - removing groups with obsolete albums
- loading group custom covers - loading group custom covers

View file

@ -311,7 +311,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
embeddedByteStream: InputStream, embeddedByteStream: InputStream,
embeddedByteLength: Long, embeddedByteLength: Long,
) { ) {
val extension = extensionFor(mimeType) val extension = extensionFor(mimeType, defaultExtension = null)
val targetFile = StorageUtils.createTempFile(context, extension).apply { val targetFile = StorageUtils.createTempFile(context, extension).apply {
transferFrom(embeddedByteStream, embeddedByteLength) transferFrom(embeddedByteStream, embeddedByteLength)
} }
@ -319,7 +319,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
val authority = "${context.applicationContext.packageName}.file_provider" val authority = "${context.applicationContext.packageName}.file_provider"
val uri = if (displayName != null) { val uri = if (displayName != null) {
// add extension to ease type identification when sharing this content // add extension to ease type identification when sharing this content
val displayNameWithExtension = if (extension == null || displayName.endsWith(extension, ignoreCase = true)) { val displayNameWithExtension = if (displayName.endsWith(extension, ignoreCase = true)) {
displayName displayName
} else { } else {
"$displayName$extension" "$displayName$extension"

View file

@ -142,16 +142,18 @@ abstract class ImageProvider {
val oldFile = File(sourcePath) val oldFile = File(sourcePath)
if (oldFile.nameWithoutExtension != desiredNameWithoutExtension) { if (oldFile.nameWithoutExtension != desiredNameWithoutExtension) {
val defaultExtension = oldFile.extension
oldFile.parent?.let { dir -> oldFile.parent?.let { dir ->
val resolution = resolveTargetFileNameWithoutExtension( val resolution = resolveTargetFileNameWithoutExtension(
contextWrapper = activity, contextWrapper = activity,
dir = dir, dir = dir,
desiredNameWithoutExtension = desiredNameWithoutExtension, desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = mimeType, mimeType = mimeType,
defaultExtension = defaultExtension,
conflictStrategy = NameConflictStrategy.RENAME, conflictStrategy = NameConflictStrategy.RENAME,
) )
resolution.nameWithoutExtension?.let { targetNameWithoutExtension -> resolution.nameWithoutExtension?.let { targetNameWithoutExtension ->
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}" val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType, defaultExtension)}"
val newFile = File(dir, targetFileName) val newFile = File(dir, targetFileName)
if (oldFile != newFile) { if (oldFile != newFile) {
newFields = renameSingle( newFields = renameSingle(
@ -277,11 +279,17 @@ abstract class ImageProvider {
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}" desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}"
} }
// there is no benefit providing input extension
// for known output MIME type
val defaultExtension = null
val resolution = resolveTargetFileNameWithoutExtension( val resolution = resolveTargetFileNameWithoutExtension(
contextWrapper = activity, contextWrapper = activity,
dir = targetDir, dir = targetDir,
desiredNameWithoutExtension = desiredNameWithoutExtension, desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = exportMimeType, mimeType = exportMimeType,
defaultExtension = defaultExtension,
conflictStrategy = nameConflictStrategy, conflictStrategy = nameConflictStrategy,
) )
val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap
@ -358,6 +366,7 @@ abstract class ImageProvider {
targetDir = targetDir, targetDir = targetDir,
targetDirDocFile = targetDirDocFile, targetDirDocFile = targetDirDocFile,
targetNameWithoutExtension = targetNameWithoutExtension, targetNameWithoutExtension = targetNameWithoutExtension,
defaultExtension = defaultExtension,
write = write, write = write,
) )
@ -465,6 +474,7 @@ abstract class ImageProvider {
dir = targetDir, dir = targetDir,
desiredNameWithoutExtension = desiredNameWithoutExtension, desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = captureMimeType, mimeType = captureMimeType,
defaultExtension = null,
conflictStrategy = nameConflictStrategy, conflictStrategy = nameConflictStrategy,
) )
} catch (e: Exception) { } catch (e: Exception) {
@ -571,13 +581,14 @@ abstract class ImageProvider {
dir: String, dir: String,
desiredNameWithoutExtension: String, desiredNameWithoutExtension: String,
mimeType: String, mimeType: String,
defaultExtension: String?,
conflictStrategy: NameConflictStrategy, conflictStrategy: NameConflictStrategy,
): NameConflictResolution { ): NameConflictResolution {
val sanitizedNameWithoutExtension = sanitizeDesiredFileName(desiredNameWithoutExtension) val sanitizedNameWithoutExtension = sanitizeDesiredFileName(desiredNameWithoutExtension)
var resolvedName: String? = sanitizedNameWithoutExtension var resolvedName: String? = sanitizedNameWithoutExtension
var replacementFile: File? = null var replacementFile: File? = null
val extension = extensionFor(mimeType) val extension = extensionFor(mimeType, defaultExtension)
val targetFile = File(dir, "$sanitizedNameWithoutExtension$extension") val targetFile = File(dir, "$sanitizedNameWithoutExtension$extension")
when (conflictStrategy) { when (conflictStrategy) {
NameConflictStrategy.RENAME -> { NameConflictStrategy.RENAME -> {

View file

@ -557,6 +557,7 @@ class MediaStoreImageProvider : ImageProvider() {
toBin: Boolean, toBin: Boolean,
): FieldMap { ): FieldMap {
val sourcePath = sourceFile?.path val sourcePath = sourceFile?.path
val sourceExtension = sourceFile?.extension
val sourceDir = sourceFile?.parent?.let { ensureTrailingSeparator(it) } val sourceDir = sourceFile?.parent?.let { ensureTrailingSeparator(it) }
if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) { if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) {
// nothing to do unless it's a renamed copy // nothing to do unless it's a renamed copy
@ -569,6 +570,7 @@ class MediaStoreImageProvider : ImageProvider() {
dir = targetDir, dir = targetDir,
desiredNameWithoutExtension = desiredNameWithoutExtension, desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = mimeType, mimeType = mimeType,
defaultExtension = sourceExtension,
conflictStrategy = nameConflictStrategy, conflictStrategy = nameConflictStrategy,
) )
val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap
@ -580,6 +582,7 @@ class MediaStoreImageProvider : ImageProvider() {
targetDir = targetDir, targetDir = targetDir,
targetDirDocFile = targetDirDocFile, targetDirDocFile = targetDirDocFile,
targetNameWithoutExtension = targetNameWithoutExtension, targetNameWithoutExtension = targetNameWithoutExtension,
defaultExtension = sourceExtension,
) { output: OutputStream -> ) { output: OutputStream ->
try { try {
sourceDocFile.copyTo(output) sourceDocFile.copyTo(output)
@ -615,12 +618,13 @@ class MediaStoreImageProvider : ImageProvider() {
targetDir: String, targetDir: String,
targetDirDocFile: DocumentFileCompat?, targetDirDocFile: DocumentFileCompat?,
targetNameWithoutExtension: String, targetNameWithoutExtension: String,
defaultExtension: String?,
write: (OutputStream) -> Unit, write: (OutputStream) -> Unit,
): String { ): String {
if (StorageUtils.isInVault(activity, targetDir)) { if (StorageUtils.isInVault(activity, targetDir)) {
return insertByFile( return insertByFile(
targetDir = targetDir, targetDir = targetDir,
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}", targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType, defaultExtension)}",
write = write, write = write,
) )
} }
@ -630,7 +634,7 @@ class MediaStoreImageProvider : ImageProvider() {
return insertByMediaStore( return insertByMediaStore(
activity = activity, activity = activity,
targetDir = targetDir, targetDir = targetDir,
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}", targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType, defaultExtension)}",
write = write, write = write,
) )
} }
@ -642,6 +646,7 @@ class MediaStoreImageProvider : ImageProvider() {
targetDir = targetDir, targetDir = targetDir,
targetDirDocFile = targetDirDocFile, targetDirDocFile = targetDirDocFile,
targetNameWithoutExtension = targetNameWithoutExtension, targetNameWithoutExtension = targetNameWithoutExtension,
defaultExtension = defaultExtension,
write = write, write = write,
) )
} }
@ -700,6 +705,7 @@ class MediaStoreImageProvider : ImageProvider() {
targetDir: String, targetDir: String,
targetDirDocFile: DocumentFileCompat?, targetDirDocFile: DocumentFileCompat?,
targetNameWithoutExtension: String, targetNameWithoutExtension: String,
defaultExtension: String?,
write: (OutputStream) -> Unit, write: (OutputStream) -> Unit,
): String { ): String {
targetDirDocFile ?: throw Exception("failed to get tree doc for directory at path=$targetDir") targetDirDocFile ?: throw Exception("failed to get tree doc for directory at path=$targetDir")
@ -708,9 +714,22 @@ class MediaStoreImageProvider : ImageProvider() {
// but in order to open an output stream to it, we need to use a `SingleDocumentFile` // but in order to open an output stream to it, we need to use a `SingleDocumentFile`
// through a document URI, not a tree URI // through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
val targetTreeFile = targetDirDocFile.createFile(mimeType, targetNameWithoutExtension) var targetTreeFile = targetDirDocFile.createFile(mimeType, targetNameWithoutExtension)
val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri) var targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
// TODO TLAD [missing extension] check whether targetDocFile.name has a valid extension
// providing a display name and a MIME type does not guarantee
// that the created document will be backed by a file with a valid media extension,
// but having an extension is essential for media detection by Android,
// so we retry with a display name that includes the extension
if ((targetDocFile.extension == null || targetDocFile.extension.isEmpty() || targetDocFile.extension == "bin") && defaultExtension != null) {
if (targetDocFile.exists()) {
targetDocFile.delete()
}
val extension = if (defaultExtension.startsWith(".")) defaultExtension else ".$defaultExtension"
targetTreeFile = targetDirDocFile.createFile(mimeType, "$targetNameWithoutExtension$extension")
targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
}
try { try {
targetDocFile.openOutputStream().use(write) targetDocFile.openOutputStream().use(write)

View file

@ -163,13 +163,24 @@ object MimeTypes {
// among other refs: // among other refs:
// - https://android.googlesource.com/platform/external/mime-support/+/refs/heads/master/mime.types // - https://android.googlesource.com/platform/external/mime-support/+/refs/heads/master/mime.types
fun extensionFor(mimeType: String): String? = when (mimeType) { fun extensionFor(mimeType: String, defaultExtension: String?): String = when (mimeType) {
AVI, AVI_VND -> ".avi" AVI, AVI_VND -> ".avi"
DNG, DNG_ADOBE -> ".dng"
HEIC, HEIF -> ".heif" HEIC, HEIF -> ".heif"
MP2T, MP2TS -> ".m2ts" MP2T, MP2TS -> ".m2ts"
PSD_VND, PSD_X -> ".psd" PSD_VND, PSD_X -> ".psd"
// TODO TLAD [missing extension] check whether to define more manual mapping and raise exception on miss else -> {
else -> MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" } val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: defaultExtension
if (ext != null) {
// fallback to provided extension when available,
// typically the original file extension when moving/renaming
if (ext.startsWith(".")) ext else ".$ext"
} else {
// fallback to generic extensions,
// as incorrect file extensions are better than none for media detection
if (isVideo(mimeType)) ".mp4" else ".jpg"
}
}
} }
val TIFF_EXTENSION_PATTERN = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE) val TIFF_EXTENSION_PATTERN = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)