From 80fad3fa1b8183257095e738da8e4268c7e5ddcc Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sat, 9 May 2026 18:28:52 +0200 Subject: [PATCH 1/3] Fix opening PDF files with external viewers --- opencloudApp/src/main/AndroidManifest.xml | 16 +++++++ .../android/extensions/ActivityExt.kt | 9 ++-- .../ui/helpers/FileOperationsHelper.java | 6 +-- .../android/utils/MimetypeIconUtil.java | 23 +++++++-- .../android/utils/MimetypeIconUtilTest.kt | 47 +++++++++++++++++++ 5 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 opencloudApp/src/test/java/eu/opencloud/android/utils/MimetypeIconUtilTest.kt diff --git a/opencloudApp/src/main/AndroidManifest.xml b/opencloudApp/src/main/AndroidManifest.xml index 9e456753a0..7bb8b15de2 100644 --- a/opencloudApp/src/main/AndroidManifest.xml +++ b/opencloudApp/src/main/AndroidManifest.xml @@ -45,6 +45,22 @@ + + + + + + + + + + + + + + + + = 0) { - val guessedMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(storagePath.substring(storagePath.lastIndexOf('.') + 1)) - if (guessedMimeType != null && guessedMimeType != type) { + val guessedMimeType = MimetypeIconUtil.getBestMimeTypeByFilenameOrDefault(storagePath, type) + if (guessedMimeType != type) { intentForGuessedMimeType = Intent(Intent.ACTION_VIEW) intentForGuessedMimeType.setDataAndType(data, guessedMimeType) intentForGuessedMimeType.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION @@ -456,8 +455,10 @@ fun FragmentActivity.sendDownloadedFilesByShareSheet(ocFiles: List) { } fun Activity.openOCFile(ocFile: OCFile) { + val finalMimeType = MimetypeIconUtil.getBestMimeTypeByFilenameOrDefault(ocFile.fileName, ocFile.mimeType) + val intentForSavedMimeType = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(getExposedFileUriForOCFile(this@openOCFile, ocFile), ocFile.mimeType) + setDataAndType(getExposedFileUriForOCFile(this@openOCFile, ocFile), finalMimeType) flags = Intent.FLAG_GRANT_READ_URI_PERMISSION if (ocFile.hasWritePermission) { flags = flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/helpers/FileOperationsHelper.java b/opencloudApp/src/main/java/eu/opencloud/android/ui/helpers/FileOperationsHelper.java index dbaaf9cb09..446d865b82 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/helpers/FileOperationsHelper.java +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/helpers/FileOperationsHelper.java @@ -31,7 +31,6 @@ import android.content.ActivityNotFoundException; import android.content.Intent; import android.net.Uri; -import android.webkit.MimeTypeMap; import eu.opencloud.android.R; import eu.opencloud.android.domain.files.model.OCFile; @@ -43,6 +42,7 @@ import eu.opencloud.android.ui.activity.FileActivity; import eu.opencloud.android.usecases.synchronization.SynchronizeFileUseCase; import eu.opencloud.android.usecases.synchronization.SynchronizeFolderUseCase; +import eu.opencloud.android.utils.MimetypeIconUtil; import eu.opencloud.android.utils.UriUtilsKt; import kotlin.Lazy; import org.jetbrains.annotations.NotNull; @@ -78,9 +78,9 @@ private Intent getIntentForGuessedMimeType(String storagePath, String type, Uri Intent intentForGuessedMimeType = null; if (storagePath != null && storagePath.lastIndexOf('.') >= 0) { - String guessedMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(storagePath.substring(storagePath.lastIndexOf('.') + 1)); + String guessedMimeType = MimetypeIconUtil.getBestMimeTypeByFilenameOrDefault(storagePath, type); - if (guessedMimeType != null && !guessedMimeType.equals(type)) { + if (!guessedMimeType.equals(type)) { intentForGuessedMimeType = new Intent(Intent.ACTION_VIEW); intentForGuessedMimeType.setDataAndType(data, guessedMimeType); int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION; diff --git a/opencloudApp/src/main/java/eu/opencloud/android/utils/MimetypeIconUtil.java b/opencloudApp/src/main/java/eu/opencloud/android/utils/MimetypeIconUtil.java index ebea8ee3ad..483b0b2d31 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/utils/MimetypeIconUtil.java +++ b/opencloudApp/src/main/java/eu/opencloud/android/utils/MimetypeIconUtil.java @@ -60,6 +60,7 @@ public class MimetypeIconUtil { /** Mapping: mime type for file extension. */ private static final Map> FILE_EXTENSION_TO_MIMETYPE_MAPPING = new HashMap>(); + public static final String UNKNOWN_MIME_TYPE = "application/octet-stream"; static { populateFileExtensionMimeTypeMapping(); @@ -95,11 +96,26 @@ public static int getFileTypeIconId(String mimetype, String filename) { public static String getBestMimeTypeByFilename(String filename) { List candidates = determineMimeTypesByFilename(filename); if (candidates == null || candidates.size() < 1) { - return "application/octet-stream"; + return UNKNOWN_MIME_TYPE; } return candidates.get(0); } + /** + * Returns a MIME type from the file extension, or {@code defaultMimeType} when the extension is unknown. + * + * @param filename Name of file + * @param defaultMimeType Fallback MIME type + * @return Best known MIME type for the file + */ + public static String getBestMimeTypeByFilenameOrDefault(String filename, String defaultMimeType) { + String mimeType = getBestMimeTypeByFilename(filename); + if (mimeType == null || mimeType.isEmpty() || UNKNOWN_MIME_TYPE.equals(mimeType)) { + return defaultMimeType; + } + return mimeType; + } + /** * determines the icon based on the mime type. * @@ -151,7 +167,8 @@ private static List determineMimeTypesByFilename(String filename) { return mimeTypeList; } else { // try detecting the mime type via android itself - String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension); + MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); + String mimeType = mimeTypeMap != null ? mimeTypeMap.getMimeTypeFromExtension(fileExtension) : null; if (mimeType != null) { return Collections.singletonList(mimeType); } else { @@ -186,7 +203,7 @@ private static void populateMimeTypeIconMapping() { MIMETYPE_TO_ICON_MAPPING.put("application/msexcel", R.drawable.file_xls); MIMETYPE_TO_ICON_MAPPING.put("application/mspowerpoint", R.drawable.file_ppt); MIMETYPE_TO_ICON_MAPPING.put("application/msword", R.drawable.file_doc); - MIMETYPE_TO_ICON_MAPPING.put("application/octet-stream", R.drawable.file); + MIMETYPE_TO_ICON_MAPPING.put(UNKNOWN_MIME_TYPE, R.drawable.file); MIMETYPE_TO_ICON_MAPPING.put("application/postscript", R.drawable.file_image); MIMETYPE_TO_ICON_MAPPING.put("application/pdf", R.drawable.file_pdf); MIMETYPE_TO_ICON_MAPPING.put("application/rss+xml", R.drawable.file_code); diff --git a/opencloudApp/src/test/java/eu/opencloud/android/utils/MimetypeIconUtilTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/utils/MimetypeIconUtilTest.kt new file mode 100644 index 0000000000..25c248fe76 --- /dev/null +++ b/opencloudApp/src/test/java/eu/opencloud/android/utils/MimetypeIconUtilTest.kt @@ -0,0 +1,47 @@ +/** + * openCloud Android client application + * + * Copyright (C) 2026 openCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package eu.opencloud.android.utils + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MimetypeIconUtilTest { + + @Test + fun `getBestMimeTypeByFilenameOrDefault prefers known file extension`() { + val mimeType = MimetypeIconUtil.getBestMimeTypeByFilenameOrDefault( + "Invoice.PDF", + MimetypeIconUtil.UNKNOWN_MIME_TYPE, + ) + + assertEquals("application/pdf", mimeType) + } + + @Test + fun `getBestMimeTypeByFilenameOrDefault falls back for unknown file extension`() { + val fallbackMimeType = "application/x-opencloud-test" + + val mimeType = MimetypeIconUtil.getBestMimeTypeByFilenameOrDefault( + "file.unknownextension", + fallbackMimeType, + ) + + assertEquals(fallbackMimeType, mimeType) + } +} From 74fc9d77c6281309f7d421f4f826c9068dd3598f Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sun, 10 May 2026 20:12:49 +0200 Subject: [PATCH 2/3] Address MIME fallback review feedback --- opencloudApp/src/main/AndroidManifest.xml | 5 +- .../android/extensions/ActivityExt.kt | 4 +- .../ui/helpers/FileOperationsHelper.java | 36 +------ .../android/utils/MimetypeIconUtil.java | 39 +++++--- .../android/utils/MimetypeIconUtilTest.kt | 94 +++++++++++++++++-- 5 files changed, 123 insertions(+), 55 deletions(-) diff --git a/opencloudApp/src/main/AndroidManifest.xml b/opencloudApp/src/main/AndroidManifest.xml index 7bb8b15de2..d12293a40c 100644 --- a/opencloudApp/src/main/AndroidManifest.xml +++ b/opencloudApp/src/main/AndroidManifest.xml @@ -45,11 +45,12 @@ - - + diff --git a/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt b/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt index dc8c7c4549..79b1779f7f 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt @@ -162,7 +162,7 @@ private fun getIntentForSavedMimeType(data: Uri, type: String): Intent { private fun getIntentForGuessedMimeType(storagePath: String, type: String, data: Uri): Intent? { var intentForGuessedMimeType: Intent? = null if (storagePath.lastIndexOf('.') >= 0) { - val guessedMimeType = MimetypeIconUtil.getBestMimeTypeByFilenameOrDefault(storagePath, type) + val guessedMimeType = MimetypeIconUtil.getBestMimeTypeForOpen(type, storagePath) if (guessedMimeType != type) { intentForGuessedMimeType = Intent(Intent.ACTION_VIEW) intentForGuessedMimeType.setDataAndType(data, guessedMimeType) @@ -455,7 +455,7 @@ fun FragmentActivity.sendDownloadedFilesByShareSheet(ocFiles: List) { } fun Activity.openOCFile(ocFile: OCFile) { - val finalMimeType = MimetypeIconUtil.getBestMimeTypeByFilenameOrDefault(ocFile.fileName, ocFile.mimeType) + val finalMimeType = MimetypeIconUtil.getBestMimeTypeForOpen(ocFile.mimeType, ocFile.fileName) val intentForSavedMimeType = Intent(Intent.ACTION_VIEW).apply { setDataAndType(getExposedFileUriForOCFile(this@openOCFile, ocFile), finalMimeType) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/helpers/FileOperationsHelper.java b/opencloudApp/src/main/java/eu/opencloud/android/ui/helpers/FileOperationsHelper.java index 446d865b82..331ebe16f7 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/helpers/FileOperationsHelper.java +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/helpers/FileOperationsHelper.java @@ -74,48 +74,20 @@ private Intent getIntentForSavedMimeType(Uri data, String type, boolean hasWrite return intentForSavedMimeType; } - private Intent getIntentForGuessedMimeType(String storagePath, String type, Uri data, boolean hasWritePermission) { - Intent intentForGuessedMimeType = null; - - if (storagePath != null && storagePath.lastIndexOf('.') >= 0) { - String guessedMimeType = MimetypeIconUtil.getBestMimeTypeByFilenameOrDefault(storagePath, type); - - if (!guessedMimeType.equals(type)) { - intentForGuessedMimeType = new Intent(Intent.ACTION_VIEW); - intentForGuessedMimeType.setDataAndType(data, guessedMimeType); - int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION; - if (hasWritePermission) { - flags = flags | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; - } - intentForGuessedMimeType.setFlags(flags); - } - } - return intentForGuessedMimeType; - } - public void openFile(OCFile ocFile) { if (ocFile != null) { + String finalMimeType = MimetypeIconUtil.getBestMimeTypeForOpen(ocFile.getMimeType(), ocFile.getFileName()); Intent intentForSavedMimeType = getIntentForSavedMimeType(UriUtilsKt.INSTANCE.getExposedFileUriForOCFile(mFileActivity, ocFile), - ocFile.getMimeType(), ocFile.getHasWritePermission()); - - Intent intentForGuessedMimeType = getIntentForGuessedMimeType(ocFile.getStoragePath(), ocFile.getMimeType(), - UriUtilsKt.INSTANCE.getExposedFileUriForOCFile(mFileActivity, ocFile), ocFile.getHasWritePermission()); + finalMimeType, ocFile.getHasWritePermission()); - openFileWithIntent(intentForSavedMimeType, intentForGuessedMimeType); + openFileWithIntent(intentForSavedMimeType); } else { Timber.e("Trying to open a NULL OCFile"); } } - private void openFileWithIntent(Intent intentForSavedMimeType, Intent intentForGuessedMimeType) { - Intent openFileWithIntent; - - if (intentForGuessedMimeType != null) { - openFileWithIntent = intentForGuessedMimeType; - } else { - openFileWithIntent = intentForSavedMimeType; - } + private void openFileWithIntent(Intent openFileWithIntent) { try { mFileActivity.startActivity(Intent.createChooser(openFileWithIntent, mFileActivity.getString(R.string.actionbar_open_with))); } catch (ActivityNotFoundException anfe) { diff --git a/opencloudApp/src/main/java/eu/opencloud/android/utils/MimetypeIconUtil.java b/opencloudApp/src/main/java/eu/opencloud/android/utils/MimetypeIconUtil.java index 483b0b2d31..0171c47a91 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/utils/MimetypeIconUtil.java +++ b/opencloudApp/src/main/java/eu/opencloud/android/utils/MimetypeIconUtil.java @@ -28,6 +28,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; /** @@ -102,18 +103,26 @@ public static String getBestMimeTypeByFilename(String filename) { } /** - * Returns a MIME type from the file extension, or {@code defaultMimeType} when the extension is unknown. + * Returns the server MIME type unless it is generic, then falls back to the file extension. * - * @param filename Name of file - * @param defaultMimeType Fallback MIME type - * @return Best known MIME type for the file + * @param serverMimeType MIME type reported by the server + * @param filename Name of file + * @return MIME type to use when opening the file */ - public static String getBestMimeTypeByFilenameOrDefault(String filename, String defaultMimeType) { - String mimeType = getBestMimeTypeByFilename(filename); - if (mimeType == null || mimeType.isEmpty() || UNKNOWN_MIME_TYPE.equals(mimeType)) { - return defaultMimeType; + public static String getBestMimeTypeForOpen(String serverMimeType, String filename) { + if (!isGenericMimeType(serverMimeType)) { + return serverMimeType; } - return mimeType; + + String mimeTypeFromFilename = getBestMimeTypeByFilename(filename); + if (isGenericMimeType(mimeTypeFromFilename)) { + return UNKNOWN_MIME_TYPE; + } + return mimeTypeFromFilename; + } + + private static boolean isGenericMimeType(String mimeType) { + return mimeType == null || mimeType.trim().isEmpty() || UNKNOWN_MIME_TYPE.equals(mimeType) || "*/*".equals(mimeType); } /** @@ -184,8 +193,16 @@ private static List determineMimeTypesByFilename(String filename) { * @return the file extension */ private static String getExtension(String filename) { - String extension = filename.substring(filename.lastIndexOf(".") + 1).toLowerCase(); - return extension; + if (filename == null || filename.isEmpty()) { + return ""; + } + + int extensionStart = filename.lastIndexOf("."); + if (extensionStart < 0 || extensionStart == filename.length() - 1) { + return ""; + } + + return filename.substring(extensionStart + 1).toLowerCase(Locale.ROOT); } /** diff --git a/opencloudApp/src/test/java/eu/opencloud/android/utils/MimetypeIconUtilTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/utils/MimetypeIconUtilTest.kt index 25c248fe76..3deaf79d3d 100644 --- a/opencloudApp/src/test/java/eu/opencloud/android/utils/MimetypeIconUtilTest.kt +++ b/opencloudApp/src/test/java/eu/opencloud/android/utils/MimetypeIconUtilTest.kt @@ -24,24 +24,102 @@ import org.junit.Test class MimetypeIconUtilTest { @Test - fun `getBestMimeTypeByFilenameOrDefault prefers known file extension`() { - val mimeType = MimetypeIconUtil.getBestMimeTypeByFilenameOrDefault( + fun `getBestMimeTypeForOpen keeps specific server mime type`() { + val mimeType = MimetypeIconUtil.getBestMimeTypeForOpen( + "application/pdf", "Invoice.PDF", + ) + + assertEquals("application/pdf", mimeType) + } + + @Test + fun `getBestMimeTypeForOpen uses file extension when server mime type is generic`() { + val mimeType = MimetypeIconUtil.getBestMimeTypeForOpen( MimetypeIconUtil.UNKNOWN_MIME_TYPE, + "Invoice.PDF", ) assertEquals("application/pdf", mimeType) } @Test - fun `getBestMimeTypeByFilenameOrDefault falls back for unknown file extension`() { - val fallbackMimeType = "application/x-opencloud-test" + fun `getBestMimeTypeForOpen uses file extension when server mime type is wildcard`() { + val mimeType = MimetypeIconUtil.getBestMimeTypeForOpen( + "*/*", + "Invoice.PDF", + ) - val mimeType = MimetypeIconUtil.getBestMimeTypeByFilenameOrDefault( - "file.unknownextension", - fallbackMimeType, + assertEquals("application/pdf", mimeType) + } + + @Test + fun `getBestMimeTypeForOpen uses file extension when server mime type is null or blank`() { + assertEquals( + "application/pdf", + MimetypeIconUtil.getBestMimeTypeForOpen( + null, + "Invoice.PDF", + ) + ) + assertEquals( + "application/pdf", + MimetypeIconUtil.getBestMimeTypeForOpen( + "", + "Invoice.PDF", + ) ) + assertEquals( + "application/pdf", + MimetypeIconUtil.getBestMimeTypeForOpen( + " ", + "Invoice.PDF", + ) + ) + } - assertEquals(fallbackMimeType, mimeType) + @Test + fun `getBestMimeTypeForOpen does not override specific server mime type`() { + val mimeType = MimetypeIconUtil.getBestMimeTypeForOpen( + "text/markdown", + "Invoice.PDF", + ) + + assertEquals("text/markdown", mimeType) + } + + @Test + fun `getBestMimeTypeForOpen falls back to unknown mime type for unknown extension`() { + val mimeType = MimetypeIconUtil.getBestMimeTypeForOpen( + MimetypeIconUtil.UNKNOWN_MIME_TYPE, + "Invoice.notarealextension", + ) + + assertEquals(MimetypeIconUtil.UNKNOWN_MIME_TYPE, mimeType) + } + + @Test + fun `getBestMimeTypeForOpen handles filenames without extensions`() { + assertEquals( + MimetypeIconUtil.UNKNOWN_MIME_TYPE, + MimetypeIconUtil.getBestMimeTypeForOpen( + MimetypeIconUtil.UNKNOWN_MIME_TYPE, + "Invoice", + ) + ) + assertEquals( + MimetypeIconUtil.UNKNOWN_MIME_TYPE, + MimetypeIconUtil.getBestMimeTypeForOpen( + MimetypeIconUtil.UNKNOWN_MIME_TYPE, + "", + ) + ) + assertEquals( + MimetypeIconUtil.UNKNOWN_MIME_TYPE, + MimetypeIconUtil.getBestMimeTypeForOpen( + MimetypeIconUtil.UNKNOWN_MIME_TYPE, + null, + ) + ) } } From 2da6f979c38e850d074dcf9fa0618d37631b4e8c Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sun, 10 May 2026 22:55:07 +0200 Subject: [PATCH 3/3] Fix PDF opening with external viewers --- .../main/java/eu/opencloud/android/extensions/ActivityExt.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt b/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt index 79b1779f7f..50e54bcad4 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt @@ -33,6 +33,7 @@ import android.net.Uri import android.text.method.LinkMovementMethod import android.util.TypedValue import android.view.inputmethod.InputMethodManager +import android.webkit.MimeTypeMap import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast @@ -162,8 +163,8 @@ private fun getIntentForSavedMimeType(data: Uri, type: String): Intent { private fun getIntentForGuessedMimeType(storagePath: String, type: String, data: Uri): Intent? { var intentForGuessedMimeType: Intent? = null if (storagePath.lastIndexOf('.') >= 0) { - val guessedMimeType = MimetypeIconUtil.getBestMimeTypeForOpen(type, storagePath) - if (guessedMimeType != type) { + val guessedMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(storagePath.substring(storagePath.lastIndexOf('.') + 1)) + if (guessedMimeType != null && guessedMimeType != type) { intentForGuessedMimeType = Intent(Intent.ACTION_VIEW) intentForGuessedMimeType.setDataAndType(data, guessedMimeType) intentForGuessedMimeType.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION