diff --git a/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt b/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt index 3ba47c3d7e3..670e586b21d 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt @@ -26,6 +26,7 @@ import com.ramcosta.composedestinations.spec.Direction import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.util.EmailComposer +import com.wire.android.util.externalShareChooserIntent import com.wire.android.util.getDeviceIdString import com.wire.android.util.getGitBuildId import com.wire.android.util.sha256 @@ -100,7 +101,7 @@ object GiveFeedbackDestination : IntentDirection { ) ) intent.selector = Intent(Intent.ACTION_SENDTO).setData(Uri.parse("mailto:")) - return Intent.createChooser(intent, context.getString(R.string.send_feedback_choose_email)) + return context.externalShareChooserIntent(intent, context.getString(R.string.send_feedback_choose_email)) } override val route: String diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/QRCodeIntents.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/QRCodeIntents.kt index 3a1e10a1a20..bdbac36d378 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/QRCodeIntents.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/QRCodeIntents.kt @@ -20,6 +20,7 @@ package com.wire.android.ui.userprofile.qr import android.content.Context import android.content.Intent import android.net.Uri +import com.wire.android.util.externalShareChooserIntent fun Context.shareLinkToProfile(selfProfileUrl: String) { val sendIntent: Intent = @@ -31,8 +32,7 @@ fun Context.shareLinkToProfile(selfProfileUrl: String) { type = "text/plain" } - val shareIntent = Intent.createChooser(sendIntent, null) - startActivity(shareIntent) + startActivity(externalShareChooserIntent(sendIntent)) } fun Context.shareQRToProfile(uri: Uri) { @@ -41,8 +41,9 @@ fun Context.shareQRToProfile(uri: Uri) { action = Intent.ACTION_SEND addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) putExtra(Intent.EXTRA_STREAM, uri) type = "image/jpg" } - startActivity(sendIntent) + startActivity(externalShareChooserIntent(sendIntent)) } diff --git a/app/src/main/kotlin/com/wire/android/util/AvatarImageManager.kt b/app/src/main/kotlin/com/wire/android/util/AvatarImageManager.kt index c8b203391e2..0ce534c595d 100644 --- a/app/src/main/kotlin/com/wire/android/util/AvatarImageManager.kt +++ b/app/src/main/kotlin/com/wire/android/util/AvatarImageManager.kt @@ -20,7 +20,6 @@ package com.wire.android.util import android.content.Context import android.net.Uri -import androidx.core.content.FileProvider import androidx.core.net.toUri import okio.Path import dev.zacsweers.metro.Inject @@ -33,6 +32,6 @@ class AvatarImageManager @Inject constructor(val context: Context) { } fun getShareableTempAvatarUri(filePath: Path): Uri { - return FileProvider.getUriForFile(context, context.getProviderAuthority(), filePath.toFile()) + return context.shareableFileProviderUri(context.fileProviderSharedCacheFile(filePath.name)) } } diff --git a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt index d00a5590135..2ab209eefaa 100644 --- a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt +++ b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt @@ -20,17 +20,21 @@ package com.wire.android.util +import android.app.ActivityOptions import android.app.DownloadManager import android.content.ActivityNotFoundException +import android.content.ComponentName import android.content.ContentResolver import android.content.ContentValues import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.database.Cursor import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever.METADATA_KEY_DURATION import android.net.Uri import android.os.Build +import android.os.Bundle import android.os.Environment import android.os.Environment.DIRECTORY_DOWNLOADS import android.os.Parcelable @@ -62,6 +66,7 @@ import java.io.File import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream +import java.nio.file.Files import java.util.Locale import kotlin.time.Duration.Companion.milliseconds @@ -73,9 +78,9 @@ suspend fun Uri.toByteArray(context: Context, dispatcher: DispatcherProvider = D } fun getTempWritableAttachmentUri(context: Context, attachmentPath: Path): Uri { - val file = attachmentPath.toFile() + val file = context.fileProviderSharedCacheFile(attachmentPath.name) file.setWritable(true) - return FileProvider.getUriForFile(context, context.getProviderAuthority(), file) + return context.fileProviderUri(file) } suspend fun createPemFile( @@ -191,7 +196,7 @@ private fun Context.saveFileDataToMediaFolder(assetName: String, downloadedDataP fun Context.fromNioPathToContentUri(nioPath: java.nio.file.Path): Uri = this.pathToUri(nioPath.toOkioPath(), null) fun Context.pathToUri(assetDataPath: Path, assetName: String?): Uri = - FileProvider.getUriForFile(this, getProviderAuthority(), assetDataPath.toFile(), assetName ?: assetDataPath.name) + shareableFileProviderUri(assetDataPath.toFile(), assetName ?: assetDataPath.name) fun Uri.getMimeType(context: Context): String? { val mimeType: String? = if (this.scheme == ContentResolver.SCHEME_CONTENT) { @@ -264,13 +269,7 @@ private fun Context.getContentFileName(uri: Uri): String? = runCatching { }.getOrNull() fun Context.startFileShareIntent(path: Path, assetName: String?) { - val assetDisplayName = assetName ?: path.name - val fileURI = FileProvider.getUriForFile( - this, - getProviderAuthority(), - path.toFile(), - assetDisplayName - ) + val fileURI = fileShareUri(path, assetName) val shareIntent = Intent(Intent.ACTION_SEND) shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) shareIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) @@ -280,9 +279,12 @@ fun Context.startFileShareIntent(path: Path, assetName: String?) { shareIntent.putExtra(Intent.EXTRA_STREAM, fileURI) assetName?.let { shareIntent.putExtra(Intent.EXTRA_SUBJECT, it) } shareIntent.type = fileURI.getMimeType(context = this) - startActivity(shareIntent) + startShareIntentWithTrustedWireTarget(shareIntent) } +fun Context.fileShareUri(path: Path, assetName: String?): Uri = + shareableFileProviderUri(path.toFile(), assetName ?: path.name) + fun saveFileToDownloadsFolder(assetName: String, assetDataPath: Path, assetDataSize: Long, context: Context): Uri? = context.saveFileDataToDownloadsFolder(assetName, assetDataPath, assetDataSize) @@ -302,11 +304,7 @@ fun Context.getUrisOfFilesInDirectory(dir: File): ArrayList { val files = ArrayList() dir.listFiles()?.map { - val uri = FileProvider.getUriForFile( - this, - getProviderAuthority(), - it - ) + val uri = shareableFileProviderUri(it) files.add(uri) } @@ -377,7 +375,7 @@ fun shareAssetFileWithExternalApp(assetDataPath: Path, context: Context, assetNa setDataAndType(assetUri, mimeType) putExtra(Intent.EXTRA_STREAM, assetUri) } - context.startActivity(intent) + context.startActivity(context.externalShareChooserIntent(intent)) } catch (e: java.lang.IllegalArgumentException) { appLogger.e("The file couldn't be found on the internal storage \n$e") onError() @@ -387,6 +385,94 @@ fun shareAssetFileWithExternalApp(assetDataPath: Path, context: Context, assetNa } } +fun Context.externalShareChooserIntent( + sendIntent: Intent, + title: CharSequence? = null, + excludeOwnComponents: Boolean = true +): Intent = + Intent.createChooser(sendIntent, title).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (!excludeOwnComponents) return@apply + val ownShareComponents = packageManager.queryIntentActivitiesCompat(sendIntent) + .map { ComponentName(it.activityInfo.packageName, it.activityInfo.name) } + .filter { it.packageName == packageName } + .toTypedArray() + if (ownShareComponents.isNotEmpty()) { + putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, ownShareComponents) + } + } + +fun Context.startShareIntentWithTrustedWireTarget(sendIntent: Intent, title: CharSequence? = null) { + val chooserIntent = externalShareChooserIntent( + sendIntent = sendIntent, + title = title, + excludeOwnComponents = !supportsTrustedWireShareCaller() + ) + startActivity(chooserIntent, shareIdentityOptionsBundle()) +} + +fun supportsTrustedWireShareCaller(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM + +private fun shareIdentityOptionsBundle(): Bundle? = + if (supportsTrustedWireShareCaller()) { + ActivityOptions.makeBasic() + .setShareIdentityEnabled(true) + .toBundle() + } else { + null + } + +fun Context.fileProviderSharedCacheFile(fileName: String): File { + val shareDirectory = fileProviderSharedCacheDirectory() + deleteStaleFileProviderSharedCacheFiles(shareDirectory) + return File(shareDirectory, findFirstUniqueName(shareDirectory, fileName.ifBlank { ATTACHMENT_FILENAME })) +} + +fun Context.shareableFileProviderUri(sourceFile: File, displayName: String? = null): Uri { + val shareFile = when { + sourceFile.isInDirectory(fileProviderSharedCacheDirectory()) -> sourceFile + else -> linkOrCopyToFileProviderSharedCache(sourceFile, displayName ?: sourceFile.name) + } + return fileProviderUri(shareFile, displayName) +} + +private fun Context.linkOrCopyToFileProviderSharedCache(sourceFile: File, displayName: String): File { + val shareFile = fileProviderSharedCacheFile(displayName) + runCatching { + Files.createLink(shareFile.toPath(), sourceFile.toPath()) + }.recoverCatching { + sourceFile.copyTo(shareFile, overwrite = true) + }.getOrThrow() + return shareFile +} + +private fun Context.fileProviderSharedCacheDirectory(): File = + File(cacheDir, FILE_PROVIDER_SHARED_CACHE_DIRECTORY).apply { mkdirs() } + +private fun Context.fileProviderUri(file: File, displayName: String? = null): Uri = + FileProvider.getUriForFile(this, getProviderAuthority(), file, displayName ?: file.name) + +private fun File.isInDirectory(directory: File): Boolean { + val directoryPath = directory.canonicalFile.toPath() + return canonicalFile.toPath().startsWith(directoryPath) +} + +private fun deleteStaleFileProviderSharedCacheFiles(directory: File) { + val oldestAllowedTimestamp = System.currentTimeMillis() - FILE_PROVIDER_SHARED_CACHE_MAX_AGE_MILLIS + directory.listFiles() + ?.filter { it.isFile && it.lastModified() < oldestAllowedTimestamp } + ?.forEach(File::delete) +} + +private fun PackageManager.queryIntentActivitiesCompat(intent: Intent) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong())) + } else { + @Suppress("DEPRECATION") + queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + } + inline fun Intent.parcelable(key: String): T? = when { Build.VERSION.SDK_INT >= SDK_VERSION -> getParcelableExtra(key, T::class.java) else -> @Suppress("DEPRECATION") getParcelableExtra(key) as? T @@ -498,5 +584,8 @@ fun getAudioLengthInMs(dataPath: Path, mimeType: String): Long = private const val ATTACHMENT_FILENAME = "attachment" private const val DATA_COPY_BUFFER_SIZE = 2048 +const val FILE_PROVIDER_SHARED_FILES_ROOT = "shared_files" +private const val FILE_PROVIDER_SHARED_CACHE_DIRECTORY = "file-provider-shares" +private const val FILE_PROVIDER_SHARED_CACHE_MAX_AGE_MILLIS = 24 * 60 * 60 * 1000L const val SDK_VERSION = 33 const val SUPPORTED_AUDIO_MIME_TYPE = "audio/wav" diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml index b95f280b658..d79dc077ee4 100644 --- a/app/src/main/res/xml/provider_paths.xml +++ b/app/src/main/res/xml/provider_paths.xml @@ -19,8 +19,21 @@ - - + name="shared_files" + path="file-provider-shares/" /> + + + + + diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt b/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt index b2a6c81e298..dc8ed9a308d 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt @@ -18,9 +18,11 @@ package com.wire.android.feature.cells.util import android.content.ActivityNotFoundException +import android.content.ComponentName import android.content.ContentValues import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.os.Environment import android.provider.MediaStore @@ -116,7 +118,9 @@ class FileHelper @Inject constructor( putExtra(Intent.EXTRA_STREAM, assetUri) } val chooserIntent = Intent.createChooser(intent, null).apply { + excludeOwnShareTargets(intent) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(chooserIntent) } catch (e: java.lang.IllegalArgumentException) { @@ -134,6 +138,7 @@ class FileHelper @Inject constructor( putExtra(Intent.EXTRA_TEXT, url) } val chooserIntent = Intent.createChooser(intent, null).apply { + excludeOwnShareTargets(intent) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(chooserIntent) @@ -156,4 +161,22 @@ class FileHelper @Inject constructor( private fun Context.pathToUri(assetDataPath: Path, assetName: String?): Uri = FileProvider.getUriForFile(this, getProviderAuthority(), assetDataPath.toFile(), assetName ?: assetDataPath.name) + + private fun Intent.excludeOwnShareTargets(sendIntent: Intent) { + val ownShareComponents = context.packageManager.queryIntentActivitiesCompat(sendIntent) + .map { ComponentName(it.activityInfo.packageName, it.activityInfo.name) } + .filter { it.packageName == context.packageName } + .toTypedArray() + if (ownShareComponents.isNotEmpty()) { + putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, ownShareComponents) + } + } + + private fun PackageManager.queryIntentActivitiesCompat(intent: Intent) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong())) + } else { + @Suppress("DEPRECATION") + queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + } }