From c9f6bf30ee747745022e207d20d627028fda2de1 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:28:03 +0200 Subject: [PATCH 1/4] refactor: add share-safe file provider helpers --- .../android/navigation/OtherDestinations.kt | 3 +- .../ui/userprofile/qr/QRCodeIntents.kt | 7 +- .../wire/android/util/AvatarImageManager.kt | 3 +- .../kotlin/com/wire/android/util/FileUtil.kt | 123 +++++++++++++++--- app/src/main/res/xml/provider_paths.xml | 21 ++- .../android/feature/cells/util/FileHelper.kt | 23 ++++ 6 files changed, 153 insertions(+), 27 deletions(-) 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) + } } From f808aefee1c130ee17d98b31f65427f6e617beab Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:28:35 +0200 Subject: [PATCH 2/4] feat: guard internal Wire share imports --- .../wire/android/ui/MiscViewModelFactory.kt | 4 + .../com/wire/android/ui/WireActivity.kt | 20 +- .../android/ui/WireActivityActionsHandler.kt | 9 +- .../wire/android/ui/WireActivityViewModel.kt | 37 +- .../sharing/ImportMediaAuthenticatedState.kt | 4 +- .../ImportMediaAuthenticatedViewModel.kt | 147 ++++++-- .../android/ui/sharing/ImportMediaNavArgs.kt | 24 ++ .../android/ui/sharing/ImportMediaScreen.kt | 35 +- app/src/main/res/values/strings.xml | 5 + .../android/ui/WireActivityViewModelTest.kt | 91 +++++ .../ImportMediaAuthenticatedViewModelTest.kt | 341 ++++++++++++++++++ 11 files changed, 664 insertions(+), 53 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaNavArgs.kt diff --git a/app/src/main/kotlin/com/wire/android/ui/MiscViewModelFactory.kt b/app/src/main/kotlin/com/wire/android/ui/MiscViewModelFactory.kt index badb3baad8b..4af7c9114bc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/MiscViewModelFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/MiscViewModelFactory.kt @@ -17,8 +17,10 @@ */ package com.wire.android.ui +import android.content.Context import androidx.lifecycle.SavedStateHandle import com.wire.android.datastore.UserDataStoreProvider +import com.wire.android.di.ApplicationContext import com.wire.android.di.CurrentAccount import com.wire.android.di.KaliumCoreLogic import com.wire.android.ui.analytics.AnalyticsConfiguration @@ -51,6 +53,7 @@ import dev.zacsweers.metro.Inject @Suppress("LongParameterList") class MiscViewModelFactory @Inject constructor( + @ApplicationContext private val context: Context, private val analyticsEnabled: AnalyticsConfiguration, private val selfServerConfig: Lazy, private val observeSyncState: ObserveSyncStateUseCase, @@ -110,6 +113,7 @@ class MiscViewModelFactory @Inject constructor( ) fun importMediaAuthenticatedViewModel() = ImportMediaAuthenticatedViewModel( + context = context, getSelf = getSelf, getConversationsPaginated = getConversationsPaginated, handleUriAsset = handleUriAsset, diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index 1ca67d69ea8..f928d0643a3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -28,6 +28,7 @@ import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels +import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.layout.Column @@ -153,12 +154,14 @@ import com.wire.android.ui.userprofile.self.LocalSelfUserProfileLogoutAction import com.wire.android.ui.userprofile.self.dialog.LogoutOptionsDialog import com.wire.android.ui.userprofile.self.dialog.LogoutOptionsDialogState import com.wire.android.util.CurrentScreenManager +import com.wire.android.ui.sharing.hasTrustedWireShareCaller import com.wire.android.util.LocalSyncStateObserver import com.wire.android.util.ShakeDetector import com.wire.android.util.SwitchAccountObserver import com.wire.android.util.SyncStateObserver import com.wire.android.util.debug.FeatureVisibilityFlags import com.wire.android.util.debug.LocalFeatureVisibilityFlags +import com.wire.android.util.getProviderAuthority import com.wire.android.util.launchUpdateTheApp import com.wire.kalium.logic.data.user.UserId import kotlinx.coroutines.Dispatchers @@ -287,10 +290,19 @@ class WireActivity : BaseActivity() { handleSynchronizeExternalData(intent) return } - setIntent(intent) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + setIntentWithCurrentCaller(intent) + } else { + setIntent(intent) + } handleNewIntent(intent) } + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + private fun setIntentWithCurrentCaller(intent: Intent) { + setIntent(intent, getCurrentCaller()) + } + private fun handleNewIntent(intent: Intent, savedInstanceState: Bundle? = null) = lifecycleScope.launch { newIntents.send(intent to savedInstanceState) } @@ -1183,7 +1195,11 @@ class WireActivity : BaseActivity() { } else { val handled = viewModel.handleIntentsThatAreNotDeepLinks(intent) if (!handled) { - viewModel.handleDeepLink(intent) + viewModel.handleDeepLink( + intent = intent, + providerAuthority = getProviderAuthority(), + hasTrustedWireShareCaller = hasTrustedWireShareCaller() + ) intent.putExtra(HANDLED_DEEPLINK_FLAG, true) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityActionsHandler.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityActionsHandler.kt index c12c371b6e0..7e781428c9c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityActionsHandler.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityActionsHandler.kt @@ -39,6 +39,7 @@ import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProf import com.ramcosta.composedestinations.generated.app.destinations.WelcomeScreenDestination import com.wire.android.ui.authentication.login.LoginPasswordPath import com.wire.android.ui.newauthentication.login.NewLoginViewModel +import com.wire.android.ui.sharing.ImportMediaNavArgs import kotlinx.coroutines.flow.Flow import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -86,10 +87,10 @@ private fun openConversation(action: OpenConversation, navigator: Navigator) { private fun openImportMediaScreen(navigator: Navigator) { navigator.navigate( - NavigationCommand( - ImportMediaScreenDestination, - BackStackMode.UPDATE_EXISTED - ) + NavigationCommand( + ImportMediaScreenDestination(ImportMediaNavArgs(arrayListOf())), + BackStackMode.UPDATE_EXISTED + ) ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt index 459a01b407c..5bdbbc73665 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt @@ -50,6 +50,8 @@ import com.wire.android.ui.common.dialogs.CustomServerDetailsDialogState import com.wire.android.ui.common.dialogs.CustomServerDialogState import com.wire.android.ui.common.dialogs.CustomServerNoNetworkDialogState import com.wire.android.ui.joinConversation.JoinConversationViaCodeState +import com.wire.android.ui.sharing.sharingUris +import com.wire.android.ui.sharing.shouldRejectSharingIntent import com.wire.android.ui.theme.Accent import com.wire.android.ui.theme.ThemeOption import com.wire.android.util.CurrentScreen @@ -110,6 +112,8 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import java.io.InputStream import java.io.InputStreamReader import dev.zacsweers.metro.Inject @@ -413,7 +417,11 @@ class WireActivityViewModel @Inject constructor( } @Suppress("ComplexMethod") - fun handleDeepLink(intent: Intent?) { + fun handleDeepLink( + intent: Intent?, + providerAuthority: String? = null, + hasTrustedWireShareCaller: Boolean = false + ) { viewModelScope.launch(dispatchers.io()) { when (val result = deepLinkProcessor.value.invoke(intent?.data, intent?.action)) { DeepLinkResult.AuthorizationNeeded -> sendAction(OnAuthorizationNeeded) @@ -442,7 +450,16 @@ class WireActivityViewModel @Inject constructor( is DeepLinkResult.OpenConversation -> sendAction(OpenConversation(result)) is DeepLinkResult.OpenOtherUserProfile -> onOpenUserProfileDeepLink(result) - DeepLinkResult.SharingIntent -> sendAction(OnShowImportMediaScreen) + DeepLinkResult.SharingIntent -> { + val shouldRejectSharingIntent = providerAuthority != null && + intent?.shouldRejectSharingIntent(providerAuthority, hasTrustedWireShareCaller) == true + if (shouldRejectSharingIntent) { + logRejectedSharingIntent(intent, providerAuthority, hasTrustedWireShareCaller) + sendAction(ShowToast(R.string.public_share_ignored_wire_internal_files)) + return@launch + } + sendAction(OnShowImportMediaScreen) + } DeepLinkResult.Unknown -> { sendAction(OnUnknownDeepLink) appLogger.e("unknown deeplink result $result") @@ -451,6 +468,22 @@ class WireActivityViewModel @Inject constructor( } } + private fun logRejectedSharingIntent( + intent: Intent?, + providerAuthority: String?, + hasTrustedWireShareCaller: Boolean + ) { + val logMap = mapOf( + "event" to "public_share_rejected", + "reason" to "wire_file_provider_uri", + "action" to intent?.action.orEmpty(), + "providerAuthority" to providerAuthority.orEmpty(), + "hasTrustedWireShareCaller" to hasTrustedWireShareCaller.toString(), + "uriCount" to (intent?.sharingUris()?.size ?: 0).toString() + ) + appLogger.w("Rejected public share intent: ${Json.encodeToString(logMap)}") + } + // Returns whether an intent was handled, or if there was nothing to do @Suppress("ReturnCount") suspend fun handleIntentsThatAreNotDeepLinks(intent: Intent?): Boolean { diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedState.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedState.kt index ce479b0b540..f01beb8d6e1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedState.kt @@ -37,7 +37,5 @@ data class ImportMediaAuthenticatedState( val selfDeletingTimer: SelfDeletionTimer = SelfDeletionTimer.Enabled(null) ) { @Stable - fun isImportingData() { - importedText?.isNotEmpty() == true || importedAssets.isNotEmpty() - } + fun hasImportedContent(): Boolean = importedText?.isNotEmpty() == true || importedAssets.isNotEmpty() } diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt index ba6bc0cde9f..863feaa3710 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt @@ -17,22 +17,25 @@ */ package com.wire.android.ui.sharing +import android.content.ContentResolver +import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Parcelable +import android.os.Build +import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.app.ShareCompat -import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.map import com.wire.android.BuildConfig import com.wire.android.appLogger +import com.wire.android.di.ApplicationContext import com.wire.android.model.ImageAsset import com.wire.android.model.SnackBarMessage import com.wire.android.ui.common.textfield.textAsFlow @@ -43,14 +46,14 @@ import com.wire.android.ui.home.conversationslist.model.ConversationItemType import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.ui.home.messagecomposer.SelfDeletionDuration import com.wire.android.util.EMPTY +import com.wire.android.util.FILE_PROVIDER_SHARED_FILES_ROOT import com.wire.android.util.dispatchers.DispatcherProvider -import com.wire.android.util.parcelableArrayList +import com.wire.android.util.getProviderAuthority import com.wire.kalium.logic.data.message.SelfDeletionTimer import com.wire.kalium.logic.data.message.SelfDeletionTimer.Companion.SELF_DELETION_LOG_TAG import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTimerSettingsForConversationUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.PersistNewSelfDeletionTimerUseCase import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow @@ -69,6 +72,7 @@ import kotlinx.coroutines.withContext @OptIn(FlowPreview::class) @Suppress("LongParameterList", "TooManyFunctions") class ImportMediaAuthenticatedViewModel( + @ApplicationContext private val context: Context, private val getSelf: ObserveSelfUserUseCase, private val getConversationsPaginated: GetConversationsFromSearchUseCase, private val handleUriAsset: HandleUriAssetUseCase, @@ -151,42 +155,77 @@ class ImportMediaAuthenticatedViewModel( if (incomingIntent.streamCount == 0) { handleSharedText(incomingIntent.text.toString()) } else { + val providerAuthority = activity.getProviderAuthority() + val hasTrustedWireCaller = activity.hasTrustedWireShareCaller() if (incomingIntent.isSingleShare) { // ACTION_SEND - handleSingleIntent(incomingIntent) + handleSingleIntent(providerAuthority, hasTrustedWireCaller, incomingIntent) } else { // ACTION_SEND_MULTIPLE - handleMultipleActionIntent(activity) + handleMultipleActionIntent(activity, providerAuthority, hasTrustedWireCaller) } } importMediaState = importMediaState.copy(isImporting = false) } + suspend fun handleReceivedDataFromInternalShare(uris: List) { + appLogger.i("Received data from internal share ${uris.size}") + importMediaState = importMediaState.copy(isImporting = true) + val providerAuthority = context.getProviderAuthority() + val importedMediaAssets = uris.mapNotNull { uri -> + if (uri.isWireInternalShareUri(providerAuthority)) { + handleImportedAsset(uri, rejectOwnFileProviderUri = false) + } else { + appLogger.w("$TAG: Ignoring internal share URI outside Wire's share provider root") + null + } + } + importMediaState = importMediaState.copy( + importedAssets = importedMediaAssets.toPersistentList(), + isImporting = false + ) + importedMediaAssets.firstOrNull { it.assetSizeExceeded != null }?.let { + onSnackbarMessage(SendMessagesSnackbarMessages.MaxAssetSizeExceeded(it.assetSizeExceeded!!)) + } + } + private fun handleSharedText(text: String) { appLogger.d("$TAG: handleSharedText") importMediaState = importMediaState.copy(importedText = text) } - private suspend fun handleSingleIntent(incomingIntent: ShareCompat.IntentReader) { + private suspend fun handleSingleIntent( + providerAuthority: String, + hasTrustedWireCaller: Boolean, + incomingIntent: ShareCompat.IntentReader + ) { incomingIntent.stream?.let { uri -> appLogger.d("$TAG: handleSingleIntent") - handleImportedAsset(uri)?.let { importedAsset -> - if (importedAsset.assetSizeExceeded != null) { - onSnackbarMessage( - SendMessagesSnackbarMessages.MaxAssetSizeExceeded(importedAsset.assetSizeExceeded) - ) - } - importMediaState = importMediaState.copy(importedAssets = persistentListOf(importedAsset)) - } + handleReceivedUrisFromSharingIntent(providerAuthority, hasTrustedWireCaller, listOf(uri)) } } - private suspend fun handleMultipleActionIntent(activity: AppCompatActivity) { + private suspend fun handleMultipleActionIntent( + activity: AppCompatActivity, + providerAuthority: String, + hasTrustedWireCaller: Boolean + ) { appLogger.d("$TAG: handleMultipleActionIntent") - val importedMediaAssets = activity.intent.parcelableArrayList(Intent.EXTRA_STREAM)?.mapNotNull { - val fileUri = it.toString().toUri() - handleImportedAsset(fileUri) - } ?: listOf() + handleReceivedUrisFromSharingIntent(providerAuthority, hasTrustedWireCaller, activity.intent.sharingUris()) + } + + internal suspend fun handleReceivedUrisFromSharingIntent( + providerAuthority: String, + hasTrustedWireCaller: Boolean, + uris: List + ) { + if (uris.shouldRejectSharingIntent(providerAuthority, hasTrustedWireCaller)) { + appLogger.w("$TAG: Rejecting share intent containing URI from Wire's own file provider") + return + } + val importedMediaAssets = uris.mapNotNull { + handleImportedAsset(it, rejectOwnFileProviderUri = false) + } importMediaState = importMediaState.copy(importedAssets = importedMediaAssets.toPersistentList()) @@ -212,19 +251,25 @@ class ImportMediaAuthenticatedViewModel( } } - private suspend fun handleImportedAsset(uri: Uri): ImportedMediaAsset? = withContext(dispatchers.io()) { - when (val result = handleUriAsset.invoke(uri, saveToDeviceIfInvalid = false)) { - is HandleUriAssetUseCase.Result.Failure.AssetTooLarge -> { - appLogger.w("$TAG: Failed to import asset message: Asset too large") - ImportedMediaAsset(result.assetBundle, result.maxLimitInMB) - } + private suspend fun handleImportedAsset(uri: Uri, rejectOwnFileProviderUri: Boolean): ImportedMediaAsset? { + if (rejectOwnFileProviderUri) { + appLogger.w("$TAG: Ignoring shared URI from Wire's own file provider") + return null + } + return withContext(dispatchers.io()) { + when (val result = handleUriAsset.invoke(uri, saveToDeviceIfInvalid = false)) { + is HandleUriAssetUseCase.Result.Failure.AssetTooLarge -> { + appLogger.w("$TAG: Failed to import asset message: Asset too large") + ImportedMediaAsset(result.assetBundle, result.maxLimitInMB) + } - HandleUriAssetUseCase.Result.Failure.Unknown -> { - appLogger.e("$TAG: Failed to import asset message: Unknown error") - null - } + HandleUriAssetUseCase.Result.Failure.Unknown -> { + appLogger.e("$TAG: Failed to import asset message: Unknown error") + null + } - is HandleUriAssetUseCase.Result.Success -> ImportedMediaAsset(result.assetBundle, null) + is HandleUriAssetUseCase.Result.Success -> ImportedMediaAsset(result.assetBundle, null) + } } } @@ -236,3 +281,43 @@ class ImportMediaAuthenticatedViewModel( private const val TAG = "[ImportMediaAuthenticatedViewModel]" } } + +internal fun Uri.isWireFileProviderUri(providerAuthority: String): Boolean = + scheme.equals(ContentResolver.SCHEME_CONTENT, ignoreCase = true) && authority == providerAuthority + +internal fun Uri.shouldRejectWireFileProviderShare(providerAuthority: String, hasTrustedWireCaller: Boolean): Boolean = + isWireFileProviderUri(providerAuthority) && !hasTrustedWireCaller + +internal fun List.shouldRejectSharingIntent(providerAuthority: String, hasTrustedWireCaller: Boolean): Boolean = + any { uri -> uri.shouldRejectWireFileProviderShare(providerAuthority, hasTrustedWireCaller) } + +internal fun Intent.sharingUris(): List = + when (action) { + Intent.ACTION_SEND -> (extras?.get(Intent.EXTRA_STREAM) as? Uri)?.let(::listOf) ?: emptyList() + Intent.ACTION_SEND_MULTIPLE -> { + @Suppress("UNCHECKED_CAST") + (extras?.get(Intent.EXTRA_STREAM) as? List<*>)?.filterIsInstance() ?: emptyList() + } + else -> emptyList() + } + +internal fun Intent.shouldRejectSharingIntent(providerAuthority: String, hasTrustedWireCaller: Boolean): Boolean = + sharingUris().shouldRejectSharingIntent(providerAuthority, hasTrustedWireCaller) + +internal fun Uri.isWireInternalShareUri(providerAuthority: String): Boolean = + isWireFileProviderUri(providerAuthority) && + pathSegments.let { segments -> + segments.size > 1 && + segments.firstOrNull() == FILE_PROVIDER_SHARED_FILES_ROOT && + segments.none { it == ".." } + } + +internal fun AppCompatActivity.hasTrustedWireShareCaller(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && + getTrustedShareCallerPackageName() == packageName + +@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) +private fun AppCompatActivity.getTrustedShareCallerPackageName(): String? = + runCatching { + caller?.getPackage() ?: initialCaller.getPackage() + }.getOrNull() diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaNavArgs.kt new file mode 100644 index 00000000000..0301da1ed19 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaNavArgs.kt @@ -0,0 +1,24 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.sharing + +import android.net.Uri + +data class ImportMediaNavArgs( + val internalAssetUriList: ArrayList +) diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt index bd5bb96c182..7afcaf0b13c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt @@ -121,24 +121,27 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import okio.Path.Companion.toPath -@WireRootDestination +@WireRootDestination(navArgs = ImportMediaNavArgs::class) @Composable fun ImportMediaScreen( + navArgs: ImportMediaNavArgs, navigator: Navigator, loginTypeSelector: LoginTypeSelector, featureFlagNotificationViewModel: FeatureFlagNotificationViewModel = featureFlagNotificationViewModel(), ) { + val navigateBack = if (navArgs.isInternalShare()) navigator::navigateBack else navigator.finish + when (val fileSharingRestrictedState = featureFlagNotificationViewModel.featureFlagState.isFileSharingState) { FeatureFlagState.FileSharingState.Loading -> { ImportMediaLoadingContent( - navigateBack = navigator.finish + navigateBack = navigateBack ) } FeatureFlagState.FileSharingState.NoUser -> { ImportMediaLoggedOutContent( fileSharingRestrictedState = fileSharingRestrictedState, - navigateBack = navigator.finish, + navigateBack = navigateBack, openWireAction = { val destination = if (loginTypeSelector.canUseNewLogin()) NewLoginScreenDestination() else WelcomeScreenDestination() navigator.navigate(NavigationCommand(destination, BackStackMode.CLEAR_WHOLE)) @@ -150,13 +153,15 @@ fun ImportMediaScreen( FeatureFlagState.FileSharingState.AllowAll, is FeatureFlagState.FileSharingState.AllowSome -> { ImportMediaAuthenticatedContent( + navArgs = navArgs, navigator = navigator, - isRestrictedInTeam = fileSharingRestrictedState == FeatureFlagState.FileSharingState.DisabledByTeam, + isRestrictedInTeam = fileSharingRestrictedState == FeatureFlagState.FileSharingState.DisabledByTeam, + navigateBack = navigateBack, ) } } - BackHandler { navigator.finish() } + BackHandler { navigateBack() } } @Composable @@ -192,8 +197,10 @@ private fun ImportMediaLoadingContent(navigateBack: () -> Unit) { @Composable private fun ImportMediaAuthenticatedContent( + navArgs: ImportMediaNavArgs, navigator: Navigator, isRestrictedInTeam: Boolean, + navigateBack: () -> Unit, checkAssetRestrictionsViewModel: CheckAssetRestrictionsViewModel = checkAssetRestrictionsViewModel(), importMediaViewModel: ImportMediaAuthenticatedViewModel = importMediaAuthenticatedViewModel(), ) { @@ -201,7 +208,7 @@ private fun ImportMediaAuthenticatedContent( ImportMediaRestrictedContent( importMediaAuthenticatedState = importMediaViewModel.importMediaState, avatarAsset = null, - navigateBack = navigator.finish + navigateBack = navigateBack ) } else { LaunchedEffect(checkAssetRestrictionsViewModel.state) { @@ -236,7 +243,7 @@ private fun ImportMediaAuthenticatedContent( }, onNewSelfDeletionTimerPicked = importMediaViewModel::onNewSelfDeletionTimerPicked, infoMessage = importMediaViewModel.infoMessage, - navigateBack = navigator.finish, + navigateBack = navigateBack, onRemoveAsset = importMediaViewModel::onRemove ) AssetTooLargeDialog( @@ -246,16 +253,22 @@ private fun ImportMediaAuthenticatedContent( val context = LocalContext.current with(importMediaViewModel.importMediaState) { - LaunchedEffect(isImportingData()) { - if (importedAssets.isEmpty() || importedText.isNullOrEmpty()) { - context.getActivity() - ?.let { activity -> importMediaViewModel.handleReceivedDataFromSharingIntent(activity) } + LaunchedEffect(navArgs.internalAssetUriList) { + if (!hasImportedContent()) { + if (navArgs.internalAssetUriList.isNotEmpty()) { + importMediaViewModel.handleReceivedDataFromInternalShare(navArgs.internalAssetUriList) + } else { + context.getActivity() + ?.let { activity -> importMediaViewModel.handleReceivedDataFromSharingIntent(activity) } + } } } } } } +private fun ImportMediaNavArgs.isInternalShare(): Boolean = internalAssetUriList.isNotEmpty() + @Composable fun ImportMediaRestrictedContent( importMediaAuthenticatedState: ImportMediaAuthenticatedState, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a32965013d3..990759a6e2b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -794,6 +794,8 @@ Message Details Copy text Share + Forward + Share externally Edit text Delete Message copied @@ -1342,6 +1344,8 @@ In group conversations, the group admin can overwrite this setting. Logs Share Logs Could not prepare logs for sharing + Share in Wire + Share externally Delete All Logs Restart slow sync Restart @@ -1462,6 +1466,7 @@ In group conversations, the group admin can overwrite this setting. Start You can not share this file because this feature is disabled for this account. You need to be logged in to Wire before you can share anything + This share was ignored because it contains internal Wire files. Open Wire Join conversation? diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt index 670f490f15c..32302cb1a34 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt @@ -21,6 +21,8 @@ package com.wire.android.ui import android.content.Intent +import android.net.Uri +import android.os.Bundle import androidx.work.Operation import androidx.work.WorkManager import app.cash.turbine.test @@ -958,6 +960,71 @@ class WireActivityViewModelTest { } } + @Test + fun `given untrusted sharing intent with mixed Wire and external provider uris, when handling deep link, then show ignored toast`() = + runTest { + val (_, viewModel) = Arrangement() + .withDeepLinkResult(DeepLinkResult.SharingIntent) + .arrange() + val intent = sharingIntent( + wireProviderUri(), + externalProviderUri("com.android.providers.media.documents") + ) + + viewModel.actions.test { + viewModel.handleDeepLink( + intent = intent, + providerAuthority = WIRE_PROVIDER_AUTHORITY, + hasTrustedWireShareCaller = false + ) + advanceUntilIdle() + assertEquals(ShowToast(R.string.public_share_ignored_wire_internal_files), expectMostRecentItem()) + expectNoEvents() + } + } + + @Test + fun `given trusted sharing intent with mixed Wire and external provider uris, when handling deep link, then import media screen is shown`() = + runTest { + val (_, viewModel) = Arrangement() + .withDeepLinkResult(DeepLinkResult.SharingIntent) + .arrange() + val intent = sharingIntent( + wireProviderUri(), + externalProviderUri("com.android.providers.media.documents") + ) + + viewModel.actions.test { + viewModel.handleDeepLink( + intent = intent, + providerAuthority = WIRE_PROVIDER_AUTHORITY, + hasTrustedWireShareCaller = true + ) + assertEquals(OnShowImportMediaScreen, expectMostRecentItem()) + } + } + + @Test + fun `given untrusted sharing intent with external provider uris, when handling deep link, then import media screen is shown`() = + runTest { + val (_, viewModel) = Arrangement() + .withDeepLinkResult(DeepLinkResult.SharingIntent) + .arrange() + val intent = sharingIntent( + externalProviderUri("com.android.providers.media.documents"), + externalProviderUri("com.google.android.apps.photos.contentprovider") + ) + + viewModel.actions.test { + viewModel.handleDeepLink( + intent = intent, + providerAuthority = WIRE_PROVIDER_AUTHORITY, + hasTrustedWireShareCaller = false + ) + assertEquals(OnShowImportMediaScreen, expectMostRecentItem()) + } + } + @Test fun `given no valid session, when checking number of sessions, then return true`() = runTest { // given @@ -1375,6 +1442,7 @@ class WireActivityViewModelTest { companion object { val USER_ID = UserId("user_id", "domain.de") val TEST_ACCOUNT_INFO = AccountInfo.Valid(USER_ID) + private const val WIRE_PROVIDER_AUTHORITY = "com.wire.android.provider" private fun mockedTestAccounts(count: Int) = List(count) { i -> TEST_ACCOUNT_INFO.copy(userId = USER_ID.copy("user_$i")) @@ -1388,6 +1456,29 @@ class WireActivityViewModelTest { } } + private fun sharingIntent(vararg uris: Uri): Intent { + val extrasBundle = mockk { + every { this@mockk.get(Intent.EXTRA_STREAM) } returns uris.toList() + } + return mockk { + every { data } returns null + every { action } returns Intent.ACTION_SEND_MULTIPLE + every { extras } returns extrasBundle + } + } + + private fun wireProviderUri(): Uri = + sharingUri(WIRE_PROVIDER_AUTHORITY) + + private fun externalProviderUri(authority: String): Uri = + sharingUri(authority) + + private fun sharingUri(authority: String): Uri = + mockk { + every { scheme } returns "content" + every { this@mockk.authority } returns authority + } + val ongoingCall = Call( CommonTopAppBarViewModelTest.conversationId, CallStatus.ESTABLISHED, diff --git a/app/src/test/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModelTest.kt index d244a9b55e5..fe262b9774b 100644 --- a/app/src/test/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModelTest.kt @@ -17,6 +17,8 @@ */ package com.wire.android.ui.sharing +import android.content.Context +import android.net.Uri import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.paging.PagingData import app.cash.turbine.test @@ -26,19 +28,27 @@ import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri import com.wire.android.framework.TestConversationItem import com.wire.android.framework.TestUser +import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.usecase.GetConversationsFromSearchUseCase import com.wire.android.ui.home.conversations.usecase.HandleUriAssetUseCase +import com.wire.kalium.logic.data.asset.AttachmentType import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTimerSettingsForConversationUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.PersistNewSelfDeletionTimerUseCase import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toPath +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -70,7 +80,329 @@ class ImportMediaAuthenticatedViewModelTest { } } + @Test + fun `given content uri from Wire file provider, when checking provider uri, then match it`() { + val uri = testUri(authority = "com.wire.android.provider") + + assertTrue(uri.isWireFileProviderUri("com.wire.android.provider")) + } + + @Test + fun `given uppercase content scheme from Wire file provider, when checking provider uri, then match it`() { + val uri = testUri(scheme = "CONTENT", authority = "com.wire.android.provider") + + assertTrue(uri.isWireFileProviderUri("com.wire.android.provider")) + } + + @Test + fun `given file uri with Wire authority, when checking provider uri, then reject it`() { + val uri = testUri(scheme = "file", authority = "com.wire.android.provider") + + assertFalse(uri.isWireFileProviderUri("com.wire.android.provider")) + } + + @Test + fun `given uri from provider with Wire authority prefix, when checking provider uri, then reject it`() { + val uri = testUri(authority = "com.wire.android.provider.evil") + + assertFalse(uri.isWireFileProviderUri("com.wire.android.provider")) + } + + @Test + fun `given uri from another content provider, when checking provider uri, then reject it`() { + val uri = testUri(authority = "com.android.providers.media.documents") + + assertFalse(uri.isWireFileProviderUri("com.wire.android.provider")) + } + + @Test + fun `given Wire provider uri from trusted Wire caller, when checking public share rejection, then allow it`() { + val uri = testUri(authority = "com.wire.android.provider") + + assertFalse( + uri.shouldRejectWireFileProviderShare( + providerAuthority = "com.wire.android.provider", + hasTrustedWireCaller = true + ) + ) + } + + @Test + fun `given Wire provider uri without trusted Wire caller, when checking public share rejection, then reject it`() { + val uri = testUri(authority = "com.wire.android.provider") + + assertTrue( + uri.shouldRejectWireFileProviderShare( + providerAuthority = "com.wire.android.provider", + hasTrustedWireCaller = false + ) + ) + } + + @Test + fun `given external provider uri without trusted Wire caller, when checking public share rejection, then allow it`() { + val uri = testUri(authority = "com.android.providers.media.documents") + + assertFalse( + uri.shouldRejectWireFileProviderShare( + providerAuthority = "com.wire.android.provider", + hasTrustedWireCaller = false + ) + ) + } + + @Test + fun `given untrusted public share with Wire provider uri, when handling sharing uris, then reject whole intent`() = + runTest(dispatcherProvider.main()) { + val wireUri = testUri(authority = "com.wire.android.provider") + val externalUri = testUri(authority = "com.android.providers.media.documents") + val (arrangement, viewModel) = Arrangement() + .withHandleUriAsset(HandleUriAssetUseCase.Result.Success(assetBundle("external-file.zip"))) + .arrange() + + viewModel.handleReceivedUrisFromSharingIntent( + providerAuthority = "com.wire.android.provider", + hasTrustedWireCaller = false, + uris = listOf(wireUri, externalUri) + ) + + assertTrue(viewModel.importMediaState.importedAssets.isEmpty()) + coVerify(exactly = 0) { + arrangement.handleUriAssetUseCase.invoke(any(), any()) + } + } + + @Test + fun `given trusted public share with Wire provider uri, when handling sharing uris, then import it`() = + runTest(dispatcherProvider.main()) { + val wireUri = testUri(authority = "com.wire.android.provider") + val assetBundle = assetBundle("wire-file.zip") + val (arrangement, viewModel) = Arrangement() + .withHandleUriAsset(HandleUriAssetUseCase.Result.Success(assetBundle)) + .arrange() + + viewModel.handleReceivedUrisFromSharingIntent( + providerAuthority = "com.wire.android.provider", + hasTrustedWireCaller = true, + uris = listOf(wireUri) + ) + + assertEquals(assetBundle, viewModel.importMediaState.importedAssets.single().assetBundle) + coVerify(exactly = 1) { + arrangement.handleUriAssetUseCase.invoke(wireUri, saveToDeviceIfInvalid = false) + } + } + + @Test + fun `given trusted public share with mixed Wire and external provider uris, when handling sharing uris, then import all`() = + runTest(dispatcherProvider.main()) { + val wireUri = testUri(authority = "com.wire.android.provider") + val externalUri = testUri(authority = "com.android.providers.media.documents") + val wireAssetBundle = assetBundle("wire-file.zip") + val externalAssetBundle = assetBundle("external-file.zip") + val (arrangement, viewModel) = Arrangement() + .withHandleUriAsset(wireUri, HandleUriAssetUseCase.Result.Success(wireAssetBundle)) + .withHandleUriAsset(externalUri, HandleUriAssetUseCase.Result.Success(externalAssetBundle)) + .arrange() + + viewModel.handleReceivedUrisFromSharingIntent( + providerAuthority = "com.wire.android.provider", + hasTrustedWireCaller = true, + uris = listOf(wireUri, externalUri) + ) + + assertEquals( + listOf(wireAssetBundle, externalAssetBundle), + viewModel.importMediaState.importedAssets.map { it.assetBundle } + ) + coVerify(exactly = 1) { + arrangement.handleUriAssetUseCase.invoke(wireUri, saveToDeviceIfInvalid = false) + } + coVerify(exactly = 1) { + arrangement.handleUriAssetUseCase.invoke(externalUri, saveToDeviceIfInvalid = false) + } + } + + @Test + fun `given untrusted public share with only external provider uris, when handling sharing uris, then import all`() = + runTest(dispatcherProvider.main()) { + val firstExternalUri = testUri(authority = "com.android.providers.media.documents") + val secondExternalUri = testUri(authority = "com.google.android.apps.photos.contentprovider") + val firstAssetBundle = assetBundle("first-external-file.zip") + val secondAssetBundle = assetBundle("second-external-file.zip") + val (arrangement, viewModel) = Arrangement() + .withHandleUriAsset(firstExternalUri, HandleUriAssetUseCase.Result.Success(firstAssetBundle)) + .withHandleUriAsset(secondExternalUri, HandleUriAssetUseCase.Result.Success(secondAssetBundle)) + .arrange() + + viewModel.handleReceivedUrisFromSharingIntent( + providerAuthority = "com.wire.android.provider", + hasTrustedWireCaller = false, + uris = listOf(firstExternalUri, secondExternalUri) + ) + + assertEquals( + listOf(firstAssetBundle, secondAssetBundle), + viewModel.importMediaState.importedAssets.map { it.assetBundle } + ) + coVerify(exactly = 1) { + arrangement.handleUriAssetUseCase.invoke(firstExternalUri, saveToDeviceIfInvalid = false) + } + coVerify(exactly = 1) { + arrangement.handleUriAssetUseCase.invoke(secondExternalUri, saveToDeviceIfInvalid = false) + } + } + + @Test + fun `given Wire provider uri under shared files, when checking internal share uri, then allow it`() { + val uri = testUri(pathSegments = listOf("shared_files", "wire-logs.zip")) + + assertTrue(uri.isWireInternalShareUri("com.wire.android.provider")) + } + + @Test + fun `given uri from another provider under shared files, when checking internal share uri, then reject it`() { + val uri = testUri( + authority = "com.android.providers.media.documents", + pathSegments = listOf("shared_files", "wire-logs.zip") + ) + + assertFalse(uri.isWireInternalShareUri("com.wire.android.provider")) + } + + @Test + fun `given Wire provider uri outside shared files, when checking internal share uri, then reject it`() { + val uri = testUri(pathSegments = listOf("cached_files", "private-file.zip")) + + assertFalse(uri.isWireInternalShareUri("com.wire.android.provider")) + } + + @Test + fun `given Wire provider uri with shared files prefix confusion, when checking internal share uri, then reject it`() { + val uri = testUri(pathSegments = listOf("shared_files_evil", "private-file.zip")) + + assertFalse(uri.isWireInternalShareUri("com.wire.android.provider")) + } + + @Test + fun `given Wire provider uri with only shared files root, when checking internal share uri, then reject it`() { + val uri = testUri(pathSegments = listOf("shared_files")) + + assertFalse(uri.isWireInternalShareUri("com.wire.android.provider")) + } + + @Test + fun `given Wire provider uri with traversal segment, when checking internal share uri, then reject it`() { + val uri = testUri(pathSegments = listOf("shared_files", "..", "cached_files", "private-file.zip")) + + assertFalse(uri.isWireInternalShareUri("com.wire.android.provider")) + } + + @Test + fun `given internal Wire file provider uri, when handling internal share, then import it`() = runTest(dispatcherProvider.main()) { + val uri = testUri(pathSegments = listOf("shared_files", "wire-logs.zip")) + val assetBundle = AssetBundle( + key = "key", + mimeType = "application/zip", + dataPath = "/tmp/wire-logs.zip".toPath(), + dataSize = 100L, + fileName = "wire-logs.zip", + assetType = AttachmentType.GENERIC_FILE + ) + val (arrangement, viewModel) = Arrangement() + .withHandleUriAsset(HandleUriAssetUseCase.Result.Success(assetBundle)) + .arrange() + + viewModel.handleReceivedDataFromInternalShare(listOf(uri)) + + assertEquals(assetBundle, viewModel.importMediaState.importedAssets.single().assetBundle) + coVerify(exactly = 1) { + arrangement.handleUriAssetUseCase.invoke(uri, saveToDeviceIfInvalid = false) + } + } + + @Test + fun `given mixed internal share uris, when handling internal share, then import only allowed Wire shared files`() = + runTest(dispatcherProvider.main()) { + val validUri = testUri(pathSegments = listOf("shared_files", "wire-logs.zip")) + val wrongRootUri = testUri(pathSegments = listOf("cached_files", "private-file.zip")) + val wrongProviderUri = testUri( + authority = "com.android.providers.media.documents", + pathSegments = listOf("shared_files", "wire-logs.zip") + ) + val assetBundle = assetBundle(fileName = "wire-logs.zip") + val (arrangement, viewModel) = Arrangement() + .withHandleUriAsset(HandleUriAssetUseCase.Result.Success(assetBundle)) + .arrange() + + viewModel.handleReceivedDataFromInternalShare(listOf(validUri, wrongRootUri, wrongProviderUri)) + + assertEquals(listOf(assetBundle), viewModel.importMediaState.importedAssets.map { it.assetBundle }) + coVerify(exactly = 1) { + arrangement.handleUriAssetUseCase.invoke(validUri, saveToDeviceIfInvalid = false) + } + coVerify(exactly = 0) { + arrangement.handleUriAssetUseCase.invoke(wrongRootUri, any()) + } + coVerify(exactly = 0) { + arrangement.handleUriAssetUseCase.invoke(wrongProviderUri, any()) + } + } + + @Test + fun `given internal Wire provider uri outside shared files, when handling internal share, then reject it`() = + runTest(dispatcherProvider.main()) { + val uri = testUri(pathSegments = listOf("cached_files", "private-file.zip")) + val (arrangement, viewModel) = Arrangement().arrange() + + viewModel.handleReceivedDataFromInternalShare(listOf(uri)) + + assertTrue(viewModel.importMediaState.importedAssets.isEmpty()) + coVerify(exactly = 0) { + arrangement.handleUriAssetUseCase.invoke(any(), any()) + } + } + + @Test + fun `given internal uri from another provider, when handling internal share, then reject it`() = + runTest(dispatcherProvider.main()) { + val uri = testUri( + authority = "com.android.providers.media.documents", + pathSegments = listOf("shared_files", "wire-logs.zip") + ) + val (arrangement, viewModel) = Arrangement().arrange() + + viewModel.handleReceivedDataFromInternalShare(listOf(uri)) + + assertTrue(viewModel.importMediaState.importedAssets.isEmpty()) + coVerify(exactly = 0) { + arrangement.handleUriAssetUseCase.invoke(any(), any()) + } + } + + private fun testUri( + scheme: String = "content", + authority: String = "com.wire.android.provider", + pathSegments: List = emptyList() + ): Uri = mockk { + every { this@mockk.scheme } returns scheme + every { this@mockk.authority } returns authority + every { this@mockk.pathSegments } returns pathSegments + } + + private fun assetBundle(fileName: String) = AssetBundle( + key = "key", + mimeType = "application/zip", + dataPath = "/tmp/$fileName".toPath(), + dataSize = 100L, + fileName = fileName, + assetType = AttachmentType.GENERIC_FILE + ) + inner class Arrangement { + val context = mockk { + every { packageName } returns "com.wire.android" + } @MockK lateinit var getSelfUser: ObserveSelfUserUseCase @@ -100,7 +432,16 @@ class ImportMediaAuthenticatedViewModelTest { mockUri() } + fun withHandleUriAsset(result: HandleUriAssetUseCase.Result) = apply { + coEvery { handleUriAssetUseCase.invoke(any(), any()) } returns result + } + + fun withHandleUriAsset(uri: Uri, result: HandleUriAssetUseCase.Result) = apply { + coEvery { handleUriAssetUseCase.invoke(uri, any()) } returns result + } + fun arrange() = this to ImportMediaAuthenticatedViewModel( + context = context, getSelf = getSelfUser, getConversationsPaginated = getConversationsPaginated, handleUriAsset = handleUriAssetUseCase, From 4a77b337b85e2d5cc45c0d484c21f9f28552955e Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:29:05 +0200 Subject: [PATCH 3/4] feat: split asset share actions --- .../android/ui/edit/ShareAssetMenuOption.kt | 42 +++++++++++++++++++ .../home/conversations/ConversationScreen.kt | 26 ++++++++++-- .../edit/AssetOptionsMenuItems.kt | 14 ++++--- .../edit/MessageOptionsMenuItems.kt | 6 ++- .../edit/MessageOptionsModalSheetLayout.kt | 23 +++++++--- .../media/ConversationMediaScreen.kt | 25 +++++++++-- .../messages/ConversationMessagesViewModel.kt | 8 ++++ .../ui/home/gallery/MediaGalleryScreen.kt | 34 +++++++++++++-- .../ui/home/gallery/MediaGalleryViewModel.kt | 30 +++++++++---- .../home/gallery/MediaGalleryViewModelTest.kt | 6 ++- 10 files changed, 180 insertions(+), 34 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/edit/ShareAssetMenuOption.kt b/app/src/main/kotlin/com/wire/android/ui/edit/ShareAssetMenuOption.kt index 3c1c5013801..ab6f8c63b4b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/edit/ShareAssetMenuOption.kt +++ b/app/src/main/kotlin/com/wire/android/ui/edit/ShareAssetMenuOption.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.res.stringResource import com.wire.android.R import com.wire.android.ui.common.bottomsheet.MenuBottomSheetItem import com.wire.android.ui.common.bottomsheet.MenuItemIcon +import com.wire.android.util.supportsTrustedWireShareCaller @Composable fun ShareAssetMenuOption(onShareAsset: () -> Unit) { @@ -37,6 +38,47 @@ fun ShareAssetMenuOption(onShareAsset: () -> Unit) { ) } +fun shareAssetMenuOptions( + onShareAssetExternally: () -> Unit, + onShareAssetViaWire: () -> Unit +): List<@Composable () -> Unit> = + if (supportsTrustedWireShareCaller()) { + listOf({ ShareAssetMenuOption(onShareAssetExternally) }) + } else { + listOf( + { ShareAssetViaWireMenuOption(onShareAssetViaWire) }, + { ShareAssetExternallyMenuOption(onShareAssetExternally) } + ) + } + +@Composable +fun ShareAssetViaWireMenuOption(onShareAsset: () -> Unit) { + MenuBottomSheetItem( + leading = { + MenuItemIcon( + id = R.drawable.ic_share_file, + contentDescription = stringResource(R.string.content_description_share_the_file), + ) + }, + title = stringResource(R.string.label_share_via_wire), + onItemClick = onShareAsset + ) +} + +@Composable +fun ShareAssetExternallyMenuOption(onShareAsset: () -> Unit) { + MenuBottomSheetItem( + leading = { + MenuItemIcon( + id = R.drawable.ic_share_file, + contentDescription = stringResource(R.string.content_description_share_the_file), + ) + }, + title = stringResource(R.string.label_share_externally), + onItemClick = onShareAsset + ) +} + @Composable fun SharePublicLinkMenuOption(onShareAsset: () -> Unit) { MenuBottomSheetItem( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 6816949e7bd..c6f94769604 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -90,6 +90,7 @@ import androidx.paging.compose.itemKey import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.GroupConversationDetailsScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.ImagesPreviewScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.ImportMediaScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.MediaGalleryScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.MessageDetailsScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination @@ -186,12 +187,14 @@ import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography import com.wire.android.ui.userprofile.service.ServiceDetailsNavArgs import com.wire.android.util.DateAndTimeParsers +import com.wire.android.util.fileShareUri import com.wire.android.util.normalizeLink import com.wire.android.util.openDownloadFolder import com.wire.android.util.serverDate import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText import com.wire.android.util.ui.collectAsLazyPagingItemsWithLifecycle +import com.wire.android.ui.sharing.ImportMediaNavArgs import com.wire.kalium.logic.data.conversation.Conversation.TypingIndicatorMode import com.wire.kalium.logic.data.conversation.InteractionAvailability import com.wire.kalium.logic.data.id.ConversationId @@ -535,7 +538,19 @@ fun ConversationScreen( }, composerMessages = sendMessageViewModel.infoMessage, conversationMessages = conversationMessagesViewModel.infoMessage, - shareAsset = conversationMessagesViewModel::shareAsset, + shareAssetExternally = conversationMessagesViewModel::shareAsset, + shareAssetViaWire = { messageId -> + conversationMessagesViewModel.shareAssetViaWire(messageId) { path, assetName -> + navigator.navigate( + NavigationCommand( + ImportMediaScreenDestination( + ImportMediaNavArgs(arrayListOf(context.fileShareUri(path, assetName))) + ), + BackStackMode.UPDATE_EXISTED + ) + ) + } + }, onDownloadAssetClick = conversationMessagesViewModel::openOrFetchAsset, onOpenAssetClick = conversationMessagesViewModel::downloadAndOpenAsset, onNavigateToReplyOriginalMessage = conversationMessagesViewModel::navigateToReplyOriginalMessage, @@ -800,7 +815,8 @@ private fun ConversationScreen( onBackButtonClick: () -> Unit, composerMessages: SharedFlow, conversationMessages: SharedFlow, - shareAsset: (Context, messageId: String) -> Unit, + shareAssetExternally: (Context, messageId: String) -> Unit, + shareAssetViaWire: (messageId: String) -> Unit, onDownloadAssetClick: (messageId: String) -> Unit, onOpenAssetClick: (messageId: String) -> Unit, onNavigateToReplyOriginalMessage: (UIMessage) -> Unit, @@ -943,7 +959,8 @@ private fun ConversationScreen( onDetailsClick = onMessageDetailsClick, onReplyClick = messageComposerStateHolder::toReply, onEditClick = messageComposerStateHolder::toEdit, - onShareAssetClick = { shareAsset(context, it) }, + onShareAssetExternallyClick = { shareAssetExternally(context, it) }, + onShareAssetViaWireClick = shareAssetViaWire, onDownloadAssetClick = onDownloadAssetClick, onOpenAssetClick = onOpenAssetClick, ) @@ -1810,7 +1827,8 @@ fun PreviewConversationScreen() = WireTheme { onBackButtonClick = {}, composerMessages = MutableStateFlow(ConversationSnackbarMessages.ErrorDownloadingAsset), conversationMessages = MutableStateFlow(ConversationSnackbarMessages.ErrorDownloadingAsset), - shareAsset = { _, _ -> }, + shareAssetExternally = { _, _ -> }, + shareAssetViaWire = {}, onOpenAssetClick = {}, onDownloadAssetClick = {}, onNavigateToReplyOriginalMessage = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/AssetOptionsMenuItems.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/AssetOptionsMenuItems.kt index 6fe78438314..2032d62e8ca 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/AssetOptionsMenuItems.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/AssetOptionsMenuItems.kt @@ -24,7 +24,7 @@ import com.wire.android.ui.edit.MessageDetailsMenuOption import com.wire.android.ui.edit.OpenAssetExternallyOption import com.wire.android.ui.edit.ReactionOption import com.wire.android.ui.edit.ReplyMessageOption -import com.wire.android.ui.edit.ShareAssetMenuOption +import com.wire.android.ui.edit.shareAssetMenuOptions // menu items with both asset options enabled (like share, download, etc.) and message options enabled (like reply, reaction, etc.) @Composable @@ -33,7 +33,8 @@ fun assetMessageOptionsMenuItems( ownReactions: Set, onDeleteClick: () -> Unit, onDetailsClick: () -> Unit, - onShareAsset: () -> Unit, + onShareAssetExternally: () -> Unit, + onShareAssetViaWire: () -> Unit, onDownloadAsset: () -> Unit, onReplyClick: () -> Unit, onReactionClick: (emoji: String) -> Unit, @@ -59,7 +60,7 @@ fun assetMessageOptionsMenuItems( add { MessageDetailsMenuOption(onDetailsClick) } add { ReplyMessageOption(onReplyClick) } add { DownloadAssetExternallyOption(onDownloadAsset) } - add { ShareAssetMenuOption(onShareAsset) } + addAll(shareAssetMenuOptions(onShareAssetExternally, onShareAssetViaWire)) if (isOpenable) add { OpenAssetExternallyOption(onOpenAsset) } add { DeleteItemMenuOption(onDeleteClick) } } @@ -72,7 +73,8 @@ fun assetMessageOptionsMenuItems( fun assetOptionsMenuItems( isEphemeral: Boolean, onDeleteClick: () -> Unit, - onShareAsset: () -> Unit, + onShareAssetExternally: () -> Unit, + onShareAssetViaWire: () -> Unit, onDownloadAsset: () -> Unit, isOpenable: Boolean = false, onOpenAsset: () -> Unit = {}, @@ -80,7 +82,9 @@ fun assetOptionsMenuItems( ): List<@Composable () -> Unit> = buildList { if (!isUploading) { add { DownloadAssetExternallyOption(onDownloadAsset) } - if (!isEphemeral) add { ShareAssetMenuOption(onShareAsset) } + if (!isEphemeral) { + addAll(shareAssetMenuOptions(onShareAssetExternally, onShareAssetViaWire)) + } if (isOpenable) add { OpenAssetExternallyOption(onOpenAsset) } } add { DeleteItemMenuOption(onDeleteClick) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsMenuItems.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsMenuItems.kt index d3ef8a11585..5a41643d025 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsMenuItems.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsMenuItems.kt @@ -36,7 +36,8 @@ fun messageOptionsMenuItems( onDetailsClick: () -> Unit, onReplyClick: () -> Unit, onEditClick: () -> Unit, - onShareAssetClick: () -> Unit, + onShareAssetExternallyClick: () -> Unit, + onShareAssetViaWireClick: () -> Unit, onDownloadAssetClick: () -> Unit, onOpenAssetClick: () -> Unit ): List<@Composable () -> Unit> { @@ -48,7 +49,8 @@ fun messageOptionsMenuItems( isOpenable = isOpenable, onDeleteClick = onDeleteClick, onDetailsClick = onDetailsClick, - onShareAsset = onShareAssetClick, + onShareAssetExternally = onShareAssetExternallyClick, + onShareAssetViaWire = onShareAssetViaWireClick, onDownloadAsset = onDownloadAssetClick, onReplyClick = onReplyClick, onReactionClick = onReactionClick, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsModalSheetLayout.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsModalSheetLayout.kt index a92194f9b00..e7b5d635dae 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsModalSheetLayout.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsModalSheetLayout.kt @@ -60,7 +60,8 @@ fun MessageOptionsModalSheetLayout( onDetailsClick: (messageId: String, isSelfMessage: Boolean) -> Unit, onReplyClick: (UIMessage.Regular) -> Unit, onEditClick: (messageId: String, messageBody: String, mentions: List, isMultipart: Boolean) -> Unit, - onShareAssetClick: (messageId: String) -> Unit, + onShareAssetExternallyClick: (messageId: String) -> Unit, + onShareAssetViaWireClick: (messageId: String) -> Unit, onDownloadAssetClick: (messageId: String) -> Unit, onOpenAssetClick: (messageId: String) -> Unit, viewModel: MessageOptionsMenuViewModel = @@ -84,7 +85,8 @@ fun MessageOptionsModalSheetLayout( onDetailsClick = onDetailsClick, onReplyClick = onReplyClick, onEditClick = onEditClick, - onShareAssetClick = onShareAssetClick, + onShareAssetExternallyClick = onShareAssetExternallyClick, + onShareAssetViaWireClick = onShareAssetViaWireClick, onDownloadAssetClick = onDownloadAssetClick, onOpenAssetClick = onOpenAssetClick ).also { @@ -118,7 +120,8 @@ private fun MessageOptionsModalContent( onDetailsClick: (messageId: String, isSelfMessage: Boolean) -> Unit, onReplyClick: (UIMessage.Regular) -> Unit, onEditClick: (messageId: String, messageBody: String, mentions: List, isMultipart: Boolean) -> Unit, - onShareAssetClick: (messageId: String) -> Unit, + onShareAssetExternallyClick: (messageId: String) -> Unit, + onShareAssetViaWireClick: (messageId: String) -> Unit, onDownloadAssetClick: (messageId: String) -> Unit, onOpenAssetClick: (messageId: String) -> Unit, ) { @@ -214,10 +217,17 @@ private fun MessageOptionsModalContent( } } }, - onShareAssetClick = remember(message.header.messageId) { + onShareAssetExternallyClick = remember(message.header.messageId) { { sheetState.hide { - onShareAssetClick(message.header.messageId) + onShareAssetExternallyClick(message.header.messageId) + } + } + }, + onShareAssetViaWireClick = remember(message.header.messageId) { + { + sheetState.hide { + onShareAssetViaWireClick(message.header.messageId) } } }, @@ -284,7 +294,8 @@ fun PreviewMessageOptionsModalSheetLayout() = WireTheme { onDetailsClick = { _, _ -> }, onReplyClick = { }, onEditClick = { _, _, _, _ -> }, - onShareAssetClick = { }, + onShareAssetExternallyClick = { }, + onShareAssetViaWireClick = { }, onDownloadAssetClick = { }, onOpenAssetClick = { } ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt index 8ab3a0b0777..57039418486 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt @@ -63,6 +63,7 @@ import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.visbility.rememberVisibilityState import com.ramcosta.composedestinations.generated.app.destinations.MediaGalleryScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.ImportMediaScreenDestination import com.wire.android.ui.home.conversations.ConversationSnackbarMessages import com.wire.android.ui.home.conversations.DownloadedAssetDialog import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState @@ -72,8 +73,10 @@ import com.wire.android.ui.home.conversations.delete.DeleteMessageDialog import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogState import com.wire.android.ui.home.conversations.edit.assetOptionsMenuItems import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel +import com.wire.android.ui.sharing.ImportMediaNavArgs import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions +import com.wire.android.util.fileShareUri import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.SnackBarMessageHandler import com.wire.android.util.ui.UIText @@ -127,7 +130,18 @@ fun ConversationMediaScreen( conversationMessagesViewModel.deleteMessageDialogState .show(DeleteMessageDialogState(deleteForEveryone, messageId, conversationMessagesViewModel.conversationId)) }, - shareAsset = remember { { conversationMessagesViewModel.shareAsset(context, it) } }, + shareAssetExternally = { conversationMessagesViewModel.shareAsset(context, it) }, + shareAssetViaWire = { messageId -> + conversationMessagesViewModel.shareAssetViaWire(messageId) { path, assetName -> + navigator.navigate( + NavigationCommand( + ImportMediaScreenDestination( + ImportMediaNavArgs(arrayListOf(context.fileShareUri(path, assetName))) + ) + ) + ) + } + }, downloadAsset = conversationMessagesViewModel::openOrFetchAsset, ) @@ -248,7 +262,8 @@ private fun Content( private fun AssetOptionsModalSheetLayout( sheetState: WireModalSheetState, deleteAsset: (messageId: String, isMyMessage: Boolean) -> Unit, - shareAsset: (messageId: String) -> Unit, + shareAssetExternally: (messageId: String) -> Unit, + shareAssetViaWire: (messageId: String) -> Unit, downloadAsset: (messageId: String) -> Unit, ) { WireModalSheetLayout( @@ -259,7 +274,8 @@ private fun AssetOptionsModalSheetLayout( isUploading = false, // only uploaded assets isEphemeral = false, // only non-self-deleting assets onDeleteClick = remember { { sheetState.hide { deleteAsset(messageId, isMyMessage) } } }, - onShareAsset = remember { { sheetState.hide { shareAsset(messageId) } } }, + onShareAssetExternally = remember { { sheetState.hide { shareAssetExternally(messageId) } } }, + onShareAssetViaWire = remember { { sheetState.hide { shareAssetViaWire(messageId) } } }, onDownloadAsset = remember { { sheetState.hide { downloadAsset(messageId) } } }, ) ) @@ -321,7 +337,8 @@ fun PreviewAssetOptionsModalSheetLayout() = WireTheme { ) ), deleteAsset = { _, _ -> }, - shareAsset = { }, + shareAssetExternally = { }, + shareAssetViaWire = { }, downloadAsset = { } ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt index f926958c805..4058bd30ec8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt @@ -433,6 +433,14 @@ class ConversationMessagesViewModel( } } + fun shareAssetViaWire(messageId: String, onAssetReady: (Path, String) -> Unit) { + viewModelScope.launch { + assetDataPath(conversationId, messageId)?.run { + onAssetReady(first, second) + } + } + } + private suspend fun assetDataPath(conversationId: QualifiedID, messageId: String): Pair? = getMessageAsset(conversationId, messageId).await().run { return when (this) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt index 2028b46cc8d..ccb0fd8821a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt @@ -37,6 +37,7 @@ import com.ramcosta.composedestinations.result.ResultBackNavigator import com.wire.android.R import com.wire.android.ui.common.R as commonR import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.ImportMediaScreenDestination import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.style.PopUpNavigationAnimation @@ -56,15 +57,20 @@ import com.wire.android.ui.edit.MessageDetailsMenuOption import com.wire.android.ui.edit.ReactionOption import com.wire.android.ui.edit.ReplyMessageOption import com.wire.android.ui.edit.ShareAssetMenuOption +import com.wire.android.ui.edit.ShareAssetExternallyMenuOption +import com.wire.android.ui.edit.ShareAssetViaWireMenuOption import com.wire.android.ui.edit.SharePublicLinkMenuOption import com.wire.android.ui.home.conversations.MediaGallerySnackbarMessages import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState import com.wire.android.ui.home.conversations.delete.DeleteMessageDialog import com.wire.android.ui.home.conversations.mediaGalleryViewModel import com.wire.android.ui.home.conversations.mock.mockedPrivateAsset +import com.wire.android.ui.sharing.ImportMediaNavArgs import com.wire.android.ui.theme.WireTheme +import com.wire.android.util.fileShareUri import com.wire.android.util.permission.rememberWriteStoragePermissionFlow import com.wire.android.util.startFileShareIntent +import com.wire.android.util.supportsTrustedWireShareCaller import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.SnackBarMessageHandler import com.wire.android.util.openDownloadFolder @@ -136,7 +142,15 @@ fun MediaGalleryScreen( HandleActions(mediaGalleryViewModel.actions) { action -> when (action) { - is MediaGalleryAction.Share -> context.startFileShareIntent(action.path, action.assetName) + is MediaGalleryAction.ShareExternally -> context.startFileShareIntent(action.path, action.assetName) + is MediaGalleryAction.ShareViaWire -> navigator.navigate( + NavigationCommand( + ImportMediaScreenDestination( + ImportMediaNavArgs(arrayListOf(context.fileShareUri(action.path, action.assetName))) + ) + ) + ) + is MediaGalleryAction.ShowDetails -> { resultNavigator.setResult( MediaGalleryNavBackArgs( @@ -240,6 +254,9 @@ private fun MediaGalleryOptionsBottomSheetLayout( val sheetState: WireModalSheetState = rememberWireModalSheetState(WireSheetValue.Expanded(Unit)) val onOptionsClick: (MenuIntent) -> Unit = remember { { sheetState.hide { onMenuIntent(it) } } } + val collapseShareOptions = supportsTrustedWireShareCaller() && + menuItems.contains(MediaGalleryMenuItem.SHARE_VIA_WIRE) && + menuItems.contains(MediaGalleryMenuItem.SHARE_EXTERNALLY) val menuItems: List<@Composable () -> Unit> = buildList { menuItems.forEach { item -> @@ -256,11 +273,20 @@ private fun MediaGalleryOptionsBottomSheetLayout( MediaGalleryMenuItem.DOWNLOAD -> add { DownloadAssetExternallyOption { onOptionsClick(MenuIntent.Download) } } - MediaGalleryMenuItem.SHARE -> add { - ShareAssetMenuOption { onOptionsClick(MenuIntent.Share) } + MediaGalleryMenuItem.SHARE_EXTERNALLY -> if (!collapseShareOptions) { + add { + ShareAssetExternallyMenuOption { onOptionsClick(MenuIntent.ShareExternally) } + } + } + MediaGalleryMenuItem.SHARE_VIA_WIRE -> add { + if (collapseShareOptions) { + ShareAssetMenuOption { onOptionsClick(MenuIntent.ShareExternally) } + } else { + ShareAssetViaWireMenuOption { onOptionsClick(MenuIntent.ShareViaWire) } + } } MediaGalleryMenuItem.SHARE_PUBLIC_LINK -> add { - SharePublicLinkMenuOption { onOptionsClick(MenuIntent.Share) } + SharePublicLinkMenuOption { onOptionsClick(MenuIntent.ShareExternally) } } MediaGalleryMenuItem.DELETE -> add { DeleteItemMenuOption { onOptionsClick(MenuIntent.Delete) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModel.kt index d01220abc11..8f1bc5bdac6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModel.kt @@ -121,7 +121,7 @@ class MediaGalleryViewModel( private fun shareAsset() = viewModelScope.launch { if (cellAssetId == null) { assetDataPath(conversationId, messageId)?.run { - sendAction(MediaGalleryAction.Share(first, second)) + sendAction(MediaGalleryAction.ShareExternally(first, second)) } } else { getCellNode(cellAssetId) @@ -189,6 +189,14 @@ class MediaGalleryViewModel( } } + private fun shareAssetViaWire() = viewModelScope.launch { + if (cellAssetId == null) { + assetDataPath(conversationId, messageId)?.run { + sendAction(MediaGalleryAction.ShareViaWire(first, second)) + } + } + } + private fun onSnackbarMessage(messageCode: MediaGallerySnackbarMessages) { viewModelScope.launch { _snackbarMessage.emit(messageCode) @@ -227,7 +235,8 @@ class MediaGalleryViewModel( MenuIntent.Download -> sendAction(MediaGalleryAction.Download) - MenuIntent.Share -> shareAsset() + MenuIntent.ShareExternally -> shareAsset() + MenuIntent.ShareViaWire -> shareAssetViaWire() MenuIntent.Delete -> { deleteMessageDialogState.show( @@ -272,13 +281,17 @@ class MediaGalleryViewModel( add(MediaGalleryMenuItem.SHOW_DETAILS) add(MediaGalleryMenuItem.REPLY) add(MediaGalleryMenuItem.DOWNLOAD) - add(MediaGalleryMenuItem.SHARE) + add(MediaGalleryMenuItem.SHARE_VIA_WIRE) + add(MediaGalleryMenuItem.SHARE_EXTERNALLY) add(MediaGalleryMenuItem.DELETE) } } } else if (cellAssetId == null) { add(MediaGalleryMenuItem.DOWNLOAD) - if (!mediaGalleryNavArgs.isEphemeral) add(MediaGalleryMenuItem.SHARE) + if (!mediaGalleryNavArgs.isEphemeral) { + add(MediaGalleryMenuItem.SHARE_VIA_WIRE) + add(MediaGalleryMenuItem.SHARE_EXTERNALLY) + } add(MediaGalleryMenuItem.DELETE) } } @@ -298,7 +311,8 @@ class MediaGalleryViewModel( sealed interface MediaGalleryAction { data class ShowDetails(val messageId: String, val isSelfAsset: Boolean) : MediaGalleryAction - data class Share(val path: Path, val assetName: String) : MediaGalleryAction + data class ShareExternally(val path: Path, val assetName: String) : MediaGalleryAction + data class ShareViaWire(val path: Path, val assetName: String) : MediaGalleryAction data class React(val messageId: String, val emoji: String) : MediaGalleryAction data class Reply(val messageId: String) : MediaGalleryAction data object Download : MediaGalleryAction @@ -312,7 +326,8 @@ sealed interface MenuIntent { data object ShowDetails : MenuIntent data object Reply : MenuIntent data object Download : MenuIntent - data object Share : MenuIntent + data object ShareExternally : MenuIntent + data object ShareViaWire : MenuIntent data object Delete : MenuIntent } @@ -321,7 +336,8 @@ enum class MediaGalleryMenuItem { SHOW_DETAILS, REPLY, DOWNLOAD, - SHARE, + SHARE_EXTERNALLY, + SHARE_VIA_WIRE, SHARE_PUBLIC_LINK, DELETE } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt index 38f1065163f..3ad57a665a3 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt @@ -270,7 +270,8 @@ class MediaGalleryViewModelTest { assertEquals( listOf( MediaGalleryMenuItem.DOWNLOAD, - MediaGalleryMenuItem.SHARE, + MediaGalleryMenuItem.SHARE_VIA_WIRE, + MediaGalleryMenuItem.SHARE_EXTERNALLY, MediaGalleryMenuItem.DELETE, ), state.menuItems @@ -371,7 +372,8 @@ class MediaGalleryViewModelTest { MediaGalleryMenuItem.SHOW_DETAILS, MediaGalleryMenuItem.REPLY, MediaGalleryMenuItem.DOWNLOAD, - MediaGalleryMenuItem.SHARE, + MediaGalleryMenuItem.SHARE_VIA_WIRE, + MediaGalleryMenuItem.SHARE_EXTERNALLY, MediaGalleryMenuItem.DELETE, ), state.menuItems From 345ef22de3a9324012f6daf154265ab0dda83195 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:33:45 +0200 Subject: [PATCH 4/4] fix: capture context for asset forwarding --- .../wire/android/ui/home/conversations/ConversationScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index c6f94769604..14180e062b6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -256,7 +256,8 @@ fun ConversationScreen( ) { val coroutineScope = rememberCoroutineScope() val uriHandler = LocalUriHandler.current - val resources = LocalContext.current.resources + val context = LocalContext.current + val resources = context.resources val showDialog = remember { mutableStateOf(ConversationScreenDialogType.NONE) } val messageComposerViewState = messageComposerViewModel.messageComposerViewState val messageComposerStateHolder = rememberMessageComposerStateHolder(