From b78bb22489855fa47b234b30cde93eac604f5c25 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 12 Oct 2026 08:36:10 +0000 Subject: [PATCH] fix(share): send original file instead of cached thumbnail A picture that was not downloaded was sent as a content URI of DiskLruImageCacheFileProvider, which serves a downscaled thumbnail from the preview cache. The provider is exported with the MANAGE_DOCUMENTS permission, so other apps could not read it either. Every file that is not on the device now goes through SendShareDialogDownloader: the original is downloaded and then sent. The audio player implements the downloader like the image preview does. A host without a downloader shows a message instead of closing the dialog silently. The choice is made by SendShareAction and covered by a unit test. DiskLruImageCacheFileProvider is removed together with its manifest entry, injector and image_cache_provider_authority. "Set as" is hidden for a picture that is not downloaded, because it used the same provider. The thumbnail descriptor for DocumentsStorageProvider moves into that class. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../android/files/FileMenuFilterIT.kt | 31 ++++ app/src/main/AndroidManifest.xml | 7 +- .../nextcloud/client/di/ComponentsModule.kt | 4 - .../player/ui/audio/AudioPlayerActivity.kt | 19 ++- .../android/files/FileMenuFilter.java | 2 +- .../DiskLruImageCacheFileProvider.java | 138 ------------------ .../providers/DocumentsStorageProvider.java | 35 ++++- .../android/ui/dialog/SendShareAction.kt | 21 +++ .../android/ui/dialog/SendShareDialog.kt | 37 ++--- .../ui/helpers/FileOperationsHelper.java | 38 +---- app/src/main/res/values/setup.xml | 1 - app/src/main/res/values/strings.xml | 2 +- app/src/qa/res/values/setup.xml | 1 - .../android/ui/dialog/SendShareActionTest.kt | 39 +++++ app/src/versionDev/res/values/setup.xml | 1 - 15 files changed, 167 insertions(+), 209 deletions(-) delete mode 100644 app/src/main/java/com/owncloud/android/providers/DiskLruImageCacheFileProvider.java create mode 100644 app/src/main/java/com/owncloud/android/ui/dialog/SendShareAction.kt create mode 100644 app/src/test/java/com/owncloud/android/ui/dialog/SendShareActionTest.kt diff --git a/app/src/androidTest/java/com/owncloud/android/files/FileMenuFilterIT.kt b/app/src/androidTest/java/com/owncloud/android/files/FileMenuFilterIT.kt index c0a0cdfbdcc5..c7b9e7925fe4 100644 --- a/app/src/androidTest/java/com/owncloud/android/files/FileMenuFilterIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/files/FileMenuFilterIT.kt @@ -364,6 +364,35 @@ class FileMenuFilterIT : AbstractIT() { } } + @Test + fun filter_setPictureAs_onlyForDownloadedImages() { + configureCapability(OCCapability()) + + val downloadedImage = OCFile("/downloaded.jpg").apply { + mimeType = IMAGE_MIMETYPE + storagePath = getDummyFile("downloaded.jpg").absolutePath + } + val onlineOnlyImage = OCFile("/online.jpg").apply { + mimeType = IMAGE_MIMETYPE + } + + launchActivity().use { + it.onActivity { activity -> + val filterFactory = FileMenuFilter.Factory(mockStorageManager, activity, editorUtils) + + val downloadedToHide = filterFactory + .newInstance(downloadedImage, mockComponentsGetter, true, user) + .getToHide(false) + assertFalse(downloadedToHide.contains(R.id.action_set_as_wallpaper)) + + val onlineToHide = filterFactory + .newInstance(onlineOnlyImage, mockComponentsGetter, true, user) + .getToHide(false) + assertTrue(onlineToHide.contains(R.id.action_set_as_wallpaper)) + } + } + } + @Test fun filter_readOnlyFile_hidesModifyingActions() { configureCapability( @@ -479,6 +508,8 @@ class FileMenuFilterIT : AbstractIT() { R.id.action_favorite ) + private const val IMAGE_MIMETYPE = "image/jpeg" + private const val OFFICE_MIMETYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 22a2c841ab65..cc15847fee80 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -503,12 +503,7 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/exposed_filepaths" /> - + toHide, boolean endToEndEncrypti } private void filterSetPictureAs(List toHide) { - if (!isSingleImage() || MimeTypeUtil.isSVG(files.iterator().next())) { + if (!isSingleImage() || MimeTypeUtil.isSVG(files.iterator().next()) || !allFileDown()) { toHide.add(R.id.action_set_as_wallpaper); } } diff --git a/app/src/main/java/com/owncloud/android/providers/DiskLruImageCacheFileProvider.java b/app/src/main/java/com/owncloud/android/providers/DiskLruImageCacheFileProvider.java deleted file mode 100644 index 97b2f0188979..000000000000 --- a/app/src/main/java/com/owncloud/android/providers/DiskLruImageCacheFileProvider.java +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2017 Tobias Kaminsky - * SPDX-FileCopyrightText: 2017 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ - -package com.owncloud.android.providers; - -import android.content.ContentProvider; -import android.content.ContentValues; -import android.database.Cursor; -import android.database.MatrixCursor; -import android.graphics.Bitmap; -import android.net.Uri; -import android.os.ParcelFileDescriptor; -import android.provider.OpenableColumns; - -import com.nextcloud.client.account.User; -import com.nextcloud.client.account.UserAccountManager; -import com.nextcloud.utils.extensions.FileExtensionsKt; -import com.owncloud.android.MainApp; -import com.owncloud.android.datamodel.FileDataStorageManager; -import com.owncloud.android.datamodel.OCFile; -import com.owncloud.android.datamodel.ThumbnailsCacheManager; -import com.owncloud.android.lib.common.utils.Log_OC; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.nio.file.Files; - -import javax.inject.Inject; - -import androidx.annotation.NonNull; -import dagger.android.AndroidInjection; - -public class DiskLruImageCacheFileProvider extends ContentProvider { - public static final String TAG = DiskLruImageCacheFileProvider.class.getSimpleName(); - - @Inject - protected UserAccountManager accountManager; - - @Override - public boolean onCreate() { - AndroidInjection.inject(this); - return true; - } - - private OCFile getFile(Uri uri) { - User user = accountManager.getUser(); - FileDataStorageManager fileDataStorageManager = new FileDataStorageManager(user, - MainApp.getAppContext().getContentResolver()); - - return fileDataStorageManager.getFileByPath(uri.getPath()); - } - - @Override - public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException { - return getParcelFileDescriptorForOCFile(getFile(uri)); - } - - public static ParcelFileDescriptor getParcelFileDescriptorForOCFile(OCFile ocFile) throws FileNotFoundException { - Bitmap thumbnail = FileExtensionsKt.getBigThumbnail(ocFile); - - // fallback to thumbnail - if (thumbnail == null) { - thumbnail = FileExtensionsKt.getSmallThumbnail(ocFile); - } - - // fallback to default image - if (thumbnail == null) { - thumbnail = ThumbnailsCacheManager.mDefaultImg; - } - - // create a file to write bitmap data - File f = new File(MainApp.getAppContext().getCacheDir(), ocFile.getFileName()); - try { - Files.createFile(f.toPath()); - - //Convert bitmap to byte array - ByteArrayOutputStream bos = new ByteArrayOutputStream(); - thumbnail.compress(Bitmap.CompressFormat.PNG, 90, bos); - byte[] bitmapData = bos.toByteArray(); - - //write the bytes in file - try (FileOutputStream fos = new FileOutputStream(f)){ - fos.write(bitmapData); - } catch (FileNotFoundException e) { - Log_OC.e(TAG, "File not found: " + e.getMessage()); - } - - } catch (Exception e) { - Log_OC.e(TAG, "Error opening file: " + e.getMessage()); - } - - return ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY); - } - - @Override - public String getType(@NonNull Uri uri) { - OCFile ocFile = getFile(uri); - return ocFile.getMimeType(); - } - - @Override - public Cursor query(@NonNull Uri uri, String[] arg1, String arg2, String[] arg3, String arg4) { - MatrixCursor cursor = null; - - OCFile ocFile = getFile(uri); - File file = new File(MainApp.getAppContext().getCacheDir(), ocFile.getFileName()); - if (file.exists()) { - cursor = new MatrixCursor(new String[] { - OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE }); - cursor.addRow(new Object[] { uri.getLastPathSegment(), - file.length() }); - } - - return cursor; - } - - @Override - public Uri insert(@NonNull Uri uri, ContentValues values) { - return null; - } - - @Override - public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) { - return 0; - } - - @Override - public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) { - return 0; - } -} diff --git a/app/src/main/java/com/owncloud/android/providers/DocumentsStorageProvider.java b/app/src/main/java/com/owncloud/android/providers/DocumentsStorageProvider.java index 8aacf15a18b3..f36a07de299d 100644 --- a/app/src/main/java/com/owncloud/android/providers/DocumentsStorageProvider.java +++ b/app/src/main/java/com/owncloud/android/providers/DocumentsStorageProvider.java @@ -15,6 +15,7 @@ import android.content.Context; import android.content.res.AssetFileDescriptor; import android.database.Cursor; +import android.graphics.Bitmap; import android.graphics.Point; import android.net.Uri; import android.os.AsyncTask; @@ -68,9 +69,12 @@ import org.nextcloud.providers.cursors.FileCursor; import org.nextcloud.providers.cursors.RootCursor; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileNotFoundException; +import java.io.FileOutputStream; import java.io.IOException; +import java.nio.file.Files; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -105,6 +109,7 @@ public class DocumentsStorageProvider extends DocumentsProvider { @VisibleForTesting static final String DOCUMENTID_SEPARATOR = "/"; private static final int DOCUMENTID_PARTS = 2; + private static final int THUMBNAIL_PNG_QUALITY = 90; private final Map rootIdToStorageManager = new HashMap<>(); private final Executor executor = Executors.newCachedThreadPool(); @@ -388,7 +393,7 @@ public AssetFileDescriptor openDocumentThumbnail(String documentId, ThumbnailsCacheManager.generateThumbnailFromOCFile(file, document.getUser(), getContext()); } - return new AssetFileDescriptor(DiskLruImageCacheFileProvider.getParcelFileDescriptorForOCFile(file), + return new AssetFileDescriptor(getThumbnailFileDescriptor(file), 0, file.getFileLength()); } finally { @@ -396,6 +401,34 @@ public AssetFileDescriptor openDocumentThumbnail(String documentId, } } + private ParcelFileDescriptor getThumbnailFileDescriptor(OCFile ocFile) throws FileNotFoundException { + Bitmap thumbnail = FileExtensionsKt.getBigThumbnail(ocFile); + + if (thumbnail == null) { + thumbnail = FileExtensionsKt.getSmallThumbnail(ocFile); + } + + if (thumbnail == null) { + thumbnail = ThumbnailsCacheManager.mDefaultImg; + } + + File thumbnailFile = new File(MainApp.getAppContext().getCacheDir(), ocFile.getFileName()); + try { + Files.createFile(thumbnailFile.toPath()); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + thumbnail.compress(Bitmap.CompressFormat.PNG, THUMBNAIL_PNG_QUALITY, bos); + + try (FileOutputStream fos = new FileOutputStream(thumbnailFile)) { + fos.write(bos.toByteArray()); + } + } catch (Exception e) { + Log_OC.e(TAG, "Error writing thumbnail file: " + e.getMessage()); + } + + return ParcelFileDescriptor.open(thumbnailFile, ParcelFileDescriptor.MODE_READ_ONLY); + } + @Override public String renameDocument(String documentId, String displayName) throws FileNotFoundException { Log_OC.d(TAG, "renameDocument(), id=" + documentId); diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/SendShareAction.kt b/app/src/main/java/com/owncloud/android/ui/dialog/SendShareAction.kt new file mode 100644 index 000000000000..eb55d9b0b053 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/dialog/SendShareAction.kt @@ -0,0 +1,21 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.dialog + +enum class SendShareAction { + SEND_NOW, + DOWNLOAD_FIRST, + DOWNLOAD_UNAVAILABLE; + + companion object { + fun resolve(isDownloaded: Boolean, hostCanDownload: Boolean): SendShareAction = when { + isDownloaded -> SEND_NOW + hostCanDownload -> DOWNLOAD_FIRST + else -> DOWNLOAD_UNAVAILABLE + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/SendShareDialog.kt b/app/src/main/java/com/owncloud/android/ui/dialog/SendShareDialog.kt index 1ea754c42ae1..ff78ca2cd3d7 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/SendShareDialog.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/SendShareDialog.kt @@ -26,6 +26,7 @@ import com.google.android.material.snackbar.Snackbar import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.client.di.Injectable import com.nextcloud.client.utils.IntentUtil.createSendIntent +import com.nextcloud.utils.SnackbarUtil import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.mdm.MDMConfig import com.owncloud.android.BuildConfig @@ -39,7 +40,6 @@ import com.owncloud.android.ui.activity.FileDisplayActivity import com.owncloud.android.ui.adapter.sendButton.SendButtonAdapter import com.owncloud.android.ui.adapter.sendButton.SendButtonData import com.owncloud.android.ui.helpers.FileOperationsHelper -import com.owncloud.android.utils.MimeTypeUtil import com.owncloud.android.utils.theme.ViewThemeUtils import javax.inject.Inject @@ -216,38 +216,33 @@ class SendShareDialog : this.fileOperationsHelper = fileOperationsHelper } - @Suppress("ReturnCount") override fun onSendButtonClick(sendButtonData: SendButtonData) { - val packageName = sendButtonData.packageName - val activityName = sendButtonData.activityName val file = file ?: return + val downloader = activity as? SendShareDialogDownloader try { - if (MimeTypeUtil.isImage(file) && !file.isDown) { - fileOperationsHelper?.sendCachedImage(file, packageName, activityName) - return - } + when (SendShareAction.resolve(file.isDown, downloader != null)) { + SendShareAction.SEND_NOW -> sendDownloadedFile(sendButtonData) - if (file.isDown) { - val activity = activity ?: return - sendIntent?.component = ComponentName(packageName, activityName) - activity.startActivity(Intent.createChooser(sendIntent, getString(R.string.send))) - return - } + SendShareAction.DOWNLOAD_FIRST -> + downloader?.downloadFile(file, sendButtonData.packageName, sendButtonData.activityName) - Log_OC.d(TAG, file.remotePath + ": File must be downloaded") - val downloader = activity as? SendShareDialogDownloader - if (downloader == null) { - Log_OC.e(TAG, "Host activity does not implement SendShareDialogDownloader: $activity") - return + SendShareAction.DOWNLOAD_UNAVAILABLE -> { + Log_OC.e(TAG, "Host activity does not implement SendShareDialogDownloader: $activity") + SnackbarUtil.show(activity, R.string.send_share_download_unavailable) + } } - - downloader.downloadFile(file, packageName, activityName) } finally { dismiss() } } + private fun sendDownloadedFile(sendButtonData: SendButtonData) { + val activity = activity ?: return + sendIntent?.component = ComponentName(sendButtonData.packageName, sendButtonData.activityName) + activity.startActivity(Intent.createChooser(sendIntent, getString(R.string.send))) + } + interface SendShareDialogDownloader { fun downloadFile(file: OCFile, packageName: String, activityName: String) } diff --git a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java index 74284f878558..d7728b8fadf7 100755 --- a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java +++ b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java @@ -16,7 +16,6 @@ import android.accounts.Account; import android.app.Activity; import android.content.ActivityNotFoundException; -import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; @@ -74,7 +73,6 @@ import com.owncloud.android.ui.fragment.OCFileListFragment; import com.owncloud.android.utils.EncryptionUtils; import com.owncloud.android.utils.FileStorageUtils; -import com.owncloud.android.utils.UriUtils; import com.owncloud.android.utils.theme.ViewThemeUtils; import org.greenrobot.eventbus.EventBus; @@ -808,26 +806,6 @@ public void sendShareFile(OCFile file) { sendShareFile(file, !file.canReshare()); } - public void sendCachedImage(OCFile file, String packageName, String activityName) { - if (file != null) { - Context context = MainApp.getAppContext(); - Intent sendIntent = new Intent(Intent.ACTION_SEND); - // set MimeType - sendIntent.setType(file.getMimeType()); - sendIntent.setComponent(new ComponentName(packageName, activityName)); - sendIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse("content://" + - context.getResources().getString(R.string.image_cache_provider_authority) + - file.getRemotePath())); - sendIntent.putExtra(Intent.ACTION_SEND, true); // Send Action - sendIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - - fileActivity.startActivity(Intent.createChooser(sendIntent, - context.getString(R.string.actionbar_send_file))); - } else { - Log_OC.wtf(TAG, "Trying to send a NULL OCFile"); - } - } - public void setPictureAs(OCFile file, View view) { if (file != null) { Context context = MainApp.getAppContext(); @@ -835,17 +813,11 @@ public void setPictureAs(OCFile file, View view) { Uri uri; try { - if (file.isDown()) { - File externalFile = new File(file.getStoragePath()); - - intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - uri = FileProvider.getUriForFile(context, - context.getResources().getString(R.string.file_provider_authority), externalFile); - } else { - uri = Uri.parse(UriUtils.URI_CONTENT_SCHEME + - context.getResources().getString(R.string.image_cache_provider_authority) + - file.getRemotePath()); - } + File externalFile = new File(file.getStoragePath()); + + intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + uri = FileProvider.getUriForFile(context, + context.getResources().getString(R.string.file_provider_authority), externalFile); intent.setDataAndType(uri, file.getMimeType()); intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); diff --git a/app/src/main/res/values/setup.xml b/app/src/main/res/values/setup.xml index 1c47e9f57d4f..cc3920ba01d3 100644 --- a/app/src/main/res/values/setup.xml +++ b/app/src/main/res/values/setup.xml @@ -18,7 +18,6 @@ com.nextcloud.android.providers.UsersAndGroupsSearchProvider.action.SHARE_WITH org.nextcloud.documents org.nextcloud.files - org.nextcloud.imageCache.provider nextcloud.db nextcloud nextcloud diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c18bcf8427e0..407e1cf37a6c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,7 +23,6 @@ New folder Settings Details - Send Sort Sort by Albums @@ -1608,6 +1607,7 @@ Cannot open file chooser Android System WebView is missing or disabled on this device Send copy to + This file can\'t be sent from here because it isn\'t on your device yet Failed to start action! Failed to load tags Action triggered diff --git a/app/src/qa/res/values/setup.xml b/app/src/qa/res/values/setup.xml index 6dd47627bcb4..b9a2a2ca6587 100644 --- a/app/src/qa/res/values/setup.xml +++ b/app/src/qa/res/values/setup.xml @@ -18,7 +18,6 @@ org.nextcloud.qa.android.providers.UsersAndGroupsSearchProvider.action.SHARE_WITH org.nextcloud.qa.documents org.nextcloud.qa.files - org.nextcloud.qa.android.providers.imageCache Nextcloud QA true diff --git a/app/src/test/java/com/owncloud/android/ui/dialog/SendShareActionTest.kt b/app/src/test/java/com/owncloud/android/ui/dialog/SendShareActionTest.kt new file mode 100644 index 000000000000..38f41db2f74f --- /dev/null +++ b/app/src/test/java/com/owncloud/android/ui/dialog/SendShareActionTest.kt @@ -0,0 +1,39 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.dialog + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SendShareActionTest { + + @Test + fun downloadedFileIsSentImmediately() { + assertEquals(SendShareAction.SEND_NOW, SendShareAction.resolve(isDownloaded = true, hostCanDownload = true)) + } + + @Test + fun downloadedFileIsSentImmediatelyEvenWhenHostCannotDownload() { + assertEquals(SendShareAction.SEND_NOW, SendShareAction.resolve(isDownloaded = true, hostCanDownload = false)) + } + + @Test + fun notDownloadedFileIsDownloadedWhenHostCanDownload() { + assertEquals( + SendShareAction.DOWNLOAD_FIRST, + SendShareAction.resolve(isDownloaded = false, hostCanDownload = true) + ) + } + + @Test + fun notDownloadedFileIsRejectedWhenHostCannotDownload() { + assertEquals( + SendShareAction.DOWNLOAD_UNAVAILABLE, + SendShareAction.resolve(isDownloaded = false, hostCanDownload = false) + ) + } +} diff --git a/app/src/versionDev/res/values/setup.xml b/app/src/versionDev/res/values/setup.xml index 4ae866e6728a..66843031678f 100644 --- a/app/src/versionDev/res/values/setup.xml +++ b/app/src/versionDev/res/values/setup.xml @@ -18,7 +18,6 @@ org.nextcloud.beta.android.providers.UsersAndGroupsSearchProvider.action.SHARE_WITH org.nextcloud.beta.documents org.nextcloud.beta.files - org.nextcloud.beta.android.providers.imageCache Nextcloud Dev true