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 = {}, ) 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/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..8b3d26e73e9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -28,6 +28,7 @@ import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels +import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.layout.Column @@ -153,6 +154,8 @@ 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 @@ -165,6 +168,7 @@ 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 @@ -214,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. @@ -232,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() @@ -283,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) } @@ -324,12 +335,38 @@ class WireActivity : BaseActivity() { handleSynchronizeExternalData(intent) return } - setIntent(intent) - handleNewIntent(intent) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + setIntentWithCurrentCaller(intent) + } else { + setIntent(intent) + } + handleNewIntent(queuedIntent(intent)) + } + + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + private fun setIntentWithCurrentCaller(intent: Intent) { + setIntent(intent, getCurrentCaller()) + } + + 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(intent: Intent, savedInstanceState: Bundle? = null) = lifecycleScope.launch { - newIntents.send(intent to savedInstanceState) + private fun handleNewIntent(queuedIntent: QueuedIntent) = lifecycleScope.launch { + newIntents.send(queuedIntent) } private fun setComposableContent(startDestination: Direction) { @@ -848,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 + ) } } } @@ -1223,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 { @@ -1246,7 +1289,12 @@ class WireActivity : BaseActivity() { } else { val handled = viewModel.handleIntentsThatAreNotDeepLinks(intent) if (!handled) { - viewModel.handleDeepLink(intent) + val providerAuthority = getProviderAuthority() + viewModel.handleDeepLink( + intent = intent, + providerAuthority = providerAuthority, + 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/debug/DebugScreen.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt index 7a3fc56c0a7..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 @@ -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,13 @@ 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.sharing.ImportSource import com.wire.android.ui.theme.WireTheme import com.wire.android.util.AppNameUtil import com.wire.android.util.logging.LogShareLauncher @@ -94,6 +98,18 @@ fun DebugScreen( dangerOptionsContent = { DangerOptions(exportObfuscatedCopyViewModel = exportObfuscatedCopyViewModel) }, + onShareLogsViaWire = { uri -> + navigator.navigate( + NavigationCommand( + ImportMediaScreenDestination( + ImportMediaNavArgs( + source = ImportSource.INTERNAL_SHARE, + internalAssetUriList = arrayListOf(uri) + ) + ) + ) + ) + }, ) } @@ -105,6 +121,7 @@ internal fun UserDebugContent( onDatabaseLoggerEnabledChanged: (Boolean) -> Unit, onDeleteLogs: () -> Unit, onFlushLogs: () -> Deferred, + onShareLogsViaWire: (Uri) -> Unit, debugDataOptionsContent: @Composable (DebugContentState) -> Unit, dangerOptionsContent: @Composable () -> Unit, ) { @@ -131,7 +148,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 +248,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 +257,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 +284,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..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 @@ -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,14 @@ 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 +import com.wire.android.ui.sharing.ImportSource @WireRootDestination @Composable @@ -63,7 +67,21 @@ 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( + source = ImportSource.INTERNAL_SHARE, + internalAssetUriList = 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..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 @@ -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( @@ -73,10 +92,14 @@ 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 + ), 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_forward, + 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_share, + contentDescription = stringResource(R.string.content_description_share_the_file), + ) + }, + title = stringResource(R.string.label_share), + 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/ui/edit/ShareAssetMenuOption.kt b/app/src/main/kotlin/com/wire/android/ui/edit/ShareAssetMenuOption.kt index 3c1c5013801..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 @@ -22,13 +22,55 @@ 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) { 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), + onItemClick = onShareAsset + ) +} + +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_forward, + 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, contentDescription = stringResource(R.string.content_description_share_the_file), ) }, 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..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 @@ -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,14 @@ import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography import com.wire.android.ui.userprofile.service.ServiceDetailsNavArgs import com.wire.android.util.DateAndTimeParsers +import com.wire.android.util.fileShareUri import com.wire.android.util.normalizeLink import com.wire.android.util.openDownloadFolder import com.wire.android.util.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 @@ -255,7 +259,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( @@ -572,7 +577,22 @@ fun ConversationScreen( }, composerMessages = sendMessageViewModel.infoMessage, conversationMessages = conversationMessagesViewModel.infoMessage, - shareAsset = conversationMessagesViewModel::shareAsset, + shareAssetExternally = conversationMessagesViewModel::shareAsset, + shareAssetViaWire = { messageId -> + conversationMessagesViewModel.prepareAssetForWireShare(messageId) { path, assetName -> + navigator.navigate( + NavigationCommand( + ImportMediaScreenDestination( + ImportMediaNavArgs( + source = ImportSource.INTERNAL_SHARE, + internalAssetUriList = arrayListOf(context.fileShareUri(path, assetName)) + ) + ), + BackStackMode.UPDATE_EXISTED + ) + ) + } + }, onDownloadAssetClick = conversationMessagesViewModel::openOrFetchAsset, onOpenAssetClick = conversationMessagesViewModel::downloadAndOpenAsset, onNavigateToReplyOriginalMessage = conversationMessagesViewModel::navigateToReplyOriginalMessage, @@ -847,7 +867,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 +1012,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 +1878,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..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 @@ -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,11 @@ 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.sharing.ImportSource 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 +131,21 @@ fun ConversationMediaScreen( conversationMessagesViewModel.deleteMessageDialogState .show(DeleteMessageDialogState(deleteForEveryone, messageId, conversationMessagesViewModel.conversationId)) }, - shareAsset = remember { { conversationMessagesViewModel.shareAsset(context, it) } }, + shareAssetExternally = { conversationMessagesViewModel.shareAsset(context, it) }, + shareAssetViaWire = { messageId -> + conversationMessagesViewModel.prepareAssetForWireShare(messageId) { path, assetName -> + navigator.navigate( + NavigationCommand( + ImportMediaScreenDestination( + ImportMediaNavArgs( + source = ImportSource.INTERNAL_SHARE, + internalAssetUriList = arrayListOf(context.fileShareUri(path, assetName)) + ) + ) + ) + ) + } + }, downloadAsset = conversationMessagesViewModel::openOrFetchAsset, ) @@ -248,7 +266,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 +278,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 +341,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..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,6 +433,14 @@ class ConversationMessagesViewModel( } } + fun prepareAssetForWireShare(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..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 @@ -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,21 @@ 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.sharing.ImportSource 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 +143,18 @@ 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( + source = ImportSource.INTERNAL_SHARE, + internalAssetUriList = arrayListOf(context.fileShareUri(action.path, action.assetName)) + ) + ) + ) + ) + is MediaGalleryAction.ShowDetails -> { resultNavigator.setResult( MediaGalleryNavBackArgs( @@ -240,6 +258,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 +277,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/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..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 @@ -17,22 +17,26 @@ */ 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.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 +47,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 +73,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 +156,56 @@ class ImportMediaAuthenticatedViewModel( if (incomingIntent.streamCount == 0) { handleSharedText(incomingIntent.text.toString()) } else { - if (incomingIntent.isSingleShare) { - // ACTION_SEND - handleSingleIntent(incomingIntent) + val providerAuthority = activity.getProviderAuthority() + val sharedUris = if (incomingIntent.isSingleShare) { + incomingIntent.stream?.let(::listOf).orEmpty() } else { - // ACTION_SEND_MULTIPLE - handleMultipleActionIntent(activity) + activity.intent.sharingUris() } + val hasTrustedWireCaller = activity.hasTrustedWireShareCaller(providerAuthority, sharedUris) + handleReceivedUrisFromSharingIntent(providerAuthority, hasTrustedWireCaller, sharedUris) } 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) { - 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)) - } + 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) } - } - - private suspend fun handleMultipleActionIntent(activity: AppCompatActivity) { - appLogger.d("$TAG: handleMultipleActionIntent") - val importedMediaAssets = activity.intent.parcelableArrayList(Intent.EXTRA_STREAM)?.mapNotNull { - val fileUri = it.toString().toUri() - handleImportedAsset(fileUri) - } ?: listOf() importMediaState = importMediaState.copy(importedAssets = importedMediaAssets.toPersistentList()) @@ -212,21 +231,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 +256,62 @@ class ImportMediaAuthenticatedViewModel( private const val TAG = "[ImportMediaAuthenticatedViewModel]" } } + +internal fun Uri.isWireFileProviderUri(providerAuthority: String): Boolean = + scheme.equals(ContentResolver.SCHEME_CONTENT, ignoreCase = true) && + authority?.substringAfterLast('@') == 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) && + authority == providerAuthority && + pathSegments.let { segments -> + segments.size > 1 && + segments.firstOrNull() == FILE_PROVIDER_SHARED_FILES_ROOT && + segments.none { it == ".." } + } + +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 && + uris.areWireFileProviderUrisReadableBy(providerAuthority) { uri -> + canCallerReadSharedUri(uri) + } + +@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) +private fun AppCompatActivity.canCallerReadSharedUri(uri: Uri): Boolean = + runCatching { + (caller ?: initialCaller).checkContentUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) == PackageManager.PERMISSION_GRANTED + }.getOrDefault(false) 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..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 @@ -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,16 +258,23 @@ 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) + } } } } } } +private fun ImportMediaNavArgs.isInternalShare(): Boolean = internalAssetUriList.isNotEmpty() + @Composable fun ImportMediaRestrictedContent( importMediaAuthenticatedState: ImportMediaAuthenticatedState, 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/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/main/kotlin/com/wire/android/util/FileUtil.kt b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt index d00a5590135..db9ea6e468a 100644 --- a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt +++ b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt @@ -42,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 @@ -51,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 @@ -73,9 +70,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 +188,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 +261,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 +271,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 +296,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 +367,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() @@ -464,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() 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( 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 41f0727a4e1..7ebfe5fa147 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -769,6 +769,7 @@ Message Details Copy text Share + Forward Edit text Delete Message copied @@ -1319,6 +1320,7 @@ In group conversations, the group admin can overwrite this setting. Logs Share Logs Could not prepare logs for sharing + Forward Delete All Logs Restart slow sync Restart @@ -1439,6 +1441,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/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/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/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt index bc39dd131b5..ff99cd315b5 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,91 @@ 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 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 { + 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 +1590,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 +1604,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/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/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 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..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 @@ -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,28 @@ 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.assertSame +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -70,7 +81,398 @@ 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 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") + + 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 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()) { + 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 readable work-profile Wire uri, when handling sharing uris, then import original uri`() = + runTest(dispatcherProvider.main()) { + val wireUri = testUri(authority = "10@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 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( + 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 +502,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, 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/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()) + } + } + } +} 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 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 b2a6c81e298..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 @@ -22,16 +22,17 @@ import android.content.ContentValues import android.content.Context import android.content.Intent 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 @@ -115,10 +116,7 @@ class FileHelper @Inject constructor( setDataAndType(assetUri, mimeType) putExtra(Intent.EXTRA_STREAM, assetUri) } - val chooserIntent = Intent.createChooser(intent, null).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(chooserIntent) + context.startShareIntentWithTrustedWireTarget(intent) } catch (e: java.lang.IllegalArgumentException) { onError() } catch (noActivityFoundException: ActivityNotFoundException) { @@ -133,10 +131,7 @@ class FileHelper @Inject constructor( setType("text/plain") putExtra(Intent.EXTRA_TEXT, url) } - val chooserIntent = Intent.createChooser(intent, null).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(chooserIntent) + context.startShareIntentWithTrustedWireTarget(intent) } catch (e: java.lang.IllegalArgumentException) { onError() } catch (noActivityFoundException: ActivityNotFoundException) { @@ -152,8 +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) + shareableFileProviderUri(assetDataPath.toFile(), assetName ?: assetDataPath.name) }