From 35c4cab54aa07028c3af1e5a75c9df885b705e4d Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:16:17 +0200 Subject: [PATCH 01/14] refactor: add share-safe file provider helpers PR 1 [WPB-26687] (#5033) --- .../android/navigation/OtherDestinations.kt | 3 +- .../avatarpicker/AvatarPickerViewModel.kt | 2 +- .../ui/userprofile/qr/QRCodeIntents.kt | 7 +- .../wire/android/util/AvatarImageManager.kt | 8 +- .../kotlin/com/wire/android/util/FileUtil.kt | 123 +++++++++++++++--- app/src/main/res/xml/provider_paths.xml | 21 ++- .../image/AvatarPickerViewModelTest.kt | 6 +- .../android/feature/cells/util/FileHelper.kt | 23 ++++ 8 files changed, 162 insertions(+), 31 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 05f114572cf..c6eb97133f0 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt @@ -28,6 +28,7 @@ import com.wire.android.R import com.wire.android.util.EmailComposer import com.wire.android.util.SupportPage import com.wire.android.util.SupportUrlResolver +import com.wire.android.util.externalShareChooserIntent import com.wire.android.util.getDeviceIdString import com.wire.android.util.getGitBuildId import com.wire.android.util.sha256 @@ -114,7 +115,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/avatarpicker/AvatarPickerViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerViewModel.kt index 2403b50c8e9..93a4a5817ea 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerViewModel.kt @@ -73,7 +73,7 @@ class AvatarPickerViewModel @Inject constructor( val infoMessage = _infoMessage.asSharedFlow() val defaultAvatarPath: Path get() = kaliumFileSystem.selfUserAvatarPath() - val temporaryAvatarUri: Uri = avatarImageManager.getShareableTempAvatarUri(defaultAvatarPath) + val temporaryAvatarUri: Uri = avatarImageManager.createCameraOutputAvatarUri(defaultAvatarPath) init { loadInitialAvatarState() 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..c4739f49c4d 100644 --- a/app/src/main/kotlin/com/wire/android/util/AvatarImageManager.kt +++ b/app/src/main/kotlin/com/wire/android/util/AvatarImageManager.kt @@ -32,7 +32,11 @@ class AvatarImageManager @Inject constructor(val context: Context) { return file.toUri() } - fun getShareableTempAvatarUri(filePath: Path): Uri { - return FileProvider.getUriForFile(context, context.getProviderAuthority(), filePath.toFile()) + /** + * Creates a temporary URI that the camera app writes the captured avatar into. + */ + fun createCameraOutputAvatarUri(filePath: Path): Uri { + val cameraOutputFile = context.fileProviderSharedCacheFile(filePath.name) + return FileProvider.getUriForFile(context, context.getProviderAuthority(), cameraOutputFile) } } 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/app/src/test/kotlin/com/wire/android/ui/userprofile/image/AvatarPickerViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/image/AvatarPickerViewModelTest.kt index a1aa3dafe47..879faeb09a7 100644 --- a/app/src/test/kotlin/com/wire/android/ui/userprofile/image/AvatarPickerViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/image/AvatarPickerViewModelTest.kt @@ -220,7 +220,7 @@ class AvatarPickerViewModelTest { } coEvery { getAvatarAsset(any()) } returns PublicAssetResult.Success(avatarPath) coEvery { avatarImageManager.getWritableAvatarUri(any()) } returns mockTargetUri - coEvery { avatarImageManager.getShareableTempAvatarUri(any()) } returns mockTargetUri + coEvery { avatarImageManager.createCameraOutputAvatarUri(any()) } returns mockTargetUri coEvery { any().resampleImageAndCopyToTempPath(any(), any(), any(), eq(true), any()) } returns 1L coEvery { any().toByteArray(any(), any()) } returns ByteArray(5) every { userDataStore.avatarAssetId } returns flow { emit(avatarAssetId) } @@ -232,7 +232,7 @@ class AvatarPickerViewModelTest { fun withFailedInitialAvatarLoad(): Arrangement { val avatarAssetId = "avatar-value@avatar-domain" coEvery { getAvatarAsset(any()) } returns PublicAssetResult.Failure(Unknown(RuntimeException("some error")), false) - coEvery { avatarImageManager.getShareableTempAvatarUri(any()) } returns mockTargetUri + coEvery { avatarImageManager.createCameraOutputAvatarUri(any()) } returns mockTargetUri every { userDataStore.avatarAssetId } returns flow { emit(avatarAssetId) } every { qualifiedIdMapper.fromStringToQualifiedID(any()) } returns QualifiedID("avatar-value", "avatar-domain") @@ -240,7 +240,7 @@ class AvatarPickerViewModelTest { } fun withNoInitialAvatar(): Arrangement { - coEvery { avatarImageManager.getShareableTempAvatarUri(any()) } returns mockTargetUri + coEvery { avatarImageManager.createCameraOutputAvatarUri(any()) } returns mockTargetUri every { userDataStore.avatarAssetId } returns flow { emit(null) } return this 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 640dea641e19d1756bcf252252b613b24cd898c1 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:14:38 +0200 Subject: [PATCH 02/14] feat: guard internal Wire share imports PR2 [WPB-26687] (#5034) --- .../wire/android/ui/MiscViewModelFactory.kt | 4 + .../com/wire/android/ui/WireActivity.kt | 20 +- .../android/ui/WireActivityActionsHandler.kt | 10 +- .../wire/android/ui/WireActivityViewModel.kt | 37 +- .../sharing/ImportMediaAuthenticatedState.kt | 4 +- .../ImportMediaAuthenticatedViewModel.kt | 144 ++++++-- .../android/ui/sharing/ImportMediaNavArgs.kt | 30 ++ .../android/ui/sharing/ImportMediaScreen.kt | 37 +- app/src/main/res/values/strings.xml | 5 + .../android/ui/WireActivityViewModelTest.kt | 91 +++++ .../ImportMediaAuthenticatedViewModelTest.kt | 341 ++++++++++++++++++ 11 files changed, 669 insertions(+), 54 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 b8d1eafa9cc..97096bedac3 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 @@ -159,12 +160,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 @@ -324,10 +327,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) } @@ -1246,7 +1258,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 f185ba43edd..3885e9120e8 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,8 @@ 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 com.wire.android.ui.sharing.ImportSource import kotlinx.coroutines.flow.Flow import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -86,10 +88,10 @@ private fun openConversation(action: OpenConversation, navigator: Navigator) { private fun openImportMediaScreen(navigator: Navigator) { navigator.navigate( - NavigationCommand( - ImportMediaScreenDestination, - BackStackMode.UPDATE_EXISTED - ) + NavigationCommand( + ImportMediaScreenDestination(ImportMediaNavArgs(source = ImportSource.EXTERNAL_SHARE)), + 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 e9e1a5f6813..d908c79c2d2 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.BackendSupportConfig @@ -114,6 +116,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 @@ -441,7 +445,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) @@ -470,7 +478,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") @@ -479,6 +496,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..6a8d7fe7b66 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) + } 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) + } importMediaState = importMediaState.copy(importedAssets = importedMediaAssets.toPersistentList()) @@ -212,21 +251,22 @@ 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): 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) + } - 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) + } } - } private fun onSnackbarMessage(type: SnackBarMessage) = viewModelScope.launch { _infoMessage.emit(type) @@ -236,3 +276,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..d515c41f41c --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaNavArgs.kt @@ -0,0 +1,30 @@ +/* + * 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 source: ImportSource, + val internalAssetUriList: ArrayList = arrayListOf() +) + +enum class ImportSource { + EXTERNAL_SHARE, + INTERNAL_SHARE +} 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 3e3fe27825a..15765dc99fd 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 @@ -123,24 +123,30 @@ 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 = when (navArgs.source) { + ImportSource.EXTERNAL_SHARE -> navigator.finish + ImportSource.INTERNAL_SHARE -> navigator::navigateBack + } + 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)) @@ -152,13 +158,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 @@ -194,8 +202,10 @@ private fun ImportMediaLoadingContent(navigateBack: () -> Unit) { @Composable private fun ImportMediaAuthenticatedContent( + navArgs: ImportMediaNavArgs, navigator: Navigator, isRestrictedInTeam: Boolean, + navigateBack: () -> Unit, checkAssetRestrictionsViewModel: CheckAssetRestrictionsViewModel = checkAssetRestrictionsViewModel(), importMediaViewModel: ImportMediaAuthenticatedViewModel = importMediaAuthenticatedViewModel(), ) { @@ -203,7 +213,7 @@ private fun ImportMediaAuthenticatedContent( ImportMediaRestrictedContent( importMediaAuthenticatedState = importMediaViewModel.importMediaState, avatarAsset = null, - navigateBack = navigator.finish + navigateBack = navigateBack ) } else { LaunchedEffect(checkAssetRestrictionsViewModel.state) { @@ -238,7 +248,7 @@ private fun ImportMediaAuthenticatedContent( }, onNewSelfDeletionTimerPicked = importMediaViewModel::onNewSelfDeletionTimerPicked, infoMessage = importMediaViewModel.infoMessage, - navigateBack = navigator.finish, + navigateBack = navigateBack, onRemoveAsset = importMediaViewModel::onRemove ) AssetTooLargeDialog( @@ -248,10 +258,15 @@ 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.source, navArgs.internalAssetUriList) { + if (!hasImportedContent()) { + when (navArgs.source) { + ImportSource.EXTERNAL_SHARE -> context.getActivity() + ?.let { activity -> importMediaViewModel.handleReceivedDataFromSharingIntent(activity) } + + ImportSource.INTERNAL_SHARE -> + importMediaViewModel.handleReceivedDataFromInternalShare(navArgs.internalAssetUriList) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 41f0727a4e1..51e1f49d0bb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -769,6 +769,8 @@ Message Details Copy text Share + Forward + Share externally Edit text Delete Message copied @@ -1319,6 +1321,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 @@ -1439,6 +1443,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 bc39dd131b5..dd79db63836 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 @@ -1062,6 +1064,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 @@ -1503,6 +1570,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")) @@ -1516,6 +1584,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 fbd980ddd5416f35de9011ea47634924a303a151 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 03/14] refactor: add share-safe file provider helpers --- .../kotlin/com/wire/android/util/AvatarImageManager.kt | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) 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 c4739f49c4d..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 @@ -32,11 +31,7 @@ class AvatarImageManager @Inject constructor(val context: Context) { return file.toUri() } - /** - * Creates a temporary URI that the camera app writes the captured avatar into. - */ - fun createCameraOutputAvatarUri(filePath: Path): Uri { - val cameraOutputFile = context.fileProviderSharedCacheFile(filePath.name) - return FileProvider.getUriForFile(context, context.getProviderAuthority(), cameraOutputFile) + fun getShareableTempAvatarUri(filePath: Path): Uri { + return context.shareableFileProviderUri(context.fileProviderSharedCacheFile(filePath.name)) } } From 412333da7920fb8651ae2552f197e1ab79c38450 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 04/14] feat: guard internal Wire share imports --- .../ui/sharing/ImportMediaAuthenticatedViewModel.kt | 9 +++++++-- .../com/wire/android/ui/sharing/ImportMediaScreen.kt | 2 ++ 2 files changed, 9 insertions(+), 2 deletions(-) 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 6a8d7fe7b66..b046f7dd115 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 @@ -251,8 +251,12 @@ class ImportMediaAuthenticatedViewModel( } } - private suspend fun handleImportedAsset(uri: Uri): ImportedMediaAsset? = - withContext(dispatchers.io()) { + 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") @@ -267,6 +271,7 @@ class ImportMediaAuthenticatedViewModel( is HandleUriAssetUseCase.Result.Success -> ImportedMediaAsset(result.assetBundle, null) } } + } private fun onSnackbarMessage(type: SnackBarMessage) = viewModelScope.launch { _infoMessage.emit(type) 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 15765dc99fd..f75d663318a 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 @@ -273,6 +273,8 @@ private fun ImportMediaAuthenticatedContent( } } +private fun ImportMediaNavArgs.isInternalShare(): Boolean = internalAssetUriList.isNotEmpty() + @Composable fun ImportMediaRestrictedContent( importMediaAuthenticatedState: ImportMediaAuthenticatedState, From 66ce409a69f4fba96a8cd21d3f453a6d7eb39892 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 05/14] 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 c9997e1acf8..75eb5fde180 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 @@ -188,11 +189,13 @@ 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.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 @@ -572,7 +575,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, @@ -847,7 +862,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, @@ -991,7 +1007,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, ) @@ -1856,7 +1873,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 dd6de3698b2..4550c993fb3 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 @@ -130,7 +130,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) @@ -198,6 +198,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) @@ -236,7 +244,8 @@ class MediaGalleryViewModel( MenuIntent.Download -> sendAction(MediaGalleryAction.Download) - MenuIntent.Share -> shareAsset() + MenuIntent.ShareExternally -> shareAsset() + MenuIntent.ShareViaWire -> shareAssetViaWire() MenuIntent.Delete -> { deleteMessageDialogState.show( @@ -283,13 +292,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) } } @@ -309,7 +322,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 @@ -323,7 +337,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 } @@ -332,7 +347,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 1eecfe540d1..fdac117fca3 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 @@ -271,7 +271,8 @@ class MediaGalleryViewModelTest { assertEquals( listOf( MediaGalleryMenuItem.DOWNLOAD, - MediaGalleryMenuItem.SHARE, + MediaGalleryMenuItem.SHARE_VIA_WIRE, + MediaGalleryMenuItem.SHARE_EXTERNALLY, MediaGalleryMenuItem.DELETE, ), state.menuItems @@ -408,7 +409,8 @@ class MediaGalleryViewModelTest { MediaGalleryMenuItem.SHOW_DETAILS, MediaGalleryMenuItem.REPLY, MediaGalleryMenuItem.DOWNLOAD, - MediaGalleryMenuItem.SHARE, + MediaGalleryMenuItem.SHARE_VIA_WIRE, + MediaGalleryMenuItem.SHARE_EXTERNALLY, MediaGalleryMenuItem.DELETE, ), state.menuItems From f8869a13fe608576122e4b6ba4c5f0ed0fc4e3d7 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 06/14] 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 75eb5fde180..50943c7212d 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 @@ -258,7 +258,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( From 2dd8b2139224b29f09edfc9f714e7489443c008b Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:29:28 +0200 Subject: [PATCH 07/14] feat: share logs via Wire --- .../com/wire/android/ui/debug/DebugScreen.kt | 30 +++++- .../android/ui/debug/LogManagementScreen.kt | 14 ++- .../com/wire/android/ui/debug/LogOptions.kt | 91 ++++++++++++++++++- .../wire/android/util/logging/LogSharing.kt | 33 +++++-- 4 files changed, 153 insertions(+), 15 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt index 7a3fc56c0a7..59f781b815a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt @@ -20,6 +20,7 @@ package com.wire.android.ui.debug import android.annotation.SuppressLint import android.content.Context +import android.net.Uri import android.widget.Toast import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background @@ -51,10 +52,12 @@ import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.ramcosta.composedestinations.generated.app.destinations.ConversationCryptoStatsScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.DebugFeatureFlagsScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.ImportMediaScreenDestination import com.wire.android.ui.common.rowitem.SectionHeader import com.wire.android.ui.home.settings.SettingsItem import com.wire.android.ui.home.settings.backup.BackupAndRestoreDialog import com.wire.android.ui.home.settings.backup.rememberBackUpAndRestoreStateHolder +import com.wire.android.ui.sharing.ImportMediaNavArgs import com.wire.android.ui.theme.WireTheme import com.wire.android.util.AppNameUtil import com.wire.android.util.logging.LogShareLauncher @@ -94,6 +97,13 @@ fun DebugScreen( dangerOptionsContent = { DangerOptions(exportObfuscatedCopyViewModel = exportObfuscatedCopyViewModel) }, + onShareLogsViaWire = { uri -> + navigator.navigate( + NavigationCommand( + ImportMediaScreenDestination(ImportMediaNavArgs(arrayListOf(uri))) + ) + ) + }, ) } @@ -105,6 +115,7 @@ internal fun UserDebugContent( onDatabaseLoggerEnabledChanged: (Boolean) -> Unit, onDeleteLogs: () -> Unit, onFlushLogs: () -> Deferred, + onShareLogsViaWire: (Uri) -> Unit, debugDataOptionsContent: @Composable (DebugContentState) -> Unit, dangerOptionsContent: @Composable () -> Unit, ) { @@ -131,7 +142,8 @@ internal fun UserDebugContent( isLoggingEnabled = isLoggingEnabled, onLoggingEnabledChange = onLoggingEnabledChange, onDeleteLogs = onDeleteLogs, - onShareLogs = { debugContentState.shareLogs(onFlushLogs) }, + onShareLogsExternally = { debugContentState.shareLogsExternally(onFlushLogs) }, + onShareLogsViaWire = { debugContentState.shareLogsViaWire(onFlushLogs, onShareLogsViaWire) }, isDBLoggerEnabled = state.isDBLoggingEnabled, onDBLoggerEnabledChange = onDatabaseLoggerEnabledChanged, isPrivateBuild = BuildConfig.PRIVATE_BUILD, @@ -230,7 +242,7 @@ data class DebugContentState( ).show() } - fun shareLogs(onFlushLogs: () -> Deferred) { + fun shareLogsExternally(onFlushLogs: () -> Deferred) { val dir = File(logPath).parentFile if (dir != null && dir.exists()) { logShareLauncher.shareLogs(dir) { @@ -239,6 +251,19 @@ data class DebugContentState( } } } + + fun shareLogsViaWire(onFlushLogs: () -> Deferred, onShareUri: (Uri) -> Unit) { + val dir = File(logPath).parentFile + if (dir != null && dir.exists()) { + logShareLauncher.shareLogsViaWire( + logsDirectory = dir, + onShareUri = onShareUri + ) { + // Flush any buffered logs before sharing to ensure completeness. + onFlushLogs().await() + } + } + } } @Preview(heightDp = 1400) @@ -253,6 +278,7 @@ internal fun PreviewUserDebugContent() = WireTheme { onLoggingEnabledChange = {}, onDeleteLogs = {}, onFlushLogs = { CompletableDeferred(Unit) }, + onShareLogsViaWire = {}, onDatabaseLoggerEnabledChanged = {}, debugDataOptionsContent = { DebugDataOptions( diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementScreen.kt b/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementScreen.kt index 0d38adb2018..ce18fb47212 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementScreen.kt @@ -17,6 +17,7 @@ */ package com.wire.android.ui.debug +import com.ramcosta.composedestinations.generated.app.destinations.ImportMediaScreenDestination import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -26,11 +27,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.wire.android.R +import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar +import com.wire.android.ui.sharing.ImportMediaNavArgs @WireRootDestination @Composable @@ -63,7 +66,16 @@ fun LogManagementScreen( isLoggingEnabled = state.isLoggingEnabled, onLoggingEnabledChange = viewModel::setLoggingEnabledState, onDeleteLogs = viewModel::deleteLogs, - onShareLogs = { contentState.shareLogs(viewModel::flushLogs) }, + onShareLogsExternally = { contentState.shareLogsExternally(viewModel::flushLogs) }, + onShareLogsViaWire = { + contentState.shareLogsViaWire(viewModel::flushLogs) { uri -> + navigator.navigate( + NavigationCommand( + ImportMediaScreenDestination(ImportMediaNavArgs(arrayListOf(uri))) + ) + ) + } + }, isDBLoggerEnabled = false, onDBLoggerEnabledChange = {}, isPrivateBuild = false diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/LogOptions.kt b/app/src/main/kotlin/com/wire/android/ui/debug/LogOptions.kt index 394309e333f..f66e223b0f2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/LogOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/LogOptions.kt @@ -34,6 +34,13 @@ import androidx.compose.ui.res.stringResource import com.wire.android.R import com.wire.android.model.Clickable import com.wire.android.ui.common.SurfaceBackgroundWrapper +import com.wire.android.ui.common.bottomsheet.MenuBottomSheetItem +import com.wire.android.ui.common.bottomsheet.MenuItemIcon +import com.wire.android.ui.common.bottomsheet.MenuModalSheetHeader +import com.wire.android.ui.common.bottomsheet.WireMenuModalSheetContent +import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout +import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState +import com.wire.android.ui.common.bottomsheet.show import com.wire.android.ui.common.button.WireSwitch import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions @@ -44,6 +51,7 @@ import com.wire.android.ui.home.settings.SettingsItem import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.supportsTrustedWireShareCaller import com.wire.android.util.ui.PreviewMultipleThemes @Composable @@ -53,10 +61,21 @@ fun LogOptions( isDBLoggerEnabled: Boolean, onDBLoggerEnabledChange: (Boolean) -> Unit, onDeleteLogs: () -> Unit, - onShareLogs: () -> Unit, + onShareLogsExternally: () -> Unit, + onShareLogsViaWire: () -> Unit, isPrivateBuild: Boolean, modifier: Modifier = Modifier ) { + val shareLogsSheetState = rememberWireModalSheetState() + val shareLogsDirectly = supportsTrustedWireShareCaller() + val onShareLogsClick: () -> Unit = { + if (shareLogsDirectly) { + onShareLogsExternally() + } else { + shareLogsSheetState.show() + } + } + Column(modifier = modifier) { SectionHeader(stringResource(R.string.label_logs_option_title)) EnableLoggingSwitch( @@ -74,9 +93,13 @@ fun LogOptions( SettingsItem( text = stringResource(R.string.label_share_logs), trailingIcon = R.drawable.ic_entypo_share, + onRowPressed = Clickable( + enabled = true, + onClick = onShareLogsClick + ), onIconPressed = Clickable( enabled = true, - onClick = onShareLogs + onClick = onShareLogsClick ) ) @@ -90,6 +113,64 @@ fun LogOptions( ) } } + + if (!shareLogsDirectly) { + WireModalSheetLayout( + sheetState = shareLogsSheetState, + sheetContent = { + WireMenuModalSheetContent( + header = MenuModalSheetHeader.Visible( + title = stringResource(R.string.label_share_logs) + ), + menuItems = shareLogsMenuItems( + onShareLogsExternally = { + shareLogsSheetState.hide { onShareLogsExternally() } + }, + onShareLogsViaWire = { + shareLogsSheetState.hide { onShareLogsViaWire() } + } + ) + ) + } + ) + } +} + +private fun shareLogsMenuItems( + onShareLogsExternally: () -> Unit, + onShareLogsViaWire: () -> Unit +): List<@Composable () -> Unit> = + listOf( + { ShareLogsInWireOption(onShareLogsViaWire) }, + { ShareLogsExternallyOption(onShareLogsExternally) } + ) + +@Composable +private fun ShareLogsInWireOption(onClick: () -> 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_logs_via_wire), + onItemClick = onClick + ) +} + +@Composable +private fun ShareLogsExternallyOption(onClick: () -> Unit) { + MenuBottomSheetItem( + leading = { + MenuItemIcon( + id = R.drawable.ic_entypo_share, + contentDescription = stringResource(R.string.content_description_share_the_file), + ) + }, + title = stringResource(R.string.label_share_logs_externally), + onItemClick = onClick + ) } @Composable @@ -172,7 +253,8 @@ fun PreviewLoggingOptionsPublicBuild() { isDBLoggerEnabled = true, onDBLoggerEnabledChange = {}, onDeleteLogs = {}, - onShareLogs = {}, + onShareLogsExternally = {}, + onShareLogsViaWire = {}, isPrivateBuild = false, ) } @@ -186,7 +268,8 @@ fun PreviewLoggingOptionsPrivateBuild() { isDBLoggerEnabled = true, onDBLoggerEnabledChange = {}, onDeleteLogs = {}, - onShareLogs = {}, + onShareLogsExternally = {}, + onShareLogsViaWire = {}, isPrivateBuild = true, ) } diff --git a/app/src/main/kotlin/com/wire/android/util/logging/LogSharing.kt b/app/src/main/kotlin/com/wire/android/util/logging/LogSharing.kt index 8bd1afbbd5a..c6f9a7a31f7 100644 --- a/app/src/main/kotlin/com/wire/android/util/logging/LogSharing.kt +++ b/app/src/main/kotlin/com/wire/android/util/logging/LogSharing.kt @@ -21,17 +21,18 @@ import android.content.ClipData import android.content.Context import android.content.Intent import android.net.Uri -import androidx.core.content.FileProvider import com.wire.android.R import com.wire.android.appLogger import com.wire.android.util.BackendSupportConfig import com.wire.android.util.EmailComposer import com.wire.android.util.SupportPage import com.wire.android.util.SupportUrlResolver +import com.wire.android.util.externalShareChooserIntent import com.wire.android.util.getDeviceIdString import com.wire.android.util.getGitBuildId -import com.wire.android.util.getProviderAuthority import com.wire.android.util.sha256 +import com.wire.android.util.shareableFileProviderUri +import com.wire.android.util.startShareIntentWithTrustedWireTarget import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -68,7 +69,21 @@ class LogShareLauncher( share( logsDirectory = logsDirectory, flushLogs = flushLogs, - intent = { archive -> context.logsSharingIntent(archive) } + shareArchive = { archive -> + context.startShareIntentWithTrustedWireTarget(context.logsSharingIntent(archive)) + } + ) + } + + fun shareLogsViaWire( + logsDirectory: File, + onShareUri: (Uri) -> Unit, + flushLogs: suspend () -> Unit = {} + ) { + share( + logsDirectory = logsDirectory, + flushLogs = flushLogs, + shareArchive = { archive -> onShareUri(context.logsSharingUri(archive)) } ) } @@ -78,20 +93,20 @@ class LogShareLauncher( share( logsDirectory = LogFileWriter.logsDirectory(context), flushLogs = flushLogs, - intent = { archive -> context.bugReportLogsSharingIntent(archive) } + shareArchive = { archive -> context.startActivity(context.bugReportLogsSharingIntent(archive)) } ) } private fun share( logsDirectory: File, flushLogs: suspend () -> Unit, - intent: suspend (File) -> Intent + shareArchive: suspend (File) -> Unit ) { coroutineScope.launch { runCatching { flushLogs() val archive = archiveCreator.create(logsDirectory) - context.startActivity(intent(archive)) + shareArchive(archive) }.onFailure { error -> appLogger.e("Failed to prepare logs for sharing", error) onFailure(error) @@ -117,8 +132,10 @@ class CompressedLogsArchiveCreator( } } +fun Context.logsSharingUri(archiveFile: File): Uri = shareableFileProviderUri(archiveFile) + fun Context.logsSharingIntent(archiveFile: File): Intent { - val archiveUri = FileProvider.getUriForFile(this, getProviderAuthority(), archiveFile) + val archiveUri = logsSharingUri(archiveFile) return Intent(Intent.ACTION_SEND).apply { addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) type = LOGS_ARCHIVE_MIME_TYPE @@ -150,7 +167,7 @@ suspend fun Context.bugReportLogsSharingIntent(archiveFile: File): Intent { ) selector = Intent(Intent.ACTION_SENDTO).setData(Uri.parse("mailto:")) } - return Intent.createChooser(intent, getString(R.string.send_feedback_choose_email)) + return externalShareChooserIntent(intent, getString(R.string.send_feedback_choose_email)) } internal fun deleteStaleCompressedLogsArchives( From 3f157d354ec9be4845ca1a54876f622737f27908 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:22:53 +0200 Subject: [PATCH 08/14] prepareAssetForWireShare --- .../wire/android/ui/home/conversations/ConversationScreen.kt | 2 +- .../ui/home/conversations/media/ConversationMediaScreen.kt | 2 +- .../conversations/messages/ConversationMessagesViewModel.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 50943c7212d..9536ba9a395 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 @@ -578,7 +578,7 @@ fun ConversationScreen( conversationMessages = conversationMessagesViewModel.infoMessage, shareAssetExternally = conversationMessagesViewModel::shareAsset, shareAssetViaWire = { messageId -> - conversationMessagesViewModel.shareAssetViaWire(messageId) { path, assetName -> + conversationMessagesViewModel.prepareAssetForWireShare(messageId) { path, assetName -> navigator.navigate( NavigationCommand( ImportMediaScreenDestination( 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 57039418486..e78ca4cc0ac 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 @@ -132,7 +132,7 @@ fun ConversationMediaScreen( }, shareAssetExternally = { conversationMessagesViewModel.shareAsset(context, it) }, shareAssetViaWire = { messageId -> - conversationMessagesViewModel.shareAssetViaWire(messageId) { path, assetName -> + conversationMessagesViewModel.prepareAssetForWireShare(messageId) { path, assetName -> navigator.navigate( NavigationCommand( ImportMediaScreenDestination( 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 4058bd30ec8..8d5ff46e31e 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,7 +433,7 @@ class ConversationMessagesViewModel( } } - fun shareAssetViaWire(messageId: String, onAssetReady: (Path, String) -> Unit) { + fun prepareAssetForWireShare(messageId: String, onAssetReady: (Path, String) -> Unit) { viewModelScope.launch { assetDataPath(conversationId, messageId)?.run { onAssetReady(first, second) From 8195a3d8e1f6ead6c8323a1d04fe7e0515e474bc Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:00:40 +0200 Subject: [PATCH 09/14] fix(cells): secure file sharing --- .../kotlin/com/wire/android/util/FileUtil.kt | 119 ----------------- .../cells/util/FileHelperSharingTest.kt | 106 +++++++++++++++ .../com/wire/android/util/FileNameUtil.kt | 42 ++++++ .../wire/android/util/SecureFileSharing.kt | 126 ++++++++++++++++++ .../android/util/SecureFileSharingTest.kt | 105 +++++++++++++++ .../android/feature/cells/util/FileHelper.kt | 44 +----- 6 files changed, 386 insertions(+), 156 deletions(-) create mode 100644 app/src/test/kotlin/com/wire/android/feature/cells/util/FileHelperSharingTest.kt create mode 100644 core/ui-common/src/main/kotlin/com/wire/android/util/FileNameUtil.kt create mode 100644 core/ui-common/src/main/kotlin/com/wire/android/util/SecureFileSharing.kt create mode 100644 core/ui-common/src/test/kotlin/com/wire/android/util/SecureFileSharingTest.kt 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 2ab209eefaa..db9ea6e468a 100644 --- a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt +++ b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt @@ -20,21 +20,17 @@ 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 @@ -46,7 +42,6 @@ import android.provider.MediaStore.MediaColumns.SIZE import android.provider.OpenableColumns import android.provider.Settings import android.webkit.MimeTypeMap -import androidx.annotation.VisibleForTesting import androidx.core.content.FileProvider import com.wire.android.R import com.wire.android.appLogger @@ -55,8 +50,6 @@ import com.wire.android.util.ImageUtil.ImageSizeClass.Medium import com.wire.android.util.dispatchers.DefaultDispatcherProvider import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.asset.isAudioMimeType -import com.wire.kalium.logic.util.buildFileName -import com.wire.kalium.logic.util.splitFileExtensionAndCopyCounter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -66,7 +59,6 @@ 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 @@ -385,94 +377,6 @@ 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 @@ -550,26 +454,6 @@ suspend fun Context.getDependenciesVersion(): Map = withContext } } -fun Context.getProviderAuthority() = "$packageName.provider" - -@VisibleForTesting -fun findFirstUniqueName(dir: File, desiredName: String): String { - var currentName: String = desiredName.sanitizeFilename() - while (File(dir, currentName).exists()) { - val (nameWithoutCopyCounter, copyCounter, extension) = currentName.splitFileExtensionAndCopyCounter() - currentName = buildFileName(nameWithoutCopyCounter, extension, copyCounter + 1).sanitizeFilename() - } - return currentName -} - -/** - * Removes disallowed characters and returns valid filename. - * - * Uses the same cases as in `isValidFatFilenameChar` and `isValidExtFilenameChar` from [android.os.FileUtils]. - */ -@VisibleForTesting -fun String.sanitizeFilename(): String = replace(Regex("[\u0000-\u001f\u007f\"*/:<>?\\\\|]"), "_") - fun getAudioLengthInMs(dataPath: Path, mimeType: String): Long = if (isAudioMimeType(mimeType)) { val retriever = MediaMetadataRetriever() @@ -584,8 +468,5 @@ 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/test/kotlin/com/wire/android/feature/cells/util/FileHelperSharingTest.kt b/app/src/test/kotlin/com/wire/android/feature/cells/util/FileHelperSharingTest.kt new file mode 100644 index 00000000000..b8e2dcb3b25 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/feature/cells/util/FileHelperSharingTest.kt @@ -0,0 +1,106 @@ +/* + * 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.feature.cells.util + +import android.app.Application +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import com.wire.android.util.FILE_PROVIDER_SHARED_FILES_ROOT +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class FileHelperSharingTest { + + private val context = ApplicationProvider.getApplicationContext() + private val fileHelper = FileHelper(context) + + @Test + fun givenCellFileOutsideProviderRoot_whenOpeningExternally_thenSharedCacheUriIsGranted() { + val sourceFile = createCellFile("open-cell.txt") + var errorCalled = false + + fileHelper.openAssetFileWithExternalApp( + localPath = sourceFile.toOkioPath(), + assetName = sourceFile.name, + mimeType = MIME_TYPE, + onError = { errorCalled = true } + ) + + val viewIntent = shadowOf(context).nextStartedActivity + val sharedUri = viewIntent.data + assertFalse(errorCalled) + assertEquals(Intent.ACTION_VIEW, viewIntent.action) + assertNotNull(sharedUri) + assertEquals(FILE_PROVIDER_SHARED_FILES_ROOT, sharedUri?.pathSegments?.first()) + assertEquals(FILE_CONTENT, sharedUri?.readText()) + } + + @Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE]) + @Test + fun givenCellFileOutsideProviderRoot_whenSharingExternally_thenChooserContainsSharedCacheUri() { + val sourceFile = createCellFile("share-cell.txt") + var errorCalled = false + + fileHelper.shareFileChooser( + assetDataPath = sourceFile.toOkioPath(), + assetName = sourceFile.name, + mimeType = MIME_TYPE, + onError = { errorCalled = true } + ) + + val chooserIntent = shadowOf(context).nextStartedActivity + val sendIntent = chooserIntent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + val sharedUri = sendIntent?.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + assertFalse(errorCalled) + assertEquals(Intent.ACTION_CHOOSER, chooserIntent.action) + assertEquals(Intent.ACTION_SEND, sendIntent?.action) + assertNotNull(sharedUri) + assertEquals(FILE_PROVIDER_SHARED_FILES_ROOT, sharedUri?.pathSegments?.first()) + assertEquals(FILE_CONTENT, sharedUri?.readText()) + } + + private fun createCellFile(name: String): File = + File(requireNotNull(context.getExternalFilesDir(null)), name).apply { + writeText(FILE_CONTENT) + } + + private fun Uri.readText(): String { + val inputStream = requireNotNull(context.contentResolver.openInputStream(this)) { + "Expected FileProvider URI to be readable" + } + return inputStream.bufferedReader().use { it.readText() } + } + + private companion object { + const val MIME_TYPE = "text/plain" + const val FILE_CONTENT = "cell content" + } +} diff --git a/core/ui-common/src/main/kotlin/com/wire/android/util/FileNameUtil.kt b/core/ui-common/src/main/kotlin/com/wire/android/util/FileNameUtil.kt new file mode 100644 index 00000000000..6d90f98741c --- /dev/null +++ b/core/ui-common/src/main/kotlin/com/wire/android/util/FileNameUtil.kt @@ -0,0 +1,42 @@ +/* + * 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.util + +import androidx.annotation.VisibleForTesting +import com.wire.kalium.logic.util.buildFileName +import com.wire.kalium.logic.util.splitFileExtensionAndCopyCounter +import java.io.File + +@VisibleForTesting +fun findFirstUniqueName(dir: File, desiredName: String): String { + var currentName: String = desiredName.sanitizeFilename() + while (File(dir, currentName).exists()) { + val (nameWithoutCopyCounter, copyCounter, extension) = currentName.splitFileExtensionAndCopyCounter() + currentName = buildFileName(nameWithoutCopyCounter, extension, copyCounter + 1).sanitizeFilename() + } + return currentName +} + +/** + * Removes disallowed characters and returns valid filename. + * + * Uses the same cases as in `isValidFatFilenameChar` and `isValidExtFilenameChar` from [android.os.FileUtils]. + */ +@VisibleForTesting +fun String.sanitizeFilename(): String = replace(Regex("[\u0000-\u001f\u007f\"*/:<>?\\\\|]"), "_") diff --git a/core/ui-common/src/main/kotlin/com/wire/android/util/SecureFileSharing.kt b/core/ui-common/src/main/kotlin/com/wire/android/util/SecureFileSharing.kt new file mode 100644 index 00000000000..6f3c1576998 --- /dev/null +++ b/core/ui-common/src/main/kotlin/com/wire/android/util/SecureFileSharing.kt @@ -0,0 +1,126 @@ +/* + * 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.util + +import android.app.ActivityOptions +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.core.content.FileProvider +import java.io.File +import java.nio.file.Files + +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() } + +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) + } + +fun Context.getProviderAuthority() = "$packageName.provider" + +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 +private const val ATTACHMENT_FILENAME = "attachment" diff --git a/core/ui-common/src/test/kotlin/com/wire/android/util/SecureFileSharingTest.kt b/core/ui-common/src/test/kotlin/com/wire/android/util/SecureFileSharingTest.kt new file mode 100644 index 00000000000..dd1c094157c --- /dev/null +++ b/core/ui-common/src/test/kotlin/com/wire/android/util/SecureFileSharingTest.kt @@ -0,0 +1,105 @@ +/* + * 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.util + +import android.content.Context +import android.net.Uri +import androidx.core.content.FileProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Path + +class SecureFileSharingTest { + + @TempDir + lateinit var temporaryDirectory: Path + + @AfterEach + fun tearDown() { + unmockkStatic(FileProvider::class) + } + + @Test + fun givenFileOutsideSharedCache_whenCreatingShareableUri_thenProviderReceivesDedicatedCacheFile() { + val cacheDirectory = temporaryDirectory.resolve("cache").toFile().apply(File::mkdirs) + val sourceFile = temporaryDirectory.resolve("external/cell.txt").toFile().apply { + parentFile?.mkdirs() + writeText(FILE_CONTENT) + } + val context = mockk { + every { cacheDir } returns cacheDirectory + every { packageName } returns PACKAGE_NAME + } + val expectedUri = mockk() + val providerFile = slot() + mockkStatic(FileProvider::class) + every { + FileProvider.getUriForFile(context, PROVIDER_AUTHORITY, capture(providerFile), DISPLAY_NAME) + } returns expectedUri + + val result = context.shareableFileProviderUri(sourceFile, DISPLAY_NAME) + + val sharedDirectory = File(cacheDirectory, FILE_PROVIDER_SHARED_CACHE_DIRECTORY) + assertEquals(expectedUri, result) + assertTrue(providerFile.captured.canonicalFile.toPath().startsWith(sharedDirectory.canonicalFile.toPath())) + assertEquals(FILE_CONTENT, providerFile.captured.readText()) + } + + @Test + fun givenTraversalCharactersInDisplayName_whenCreatingShareableUri_thenCacheFileStaysInsideDedicatedDirectory() { + val cacheDirectory = temporaryDirectory.resolve("cache").toFile().apply(File::mkdirs) + val sourceFile = temporaryDirectory.resolve("external/cell.txt").toFile().apply { + parentFile?.mkdirs() + writeText(FILE_CONTENT) + } + val context = mockk { + every { cacheDir } returns cacheDirectory + every { packageName } returns PACKAGE_NAME + } + val providerFile = slot() + mockkStatic(FileProvider::class) + every { + FileProvider.getUriForFile(context, PROVIDER_AUTHORITY, capture(providerFile), TRAVERSAL_DISPLAY_NAME) + } returns mockk() + + context.shareableFileProviderUri(sourceFile, TRAVERSAL_DISPLAY_NAME) + + val sharedDirectory = File(cacheDirectory, FILE_PROVIDER_SHARED_CACHE_DIRECTORY) + assertTrue(providerFile.captured.canonicalFile.toPath().startsWith(sharedDirectory.canonicalFile.toPath())) + assertEquals(".._.._cell.txt", providerFile.captured.name) + } + + private companion object { + const val PACKAGE_NAME = "com.wire" + const val PROVIDER_AUTHORITY = "$PACKAGE_NAME.provider" + const val FILE_PROVIDER_SHARED_CACHE_DIRECTORY = "file-provider-shares" + const val DISPLAY_NAME = "Cell document.txt" + const val TRAVERSAL_DISPLAY_NAME = "../../cell.txt" + const val FILE_CONTENT = "cell content" + } +} 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 dc8ed9a308d..d6e8ff760ce 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,22 +18,21 @@ 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.Build import android.os.Environment import android.provider.MediaStore -import android.os.Build -import androidx.core.content.FileProvider import com.wire.android.di.ApplicationContext +import com.wire.android.util.shareableFileProviderUri +import com.wire.android.util.startShareIntentWithTrustedWireTarget +import dev.zacsweers.metro.Inject import okio.Path import java.io.File import java.io.FileOutputStream import java.io.OutputStream -import dev.zacsweers.metro.Inject class FileHelper @Inject constructor( @ApplicationContext private val context: Context @@ -117,12 +116,7 @@ class FileHelper @Inject constructor( setDataAndType(assetUri, mimeType) 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) + context.startShareIntentWithTrustedWireTarget(intent) } catch (e: java.lang.IllegalArgumentException) { onError() } catch (noActivityFoundException: ActivityNotFoundException) { @@ -137,11 +131,7 @@ class FileHelper @Inject constructor( setType("text/plain") putExtra(Intent.EXTRA_TEXT, url) } - val chooserIntent = Intent.createChooser(intent, null).apply { - excludeOwnShareTargets(intent) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(chooserIntent) + context.startShareIntentWithTrustedWireTarget(intent) } catch (e: java.lang.IllegalArgumentException) { onError() } catch (noActivityFoundException: ActivityNotFoundException) { @@ -157,26 +147,6 @@ class FileHelper @Inject constructor( */ fun getExternalFilesDir(): File = context.getExternalFilesDir(null) ?: context.filesDir - private fun Context.getProviderAuthority() = "$packageName.provider" - 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) - } + shareableFileProviderUri(assetDataPath.toFile(), assetName ?: assetDataPath.name) } From ace75b37ac27765181279862947aa4204df6f38c Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:31:41 +0200 Subject: [PATCH 10/14] adjust design to spec --- .../com/wire/android/ui/debug/DebugScreen.kt | 8 +++- .../android/ui/debug/LogManagementScreen.kt | 8 +++- .../com/wire/android/ui/debug/LogOptions.kt | 8 ++-- .../android/ui/edit/ShareAssetMenuOption.kt | 8 ++-- .../home/conversations/ConversationScreen.kt | 6 ++- .../media/ConversationMediaScreen.kt | 6 ++- .../ui/home/gallery/MediaGalleryScreen.kt | 6 ++- .../ImportMediaAuthenticatedViewModel.kt | 9 +--- .../wire/android/util/AvatarImageManager.kt | 9 +++- app/src/main/res/drawable/ic_forward.xml | 26 +++++++++++ app/src/main/res/drawable/ic_share.xml | 46 +++++++++++++++---- app/src/main/res/values/strings.xml | 4 +- 12 files changed, 111 insertions(+), 33 deletions(-) create mode 100644 app/src/main/res/drawable/ic_forward.xml diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt index 59f781b815a..987027dd6e4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt @@ -58,6 +58,7 @@ import com.wire.android.ui.home.settings.SettingsItem import com.wire.android.ui.home.settings.backup.BackupAndRestoreDialog import com.wire.android.ui.home.settings.backup.rememberBackUpAndRestoreStateHolder import com.wire.android.ui.sharing.ImportMediaNavArgs +import com.wire.android.ui.sharing.ImportSource import com.wire.android.ui.theme.WireTheme import com.wire.android.util.AppNameUtil import com.wire.android.util.logging.LogShareLauncher @@ -100,7 +101,12 @@ fun DebugScreen( onShareLogsViaWire = { uri -> navigator.navigate( NavigationCommand( - ImportMediaScreenDestination(ImportMediaNavArgs(arrayListOf(uri))) + ImportMediaScreenDestination( + ImportMediaNavArgs( + source = ImportSource.INTERNAL_SHARE, + internalAssetUriList = arrayListOf(uri) + ) + ) ) ) }, diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementScreen.kt b/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementScreen.kt index ce18fb47212..4661a563451 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementScreen.kt @@ -34,6 +34,7 @@ import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.sharing.ImportMediaNavArgs +import com.wire.android.ui.sharing.ImportSource @WireRootDestination @Composable @@ -71,7 +72,12 @@ fun LogManagementScreen( contentState.shareLogsViaWire(viewModel::flushLogs) { uri -> navigator.navigate( NavigationCommand( - ImportMediaScreenDestination(ImportMediaNavArgs(arrayListOf(uri))) + ImportMediaScreenDestination( + ImportMediaNavArgs( + source = ImportSource.INTERNAL_SHARE, + internalAssetUriList = arrayListOf(uri) + ) + ) ) ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/LogOptions.kt b/app/src/main/kotlin/com/wire/android/ui/debug/LogOptions.kt index f66e223b0f2..7f1450305e1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/LogOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/LogOptions.kt @@ -92,7 +92,7 @@ fun LogOptions( if (isLoggingEnabled) { SettingsItem( text = stringResource(R.string.label_share_logs), - trailingIcon = R.drawable.ic_entypo_share, + trailingIcon = R.drawable.ic_share, onRowPressed = Clickable( enabled = true, onClick = onShareLogsClick @@ -150,7 +150,7 @@ private fun ShareLogsInWireOption(onClick: () -> Unit) { MenuBottomSheetItem( leading = { MenuItemIcon( - id = R.drawable.ic_share_file, + id = R.drawable.ic_forward, contentDescription = stringResource(R.string.content_description_share_the_file), ) }, @@ -164,11 +164,11 @@ private fun ShareLogsExternallyOption(onClick: () -> Unit) { MenuBottomSheetItem( leading = { MenuItemIcon( - id = R.drawable.ic_entypo_share, + id = R.drawable.ic_share, contentDescription = stringResource(R.string.content_description_share_the_file), ) }, - title = stringResource(R.string.label_share_logs_externally), + title = stringResource(R.string.label_share), onItemClick = onClick ) } 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 ab6f8c63b4b..2f9a2f3b6b0 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 @@ -29,7 +29,7 @@ fun ShareAssetMenuOption(onShareAsset: () -> Unit) { MenuBottomSheetItem( leading = { MenuItemIcon( - id = R.drawable.ic_share_file, + id = R.drawable.ic_share, contentDescription = stringResource(R.string.content_description_share_the_file), ) }, @@ -56,7 +56,7 @@ fun ShareAssetViaWireMenuOption(onShareAsset: () -> Unit) { MenuBottomSheetItem( leading = { MenuItemIcon( - id = R.drawable.ic_share_file, + id = R.drawable.ic_forward, contentDescription = stringResource(R.string.content_description_share_the_file), ) }, @@ -70,11 +70,11 @@ fun ShareAssetExternallyMenuOption(onShareAsset: () -> Unit) { MenuBottomSheetItem( leading = { MenuItemIcon( - id = R.drawable.ic_share_file, + id = R.drawable.ic_share, contentDescription = stringResource(R.string.content_description_share_the_file), ) }, - title = stringResource(R.string.label_share_externally), + title = stringResource(R.string.label_share), onItemClick = onShareAsset ) } 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 9536ba9a395..138f9219ed6 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 @@ -196,6 +196,7 @@ 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.android.ui.sharing.ImportSource 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 @@ -582,7 +583,10 @@ fun ConversationScreen( navigator.navigate( NavigationCommand( ImportMediaScreenDestination( - ImportMediaNavArgs(arrayListOf(context.fileShareUri(path, assetName))) + ImportMediaNavArgs( + source = ImportSource.INTERNAL_SHARE, + internalAssetUriList = arrayListOf(context.fileShareUri(path, assetName)) + ) ), BackStackMode.UPDATE_EXISTED ) 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 e78ca4cc0ac..9cfc58678d9 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 @@ -74,6 +74,7 @@ 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.sharing.ImportSource import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.util.fileShareUri @@ -136,7 +137,10 @@ fun ConversationMediaScreen( navigator.navigate( NavigationCommand( ImportMediaScreenDestination( - ImportMediaNavArgs(arrayListOf(context.fileShareUri(path, assetName))) + ImportMediaNavArgs( + source = ImportSource.INTERNAL_SHARE, + internalAssetUriList = arrayListOf(context.fileShareUri(path, assetName)) + ) ) ) ) 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 ccb0fd8821a..d0649d99109 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 @@ -66,6 +66,7 @@ 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.sharing.ImportSource import com.wire.android.ui.theme.WireTheme import com.wire.android.util.fileShareUri import com.wire.android.util.permission.rememberWriteStoragePermissionFlow @@ -146,7 +147,10 @@ fun MediaGalleryScreen( is MediaGalleryAction.ShareViaWire -> navigator.navigate( NavigationCommand( ImportMediaScreenDestination( - ImportMediaNavArgs(arrayListOf(context.fileShareUri(action.path, action.assetName))) + ImportMediaNavArgs( + source = ImportSource.INTERNAL_SHARE, + internalAssetUriList = arrayListOf(context.fileShareUri(action.path, action.assetName)) + ) ) ) ) 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 b046f7dd115..6a8d7fe7b66 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 @@ -251,12 +251,8 @@ class ImportMediaAuthenticatedViewModel( } } - 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()) { + 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") @@ -271,7 +267,6 @@ class ImportMediaAuthenticatedViewModel( is HandleUriAssetUseCase.Result.Success -> ImportedMediaAsset(result.assetBundle, null) } } - } private fun onSnackbarMessage(type: SnackBarMessage) = viewModelScope.launch { _infoMessage.emit(type) 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 0ce534c595d..c4739f49c4d 100644 --- a/app/src/main/kotlin/com/wire/android/util/AvatarImageManager.kt +++ b/app/src/main/kotlin/com/wire/android/util/AvatarImageManager.kt @@ -20,6 +20,7 @@ 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 @@ -31,7 +32,11 @@ class AvatarImageManager @Inject constructor(val context: Context) { return file.toUri() } - fun getShareableTempAvatarUri(filePath: Path): Uri { - return context.shareableFileProviderUri(context.fileProviderSharedCacheFile(filePath.name)) + /** + * Creates a temporary URI that the camera app writes the captured avatar into. + */ + fun createCameraOutputAvatarUri(filePath: Path): Uri { + val cameraOutputFile = context.fileProviderSharedCacheFile(filePath.name) + return FileProvider.getUriForFile(context, context.getProviderAuthority(), cameraOutputFile) } } diff --git a/app/src/main/res/drawable/ic_forward.xml b/app/src/main/res/drawable/ic_forward.xml new file mode 100644 index 00000000000..6a2ce881486 --- /dev/null +++ b/app/src/main/res/drawable/ic_forward.xml @@ -0,0 +1,26 @@ + + + + diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml index 0b0add6ee4d..33af602d5cb 100644 --- a/app/src/main/res/drawable/ic_share.xml +++ b/app/src/main/res/drawable/ic_share.xml @@ -1,6 +1,6 @@ - + android:width="16dp" + android:height="16dp" + android:viewportWidth="16" + android:viewportHeight="16"> + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 51e1f49d0bb..7ebfe5fa147 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -770,7 +770,6 @@ Copy text Share Forward - Share externally Edit text Delete Message copied @@ -1321,8 +1320,7 @@ In group conversations, the group admin can overwrite this setting. Logs Share Logs Could not prepare logs for sharing - Share in Wire - Share externally + Forward Delete All Logs Restart slow sync Restart From d364fac8ef09d2e5a8e5eb0da3e1d19017a8594f Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:59:44 +0200 Subject: [PATCH 11/14] feat: validate URI schema for asset imports and add unit tests --- .../com/wire/android/util/FileManager.kt | 11 ++-- .../usecase/HandleUriAssetUseCaseTest.kt | 25 ++++++-- .../com/wire/android/util/FileManagerTest.kt | 57 +++++++++++++++++++ 3 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 app/src/test/kotlin/com/wire/android/util/FileManagerTest.kt diff --git a/app/src/main/kotlin/com/wire/android/util/FileManager.kt b/app/src/main/kotlin/com/wire/android/util/FileManager.kt index 4fe96d04049..c40684ce965 100644 --- a/app/src/main/kotlin/com/wire/android/util/FileManager.kt +++ b/app/src/main/kotlin/com/wire/android/util/FileManager.kt @@ -18,6 +18,7 @@ package com.wire.android.util +import android.content.ContentResolver import android.content.Context import android.net.Uri import com.wire.android.appLogger @@ -152,19 +153,19 @@ class FileManager @Inject constructor(@ApplicationContext private val context: C } /** - * Validates the schema of the given Uri. - * We are excluding file, as we don't process file URIs. + * Validates that the given [Uri] uses a supported scheme for importing an asset. * - * If invalid schema is found, an [IllegalArgumentException] is thrown. + * If an unsupported scheme is found, an [IllegalArgumentException] is thrown. */ fun checkValidSchema(uri: Uri) { appLogger.d("Validating Uri schema for path: ${uri.path} with scheme: ${uri.scheme}") - if (INVALID_SCHEMA.equals(uri.scheme, ignoreCase = true)) throw IllegalArgumentException("File URI is not supported") + require(ContentResolver.SCHEME_CONTENT.equals(uri.scheme, ignoreCase = true)) { + "Only content URIs are supported" + } } companion object { private const val TEMP_IMG_ATTACHMENT_FILENAME = "image_attachment.jpg" private const val TEMP_VIDEO_ATTACHMENT_FILENAME = "video_attachment.mp4" - private const val INVALID_SCHEMA = "file" } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/HandleUriAssetUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/HandleUriAssetUseCaseTest.kt index f6910fe9735..c922ed628ff 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/HandleUriAssetUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/HandleUriAssetUseCaseTest.kt @@ -30,6 +30,7 @@ import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCase.AssetSizeLim import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest @@ -46,13 +47,14 @@ class HandleUriAssetUseCaseTest { private val dispatcher = StandardTestDispatcher() @Test - fun `given an invalid url schema, when invoked, then result should not succeed`() = + fun `given an unsupported URI scheme, when invoked, then result should not succeed`() = runTest(dispatcher) { // Given val limit = ASSET_SIZE_DEFAULT_LIMIT_BYTES - val (_, useCase) = Arrangement() + val (arrangement, useCase) = Arrangement() .withGetAssetSizeLimitUseCase(true, limit) .withGetAssetBundleFromUri(null) + .withUnsupportedUriScheme() .arrange() // When @@ -60,6 +62,9 @@ class HandleUriAssetUseCaseTest { // Then assert(result is HandleUriAssetUseCase.Result.Failure.Unknown) + coVerify(exactly = 0) { + arrangement.fileManager.getAssetBundleFromUri(any(), any(), any(), any(), any()) + } } @Test @@ -81,7 +86,7 @@ class HandleUriAssetUseCaseTest { .arrange() // When - val result = useCase.invoke("mocked_image.jpeg".toUri(), false) + val result = useCase.invoke(CONTENT_URI, false) // Then assert(result is HandleUriAssetUseCase.Result.Success) @@ -106,7 +111,7 @@ class HandleUriAssetUseCaseTest { .arrange() // When - val result = useCase.invoke("mocked_image.jpeg".toUri(), false) + val result = useCase.invoke(CONTENT_URI, false) // Then assert(result is HandleUriAssetUseCase.Result.Failure.AssetTooLarge) @@ -132,7 +137,7 @@ class HandleUriAssetUseCaseTest { .arrange() // When - val result = useCase.invoke("mocked_image.jpeg".toUri(), true) + val result = useCase.invoke(CONTENT_URI, true) // Then coVerify { @@ -159,7 +164,7 @@ class HandleUriAssetUseCaseTest { .arrange() // When - val result = useCase.invoke("mocked_image.jpeg".toUri(), false) + val result = useCase.invoke(CONTENT_URI, false) // Then assert(result is HandleUriAssetUseCase.Result.Failure.Unknown) @@ -183,6 +188,10 @@ class HandleUriAssetUseCaseTest { coEvery { fileManager.getAssetBundleFromUri(any(), any(), any(), any(), any()) } returns assetBundle } + fun withUnsupportedUriScheme() = apply { + every { fileManager.checkValidSchema(any()) } throws IllegalArgumentException("Unsupported URI scheme") + } + fun withGetAssetSizeLimitUseCase(isImage: Boolean, assetSizeLimit: Long) = apply { coEvery { getAssetSizeLimitUseCase(eq(isImage)) } returns assetSizeLimit return this @@ -199,4 +208,8 @@ class HandleUriAssetUseCaseTest { TestDispatcherProvider(), ) } + + companion object { + private val CONTENT_URI = "content://example.provider/mocked_image.jpeg".toUri() + } } diff --git a/app/src/test/kotlin/com/wire/android/util/FileManagerTest.kt b/app/src/test/kotlin/com/wire/android/util/FileManagerTest.kt new file mode 100644 index 00000000000..687bcda40de --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/util/FileManagerTest.kt @@ -0,0 +1,57 @@ +/* + * 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.util + +import android.app.Application +import androidx.core.net.toUri +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class FileManagerTest { + + private val fileManager = FileManager(ApplicationProvider.getApplicationContext()) + + @Test + fun givenContentUri_whenCheckingScheme_thenItIsAccepted() { + fileManager.checkValidSchema("content://example.provider/assets/1".toUri()) + } + + @Test + fun givenUnsupportedUriScheme_whenCheckingScheme_thenItIsRejected() { + val unsupportedUris = listOf( + "file:///data/asset.txt", + "https://example.com/asset.txt", + "data:text/plain,asset", + "android.resource://com.example/raw/asset", + "custom://example/asset", + "asset-without-scheme.txt", + ) + + unsupportedUris.forEach { uri -> + assertThrows("Expected $uri to be rejected", IllegalArgumentException::class.java) { + fileManager.checkValidSchema(uri.toUri()) + } + } + } +} From 2d0a84b26f4d27be1b780d4a529be5d695a216b3 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:34:57 +0200 Subject: [PATCH 12/14] fix: secure cross-profile Wire shares [WPB-26687] --- .../com/wire/android/ui/WireActivity.kt | 56 +++++++++++--- .../ImportMediaAuthenticatedViewModel.kt | 63 ++++++++-------- .../android/ui/WireActivityViewModelTest.kt | 20 +++++ .../ImportMediaAuthenticatedViewModelTest.kt | 74 ++++++++++++++++++- 4 files changed, 167 insertions(+), 46 deletions(-) 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 97096bedac3..8b3d26e73e9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -154,13 +154,14 @@ import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedDialog import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedState import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedViewModel import com.wire.android.ui.settings.devices.e2ei.E2EICertificateDetails +import com.wire.android.ui.sharing.hasTrustedWireShareCaller +import com.wire.android.ui.sharing.sharingUris import com.wire.android.ui.theme.ThemeOption import com.wire.android.ui.theme.WireTheme 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 @@ -217,7 +218,14 @@ class WireActivity : BaseActivity() { } } - private val newIntents = Channel>(Channel.UNLIMITED) // keep new intents until subscribed but do not replay them + private data class QueuedIntent( + val intent: Intent, + val savedInstanceState: Bundle?, + val hasTrustedWireShareCaller: Boolean + ) + + // Keep new intents until subscribed but do not replay them. + private val newIntents = Channel(Channel.UNLIMITED) private lateinit var shakeDetector: ShakeDetector // This flag is used to keep the splash screen open until the first screen is drawn. @@ -235,7 +243,7 @@ class WireActivity : BaseActivity() { wireApplicationGraph.inject(this) super.onCreate(savedInstanceState) splashScreen.setKeepOnScreenCondition { shouldKeepSplashOpen } - traceStartup("activity.onCreate.afterSuper", startupAt) + val initialQueuedIntent = captureInitialIntent(startupAt, savedInstanceState) enableEdgeToEdge() setupOrientationForDevice() @@ -286,7 +294,7 @@ class WireActivity : BaseActivity() { (application as? WireApplication)?.initializeDeferredLoggingAfterSplash() traceStartup("activity.deferredLogging.triggered", startupAt) - handleNewIntent(intent, savedInstanceState) + handleNewIntent(initialQueuedIntent) traceStartup("activity.initialIntent.dispatched", startupAt) } @@ -332,7 +340,7 @@ class WireActivity : BaseActivity() { } else { setIntent(intent) } - handleNewIntent(intent) + handleNewIntent(queuedIntent(intent)) } @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @@ -340,8 +348,25 @@ class WireActivity : BaseActivity() { setIntent(intent, getCurrentCaller()) } - private fun handleNewIntent(intent: Intent, savedInstanceState: Bundle? = null) = lifecycleScope.launch { - newIntents.send(intent to savedInstanceState) + private fun queuedIntent(intent: Intent, savedInstanceState: Bundle? = null): QueuedIntent { + val providerAuthority = getProviderAuthority() + return QueuedIntent( + intent = intent, + savedInstanceState = savedInstanceState, + hasTrustedWireShareCaller = hasTrustedWireShareCaller( + providerAuthority = providerAuthority, + uris = intent.sharingUris() + ) + ) + } + + private fun captureInitialIntent(startupAt: Long, savedInstanceState: Bundle?): QueuedIntent { + traceStartup("activity.onCreate.afterSuper", startupAt) + return queuedIntent(intent, savedInstanceState) + } + + private fun handleNewIntent(queuedIntent: QueuedIntent) = lifecycleScope.launch { + newIntents.send(queuedIntent) } private fun setComposableContent(startDestination: Direction) { @@ -860,9 +885,14 @@ class WireActivity : BaseActivity() { newIntents .receiveAsFlow() .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .collectLatest { (intent, savedInstanceState) -> + .collectLatest { queuedIntent -> currentKeyboardController?.hide() - handleDeepLinkOrIntent(currentNavigator, intent, savedInstanceState) + handleDeepLinkOrIntent( + navigator = currentNavigator, + intent = queuedIntent.intent, + savedInstanceState = queuedIntent.savedInstanceState, + hasTrustedWireShareCaller = queuedIntent.hasTrustedWireShareCaller + ) } } } @@ -1235,7 +1265,8 @@ class WireActivity : BaseActivity() { private suspend fun handleDeepLinkOrIntent( navigator: Navigator, intent: Intent?, - savedInstanceState: Bundle? = null + savedInstanceState: Bundle? = null, + hasTrustedWireShareCaller: Boolean = false ) { val navigate: (NavigationCommand) -> Unit = { runOnUiThread { @@ -1258,10 +1289,11 @@ class WireActivity : BaseActivity() { } else { val handled = viewModel.handleIntentsThatAreNotDeepLinks(intent) if (!handled) { + val providerAuthority = getProviderAuthority() viewModel.handleDeepLink( intent = intent, - providerAuthority = getProviderAuthority(), - hasTrustedWireShareCaller = hasTrustedWireShareCaller() + providerAuthority = providerAuthority, + hasTrustedWireShareCaller = hasTrustedWireShareCaller ) intent.putExtra(HANDLED_DEEPLINK_FLAG, true) } 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 6a8d7fe7b66..29c17b97d13 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 @@ -20,6 +20,7 @@ package com.wire.android.ui.sharing import android.content.ContentResolver import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.os.Build import androidx.annotation.RequiresApi @@ -156,14 +157,13 @@ class ImportMediaAuthenticatedViewModel( handleSharedText(incomingIntent.text.toString()) } else { val providerAuthority = activity.getProviderAuthority() - val hasTrustedWireCaller = activity.hasTrustedWireShareCaller() - if (incomingIntent.isSingleShare) { - // ACTION_SEND - handleSingleIntent(providerAuthority, hasTrustedWireCaller, incomingIntent) + val sharedUris = if (incomingIntent.isSingleShare) { + incomingIntent.stream?.let(::listOf).orEmpty() } else { - // ACTION_SEND_MULTIPLE - handleMultipleActionIntent(activity, providerAuthority, hasTrustedWireCaller) + activity.intent.sharingUris() } + val hasTrustedWireCaller = activity.hasTrustedWireShareCaller(providerAuthority, sharedUris) + handleReceivedUrisFromSharingIntent(providerAuthority, hasTrustedWireCaller, sharedUris) } importMediaState = importMediaState.copy(isImporting = false) } @@ -194,26 +194,6 @@ class ImportMediaAuthenticatedViewModel( importMediaState = importMediaState.copy(importedText = text) } - private suspend fun handleSingleIntent( - providerAuthority: String, - hasTrustedWireCaller: Boolean, - incomingIntent: ShareCompat.IntentReader - ) { - incomingIntent.stream?.let { uri -> - appLogger.d("$TAG: handleSingleIntent") - handleReceivedUrisFromSharingIntent(providerAuthority, hasTrustedWireCaller, listOf(uri)) - } - } - - private suspend fun handleMultipleActionIntent( - activity: AppCompatActivity, - providerAuthority: String, - hasTrustedWireCaller: Boolean - ) { - appLogger.d("$TAG: handleMultipleActionIntent") - handleReceivedUrisFromSharingIntent(providerAuthority, hasTrustedWireCaller, activity.intent.sharingUris()) - } - internal suspend fun handleReceivedUrisFromSharingIntent( providerAuthority: String, hasTrustedWireCaller: Boolean, @@ -278,7 +258,8 @@ class ImportMediaAuthenticatedViewModel( } internal fun Uri.isWireFileProviderUri(providerAuthority: String): Boolean = - scheme.equals(ContentResolver.SCHEME_CONTENT, ignoreCase = true) && authority == providerAuthority + scheme.equals(ContentResolver.SCHEME_CONTENT, ignoreCase = true) && + authority?.substringAfterLast('@') == providerAuthority internal fun Uri.shouldRejectWireFileProviderShare(providerAuthority: String, hasTrustedWireCaller: Boolean): Boolean = isWireFileProviderUri(providerAuthority) && !hasTrustedWireCaller @@ -301,18 +282,36 @@ internal fun Intent.shouldRejectSharingIntent(providerAuthority: String, hasTrus internal fun Uri.isWireInternalShareUri(providerAuthority: String): Boolean = isWireFileProviderUri(providerAuthority) && + authority == providerAuthority && pathSegments.let { segments -> segments.size > 1 && segments.firstOrNull() == FILE_PROVIDER_SHARED_FILES_ROOT && segments.none { it == ".." } } -internal fun AppCompatActivity.hasTrustedWireShareCaller(): Boolean = +internal fun List.areWireFileProviderUrisReadableBy( + providerAuthority: String, + canReadUri: (Uri) -> Boolean +): Boolean = + filter { it.isWireFileProviderUri(providerAuthority) } + .takeIf { it.isNotEmpty() } + ?.all { uri -> runCatching { canReadUri(uri) }.getOrDefault(false) } + ?: false + +internal fun AppCompatActivity.hasTrustedWireShareCaller( + providerAuthority: String, + uris: List +): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && - getTrustedShareCallerPackageName() == packageName + uris.areWireFileProviderUrisReadableBy(providerAuthority) { uri -> + canCallerReadSharedUri(uri) + } @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) -private fun AppCompatActivity.getTrustedShareCallerPackageName(): String? = +private fun AppCompatActivity.canCallerReadSharedUri(uri: Uri): Boolean = runCatching { - caller?.getPackage() ?: initialCaller.getPackage() - }.getOrNull() + (caller ?: initialCaller).checkContentUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) == PackageManager.PERMISSION_GRANTED + }.getOrDefault(false) 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 dd79db63836..ff99cd315b5 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt @@ -1087,6 +1087,26 @@ class WireActivityViewModelTest { } } + @Test + fun `given untrusted sharing intent with work-profile Wire provider uri, when handling deep link, then show ignored toast`() = + runTest { + val (_, viewModel) = Arrangement() + .withDeepLinkResult(DeepLinkResult.SharingIntent) + .arrange() + val intent = sharingIntent(sharingUri("10@$WIRE_PROVIDER_AUTHORITY")) + + 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 { 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 fe262b9774b..10733739ee8 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 @@ -48,6 +48,7 @@ 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.assertSame import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -94,6 +95,13 @@ class ImportMediaAuthenticatedViewModelTest { assertTrue(uri.isWireFileProviderUri("com.wire.android.provider")) } + @Test + fun `given user-qualified content uri from Wire file provider, when checking provider uri, then match it`() { + val uri = testUri(authority = "10@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") @@ -151,6 +159,58 @@ class ImportMediaAuthenticatedViewModelTest { ) } + @Test + fun `given readable work-profile Wire uri, when checking caller access, then preserve and check original uri`() { + val workProfileUri = testUri(authority = "10@com.wire.android.provider") + var checkedUri: Uri? = null + + val result = listOf(workProfileUri).areWireFileProviderUrisReadableBy("com.wire.android.provider") { uri -> + checkedUri = uri + true + } + + assertTrue(result) + assertSame(workProfileUri, checkedUri) + } + + @Test + fun `given one unreadable Wire uri, when checking caller access, then reject all Wire uris`() { + val readableUri = testUri(authority = "10@com.wire.android.provider") + val unreadableUri = testUri(authority = "0@com.wire.android.provider") + + val result = listOf(readableUri, unreadableUri) + .areWireFileProviderUrisReadableBy("com.wire.android.provider") { uri -> + uri === readableUri + } + + assertFalse(result) + } + + @Test + fun `given caller permission check fails, when checking caller access, then reject Wire uris`() { + val wireUri = testUri(authority = "10@com.wire.android.provider") + + val result = listOf(wireUri).areWireFileProviderUrisReadableBy("com.wire.android.provider") { + throw SecurityException("Caller access is unavailable") + } + + assertFalse(result) + } + + @Test + fun `given only external provider uris, when checking Wire caller access, then do not invoke permission check`() { + val externalUri = testUri(authority = "com.android.providers.media.documents") + var permissionChecked = false + + val result = listOf(externalUri).areWireFileProviderUrisReadableBy("com.wire.android.provider") { + permissionChecked = true + true + } + + assertFalse(result) + assertFalse(permissionChecked) + } + @Test fun `given untrusted public share with Wire provider uri, when handling sharing uris, then reject whole intent`() = runTest(dispatcherProvider.main()) { @@ -173,9 +233,9 @@ class ImportMediaAuthenticatedViewModelTest { } @Test - fun `given trusted public share with Wire provider uri, when handling sharing uris, then import it`() = + fun `given readable work-profile Wire uri, when handling sharing uris, then import original uri`() = runTest(dispatcherProvider.main()) { - val wireUri = testUri(authority = "com.wire.android.provider") + val wireUri = testUri(authority = "10@com.wire.android.provider") val assetBundle = assetBundle("wire-file.zip") val (arrangement, viewModel) = Arrangement() .withHandleUriAsset(HandleUriAssetUseCase.Result.Success(assetBundle)) @@ -260,6 +320,16 @@ class ImportMediaAuthenticatedViewModelTest { assertTrue(uri.isWireInternalShareUri("com.wire.android.provider")) } + @Test + fun `given user-qualified Wire provider uri, when checking internal share uri, then reject it`() { + val uri = testUri( + authority = "10@com.wire.android.provider", + pathSegments = listOf("shared_files", "wire-logs.zip") + ) + + assertFalse(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( From 9c03f9267a83aa20b179878ba86a29754c2b8760 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:23:17 +0200 Subject: [PATCH 13/14] update compose stability dump --- app/stability/app-devDebug.stability | 111 ++++++++++++++++++--------- 1 file changed, 74 insertions(+), 37 deletions(-) diff --git a/app/stability/app-devDebug.stability b/app/stability/app-devDebug.stability index 38306685fb8..30f7c7a0d41 100644 --- a/app/stability/app-devDebug.stability +++ b/app/stability/app-devDebug.stability @@ -848,11 +848,11 @@ private fun com.wire.android.ui.WireActivity.rememberWireActivityGraphContext(ap - isSessionTransitionInProgress: STABLE (primitive type) @Composable -private fun com.wire.android.ui.WireActivity.wireActivityScopedViewModels(graph: com.wire.android.di.metro.AppSessionViewModelGraph): com.wire.android.ui.WireActivityScopedViewModels - skippable: false +private fun com.wire.android.ui.WireActivity.wireActivityScopedViewModels(retainedSessionGraph: com.wire.android.ui.RetainedSessionGraph): com.wire.android.ui.WireActivityScopedViewModels + skippable: true restartable: true params: - - graph: RUNTIME (requires runtime check) + - retainedSessionGraph: STABLE (marked @Stable or @Immutable) @Composable public fun com.wire.android.ui.analyticsUsageViewModel(): com.wire.android.ui.analytics.AnalyticsUsageViewModel @@ -2785,16 +2785,6 @@ public fun com.wire.android.ui.common.UnderConstructionScreen(screenName: kotlin - screenName: STABLE (String is immutable) - modifier: STABLE (marked @Stable or @Immutable) -@Composable -public fun com.wire.android.ui.common.WireRadioButton(checked: kotlin.Boolean, modifier: androidx.compose.ui.Modifier, onButtonChecked: kotlin.Function0?, enabled: kotlin.Boolean): kotlin.Unit - skippable: true - restartable: true - params: - - checked: STABLE (primitive type) - - modifier: STABLE (marked @Stable or @Immutable) - - onButtonChecked: STABLE (function type) - - enabled: STABLE (primitive type) - @Composable public fun com.wire.android.ui.common.animateAsStateRotationToRight(isOpen: kotlin.Boolean): androidx.compose.runtime.State skippable: true @@ -3658,7 +3648,7 @@ public fun com.wire.android.ui.debug.LogManagementScreen(navigator: com.wire.and - viewModel: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.wire.android.ui.debug.LogOptions(isLoggingEnabled: kotlin.Boolean, onLoggingEnabledChange: kotlin.Function1, isDBLoggerEnabled: kotlin.Boolean, onDBLoggerEnabledChange: kotlin.Function1, onDeleteLogs: kotlin.Function0, onShareLogs: kotlin.Function0, isPrivateBuild: kotlin.Boolean, modifier: androidx.compose.ui.Modifier): kotlin.Unit +public fun com.wire.android.ui.debug.LogOptions(isLoggingEnabled: kotlin.Boolean, onLoggingEnabledChange: kotlin.Function1, isDBLoggerEnabled: kotlin.Boolean, onDBLoggerEnabledChange: kotlin.Function1, onDeleteLogs: kotlin.Function0, onShareLogsExternally: kotlin.Function0, onShareLogsViaWire: kotlin.Function0, isPrivateBuild: kotlin.Boolean, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: @@ -3667,7 +3657,8 @@ public fun com.wire.android.ui.debug.LogOptions(isLoggingEnabled: kotlin.Boolean - isDBLoggerEnabled: STABLE (primitive type) - onDBLoggerEnabledChange: STABLE (function type) - onDeleteLogs: STABLE (function type) - - onShareLogs: STABLE (function type) + - onShareLogsExternally: STABLE (function type) + - onShareLogsViaWire: STABLE (function type) - isPrivateBuild: STABLE (primitive type) - modifier: STABLE (marked @Stable or @Immutable) @@ -3714,6 +3705,20 @@ private fun com.wire.android.ui.debug.RestartSlowSyncButton(onClick: kotlin.Func params: - onClick: STABLE (function type) +@Composable +private fun com.wire.android.ui.debug.ShareLogsExternallyOption(onClick: kotlin.Function0): kotlin.Unit + skippable: true + restartable: true + params: + - onClick: STABLE (function type) + +@Composable +private fun com.wire.android.ui.debug.ShareLogsInWireOption(onClick: kotlin.Function0): kotlin.Unit + skippable: true + restartable: true + params: + - onClick: STABLE (function type) + @Composable private fun com.wire.android.ui.debug.UserDataBaseProfileSwitch(isEnabled: kotlin.Boolean, onCheckedChange: kotlin.Function1): kotlin.Unit skippable: true @@ -3723,7 +3728,7 @@ private fun com.wire.android.ui.debug.UserDataBaseProfileSwitch(isEnabled: kotli - onCheckedChange: STABLE (function type) @Composable -internal fun com.wire.android.ui.debug.UserDebugContent(state: com.wire.android.ui.debug.UserDebugState, onNavigationPressed: kotlin.Function0, onLoggingEnabledChange: kotlin.Function1, onDatabaseLoggerEnabledChanged: kotlin.Function1, onDeleteLogs: kotlin.Function0, onFlushLogs: kotlin.Function0>, debugDataOptionsContent: @[Composable] androidx.compose.runtime.internal.ComposableFunction1, dangerOptionsContent: @[Composable] androidx.compose.runtime.internal.ComposableFunction0): kotlin.Unit +internal fun com.wire.android.ui.debug.UserDebugContent(state: com.wire.android.ui.debug.UserDebugState, onNavigationPressed: kotlin.Function0, onLoggingEnabledChange: kotlin.Function1, onDatabaseLoggerEnabledChanged: kotlin.Function1, onDeleteLogs: kotlin.Function0, onFlushLogs: kotlin.Function0>, onShareLogsViaWire: kotlin.Function1, debugDataOptionsContent: @[Composable] androidx.compose.runtime.internal.ComposableFunction1, dangerOptionsContent: @[Composable] androidx.compose.runtime.internal.ComposableFunction0): kotlin.Unit skippable: true restartable: true params: @@ -3733,6 +3738,7 @@ internal fun com.wire.android.ui.debug.UserDebugContent(state: com.wire.android. - onDatabaseLoggerEnabledChanged: STABLE (function type) - onDeleteLogs: STABLE (function type) - onFlushLogs: STABLE (function type) + - onShareLogsViaWire: STABLE (function type) - debugDataOptionsContent: STABLE (composable function type) - dangerOptionsContent: STABLE (composable function type) @@ -4029,6 +4035,13 @@ public fun com.wire.android.ui.edit.ReplyMessageOption(onReplyItemClick: kotlin. params: - onReplyItemClick: STABLE (function type) +@Composable +public fun com.wire.android.ui.edit.ShareAssetExternallyMenuOption(onShareAsset: kotlin.Function0): kotlin.Unit + skippable: true + restartable: true + params: + - onShareAsset: STABLE (function type) + @Composable public fun com.wire.android.ui.edit.ShareAssetMenuOption(onShareAsset: kotlin.Function0): kotlin.Unit skippable: true @@ -4036,6 +4049,13 @@ public fun com.wire.android.ui.edit.ShareAssetMenuOption(onShareAsset: kotlin.Fu params: - onShareAsset: STABLE (function type) +@Composable +public fun com.wire.android.ui.edit.ShareAssetViaWireMenuOption(onShareAsset: kotlin.Function0): kotlin.Unit + skippable: true + restartable: true + params: + - onShareAsset: STABLE (function type) + @Composable public fun com.wire.android.ui.edit.SharePublicLinkMenuOption(onShareAsset: kotlin.Function0): kotlin.Unit skippable: true @@ -4537,7 +4557,7 @@ public fun com.wire.android.ui.home.conversations.ConversationScreen(navigator: - messageAttachmentsViewModel: UNSTABLE (has mutable properties or unstable members) @Composable -private fun com.wire.android.ui.home.conversations.ConversationScreen(bannerMessage: com.wire.android.util.ui.UIText?, messageComposerViewState: com.wire.android.ui.home.conversations.MessageComposerViewState, conversationCallViewState: com.wire.android.ui.home.conversations.call.ConversationCallViewState, conversationInfoViewState: com.wire.android.ui.home.conversations.info.ConversationInfoViewState, conversationMessagesViewState: com.wire.android.ui.home.conversations.messages.ConversationMessagesViewState, attachments: kotlin.collections.List, bottomSheetVisible: kotlin.Boolean, onOpenProfile: kotlin.Function1<@[ParameterName(name = \, onMessageDetailsClick: kotlin.Function2<@[ParameterName(name = \, onSendMessage: kotlin.Function1, onPingOptionClicked: kotlin.Function0, onImagesPicked: kotlin.Function2, kotlin.Boolean, kotlin.Unit>, onAttachmentPicked: kotlin.Function1, onAudioRecorded: kotlin.Function1, onDeleteMessage: kotlin.Function2, onAssetItemClicked: kotlin.Function1, onImageFullScreenMode: kotlin.Function3, onVideoClick: kotlin.Function3<@[ParameterName(name = \, onStartCall: kotlin.Function0, onJoinCall: kotlin.Function0, onReactionClick: kotlin.Function2<@[ParameterName(name = \, onResetSessionClick: kotlin.Function2<@[ParameterName(name = \, onUpdateConversationReadDate: kotlin.Function1, onDropDownClick: kotlin.Function0, onBackButtonClick: kotlin.Function0, composerMessages: kotlinx.coroutines.flow.SharedFlow, conversationMessages: kotlinx.coroutines.flow.SharedFlow, shareAsset: kotlin.Function2, onSelfDeletingMessageRead: kotlin.Function1, onNewSelfDeletingMessagesStatus: kotlin.Function1, tempWritableImageUri: android.net.Uri?, tempWritableVideoUri: android.net.Uri?, onFailedMessageRetryClicked: kotlin.Function2, onClearMentionSearchResult: kotlin.Function0, onPermissionPermanentlyDenied: kotlin.Function1<@[ParameterName(name = \, conversationScreenState: com.wire.android.ui.home.conversations.ConversationScreenState, messageComposerStateHolder: com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder, onLinkClick: kotlin.Function1, openDrawingCanvas: kotlin.Function0, onAttachmentClick: kotlin.Function1, onAttachmentMenuClick: kotlin.Function1, currentTimeInMillisFlow: kotlinx.coroutines.flow.Flow, onReachedOldestMessage: kotlin.Function0, isFetchingOlderMessages: kotlin.Boolean, hasMoreRemoteMessages: kotlin.Boolean, isWireCellsEnabled: kotlin.Boolean): kotlin.Unit +private fun com.wire.android.ui.home.conversations.ConversationScreen(bannerMessage: com.wire.android.util.ui.UIText?, messageComposerViewState: com.wire.android.ui.home.conversations.MessageComposerViewState, conversationCallViewState: com.wire.android.ui.home.conversations.call.ConversationCallViewState, conversationInfoViewState: com.wire.android.ui.home.conversations.info.ConversationInfoViewState, conversationMessagesViewState: com.wire.android.ui.home.conversations.messages.ConversationMessagesViewState, attachments: kotlin.collections.List, bottomSheetVisible: kotlin.Boolean, onOpenProfile: kotlin.Function1<@[ParameterName(name = \, onMessageDetailsClick: kotlin.Function2<@[ParameterName(name = \, onSendMessage: kotlin.Function1, onPingOptionClicked: kotlin.Function0, onImagesPicked: kotlin.Function2, kotlin.Boolean, kotlin.Unit>, onAttachmentPicked: kotlin.Function1, onAudioRecorded: kotlin.Function1, onDeleteMessage: kotlin.Function2, onAssetItemClicked: kotlin.Function1, onImageFullScreenMode: kotlin.Function3, onVideoClick: kotlin.Function3<@[ParameterName(name = \, onStartCall: kotlin.Function0, onJoinCall: kotlin.Function0, onReactionClick: kotlin.Function2<@[ParameterName(name = \, onResetSessionClick: kotlin.Function2<@[ParameterName(name = \, onUpdateConversationReadDate: kotlin.Function1, onDropDownClick: kotlin.Function0, onBackButtonClick: kotlin.Function0, composerMessages: kotlinx.coroutines.flow.SharedFlow, conversationMessages: kotlinx.coroutines.flow.SharedFlow, shareAssetExternally: kotlin.Function2, onSelfDeletingMessageRead: kotlin.Function1, onNewSelfDeletingMessagesStatus: kotlin.Function1, tempWritableImageUri: android.net.Uri?, tempWritableVideoUri: android.net.Uri?, onFailedMessageRetryClicked: kotlin.Function2, onClearMentionSearchResult: kotlin.Function0, onPermissionPermanentlyDenied: kotlin.Function1<@[ParameterName(name = \, conversationScreenState: com.wire.android.ui.home.conversations.ConversationScreenState, messageComposerStateHolder: com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder, onLinkClick: kotlin.Function1, openDrawingCanvas: kotlin.Function0, onAttachmentClick: kotlin.Function1, onAttachmentMenuClick: kotlin.Function1, currentTimeInMillisFlow: kotlinx.coroutines.flow.Flow, onReachedOldestMessage: kotlin.Function0, isFetchingOlderMessages: kotlin.Boolean, hasMoreRemoteMessages: kotlin.Boolean, isWireCellsEnabled: kotlin.Boolean): kotlin.Unit skippable: false restartable: true params: @@ -4568,7 +4588,8 @@ private fun com.wire.android.ui.home.conversations.ConversationScreen(bannerMess - onBackButtonClick: STABLE (function type) - composerMessages: RUNTIME (requires runtime check) - conversationMessages: RUNTIME (requires runtime check) - - shareAsset: STABLE (function type) + - shareAssetExternally: STABLE (function type) + - shareAssetViaWire: STABLE (function type) - onDownloadAssetClick: STABLE (function type) - onOpenAssetClick: STABLE (function type) - onNavigateToReplyOriginalMessage: STABLE (function type) @@ -4592,7 +4613,7 @@ private fun com.wire.android.ui.home.conversations.ConversationScreen(bannerMess - isWireCellsEnabled: STABLE (primitive type) @Composable -private fun com.wire.android.ui.home.conversations.ConversationScreenContent(conversationId: com.wire.kalium.logic.data.id.QualifiedID, bottomSheetVisible: kotlin.Boolean, lastUnreadMessageInstant: kotlinx.datetime.Instant?, unreadEventCount: kotlin.Int, playingAudioMessage: com.wire.android.media.audiomessage.PlayingAudioMessage, assetStatuses: kotlinx.collections.immutable.PersistentMap, selectedMessageId: kotlin.String?, messageComposerStateHolder: com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder, attachments: kotlin.collections.List, messages: kotlinx.coroutines.flow.Flow>, onSendMessage: kotlin.Function1, onPingOptionClicked: kotlin.Function0, onImagesPicked: kotlin.Function2, kotlin.Boolean, kotlin.Unit>, onAttachmentPicked: kotlin.Function1, onAudioRecorded: kotlin.Function1, onAssetItemClicked: kotlin.Function1, onImageFullScreenMode: kotlin.Function3, onVideoClick: kotlin.Function3<@[ParameterName(name = \, onReactionClicked: kotlin.Function2, onResetSessionClicked: kotlin.Function2<@[ParameterName(name = \, onOpenProfile: kotlin.Function1<@[ParameterName(name = \, onUpdateConversationReadDate: kotlin.Function1, onShowEditingOptions: kotlin.Function1, onSwipedToReply: kotlin.Function1, onSelfDeletingMessageRead: kotlin.Function1, conversationDetailsData: com.wire.android.ui.home.conversations.info.ConversationDetailsData, onFailedMessageRetryClicked: kotlin.Function2, onFailedMessageCancelClicked: kotlin.Function1, onChangeSelfDeletionClicked: kotlin.Function1, onClearMentionSearchResult: kotlin.Function0, onLocationClicked: kotlin.Function0, onPermissionPermanentlyDenied: kotlin.Function1<@[ParameterName(name = \, tempWritableImageUri: android.net.Uri?, tempWritableVideoUri: android.net.Uri?, onLinkClick: kotlin.Function1, onNavigateToReplyOriginalMessage: kotlin.Function1, openDrawingCanvas: kotlin.Function0, onAttachmentClick: kotlin.Function1, onAttachmentMenuClick: kotlin.Function1, currentTimeInMillisFlow: kotlinx.coroutines.flow.Flow, onReachedOldestMessage: kotlin.Function0, showHistoryLoadingIndicator: kotlin.Boolean, isFetchingOlderMessages: kotlin.Boolean, hasMoreRemoteMessages: kotlin.Boolean, isBubbleUiEnabled: kotlin.Boolean, isWireCellsEnabled: kotlin.Boolean): kotlin.Unit +private fun com.wire.android.ui.home.conversations.ConversationScreenContent(conversationId: com.wire.kalium.logic.data.id.QualifiedID, bottomSheetVisible: kotlin.Boolean, lastUnreadMessageInstant: kotlinx.datetime.Instant?, unreadEventCount: kotlin.Int, playingAudioMessage: com.wire.android.media.audiomessage.PlayingAudioMessage, assetStatuses: kotlinx.collections.immutable.PersistentMap, selectedMessageId: kotlin.String?, messageComposerStateHolder: com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder, attachments: kotlin.collections.List, messages: kotlinx.coroutines.flow.Flow>, onSendMessage: kotlin.Function1, onPingOptionClicked: kotlin.Function0, onImagesPicked: kotlin.Function2, kotlin.Boolean, kotlin.Unit>, onAttachmentPicked: kotlin.Function1, onAudioRecorded: kotlin.Function1, onAssetItemClicked: kotlin.Function1, onImageFullScreenMode: kotlin.Function3, onVideoClick: kotlin.Function3<@[ParameterName(name = \, onReactionClicked: kotlin.Function2, onResetSessionClicked: kotlin.Function2<@[ParameterName(name = \, onOpenProfile: kotlin.Function1<@[ParameterName(name = \, onUpdateConversationReadDate: kotlin.Function1, onShowEditingOptions: kotlin.Function1, onSwipedToReply: kotlin.Function1, onSelfDeletingMessageRead: kotlin.Function1, conversationDetailsData: com.wire.android.ui.home.conversations.info.ConversationDetailsData, onFailedMessageRetryClicked: kotlin.Function2, onFailedMessageCancelClicked: kotlin.Function1, onChangeSelfDeletionClicked: kotlin.Function1, onClearMentionSearchResult: kotlin.Function0, onLocationClicked: kotlin.Function0, onPermissionPermanentlyDenied: kotlin.Function1<@[ParameterName(name = \, tempWritableImageUri: android.net.Uri?, tempWritableVideoUri: android.net.Uri?, onLinkClick: kotlin.Function1, onNavigateToReplyOriginalMessage: kotlin.Function1, openDrawingCanvas: kotlin.Function0, onAttachmentClick: kotlin.Function1, onAttachmentMenuClick: kotlin.Function1, currentTimeInMillisFlow: kotlinx.coroutines.flow.Flow, onReachedOldestMessage: kotlin.Function0, showHistoryLoadingIndicator: kotlin.Boolean, isFetchingOlderMessages: kotlin.Boolean, hasMoreRemoteMessages: kotlin.Boolean, isBubbleUiEnabled: kotlin.Boolean, isWireCellsEnabled: kotlin.Boolean): kotlin.Unit skippable: false restartable: true params: @@ -4738,17 +4759,17 @@ private fun com.wire.android.ui.home.conversations.MenuItem(iconRes: kotlin.Int, - onClick: STABLE (function type) @Composable -private fun com.wire.android.ui.home.conversations.MessageGroupDateTime(now: kotlin.Long, messageDateTime: java.util.Date, messageDateTimeGroup: com.wire.android.mapper.MessageDateTimeGroup?, isBubbleUiEnabled: kotlin.Boolean): kotlin.Unit - skippable: false +private fun com.wire.android.ui.home.conversations.MessageGroupDateTime(now: kotlin.Long, messageDateTime: kotlinx.datetime.Instant, messageDateTimeGroup: com.wire.android.mapper.MessageDateTimeGroup, isBubbleUiEnabled: kotlin.Boolean): kotlin.Unit + skippable: true restartable: true params: - now: STABLE (primitive type) - - messageDateTime: UNSTABLE (mutable Java class) + - messageDateTime: STABLE (matched by stability configuration) - messageDateTimeGroup: STABLE (class with no mutable properties) - isBubbleUiEnabled: STABLE (primitive type) @Composable -public fun com.wire.android.ui.home.conversations.MessageList(lazyPagingMessages: androidx.paging.compose.LazyPagingItems, lazyListState: androidx.compose.foundation.lazy.LazyListState, lastUnreadMessageInstant: kotlinx.datetime.Instant?, playingAudioMessage: com.wire.android.media.audiomessage.PlayingAudioMessage, assetStatuses: kotlinx.collections.immutable.PersistentMap, onUpdateConversationReadDate: kotlin.Function1, onSwipedToReply: kotlin.Function1, onSwipedToReact: kotlin.Function1, onSelfDeletingMessageRead: kotlin.Function1, conversationDetailsData: com.wire.android.ui.home.conversations.info.ConversationDetailsData, selectedMessageId: kotlin.String?, interactionAvailability: com.wire.kalium.logic.data.conversation.InteractionAvailability, clickActions: com.wire.android.ui.home.conversations.messages.item.MessageClickActions.Content, modifier: androidx.compose.ui.Modifier, currentTimeInMillisFlow: kotlinx.coroutines.flow.Flow, showHistoryLoadingIndicator: kotlin.Boolean, isFetchingOlderMessages: kotlin.Boolean, hasMoreRemoteMessages: kotlin.Boolean, isBubbleUiEnabled: kotlin.Boolean, isWireCellsEnabled: kotlin.Boolean, onReachedOldestMessage: kotlin.Function0): kotlin.Unit +public fun com.wire.android.ui.home.conversations.MessageList(lazyPagingMessages: androidx.paging.compose.LazyPagingItems, lazyListState: androidx.compose.foundation.lazy.LazyListState, lastUnreadMessageInstant: kotlinx.datetime.Instant?, playingAudioMessage: com.wire.android.media.audiomessage.PlayingAudioMessage, assetStatuses: kotlinx.collections.immutable.PersistentMap, onUpdateConversationReadDate: kotlin.Function1, onSwipedToReply: kotlin.Function1, onSwipedToReact: kotlin.Function1, onSelfDeletingMessageRead: kotlin.Function1, conversationDetailsData: com.wire.android.ui.home.conversations.info.ConversationDetailsData, selectedMessageId: kotlin.String?, interactionAvailability: com.wire.kalium.logic.data.conversation.InteractionAvailability, clickActions: com.wire.android.ui.home.conversations.messages.item.MessageClickActions.Content, modifier: androidx.compose.ui.Modifier, currentTimeInMillisFlow: kotlinx.coroutines.flow.Flow, showHistoryLoadingIndicator: kotlin.Boolean, isFetchingOlderMessages: kotlin.Boolean, hasMoreRemoteMessages: kotlin.Boolean, isBubbleUiEnabled: kotlin.Boolean, isWireCellsEnabled: kotlin.Boolean, onReachedOldestMessage: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: @@ -5589,7 +5610,7 @@ public fun com.wire.android.ui.home.conversations.details.updatechannelaccess.Ch - updateChannelAccessViewModel: UNSTABLE (has mutable properties or unstable members) @Composable -private fun com.wire.android.ui.home.conversations.edit.MessageOptionsModalContent(message: com.wire.android.ui.home.conversations.model.UIMessage.Regular, sheetState: com.wire.android.ui.common.bottomsheet.WireModalSheetState, isNetworkAvailable: kotlin.Boolean, onCopyClick: kotlin.Function1<@[ParameterName(name = \, onDeleteClick: kotlin.Function2<@[ParameterName(name = \, onReactionClick: kotlin.Function2<@[ParameterName(name = \, onDetailsClick: kotlin.Function2<@[ParameterName(name = \, onReplyClick: kotlin.Function1, onEditClick: kotlin.Function4<@[ParameterName(name = \, onShareAssetClick: kotlin.Function1<@[ParameterName(name = \, onDownloadAssetClick: kotlin.Function1<@[ParameterName(name = \, onOpenAssetClick: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit +private fun com.wire.android.ui.home.conversations.edit.MessageOptionsModalContent(message: com.wire.android.ui.home.conversations.model.UIMessage.Regular, sheetState: com.wire.android.ui.common.bottomsheet.WireModalSheetState, isNetworkAvailable: kotlin.Boolean, onCopyClick: kotlin.Function1<@[ParameterName(name = \, onDeleteClick: kotlin.Function2<@[ParameterName(name = \, onReactionClick: kotlin.Function2<@[ParameterName(name = \, onDetailsClick: kotlin.Function2<@[ParameterName(name = \, onReplyClick: kotlin.Function1, onEditClick: kotlin.Function4<@[ParameterName(name = \, onShareAssetExternallyClick: kotlin.Function1<@[ParameterName(name = \, onShareAssetViaWireClick: kotlin.Function1<@[ParameterName(name = \, onDownloadAssetClick: kotlin.Function1<@[ParameterName(name = \, onOpenAssetClick: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit skippable: false restartable: true params: @@ -5602,12 +5623,13 @@ private fun com.wire.android.ui.home.conversations.edit.MessageOptionsModalConte - onDetailsClick: STABLE (function type) - onReplyClick: STABLE (function type) - onEditClick: STABLE (function type) - - onShareAssetClick: STABLE (function type) + - onShareAssetExternallyClick: STABLE (function type) + - onShareAssetViaWireClick: STABLE (function type) - onDownloadAssetClick: STABLE (function type) - onOpenAssetClick: STABLE (function type) @Composable -public fun com.wire.android.ui.home.conversations.edit.MessageOptionsModalSheetLayout(conversationId: com.wire.kalium.logic.data.id.QualifiedID, sheetState: com.wire.android.ui.common.bottomsheet.WireModalSheetState, isNetworkAvailable: kotlin.Boolean, onCopyClick: kotlin.Function1<@[ParameterName(name = \, onDeleteClick: kotlin.Function2<@[ParameterName(name = \, onReactionClick: kotlin.Function2<@[ParameterName(name = \, onDetailsClick: kotlin.Function2<@[ParameterName(name = \, onReplyClick: kotlin.Function1, onEditClick: kotlin.Function4<@[ParameterName(name = \, onShareAssetClick: kotlin.Function1<@[ParameterName(name = \, onDownloadAssetClick: kotlin.Function1<@[ParameterName(name = \, onOpenAssetClick: kotlin.Function1<@[ParameterName(name = \, viewModel: com.wire.android.ui.home.conversations.edit.MessageOptionsMenuViewModel): kotlin.Unit +public fun com.wire.android.ui.home.conversations.edit.MessageOptionsModalSheetLayout(conversationId: com.wire.kalium.logic.data.id.QualifiedID, sheetState: com.wire.android.ui.common.bottomsheet.WireModalSheetState, isNetworkAvailable: kotlin.Boolean, onCopyClick: kotlin.Function1<@[ParameterName(name = \, onDeleteClick: kotlin.Function2<@[ParameterName(name = \, onReactionClick: kotlin.Function2<@[ParameterName(name = \, onDetailsClick: kotlin.Function2<@[ParameterName(name = \, onReplyClick: kotlin.Function1, onEditClick: kotlin.Function4<@[ParameterName(name = \, onShareAssetExternallyClick: kotlin.Function1<@[ParameterName(name = \, onShareAssetViaWireClick: kotlin.Function1<@[ParameterName(name = \, onDownloadAssetClick: kotlin.Function1<@[ParameterName(name = \, onOpenAssetClick: kotlin.Function1<@[ParameterName(name = \, viewModel: com.wire.android.ui.home.conversations.edit.MessageOptionsMenuViewModel): kotlin.Unit skippable: false restartable: true params: @@ -5620,13 +5642,14 @@ public fun com.wire.android.ui.home.conversations.edit.MessageOptionsModalSheetL - onDetailsClick: STABLE (function type) - onReplyClick: STABLE (function type) - onEditClick: STABLE (function type) - - onShareAssetClick: STABLE (function type) + - onShareAssetExternallyClick: STABLE (function type) + - onShareAssetViaWireClick: STABLE (function type) - onDownloadAssetClick: STABLE (function type) - onOpenAssetClick: STABLE (function type) - viewModel: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.home.conversations.edit.assetMessageOptionsMenuItems(isEphemeral: kotlin.Boolean, ownReactions: kotlin.collections.Set, onDeleteClick: kotlin.Function0, onDetailsClick: kotlin.Function0, onShareAsset: kotlin.Function0, onDownloadAsset: kotlin.Function0, onReplyClick: kotlin.Function0, onReactionClick: kotlin.Function1<@[ParameterName(name = \, isOpenable: kotlin.Boolean, onOpenAsset: kotlin.Function0, isUploading: kotlin.Boolean): kotlin.collections.List<@[Composable] androidx.compose.runtime.internal.ComposableFunction0> +public fun com.wire.android.ui.home.conversations.edit.assetMessageOptionsMenuItems(isEphemeral: kotlin.Boolean, ownReactions: kotlin.collections.Set, onDeleteClick: kotlin.Function0, onDetailsClick: kotlin.Function0, onShareAssetExternally: kotlin.Function0, onShareAssetViaWire: kotlin.Function0, onDownloadAsset: kotlin.Function0, onReplyClick: kotlin.Function0, onReactionClick: kotlin.Function1<@[ParameterName(name = \, isOpenable: kotlin.Boolean, onOpenAsset: kotlin.Function0, isUploading: kotlin.Boolean): kotlin.collections.List<@[Composable] androidx.compose.runtime.internal.ComposableFunction0> skippable: false restartable: true params: @@ -5634,7 +5657,8 @@ public fun com.wire.android.ui.home.conversations.edit.assetMessageOptionsMenuIt - ownReactions: RUNTIME (requires runtime check) - onDeleteClick: STABLE (function type) - onDetailsClick: STABLE (function type) - - onShareAsset: STABLE (function type) + - onShareAssetExternally: STABLE (function type) + - onShareAssetViaWire: STABLE (function type) - onDownloadAsset: STABLE (function type) - onReplyClick: STABLE (function type) - onReactionClick: STABLE (function type) @@ -5643,20 +5667,21 @@ public fun com.wire.android.ui.home.conversations.edit.assetMessageOptionsMenuIt - isUploading: STABLE (primitive type) @Composable -public fun com.wire.android.ui.home.conversations.edit.assetOptionsMenuItems(isEphemeral: kotlin.Boolean, onDeleteClick: kotlin.Function0, onShareAsset: kotlin.Function0, onDownloadAsset: kotlin.Function0, isOpenable: kotlin.Boolean, onOpenAsset: kotlin.Function0, isUploading: kotlin.Boolean): kotlin.collections.List<@[Composable] androidx.compose.runtime.internal.ComposableFunction0> +public fun com.wire.android.ui.home.conversations.edit.assetOptionsMenuItems(isEphemeral: kotlin.Boolean, onDeleteClick: kotlin.Function0, onShareAssetExternally: kotlin.Function0, onShareAssetViaWire: kotlin.Function0, onDownloadAsset: kotlin.Function0, isOpenable: kotlin.Boolean, onOpenAsset: kotlin.Function0, isUploading: kotlin.Boolean): kotlin.collections.List<@[Composable] androidx.compose.runtime.internal.ComposableFunction0> skippable: true restartable: true params: - isEphemeral: STABLE (primitive type) - onDeleteClick: STABLE (function type) - - onShareAsset: STABLE (function type) + - onShareAssetExternally: STABLE (function type) + - onShareAssetViaWire: STABLE (function type) - onDownloadAsset: STABLE (function type) - isOpenable: STABLE (primitive type) - onOpenAsset: STABLE (function type) - isUploading: STABLE (primitive type) @Composable -public fun com.wire.android.ui.home.conversations.edit.messageOptionsMenuItems(isAssetMessage: kotlin.Boolean, isEphemeral: kotlin.Boolean, isUploading: kotlin.Boolean, isOpenable: kotlin.Boolean, isComposite: kotlin.Boolean, isEditable: kotlin.Boolean, isCopyable: kotlin.Boolean, ownReactions: kotlin.collections.Set, onCopyClick: kotlin.Function0, onDeleteClick: kotlin.Function0, onReactionClick: kotlin.Function1<@[ParameterName(name = \, onDetailsClick: kotlin.Function0, onReplyClick: kotlin.Function0, onEditClick: kotlin.Function0, onShareAssetClick: kotlin.Function0, onDownloadAssetClick: kotlin.Function0, onOpenAssetClick: kotlin.Function0): kotlin.collections.List<@[Composable] androidx.compose.runtime.internal.ComposableFunction0> +public fun com.wire.android.ui.home.conversations.edit.messageOptionsMenuItems(isAssetMessage: kotlin.Boolean, isEphemeral: kotlin.Boolean, isUploading: kotlin.Boolean, isOpenable: kotlin.Boolean, isComposite: kotlin.Boolean, isEditable: kotlin.Boolean, isCopyable: kotlin.Boolean, ownReactions: kotlin.collections.Set, onCopyClick: kotlin.Function0, onDeleteClick: kotlin.Function0, onReactionClick: kotlin.Function1<@[ParameterName(name = \, onDetailsClick: kotlin.Function0, onReplyClick: kotlin.Function0, onEditClick: kotlin.Function0, onShareAssetExternallyClick: kotlin.Function0, onShareAssetViaWireClick: kotlin.Function0, onDownloadAssetClick: kotlin.Function0, onOpenAssetClick: kotlin.Function0): kotlin.collections.List<@[Composable] androidx.compose.runtime.internal.ComposableFunction0> skippable: false restartable: true params: @@ -5674,7 +5699,8 @@ public fun com.wire.android.ui.home.conversations.edit.messageOptionsMenuItems(i - onDetailsClick: STABLE (function type) - onReplyClick: STABLE (function type) - onEditClick: STABLE (function type) - - onShareAssetClick: STABLE (function type) + - onShareAssetExternallyClick: STABLE (function type) + - onShareAssetViaWireClick: STABLE (function type) - onDownloadAssetClick: STABLE (function type) - onOpenAssetClick: STABLE (function type) @@ -5820,13 +5846,14 @@ private fun com.wire.android.ui.home.conversations.media.AssetMessagesListConten - onItemLongClicked: STABLE (function type) @Composable -private fun com.wire.android.ui.home.conversations.media.AssetOptionsModalSheetLayout(sheetState: com.wire.android.ui.common.bottomsheet.WireModalSheetState, deleteAsset: kotlin.Function2<@[ParameterName(name = \, shareAsset: kotlin.Function1<@[ParameterName(name = \, downloadAsset: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit +private fun com.wire.android.ui.home.conversations.media.AssetOptionsModalSheetLayout(sheetState: com.wire.android.ui.common.bottomsheet.WireModalSheetState, deleteAsset: kotlin.Function2<@[ParameterName(name = \, shareAssetExternally: kotlin.Function1<@[ParameterName(name = \, shareAssetViaWire: kotlin.Function1<@[ParameterName(name = \, downloadAsset: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit skippable: false restartable: true params: - sheetState: UNSTABLE (has mutable properties or unstable members) - deleteAsset: STABLE (function type) - - shareAsset: STABLE (function type) + - shareAssetExternally: STABLE (function type) + - shareAssetViaWire: STABLE (function type) - downloadAsset: STABLE (function type) @Composable @@ -10874,12 +10901,14 @@ private fun com.wire.android.ui.sharing.ImportAssetsCarrousel(importedItemsList: - onRemoveAsset: STABLE (function type) @Composable -private fun com.wire.android.ui.sharing.ImportMediaAuthenticatedContent(navigator: com.wire.android.navigation.Navigator, isRestrictedInTeam: kotlin.Boolean, checkAssetRestrictionsViewModel: com.wire.android.ui.home.conversations.media.CheckAssetRestrictionsViewModel, importMediaViewModel: com.wire.android.ui.sharing.ImportMediaAuthenticatedViewModel): kotlin.Unit +private fun com.wire.android.ui.sharing.ImportMediaAuthenticatedContent(navArgs: com.wire.android.ui.sharing.ImportMediaNavArgs, navigator: com.wire.android.navigation.Navigator, isRestrictedInTeam: kotlin.Boolean, navigateBack: kotlin.Function0, checkAssetRestrictionsViewModel: com.wire.android.ui.home.conversations.media.CheckAssetRestrictionsViewModel, importMediaViewModel: com.wire.android.ui.sharing.ImportMediaAuthenticatedViewModel): kotlin.Unit skippable: false restartable: true params: + - navArgs: UNSTABLE (has mutable properties or unstable members) - navigator: STABLE (marked @Stable or @Immutable) - isRestrictedInTeam: STABLE (primitive type) + - navigateBack: STABLE (function type) - checkAssetRestrictionsViewModel: UNSTABLE (has mutable properties or unstable members) - importMediaViewModel: UNSTABLE (has mutable properties or unstable members) @@ -10947,10 +10976,11 @@ public fun com.wire.android.ui.sharing.ImportMediaRestrictedContent(importMediaA - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.sharing.ImportMediaScreen(navigator: com.wire.android.navigation.Navigator, loginTypeSelector: com.wire.android.navigation.LoginTypeSelector, featureFlagNotificationViewModel: com.wire.android.ui.home.sync.FeatureFlagNotificationViewModel): kotlin.Unit +public fun com.wire.android.ui.sharing.ImportMediaScreen(navArgs: com.wire.android.ui.sharing.ImportMediaNavArgs, navigator: com.wire.android.navigation.Navigator, loginTypeSelector: com.wire.android.navigation.LoginTypeSelector, featureFlagNotificationViewModel: com.wire.android.ui.home.sync.FeatureFlagNotificationViewModel): kotlin.Unit skippable: false restartable: true params: + - navArgs: UNSTABLE (has mutable properties or unstable members) - navigator: STABLE (marked @Stable or @Immutable) - loginTypeSelector: RUNTIME (requires runtime check) - featureFlagNotificationViewModel: UNSTABLE (has mutable properties or unstable members) @@ -11786,6 +11816,13 @@ public fun com.wire.android.ui.userprofile.teammigration.step4.TeamMigrationDone - navigator: STABLE (marked @Stable or @Immutable) - teamMigrationViewModel: UNSTABLE (has mutable properties or unstable members) +@Composable +internal fun com.wire.android.ui.wireActivityCurrentBackStackEntryAsState(navigator: com.wire.android.navigation.Navigator): androidx.compose.runtime.State + skippable: true + restartable: true + params: + - navigator: STABLE (marked @Stable or @Immutable) + @Composable public fun com.wire.android.util.lifecycle.rememberLifecycleEvent(lifecycleOwner: androidx.lifecycle.LifecycleOwner): androidx.lifecycle.Lifecycle.Event skippable: false From cea62e803beba6a61e09fefbcdacbc74796b6c9d Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:07:24 +0200 Subject: [PATCH 14/14] test(debug): add missing share callback --- .../kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt b/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt index 25b453687d3..bfa20e52e58 100644 --- a/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt +++ b/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt @@ -41,6 +41,7 @@ class DebugScreenComposeTest { onDeleteLogs = {}, onDatabaseLoggerEnabledChanged = {}, onFlushLogs = { CompletableDeferred(Unit) }, + onShareLogsViaWire = {}, debugDataOptionsContent = {}, dangerOptionsContent = {}, )