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..ae76a822686 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 @@ -43,6 +43,7 @@ class DebugScreenComposeTest { onFlushLogs = { CompletableDeferred(Unit) }, debugDataOptionsContent = {}, dangerOptionsContent = {}, + onShareLogsViaWire = {}, ) } } diff --git a/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt index 5c372cfb77b..57a0ee5ab25 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt @@ -21,6 +21,14 @@ package com.wire.android.mapper import com.wire.android.R import com.wire.android.ui.home.conversations.findUser import com.wire.android.ui.home.conversations.model.UIMessageContent +import com.wire.android.ui.home.conversations.model.UIMessageContent.SystemMessage.ConversationStartedWithMembers +import com.wire.android.ui.home.conversations.model.UIMessageContent.SystemMessage.FederationMemberRemoved +import com.wire.android.ui.home.conversations.model.UIMessageContent.SystemMessage.MemberAdded +import com.wire.android.ui.home.conversations.model.UIMessageContent.SystemMessage.MemberFailedToAdd +import com.wire.android.ui.home.conversations.model.UIMessageContent.SystemMessage.MemberJoined +import com.wire.android.ui.home.conversations.model.UIMessageContent.SystemMessage.MemberLeft +import com.wire.android.ui.home.conversations.model.UIMessageContent.SystemMessage.MemberRemoved +import com.wire.android.ui.home.conversations.model.UIMessageContent.SystemMessage.TeamMemberRemoved import com.wire.android.ui.home.conversations.selfdeletion.SelfDeletionMapper.toSelfDeletionDuration import com.wire.android.util.formatFullDateShortTime import com.wire.android.util.orDefault @@ -245,9 +253,9 @@ class SystemMessageContentMapper @Inject constructor( return when (content) { is Added -> if (isAuthorSelfAction) { - UIMessageContent.SystemMessage.MemberJoined(author = authorName, isSelfTriggered = isSelfTriggered) + MemberJoined(author = authorName, isSelfTriggered = isSelfTriggered) } else { - UIMessageContent.SystemMessage.MemberAdded( + MemberAdded( author = authorName, memberNames = memberNameList, isSelfTriggered = isSelfTriggered @@ -256,9 +264,9 @@ class SystemMessageContentMapper @Inject constructor( is Removed -> if (isAuthorSelfAction) { - UIMessageContent.SystemMessage.MemberLeft(author = authorName, isSelfTriggered = isSelfTriggered) + MemberLeft(author = authorName, isSelfTriggered = isSelfTriggered) } else { - UIMessageContent.SystemMessage.MemberRemoved( + MemberRemoved( author = authorName, memberNames = memberNameList, isSelfTriggered = isSelfTriggered @@ -266,10 +274,10 @@ class SystemMessageContentMapper @Inject constructor( } is CreationAdded -> { - UIMessageContent.SystemMessage.ConversationStartedWithMembers(memberNames = memberNameList) + ConversationStartedWithMembers(memberNames = memberNameList) } - is FailedToAdd -> UIMessageContent.SystemMessage.MemberFailedToAdd( + is FailedToAdd -> MemberFailedToAdd( memberNames = memberNameList, type = when (content.type) { FailedToAdd.Type.Federation -> UIMessageContent.SystemMessage.MemberFailedToAdd.Type.Federation @@ -279,11 +287,11 @@ class SystemMessageContentMapper @Inject constructor( } ) - is MemberChange.FederationRemoved -> UIMessageContent.SystemMessage.FederationMemberRemoved( + is MemberChange.FederationRemoved -> FederationMemberRemoved( memberNames = memberNameList ) - is MemberChange.RemovedFromTeam -> UIMessageContent.SystemMessage.TeamMemberRemoved( + is MemberChange.RemovedFromTeam -> TeamMemberRemoved( author = authorName, memberNames = memberNameList ) diff --git a/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt b/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt index 3ba47c3d7e3..670e586b21d 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt @@ -26,6 +26,7 @@ import com.ramcosta.composedestinations.spec.Direction import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.util.EmailComposer +import com.wire.android.util.externalShareChooserIntent import com.wire.android.util.getDeviceIdString import com.wire.android.util.getGitBuildId import com.wire.android.util.sha256 @@ -100,7 +101,7 @@ object GiveFeedbackDestination : IntentDirection { ) ) intent.selector = Intent(Intent.ACTION_SENDTO).setData(Uri.parse("mailto:")) - return Intent.createChooser(intent, context.getString(R.string.send_feedback_choose_email)) + return context.externalShareChooserIntent(intent, context.getString(R.string.send_feedback_choose_email)) } override val route: String diff --git a/app/src/main/kotlin/com/wire/android/ui/MiscViewModelFactory.kt b/app/src/main/kotlin/com/wire/android/ui/MiscViewModelFactory.kt index badb3baad8b..4af7c9114bc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/MiscViewModelFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/MiscViewModelFactory.kt @@ -17,8 +17,10 @@ */ package com.wire.android.ui +import android.content.Context import androidx.lifecycle.SavedStateHandle import com.wire.android.datastore.UserDataStoreProvider +import com.wire.android.di.ApplicationContext import com.wire.android.di.CurrentAccount import com.wire.android.di.KaliumCoreLogic import com.wire.android.ui.analytics.AnalyticsConfiguration @@ -51,6 +53,7 @@ import dev.zacsweers.metro.Inject @Suppress("LongParameterList") class MiscViewModelFactory @Inject constructor( + @ApplicationContext private val context: Context, private val analyticsEnabled: AnalyticsConfiguration, private val selfServerConfig: Lazy, private val observeSyncState: ObserveSyncStateUseCase, @@ -110,6 +113,7 @@ class MiscViewModelFactory @Inject constructor( ) fun importMediaAuthenticatedViewModel() = ImportMediaAuthenticatedViewModel( + context = context, getSelf = getSelf, getConversationsPaginated = getConversationsPaginated, handleUriAsset = handleUriAsset, diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index 1ca67d69ea8..f928d0643a3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -28,6 +28,7 @@ import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels +import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.layout.Column @@ -153,12 +154,14 @@ import com.wire.android.ui.userprofile.self.LocalSelfUserProfileLogoutAction import com.wire.android.ui.userprofile.self.dialog.LogoutOptionsDialog import com.wire.android.ui.userprofile.self.dialog.LogoutOptionsDialogState import com.wire.android.util.CurrentScreenManager +import com.wire.android.ui.sharing.hasTrustedWireShareCaller import com.wire.android.util.LocalSyncStateObserver import com.wire.android.util.ShakeDetector import com.wire.android.util.SwitchAccountObserver import com.wire.android.util.SyncStateObserver import com.wire.android.util.debug.FeatureVisibilityFlags import com.wire.android.util.debug.LocalFeatureVisibilityFlags +import com.wire.android.util.getProviderAuthority import com.wire.android.util.launchUpdateTheApp import com.wire.kalium.logic.data.user.UserId import kotlinx.coroutines.Dispatchers @@ -287,10 +290,19 @@ class WireActivity : BaseActivity() { handleSynchronizeExternalData(intent) return } - setIntent(intent) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + setIntentWithCurrentCaller(intent) + } else { + setIntent(intent) + } handleNewIntent(intent) } + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + private fun setIntentWithCurrentCaller(intent: Intent) { + setIntent(intent, getCurrentCaller()) + } + private fun handleNewIntent(intent: Intent, savedInstanceState: Bundle? = null) = lifecycleScope.launch { newIntents.send(intent to savedInstanceState) } @@ -1183,7 +1195,11 @@ class WireActivity : BaseActivity() { } else { val handled = viewModel.handleIntentsThatAreNotDeepLinks(intent) if (!handled) { - viewModel.handleDeepLink(intent) + viewModel.handleDeepLink( + intent = intent, + providerAuthority = getProviderAuthority(), + hasTrustedWireShareCaller = hasTrustedWireShareCaller() + ) intent.putExtra(HANDLED_DEEPLINK_FLAG, true) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityActionsHandler.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityActionsHandler.kt index c12c371b6e0..7e781428c9c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityActionsHandler.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityActionsHandler.kt @@ -39,6 +39,7 @@ import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProf import com.ramcosta.composedestinations.generated.app.destinations.WelcomeScreenDestination import com.wire.android.ui.authentication.login.LoginPasswordPath import com.wire.android.ui.newauthentication.login.NewLoginViewModel +import com.wire.android.ui.sharing.ImportMediaNavArgs import kotlinx.coroutines.flow.Flow import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -86,10 +87,10 @@ private fun openConversation(action: OpenConversation, navigator: Navigator) { private fun openImportMediaScreen(navigator: Navigator) { navigator.navigate( - NavigationCommand( - ImportMediaScreenDestination, - BackStackMode.UPDATE_EXISTED - ) + NavigationCommand( + ImportMediaScreenDestination(ImportMediaNavArgs(arrayListOf())), + BackStackMode.UPDATE_EXISTED + ) ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt index 459a01b407c..5bdbbc73665 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt @@ -50,6 +50,8 @@ import com.wire.android.ui.common.dialogs.CustomServerDetailsDialogState import com.wire.android.ui.common.dialogs.CustomServerDialogState import com.wire.android.ui.common.dialogs.CustomServerNoNetworkDialogState import com.wire.android.ui.joinConversation.JoinConversationViaCodeState +import com.wire.android.ui.sharing.sharingUris +import com.wire.android.ui.sharing.shouldRejectSharingIntent import com.wire.android.ui.theme.Accent import com.wire.android.ui.theme.ThemeOption import com.wire.android.util.CurrentScreen @@ -110,6 +112,8 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import java.io.InputStream import java.io.InputStreamReader import dev.zacsweers.metro.Inject @@ -413,7 +417,11 @@ class WireActivityViewModel @Inject constructor( } @Suppress("ComplexMethod") - fun handleDeepLink(intent: Intent?) { + fun handleDeepLink( + intent: Intent?, + providerAuthority: String? = null, + hasTrustedWireShareCaller: Boolean = false + ) { viewModelScope.launch(dispatchers.io()) { when (val result = deepLinkProcessor.value.invoke(intent?.data, intent?.action)) { DeepLinkResult.AuthorizationNeeded -> sendAction(OnAuthorizationNeeded) @@ -442,7 +450,16 @@ class WireActivityViewModel @Inject constructor( is DeepLinkResult.OpenConversation -> sendAction(OpenConversation(result)) is DeepLinkResult.OpenOtherUserProfile -> onOpenUserProfileDeepLink(result) - DeepLinkResult.SharingIntent -> sendAction(OnShowImportMediaScreen) + DeepLinkResult.SharingIntent -> { + val shouldRejectSharingIntent = providerAuthority != null && + intent?.shouldRejectSharingIntent(providerAuthority, hasTrustedWireShareCaller) == true + if (shouldRejectSharingIntent) { + logRejectedSharingIntent(intent, providerAuthority, hasTrustedWireShareCaller) + sendAction(ShowToast(R.string.public_share_ignored_wire_internal_files)) + return@launch + } + sendAction(OnShowImportMediaScreen) + } DeepLinkResult.Unknown -> { sendAction(OnUnknownDeepLink) appLogger.e("unknown deeplink result $result") @@ -451,6 +468,22 @@ class WireActivityViewModel @Inject constructor( } } + private fun logRejectedSharingIntent( + intent: Intent?, + providerAuthority: String?, + hasTrustedWireShareCaller: Boolean + ) { + val logMap = mapOf( + "event" to "public_share_rejected", + "reason" to "wire_file_provider_uri", + "action" to intent?.action.orEmpty(), + "providerAuthority" to providerAuthority.orEmpty(), + "hasTrustedWireShareCaller" to hasTrustedWireShareCaller.toString(), + "uriCount" to (intent?.sharingUris()?.size ?: 0).toString() + ) + appLogger.w("Rejected public share intent: ${Json.encodeToString(logMap)}") + } + // Returns whether an intent was handled, or if there was nothing to do @Suppress("ReturnCount") suspend fun handleIntentsThatAreNotDeepLinks(intent: Intent?): Boolean { diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt index 7a3fc56c0a7..59f781b815a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt @@ -20,6 +20,7 @@ package com.wire.android.ui.debug import android.annotation.SuppressLint import android.content.Context +import android.net.Uri import android.widget.Toast import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background @@ -51,10 +52,12 @@ import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.ramcosta.composedestinations.generated.app.destinations.ConversationCryptoStatsScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.DebugFeatureFlagsScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.ImportMediaScreenDestination import com.wire.android.ui.common.rowitem.SectionHeader import com.wire.android.ui.home.settings.SettingsItem import com.wire.android.ui.home.settings.backup.BackupAndRestoreDialog import com.wire.android.ui.home.settings.backup.rememberBackUpAndRestoreStateHolder +import com.wire.android.ui.sharing.ImportMediaNavArgs import com.wire.android.ui.theme.WireTheme import com.wire.android.util.AppNameUtil import com.wire.android.util.logging.LogShareLauncher @@ -94,6 +97,13 @@ fun DebugScreen( dangerOptionsContent = { DangerOptions(exportObfuscatedCopyViewModel = exportObfuscatedCopyViewModel) }, + onShareLogsViaWire = { uri -> + navigator.navigate( + NavigationCommand( + ImportMediaScreenDestination(ImportMediaNavArgs(arrayListOf(uri))) + ) + ) + }, ) } @@ -105,6 +115,7 @@ internal fun UserDebugContent( onDatabaseLoggerEnabledChanged: (Boolean) -> Unit, onDeleteLogs: () -> Unit, onFlushLogs: () -> Deferred, + onShareLogsViaWire: (Uri) -> Unit, debugDataOptionsContent: @Composable (DebugContentState) -> Unit, dangerOptionsContent: @Composable () -> Unit, ) { @@ -131,7 +142,8 @@ internal fun UserDebugContent( isLoggingEnabled = isLoggingEnabled, onLoggingEnabledChange = onLoggingEnabledChange, onDeleteLogs = onDeleteLogs, - onShareLogs = { debugContentState.shareLogs(onFlushLogs) }, + onShareLogsExternally = { debugContentState.shareLogsExternally(onFlushLogs) }, + onShareLogsViaWire = { debugContentState.shareLogsViaWire(onFlushLogs, onShareLogsViaWire) }, isDBLoggerEnabled = state.isDBLoggingEnabled, onDBLoggerEnabledChange = onDatabaseLoggerEnabledChanged, isPrivateBuild = BuildConfig.PRIVATE_BUILD, @@ -230,7 +242,7 @@ data class DebugContentState( ).show() } - fun shareLogs(onFlushLogs: () -> Deferred) { + fun shareLogsExternally(onFlushLogs: () -> Deferred) { val dir = File(logPath).parentFile if (dir != null && dir.exists()) { logShareLauncher.shareLogs(dir) { @@ -239,6 +251,19 @@ data class DebugContentState( } } } + + fun shareLogsViaWire(onFlushLogs: () -> Deferred, onShareUri: (Uri) -> Unit) { + val dir = File(logPath).parentFile + if (dir != null && dir.exists()) { + logShareLauncher.shareLogsViaWire( + logsDirectory = dir, + onShareUri = onShareUri + ) { + // Flush any buffered logs before sharing to ensure completeness. + onFlushLogs().await() + } + } + } } @Preview(heightDp = 1400) @@ -253,6 +278,7 @@ internal fun PreviewUserDebugContent() = WireTheme { onLoggingEnabledChange = {}, onDeleteLogs = {}, onFlushLogs = { CompletableDeferred(Unit) }, + onShareLogsViaWire = {}, onDatabaseLoggerEnabledChanged = {}, debugDataOptionsContent = { DebugDataOptions( diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementScreen.kt b/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementScreen.kt index 0d38adb2018..ce18fb47212 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementScreen.kt @@ -17,6 +17,7 @@ */ package com.wire.android.ui.debug +import com.ramcosta.composedestinations.generated.app.destinations.ImportMediaScreenDestination import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -26,11 +27,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.wire.android.R +import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar +import com.wire.android.ui.sharing.ImportMediaNavArgs @WireRootDestination @Composable @@ -63,7 +66,16 @@ fun LogManagementScreen( isLoggingEnabled = state.isLoggingEnabled, onLoggingEnabledChange = viewModel::setLoggingEnabledState, onDeleteLogs = viewModel::deleteLogs, - onShareLogs = { contentState.shareLogs(viewModel::flushLogs) }, + onShareLogsExternally = { contentState.shareLogsExternally(viewModel::flushLogs) }, + onShareLogsViaWire = { + contentState.shareLogsViaWire(viewModel::flushLogs) { uri -> + navigator.navigate( + NavigationCommand( + ImportMediaScreenDestination(ImportMediaNavArgs(arrayListOf(uri))) + ) + ) + } + }, isDBLoggerEnabled = false, onDBLoggerEnabledChange = {}, isPrivateBuild = false diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/LogOptions.kt b/app/src/main/kotlin/com/wire/android/ui/debug/LogOptions.kt index 394309e333f..82673dd2b0e 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 @@ -53,10 +60,14 @@ fun LogOptions( isDBLoggerEnabled: Boolean, onDBLoggerEnabledChange: (Boolean) -> Unit, onDeleteLogs: () -> Unit, - onShareLogs: () -> Unit, + onShareLogsExternally: () -> Unit, + onShareLogsViaWire: () -> Unit, isPrivateBuild: Boolean, modifier: Modifier = Modifier ) { + val shareLogsSheetState = rememberWireModalSheetState() + val onShareLogsClick: () -> Unit = { shareLogsSheetState.show() } + Column(modifier = modifier) { SectionHeader(stringResource(R.string.label_logs_option_title)) EnableLoggingSwitch( @@ -73,10 +84,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 +105,62 @@ fun LogOptions( ) } } + + 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.label_share_via_wire), + ) + }, + title = stringResource(R.string.label_share_via_wire), + onItemClick = onClick + ) +} + +@Composable +private fun ShareLogsExternallyOption(onClick: () -> Unit) { + MenuBottomSheetItem( + leading = { + MenuItemIcon( + id = R.drawable.ic_share, + contentDescription = stringResource(R.string.label_share), + ) + }, + title = stringResource(R.string.label_share), + onItemClick = onClick + ) } @Composable @@ -172,7 +243,8 @@ fun PreviewLoggingOptionsPublicBuild() { isDBLoggerEnabled = true, onDBLoggerEnabledChange = {}, onDeleteLogs = {}, - onShareLogs = {}, + onShareLogsExternally = {}, + onShareLogsViaWire = {}, isPrivateBuild = false, ) } @@ -186,7 +258,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..137c55160de 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 @@ -23,13 +23,36 @@ import com.wire.android.R import com.wire.android.ui.common.bottomsheet.MenuBottomSheetItem import com.wire.android.ui.common.bottomsheet.MenuItemIcon +fun shareAssetMenuOptions( + onShareAssetExternally: () -> Unit, + onShareAssetViaWire: () -> Unit +): List<@Composable () -> Unit> = + listOf( + { ShareAssetViaWireMenuOption(onShareAssetViaWire) }, + { ShareAssetExternallyMenuOption(onShareAssetExternally) } + ) + @Composable -fun ShareAssetMenuOption(onShareAsset: () -> Unit) { +fun ShareAssetViaWireMenuOption(onShareAsset: () -> Unit) { MenuBottomSheetItem( leading = { MenuItemIcon( - id = R.drawable.ic_share_file, - contentDescription = stringResource(R.string.content_description_share_the_file), + id = R.drawable.ic_forward, + contentDescription = stringResource(R.string.label_share_via_wire), + ) + }, + 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.label_share), ) }, title = stringResource(R.string.label_share), diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 6816949e7bd..14180e062b6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -90,6 +90,7 @@ import androidx.paging.compose.itemKey import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.GroupConversationDetailsScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.ImagesPreviewScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.ImportMediaScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.MediaGalleryScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.MessageDetailsScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination @@ -186,12 +187,14 @@ import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography import com.wire.android.ui.userprofile.service.ServiceDetailsNavArgs import com.wire.android.util.DateAndTimeParsers +import com.wire.android.util.fileShareUri import com.wire.android.util.normalizeLink import com.wire.android.util.openDownloadFolder import com.wire.android.util.serverDate import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText import com.wire.android.util.ui.collectAsLazyPagingItemsWithLifecycle +import com.wire.android.ui.sharing.ImportMediaNavArgs import com.wire.kalium.logic.data.conversation.Conversation.TypingIndicatorMode import com.wire.kalium.logic.data.conversation.InteractionAvailability import com.wire.kalium.logic.data.id.ConversationId @@ -253,7 +256,8 @@ fun ConversationScreen( ) { val coroutineScope = rememberCoroutineScope() val uriHandler = LocalUriHandler.current - val resources = LocalContext.current.resources + val context = LocalContext.current + val resources = context.resources val showDialog = remember { mutableStateOf(ConversationScreenDialogType.NONE) } val messageComposerViewState = messageComposerViewModel.messageComposerViewState val messageComposerStateHolder = rememberMessageComposerStateHolder( @@ -535,7 +539,19 @@ fun ConversationScreen( }, composerMessages = sendMessageViewModel.infoMessage, conversationMessages = conversationMessagesViewModel.infoMessage, - shareAsset = conversationMessagesViewModel::shareAsset, + shareAssetExternally = conversationMessagesViewModel::shareAsset, + shareAssetViaWire = { messageId -> + conversationMessagesViewModel.shareAssetViaWire(messageId) { path, assetName -> + navigator.navigate( + NavigationCommand( + ImportMediaScreenDestination( + ImportMediaNavArgs(arrayListOf(context.fileShareUri(path, assetName))) + ), + BackStackMode.UPDATE_EXISTED + ) + ) + } + }, onDownloadAssetClick = conversationMessagesViewModel::openOrFetchAsset, onOpenAssetClick = conversationMessagesViewModel::downloadAndOpenAsset, onNavigateToReplyOriginalMessage = conversationMessagesViewModel::navigateToReplyOriginalMessage, @@ -800,7 +816,8 @@ private fun ConversationScreen( onBackButtonClick: () -> Unit, composerMessages: SharedFlow, conversationMessages: SharedFlow, - shareAsset: (Context, messageId: String) -> Unit, + shareAssetExternally: (Context, messageId: String) -> Unit, + shareAssetViaWire: (messageId: String) -> Unit, onDownloadAssetClick: (messageId: String) -> Unit, onOpenAssetClick: (messageId: String) -> Unit, onNavigateToReplyOriginalMessage: (UIMessage) -> Unit, @@ -943,7 +960,8 @@ private fun ConversationScreen( onDetailsClick = onMessageDetailsClick, onReplyClick = messageComposerStateHolder::toReply, onEditClick = messageComposerStateHolder::toEdit, - onShareAssetClick = { shareAsset(context, it) }, + onShareAssetExternallyClick = { shareAssetExternally(context, it) }, + onShareAssetViaWireClick = shareAssetViaWire, onDownloadAssetClick = onDownloadAssetClick, onOpenAssetClick = onOpenAssetClick, ) @@ -1810,7 +1828,8 @@ fun PreviewConversationScreen() = WireTheme { onBackButtonClick = {}, composerMessages = MutableStateFlow(ConversationSnackbarMessages.ErrorDownloadingAsset), conversationMessages = MutableStateFlow(ConversationSnackbarMessages.ErrorDownloadingAsset), - shareAsset = { _, _ -> }, + shareAssetExternally = { _, _ -> }, + shareAssetViaWire = {}, onOpenAssetClick = {}, onDownloadAssetClick = {}, onNavigateToReplyOriginalMessage = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/AssetOptionsMenuItems.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/AssetOptionsMenuItems.kt index 6fe78438314..2032d62e8ca 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/AssetOptionsMenuItems.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/AssetOptionsMenuItems.kt @@ -24,7 +24,7 @@ import com.wire.android.ui.edit.MessageDetailsMenuOption import com.wire.android.ui.edit.OpenAssetExternallyOption import com.wire.android.ui.edit.ReactionOption import com.wire.android.ui.edit.ReplyMessageOption -import com.wire.android.ui.edit.ShareAssetMenuOption +import com.wire.android.ui.edit.shareAssetMenuOptions // menu items with both asset options enabled (like share, download, etc.) and message options enabled (like reply, reaction, etc.) @Composable @@ -33,7 +33,8 @@ fun assetMessageOptionsMenuItems( ownReactions: Set, onDeleteClick: () -> Unit, onDetailsClick: () -> Unit, - onShareAsset: () -> Unit, + onShareAssetExternally: () -> Unit, + onShareAssetViaWire: () -> Unit, onDownloadAsset: () -> Unit, onReplyClick: () -> Unit, onReactionClick: (emoji: String) -> Unit, @@ -59,7 +60,7 @@ fun assetMessageOptionsMenuItems( add { MessageDetailsMenuOption(onDetailsClick) } add { ReplyMessageOption(onReplyClick) } add { DownloadAssetExternallyOption(onDownloadAsset) } - add { ShareAssetMenuOption(onShareAsset) } + addAll(shareAssetMenuOptions(onShareAssetExternally, onShareAssetViaWire)) if (isOpenable) add { OpenAssetExternallyOption(onOpenAsset) } add { DeleteItemMenuOption(onDeleteClick) } } @@ -72,7 +73,8 @@ fun assetMessageOptionsMenuItems( fun assetOptionsMenuItems( isEphemeral: Boolean, onDeleteClick: () -> Unit, - onShareAsset: () -> Unit, + onShareAssetExternally: () -> Unit, + onShareAssetViaWire: () -> Unit, onDownloadAsset: () -> Unit, isOpenable: Boolean = false, onOpenAsset: () -> Unit = {}, @@ -80,7 +82,9 @@ fun assetOptionsMenuItems( ): List<@Composable () -> Unit> = buildList { if (!isUploading) { add { DownloadAssetExternallyOption(onDownloadAsset) } - if (!isEphemeral) add { ShareAssetMenuOption(onShareAsset) } + if (!isEphemeral) { + addAll(shareAssetMenuOptions(onShareAssetExternally, onShareAssetViaWire)) + } if (isOpenable) add { OpenAssetExternallyOption(onOpenAsset) } } add { DeleteItemMenuOption(onDeleteClick) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsMenuItems.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsMenuItems.kt index d3ef8a11585..5a41643d025 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsMenuItems.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsMenuItems.kt @@ -36,7 +36,8 @@ fun messageOptionsMenuItems( onDetailsClick: () -> Unit, onReplyClick: () -> Unit, onEditClick: () -> Unit, - onShareAssetClick: () -> Unit, + onShareAssetExternallyClick: () -> Unit, + onShareAssetViaWireClick: () -> Unit, onDownloadAssetClick: () -> Unit, onOpenAssetClick: () -> Unit ): List<@Composable () -> Unit> { @@ -48,7 +49,8 @@ fun messageOptionsMenuItems( isOpenable = isOpenable, onDeleteClick = onDeleteClick, onDetailsClick = onDetailsClick, - onShareAsset = onShareAssetClick, + onShareAssetExternally = onShareAssetExternallyClick, + onShareAssetViaWire = onShareAssetViaWireClick, onDownloadAsset = onDownloadAssetClick, onReplyClick = onReplyClick, onReactionClick = onReactionClick, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsModalSheetLayout.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsModalSheetLayout.kt index a92194f9b00..e7b5d635dae 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsModalSheetLayout.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsModalSheetLayout.kt @@ -60,7 +60,8 @@ fun MessageOptionsModalSheetLayout( onDetailsClick: (messageId: String, isSelfMessage: Boolean) -> Unit, onReplyClick: (UIMessage.Regular) -> Unit, onEditClick: (messageId: String, messageBody: String, mentions: List, isMultipart: Boolean) -> Unit, - onShareAssetClick: (messageId: String) -> Unit, + onShareAssetExternallyClick: (messageId: String) -> Unit, + onShareAssetViaWireClick: (messageId: String) -> Unit, onDownloadAssetClick: (messageId: String) -> Unit, onOpenAssetClick: (messageId: String) -> Unit, viewModel: MessageOptionsMenuViewModel = @@ -84,7 +85,8 @@ fun MessageOptionsModalSheetLayout( onDetailsClick = onDetailsClick, onReplyClick = onReplyClick, onEditClick = onEditClick, - onShareAssetClick = onShareAssetClick, + onShareAssetExternallyClick = onShareAssetExternallyClick, + onShareAssetViaWireClick = onShareAssetViaWireClick, onDownloadAssetClick = onDownloadAssetClick, onOpenAssetClick = onOpenAssetClick ).also { @@ -118,7 +120,8 @@ private fun MessageOptionsModalContent( onDetailsClick: (messageId: String, isSelfMessage: Boolean) -> Unit, onReplyClick: (UIMessage.Regular) -> Unit, onEditClick: (messageId: String, messageBody: String, mentions: List, isMultipart: Boolean) -> Unit, - onShareAssetClick: (messageId: String) -> Unit, + onShareAssetExternallyClick: (messageId: String) -> Unit, + onShareAssetViaWireClick: (messageId: String) -> Unit, onDownloadAssetClick: (messageId: String) -> Unit, onOpenAssetClick: (messageId: String) -> Unit, ) { @@ -214,10 +217,17 @@ private fun MessageOptionsModalContent( } } }, - onShareAssetClick = remember(message.header.messageId) { + onShareAssetExternallyClick = remember(message.header.messageId) { { sheetState.hide { - onShareAssetClick(message.header.messageId) + onShareAssetExternallyClick(message.header.messageId) + } + } + }, + onShareAssetViaWireClick = remember(message.header.messageId) { + { + sheetState.hide { + onShareAssetViaWireClick(message.header.messageId) } } }, @@ -284,7 +294,8 @@ fun PreviewMessageOptionsModalSheetLayout() = WireTheme { onDetailsClick = { _, _ -> }, onReplyClick = { }, onEditClick = { _, _, _, _ -> }, - onShareAssetClick = { }, + onShareAssetExternallyClick = { }, + onShareAssetViaWireClick = { }, onDownloadAssetClick = { }, onOpenAssetClick = { } ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt index 8ab3a0b0777..57039418486 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt @@ -63,6 +63,7 @@ import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.visbility.rememberVisibilityState import com.ramcosta.composedestinations.generated.app.destinations.MediaGalleryScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.ImportMediaScreenDestination import com.wire.android.ui.home.conversations.ConversationSnackbarMessages import com.wire.android.ui.home.conversations.DownloadedAssetDialog import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState @@ -72,8 +73,10 @@ import com.wire.android.ui.home.conversations.delete.DeleteMessageDialog import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogState import com.wire.android.ui.home.conversations.edit.assetOptionsMenuItems import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel +import com.wire.android.ui.sharing.ImportMediaNavArgs import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions +import com.wire.android.util.fileShareUri import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.SnackBarMessageHandler import com.wire.android.util.ui.UIText @@ -127,7 +130,18 @@ fun ConversationMediaScreen( conversationMessagesViewModel.deleteMessageDialogState .show(DeleteMessageDialogState(deleteForEveryone, messageId, conversationMessagesViewModel.conversationId)) }, - shareAsset = remember { { conversationMessagesViewModel.shareAsset(context, it) } }, + shareAssetExternally = { conversationMessagesViewModel.shareAsset(context, it) }, + shareAssetViaWire = { messageId -> + conversationMessagesViewModel.shareAssetViaWire(messageId) { path, assetName -> + navigator.navigate( + NavigationCommand( + ImportMediaScreenDestination( + ImportMediaNavArgs(arrayListOf(context.fileShareUri(path, assetName))) + ) + ) + ) + } + }, downloadAsset = conversationMessagesViewModel::openOrFetchAsset, ) @@ -248,7 +262,8 @@ private fun Content( private fun AssetOptionsModalSheetLayout( sheetState: WireModalSheetState, deleteAsset: (messageId: String, isMyMessage: Boolean) -> Unit, - shareAsset: (messageId: String) -> Unit, + shareAssetExternally: (messageId: String) -> Unit, + shareAssetViaWire: (messageId: String) -> Unit, downloadAsset: (messageId: String) -> Unit, ) { WireModalSheetLayout( @@ -259,7 +274,8 @@ private fun AssetOptionsModalSheetLayout( isUploading = false, // only uploaded assets isEphemeral = false, // only non-self-deleting assets onDeleteClick = remember { { sheetState.hide { deleteAsset(messageId, isMyMessage) } } }, - onShareAsset = remember { { sheetState.hide { shareAsset(messageId) } } }, + onShareAssetExternally = remember { { sheetState.hide { shareAssetExternally(messageId) } } }, + onShareAssetViaWire = remember { { sheetState.hide { shareAssetViaWire(messageId) } } }, onDownloadAsset = remember { { sheetState.hide { downloadAsset(messageId) } } }, ) ) @@ -321,7 +337,8 @@ fun PreviewAssetOptionsModalSheetLayout() = WireTheme { ) ), deleteAsset = { _, _ -> }, - shareAsset = { }, + shareAssetExternally = { }, + shareAssetViaWire = { }, downloadAsset = { } ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt index f926958c805..4058bd30ec8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt @@ -433,6 +433,14 @@ class ConversationMessagesViewModel( } } + fun shareAssetViaWire(messageId: String, onAssetReady: (Path, String) -> Unit) { + viewModelScope.launch { + assetDataPath(conversationId, messageId)?.run { + onAssetReady(first, second) + } + } + } + private suspend fun assetDataPath(conversationId: QualifiedID, messageId: String): Pair? = getMessageAsset(conversationId, messageId).await().run { return when (this) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt index 2028b46cc8d..74065a40b3a 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 @@ -55,14 +56,17 @@ import com.wire.android.ui.edit.DownloadAssetExternallyOption import com.wire.android.ui.edit.MessageDetailsMenuOption import com.wire.android.ui.edit.ReactionOption import com.wire.android.ui.edit.ReplyMessageOption -import com.wire.android.ui.edit.ShareAssetMenuOption +import com.wire.android.ui.edit.ShareAssetExternallyMenuOption +import com.wire.android.ui.edit.ShareAssetViaWireMenuOption import com.wire.android.ui.edit.SharePublicLinkMenuOption import com.wire.android.ui.home.conversations.MediaGallerySnackbarMessages import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState import com.wire.android.ui.home.conversations.delete.DeleteMessageDialog import com.wire.android.ui.home.conversations.mediaGalleryViewModel import com.wire.android.ui.home.conversations.mock.mockedPrivateAsset +import com.wire.android.ui.sharing.ImportMediaNavArgs import com.wire.android.ui.theme.WireTheme +import com.wire.android.util.fileShareUri import com.wire.android.util.permission.rememberWriteStoragePermissionFlow import com.wire.android.util.startFileShareIntent import com.wire.android.util.ui.PreviewMultipleThemes @@ -136,7 +140,15 @@ fun MediaGalleryScreen( HandleActions(mediaGalleryViewModel.actions) { action -> when (action) { - is MediaGalleryAction.Share -> context.startFileShareIntent(action.path, action.assetName) + is MediaGalleryAction.ShareExternally -> context.startFileShareIntent(action.path, action.assetName) + is MediaGalleryAction.ShareViaWire -> navigator.navigate( + NavigationCommand( + ImportMediaScreenDestination( + ImportMediaNavArgs(arrayListOf(context.fileShareUri(action.path, action.assetName))) + ) + ) + ) + is MediaGalleryAction.ShowDetails -> { resultNavigator.setResult( MediaGalleryNavBackArgs( @@ -256,11 +268,14 @@ private fun MediaGalleryOptionsBottomSheetLayout( MediaGalleryMenuItem.DOWNLOAD -> add { DownloadAssetExternallyOption { onOptionsClick(MenuIntent.Download) } } - MediaGalleryMenuItem.SHARE -> add { - ShareAssetMenuOption { onOptionsClick(MenuIntent.Share) } + MediaGalleryMenuItem.SHARE_EXTERNALLY -> add { + ShareAssetExternallyMenuOption { onOptionsClick(MenuIntent.ShareExternally) } + } + MediaGalleryMenuItem.SHARE_VIA_WIRE -> add { + ShareAssetViaWireMenuOption { onOptionsClick(MenuIntent.ShareViaWire) } } MediaGalleryMenuItem.SHARE_PUBLIC_LINK -> add { - SharePublicLinkMenuOption { onOptionsClick(MenuIntent.Share) } + SharePublicLinkMenuOption { onOptionsClick(MenuIntent.ShareExternally) } } MediaGalleryMenuItem.DELETE -> add { DeleteItemMenuOption { onOptionsClick(MenuIntent.Delete) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModel.kt index d01220abc11..8f1bc5bdac6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModel.kt @@ -121,7 +121,7 @@ class MediaGalleryViewModel( private fun shareAsset() = viewModelScope.launch { if (cellAssetId == null) { assetDataPath(conversationId, messageId)?.run { - sendAction(MediaGalleryAction.Share(first, second)) + sendAction(MediaGalleryAction.ShareExternally(first, second)) } } else { getCellNode(cellAssetId) @@ -189,6 +189,14 @@ class MediaGalleryViewModel( } } + private fun shareAssetViaWire() = viewModelScope.launch { + if (cellAssetId == null) { + assetDataPath(conversationId, messageId)?.run { + sendAction(MediaGalleryAction.ShareViaWire(first, second)) + } + } + } + private fun onSnackbarMessage(messageCode: MediaGallerySnackbarMessages) { viewModelScope.launch { _snackbarMessage.emit(messageCode) @@ -227,7 +235,8 @@ class MediaGalleryViewModel( MenuIntent.Download -> sendAction(MediaGalleryAction.Download) - MenuIntent.Share -> shareAsset() + MenuIntent.ShareExternally -> shareAsset() + MenuIntent.ShareViaWire -> shareAssetViaWire() MenuIntent.Delete -> { deleteMessageDialogState.show( @@ -272,13 +281,17 @@ class MediaGalleryViewModel( add(MediaGalleryMenuItem.SHOW_DETAILS) add(MediaGalleryMenuItem.REPLY) add(MediaGalleryMenuItem.DOWNLOAD) - add(MediaGalleryMenuItem.SHARE) + add(MediaGalleryMenuItem.SHARE_VIA_WIRE) + add(MediaGalleryMenuItem.SHARE_EXTERNALLY) add(MediaGalleryMenuItem.DELETE) } } } else if (cellAssetId == null) { add(MediaGalleryMenuItem.DOWNLOAD) - if (!mediaGalleryNavArgs.isEphemeral) add(MediaGalleryMenuItem.SHARE) + if (!mediaGalleryNavArgs.isEphemeral) { + add(MediaGalleryMenuItem.SHARE_VIA_WIRE) + add(MediaGalleryMenuItem.SHARE_EXTERNALLY) + } add(MediaGalleryMenuItem.DELETE) } } @@ -298,7 +311,8 @@ class MediaGalleryViewModel( sealed interface MediaGalleryAction { data class ShowDetails(val messageId: String, val isSelfAsset: Boolean) : MediaGalleryAction - data class Share(val path: Path, val assetName: String) : MediaGalleryAction + data class ShareExternally(val path: Path, val assetName: String) : MediaGalleryAction + data class ShareViaWire(val path: Path, val assetName: String) : MediaGalleryAction data class React(val messageId: String, val emoji: String) : MediaGalleryAction data class Reply(val messageId: String) : MediaGalleryAction data object Download : MediaGalleryAction @@ -312,7 +326,8 @@ sealed interface MenuIntent { data object ShowDetails : MenuIntent data object Reply : MenuIntent data object Download : MenuIntent - data object Share : MenuIntent + data object ShareExternally : MenuIntent + data object ShareViaWire : MenuIntent data object Delete : MenuIntent } @@ -321,7 +336,8 @@ enum class MediaGalleryMenuItem { SHOW_DETAILS, REPLY, DOWNLOAD, - SHARE, + SHARE_EXTERNALLY, + SHARE_VIA_WIRE, SHARE_PUBLIC_LINK, DELETE } diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedState.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedState.kt index ce479b0b540..f01beb8d6e1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedState.kt @@ -37,7 +37,5 @@ data class ImportMediaAuthenticatedState( val selfDeletingTimer: SelfDeletionTimer = SelfDeletionTimer.Enabled(null) ) { @Stable - fun isImportingData() { - importedText?.isNotEmpty() == true || importedAssets.isNotEmpty() - } + fun hasImportedContent(): Boolean = importedText?.isNotEmpty() == true || importedAssets.isNotEmpty() } diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt index ba6bc0cde9f..863feaa3710 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt @@ -17,22 +17,25 @@ */ package com.wire.android.ui.sharing +import android.content.ContentResolver +import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Parcelable +import android.os.Build +import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.app.ShareCompat -import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.map import com.wire.android.BuildConfig import com.wire.android.appLogger +import com.wire.android.di.ApplicationContext import com.wire.android.model.ImageAsset import com.wire.android.model.SnackBarMessage import com.wire.android.ui.common.textfield.textAsFlow @@ -43,14 +46,14 @@ import com.wire.android.ui.home.conversationslist.model.ConversationItemType import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.ui.home.messagecomposer.SelfDeletionDuration import com.wire.android.util.EMPTY +import com.wire.android.util.FILE_PROVIDER_SHARED_FILES_ROOT import com.wire.android.util.dispatchers.DispatcherProvider -import com.wire.android.util.parcelableArrayList +import com.wire.android.util.getProviderAuthority import com.wire.kalium.logic.data.message.SelfDeletionTimer import com.wire.kalium.logic.data.message.SelfDeletionTimer.Companion.SELF_DELETION_LOG_TAG import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTimerSettingsForConversationUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.PersistNewSelfDeletionTimerUseCase import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow @@ -69,6 +72,7 @@ import kotlinx.coroutines.withContext @OptIn(FlowPreview::class) @Suppress("LongParameterList", "TooManyFunctions") class ImportMediaAuthenticatedViewModel( + @ApplicationContext private val context: Context, private val getSelf: ObserveSelfUserUseCase, private val getConversationsPaginated: GetConversationsFromSearchUseCase, private val handleUriAsset: HandleUriAssetUseCase, @@ -151,42 +155,77 @@ class ImportMediaAuthenticatedViewModel( if (incomingIntent.streamCount == 0) { handleSharedText(incomingIntent.text.toString()) } else { + val providerAuthority = activity.getProviderAuthority() + val hasTrustedWireCaller = activity.hasTrustedWireShareCaller() if (incomingIntent.isSingleShare) { // ACTION_SEND - handleSingleIntent(incomingIntent) + handleSingleIntent(providerAuthority, hasTrustedWireCaller, incomingIntent) } else { // ACTION_SEND_MULTIPLE - handleMultipleActionIntent(activity) + handleMultipleActionIntent(activity, providerAuthority, hasTrustedWireCaller) } } importMediaState = importMediaState.copy(isImporting = false) } + suspend fun handleReceivedDataFromInternalShare(uris: List) { + appLogger.i("Received data from internal share ${uris.size}") + importMediaState = importMediaState.copy(isImporting = true) + val providerAuthority = context.getProviderAuthority() + val importedMediaAssets = uris.mapNotNull { uri -> + if (uri.isWireInternalShareUri(providerAuthority)) { + handleImportedAsset(uri, rejectOwnFileProviderUri = false) + } else { + appLogger.w("$TAG: Ignoring internal share URI outside Wire's share provider root") + null + } + } + importMediaState = importMediaState.copy( + importedAssets = importedMediaAssets.toPersistentList(), + isImporting = false + ) + importedMediaAssets.firstOrNull { it.assetSizeExceeded != null }?.let { + onSnackbarMessage(SendMessagesSnackbarMessages.MaxAssetSizeExceeded(it.assetSizeExceeded!!)) + } + } + private fun handleSharedText(text: String) { appLogger.d("$TAG: handleSharedText") importMediaState = importMediaState.copy(importedText = text) } - private suspend fun handleSingleIntent(incomingIntent: ShareCompat.IntentReader) { + private suspend fun handleSingleIntent( + providerAuthority: String, + hasTrustedWireCaller: Boolean, + incomingIntent: ShareCompat.IntentReader + ) { incomingIntent.stream?.let { uri -> appLogger.d("$TAG: handleSingleIntent") - handleImportedAsset(uri)?.let { importedAsset -> - if (importedAsset.assetSizeExceeded != null) { - onSnackbarMessage( - SendMessagesSnackbarMessages.MaxAssetSizeExceeded(importedAsset.assetSizeExceeded) - ) - } - importMediaState = importMediaState.copy(importedAssets = persistentListOf(importedAsset)) - } + handleReceivedUrisFromSharingIntent(providerAuthority, hasTrustedWireCaller, listOf(uri)) } } - private suspend fun handleMultipleActionIntent(activity: AppCompatActivity) { + private suspend fun handleMultipleActionIntent( + activity: AppCompatActivity, + providerAuthority: String, + hasTrustedWireCaller: Boolean + ) { appLogger.d("$TAG: handleMultipleActionIntent") - val importedMediaAssets = activity.intent.parcelableArrayList(Intent.EXTRA_STREAM)?.mapNotNull { - val fileUri = it.toString().toUri() - handleImportedAsset(fileUri) - } ?: listOf() + handleReceivedUrisFromSharingIntent(providerAuthority, hasTrustedWireCaller, activity.intent.sharingUris()) + } + + internal suspend fun handleReceivedUrisFromSharingIntent( + providerAuthority: String, + hasTrustedWireCaller: Boolean, + uris: List + ) { + if (uris.shouldRejectSharingIntent(providerAuthority, hasTrustedWireCaller)) { + appLogger.w("$TAG: Rejecting share intent containing URI from Wire's own file provider") + return + } + val importedMediaAssets = uris.mapNotNull { + handleImportedAsset(it, rejectOwnFileProviderUri = false) + } importMediaState = importMediaState.copy(importedAssets = importedMediaAssets.toPersistentList()) @@ -212,19 +251,25 @@ class ImportMediaAuthenticatedViewModel( } } - private suspend fun handleImportedAsset(uri: Uri): ImportedMediaAsset? = withContext(dispatchers.io()) { - when (val result = handleUriAsset.invoke(uri, saveToDeviceIfInvalid = false)) { - is HandleUriAssetUseCase.Result.Failure.AssetTooLarge -> { - appLogger.w("$TAG: Failed to import asset message: Asset too large") - ImportedMediaAsset(result.assetBundle, result.maxLimitInMB) - } + private suspend fun handleImportedAsset(uri: Uri, rejectOwnFileProviderUri: Boolean): ImportedMediaAsset? { + if (rejectOwnFileProviderUri) { + appLogger.w("$TAG: Ignoring shared URI from Wire's own file provider") + return null + } + return withContext(dispatchers.io()) { + when (val result = handleUriAsset.invoke(uri, saveToDeviceIfInvalid = false)) { + is HandleUriAssetUseCase.Result.Failure.AssetTooLarge -> { + appLogger.w("$TAG: Failed to import asset message: Asset too large") + ImportedMediaAsset(result.assetBundle, result.maxLimitInMB) + } - HandleUriAssetUseCase.Result.Failure.Unknown -> { - appLogger.e("$TAG: Failed to import asset message: Unknown error") - null - } + HandleUriAssetUseCase.Result.Failure.Unknown -> { + appLogger.e("$TAG: Failed to import asset message: Unknown error") + null + } - is HandleUriAssetUseCase.Result.Success -> ImportedMediaAsset(result.assetBundle, null) + is HandleUriAssetUseCase.Result.Success -> ImportedMediaAsset(result.assetBundle, null) + } } } @@ -236,3 +281,43 @@ class ImportMediaAuthenticatedViewModel( private const val TAG = "[ImportMediaAuthenticatedViewModel]" } } + +internal fun Uri.isWireFileProviderUri(providerAuthority: String): Boolean = + scheme.equals(ContentResolver.SCHEME_CONTENT, ignoreCase = true) && authority == providerAuthority + +internal fun Uri.shouldRejectWireFileProviderShare(providerAuthority: String, hasTrustedWireCaller: Boolean): Boolean = + isWireFileProviderUri(providerAuthority) && !hasTrustedWireCaller + +internal fun List.shouldRejectSharingIntent(providerAuthority: String, hasTrustedWireCaller: Boolean): Boolean = + any { uri -> uri.shouldRejectWireFileProviderShare(providerAuthority, hasTrustedWireCaller) } + +internal fun Intent.sharingUris(): List = + when (action) { + Intent.ACTION_SEND -> (extras?.get(Intent.EXTRA_STREAM) as? Uri)?.let(::listOf) ?: emptyList() + Intent.ACTION_SEND_MULTIPLE -> { + @Suppress("UNCHECKED_CAST") + (extras?.get(Intent.EXTRA_STREAM) as? List<*>)?.filterIsInstance() ?: emptyList() + } + else -> emptyList() + } + +internal fun Intent.shouldRejectSharingIntent(providerAuthority: String, hasTrustedWireCaller: Boolean): Boolean = + sharingUris().shouldRejectSharingIntent(providerAuthority, hasTrustedWireCaller) + +internal fun Uri.isWireInternalShareUri(providerAuthority: String): Boolean = + isWireFileProviderUri(providerAuthority) && + pathSegments.let { segments -> + segments.size > 1 && + segments.firstOrNull() == FILE_PROVIDER_SHARED_FILES_ROOT && + segments.none { it == ".." } + } + +internal fun AppCompatActivity.hasTrustedWireShareCaller(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && + getTrustedShareCallerPackageName() == packageName + +@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) +private fun AppCompatActivity.getTrustedShareCallerPackageName(): String? = + runCatching { + caller?.getPackage() ?: initialCaller.getPackage() + }.getOrNull() diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaNavArgs.kt new file mode 100644 index 00000000000..0301da1ed19 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaNavArgs.kt @@ -0,0 +1,24 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.sharing + +import android.net.Uri + +data class ImportMediaNavArgs( + val internalAssetUriList: ArrayList +) diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt index bd5bb96c182..7afcaf0b13c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt @@ -121,24 +121,27 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import okio.Path.Companion.toPath -@WireRootDestination +@WireRootDestination(navArgs = ImportMediaNavArgs::class) @Composable fun ImportMediaScreen( + navArgs: ImportMediaNavArgs, navigator: Navigator, loginTypeSelector: LoginTypeSelector, featureFlagNotificationViewModel: FeatureFlagNotificationViewModel = featureFlagNotificationViewModel(), ) { + val navigateBack = if (navArgs.isInternalShare()) navigator::navigateBack else navigator.finish + when (val fileSharingRestrictedState = featureFlagNotificationViewModel.featureFlagState.isFileSharingState) { FeatureFlagState.FileSharingState.Loading -> { ImportMediaLoadingContent( - navigateBack = navigator.finish + navigateBack = navigateBack ) } FeatureFlagState.FileSharingState.NoUser -> { ImportMediaLoggedOutContent( fileSharingRestrictedState = fileSharingRestrictedState, - navigateBack = navigator.finish, + navigateBack = navigateBack, openWireAction = { val destination = if (loginTypeSelector.canUseNewLogin()) NewLoginScreenDestination() else WelcomeScreenDestination() navigator.navigate(NavigationCommand(destination, BackStackMode.CLEAR_WHOLE)) @@ -150,13 +153,15 @@ fun ImportMediaScreen( FeatureFlagState.FileSharingState.AllowAll, is FeatureFlagState.FileSharingState.AllowSome -> { ImportMediaAuthenticatedContent( + navArgs = navArgs, navigator = navigator, - isRestrictedInTeam = fileSharingRestrictedState == FeatureFlagState.FileSharingState.DisabledByTeam, + isRestrictedInTeam = fileSharingRestrictedState == FeatureFlagState.FileSharingState.DisabledByTeam, + navigateBack = navigateBack, ) } } - BackHandler { navigator.finish() } + BackHandler { navigateBack() } } @Composable @@ -192,8 +197,10 @@ private fun ImportMediaLoadingContent(navigateBack: () -> Unit) { @Composable private fun ImportMediaAuthenticatedContent( + navArgs: ImportMediaNavArgs, navigator: Navigator, isRestrictedInTeam: Boolean, + navigateBack: () -> Unit, checkAssetRestrictionsViewModel: CheckAssetRestrictionsViewModel = checkAssetRestrictionsViewModel(), importMediaViewModel: ImportMediaAuthenticatedViewModel = importMediaAuthenticatedViewModel(), ) { @@ -201,7 +208,7 @@ private fun ImportMediaAuthenticatedContent( ImportMediaRestrictedContent( importMediaAuthenticatedState = importMediaViewModel.importMediaState, avatarAsset = null, - navigateBack = navigator.finish + navigateBack = navigateBack ) } else { LaunchedEffect(checkAssetRestrictionsViewModel.state) { @@ -236,7 +243,7 @@ private fun ImportMediaAuthenticatedContent( }, onNewSelfDeletionTimerPicked = importMediaViewModel::onNewSelfDeletionTimerPicked, infoMessage = importMediaViewModel.infoMessage, - navigateBack = navigator.finish, + navigateBack = navigateBack, onRemoveAsset = importMediaViewModel::onRemove ) AssetTooLargeDialog( @@ -246,16 +253,22 @@ private fun ImportMediaAuthenticatedContent( val context = LocalContext.current with(importMediaViewModel.importMediaState) { - LaunchedEffect(isImportingData()) { - if (importedAssets.isEmpty() || importedText.isNullOrEmpty()) { - context.getActivity() - ?.let { activity -> importMediaViewModel.handleReceivedDataFromSharingIntent(activity) } + LaunchedEffect(navArgs.internalAssetUriList) { + if (!hasImportedContent()) { + if (navArgs.internalAssetUriList.isNotEmpty()) { + importMediaViewModel.handleReceivedDataFromInternalShare(navArgs.internalAssetUriList) + } else { + context.getActivity() + ?.let { activity -> importMediaViewModel.handleReceivedDataFromSharingIntent(activity) } + } } } } } } +private fun ImportMediaNavArgs.isInternalShare(): Boolean = internalAssetUriList.isNotEmpty() + @Composable fun ImportMediaRestrictedContent( importMediaAuthenticatedState: ImportMediaAuthenticatedState, diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/QRCodeIntents.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/QRCodeIntents.kt index 3a1e10a1a20..bdbac36d378 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/QRCodeIntents.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/QRCodeIntents.kt @@ -20,6 +20,7 @@ package com.wire.android.ui.userprofile.qr import android.content.Context import android.content.Intent import android.net.Uri +import com.wire.android.util.externalShareChooserIntent fun Context.shareLinkToProfile(selfProfileUrl: String) { val sendIntent: Intent = @@ -31,8 +32,7 @@ fun Context.shareLinkToProfile(selfProfileUrl: String) { type = "text/plain" } - val shareIntent = Intent.createChooser(sendIntent, null) - startActivity(shareIntent) + startActivity(externalShareChooserIntent(sendIntent)) } fun Context.shareQRToProfile(uri: Uri) { @@ -41,8 +41,9 @@ fun Context.shareQRToProfile(uri: Uri) { action = Intent.ACTION_SEND addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) putExtra(Intent.EXTRA_STREAM, uri) type = "image/jpg" } - startActivity(sendIntent) + startActivity(externalShareChooserIntent(sendIntent)) } diff --git a/app/src/main/kotlin/com/wire/android/util/AvatarImageManager.kt b/app/src/main/kotlin/com/wire/android/util/AvatarImageManager.kt index c8b203391e2..0ce534c595d 100644 --- a/app/src/main/kotlin/com/wire/android/util/AvatarImageManager.kt +++ b/app/src/main/kotlin/com/wire/android/util/AvatarImageManager.kt @@ -20,7 +20,6 @@ package com.wire.android.util import android.content.Context import android.net.Uri -import androidx.core.content.FileProvider import androidx.core.net.toUri import okio.Path import dev.zacsweers.metro.Inject @@ -33,6 +32,6 @@ class AvatarImageManager @Inject constructor(val context: Context) { } fun getShareableTempAvatarUri(filePath: Path): Uri { - return FileProvider.getUriForFile(context, context.getProviderAuthority(), filePath.toFile()) + return context.shareableFileProviderUri(context.fileProviderSharedCacheFile(filePath.name)) } } diff --git a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt index d00a5590135..ebf0b16be87 100644 --- a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt +++ b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt @@ -20,17 +20,21 @@ package com.wire.android.util +import android.app.ActivityOptions import android.app.DownloadManager import android.content.ActivityNotFoundException +import android.content.ComponentName import android.content.ContentResolver import android.content.ContentValues import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.database.Cursor import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever.METADATA_KEY_DURATION import android.net.Uri import android.os.Build +import android.os.Bundle import android.os.Environment import android.os.Environment.DIRECTORY_DOWNLOADS import android.os.Parcelable @@ -62,6 +66,7 @@ import java.io.File import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream +import java.nio.file.Files import java.util.Locale import kotlin.time.Duration.Companion.milliseconds @@ -73,9 +78,9 @@ suspend fun Uri.toByteArray(context: Context, dispatcher: DispatcherProvider = D } fun getTempWritableAttachmentUri(context: Context, attachmentPath: Path): Uri { - val file = attachmentPath.toFile() + val file = context.fileProviderSharedCacheFile(attachmentPath.name) file.setWritable(true) - return FileProvider.getUriForFile(context, context.getProviderAuthority(), file) + return context.fileProviderUri(file) } suspend fun createPemFile( @@ -191,7 +196,7 @@ private fun Context.saveFileDataToMediaFolder(assetName: String, downloadedDataP fun Context.fromNioPathToContentUri(nioPath: java.nio.file.Path): Uri = this.pathToUri(nioPath.toOkioPath(), null) fun Context.pathToUri(assetDataPath: Path, assetName: String?): Uri = - FileProvider.getUriForFile(this, getProviderAuthority(), assetDataPath.toFile(), assetName ?: assetDataPath.name) + shareableFileProviderUri(assetDataPath.toFile(), assetName ?: assetDataPath.name) fun Uri.getMimeType(context: Context): String? { val mimeType: String? = if (this.scheme == ContentResolver.SCHEME_CONTENT) { @@ -264,13 +269,7 @@ private fun Context.getContentFileName(uri: Uri): String? = runCatching { }.getOrNull() fun Context.startFileShareIntent(path: Path, assetName: String?) { - val assetDisplayName = assetName ?: path.name - val fileURI = FileProvider.getUriForFile( - this, - getProviderAuthority(), - path.toFile(), - assetDisplayName - ) + val fileURI = fileShareUri(path, assetName) val shareIntent = Intent(Intent.ACTION_SEND) shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) shareIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) @@ -280,9 +279,12 @@ fun Context.startFileShareIntent(path: Path, assetName: String?) { shareIntent.putExtra(Intent.EXTRA_STREAM, fileURI) assetName?.let { shareIntent.putExtra(Intent.EXTRA_SUBJECT, it) } shareIntent.type = fileURI.getMimeType(context = this) - startActivity(shareIntent) + startActivity(externalShareChooserIntent(shareIntent)) } +fun Context.fileShareUri(path: Path, assetName: String?): Uri = + shareableFileProviderUri(path.toFile(), assetName ?: path.name) + fun saveFileToDownloadsFolder(assetName: String, assetDataPath: Path, assetDataSize: Long, context: Context): Uri? = context.saveFileDataToDownloadsFolder(assetName, assetDataPath, assetDataSize) @@ -302,11 +304,7 @@ fun Context.getUrisOfFilesInDirectory(dir: File): ArrayList { val files = ArrayList() dir.listFiles()?.map { - val uri = FileProvider.getUriForFile( - this, - getProviderAuthority(), - it - ) + val uri = shareableFileProviderUri(it) files.add(uri) } @@ -377,7 +375,7 @@ fun shareAssetFileWithExternalApp(assetDataPath: Path, context: Context, assetNa setDataAndType(assetUri, mimeType) putExtra(Intent.EXTRA_STREAM, assetUri) } - context.startActivity(intent) + context.startActivity(context.externalShareChooserIntent(intent)) } catch (e: java.lang.IllegalArgumentException) { appLogger.e("The file couldn't be found on the internal storage \n$e") onError() @@ -387,6 +385,94 @@ fun shareAssetFileWithExternalApp(assetDataPath: Path, context: Context, assetNa } } +fun Context.externalShareChooserIntent( + sendIntent: Intent, + title: CharSequence? = null, + excludeOwnComponents: Boolean = true +): Intent = + Intent.createChooser(sendIntent, title).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (!excludeOwnComponents) return@apply + val ownShareComponents = packageManager.queryIntentActivitiesCompat(sendIntent) + .map { ComponentName(it.activityInfo.packageName, it.activityInfo.name) } + .filter { it.packageName == packageName } + .toTypedArray() + if (ownShareComponents.isNotEmpty()) { + putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, ownShareComponents) + } + } + +fun Context.startShareIntentWithTrustedWireTarget(sendIntent: Intent, title: CharSequence? = null) { + val chooserIntent = externalShareChooserIntent( + sendIntent = sendIntent, + title = title, + excludeOwnComponents = !supportsTrustedWireShareCaller() + ) + startActivity(chooserIntent, shareIdentityOptionsBundle()) +} + +fun supportsTrustedWireShareCaller(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM + +private fun shareIdentityOptionsBundle(): Bundle? = + if (supportsTrustedWireShareCaller()) { + ActivityOptions.makeBasic() + .setShareIdentityEnabled(true) + .toBundle() + } else { + null + } + +fun Context.fileProviderSharedCacheFile(fileName: String): File { + val shareDirectory = fileProviderSharedCacheDirectory() + deleteStaleFileProviderSharedCacheFiles(shareDirectory) + return File(shareDirectory, findFirstUniqueName(shareDirectory, fileName.ifBlank { ATTACHMENT_FILENAME })) +} + +fun Context.shareableFileProviderUri(sourceFile: File, displayName: String? = null): Uri { + val shareFile = when { + sourceFile.isInDirectory(fileProviderSharedCacheDirectory()) -> sourceFile + else -> linkOrCopyToFileProviderSharedCache(sourceFile, displayName ?: sourceFile.name) + } + return fileProviderUri(shareFile, displayName) +} + +private fun Context.linkOrCopyToFileProviderSharedCache(sourceFile: File, displayName: String): File { + val shareFile = fileProviderSharedCacheFile(displayName) + runCatching { + Files.createLink(shareFile.toPath(), sourceFile.toPath()) + }.recoverCatching { + sourceFile.copyTo(shareFile, overwrite = true) + }.getOrThrow() + return shareFile +} + +private fun Context.fileProviderSharedCacheDirectory(): File = + File(cacheDir, FILE_PROVIDER_SHARED_CACHE_DIRECTORY).apply { mkdirs() } + +private fun Context.fileProviderUri(file: File, displayName: String? = null): Uri = + FileProvider.getUriForFile(this, getProviderAuthority(), file, displayName ?: file.name) + +private fun File.isInDirectory(directory: File): Boolean { + val directoryPath = directory.canonicalFile.toPath() + return canonicalFile.toPath().startsWith(directoryPath) +} + +private fun deleteStaleFileProviderSharedCacheFiles(directory: File) { + val oldestAllowedTimestamp = System.currentTimeMillis() - FILE_PROVIDER_SHARED_CACHE_MAX_AGE_MILLIS + directory.listFiles() + ?.filter { it.isFile && it.lastModified() < oldestAllowedTimestamp } + ?.forEach(File::delete) +} + +private fun PackageManager.queryIntentActivitiesCompat(intent: Intent) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong())) + } else { + @Suppress("DEPRECATION") + queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + } + inline fun Intent.parcelable(key: String): T? = when { Build.VERSION.SDK_INT >= SDK_VERSION -> getParcelableExtra(key, T::class.java) else -> @Suppress("DEPRECATION") getParcelableExtra(key) as? T @@ -498,5 +584,8 @@ fun getAudioLengthInMs(dataPath: Path, mimeType: String): Long = private const val ATTACHMENT_FILENAME = "attachment" private const val DATA_COPY_BUFFER_SIZE = 2048 +const val FILE_PROVIDER_SHARED_FILES_ROOT = "shared_files" +private const val FILE_PROVIDER_SHARED_CACHE_DIRECTORY = "file-provider-shares" +private const val FILE_PROVIDER_SHARED_CACHE_MAX_AGE_MILLIS = 24 * 60 * 60 * 1000L const val SDK_VERSION = 33 const val SUPPORTED_AUDIO_MIME_TYPE = "audio/wav" diff --git a/app/src/main/kotlin/com/wire/android/util/logging/LogSharing.kt b/app/src/main/kotlin/com/wire/android/util/logging/LogSharing.kt index eb76446b5a9..a6317e44bd8 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,14 +21,14 @@ 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.EmailComposer +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 kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -65,7 +65,21 @@ class LogShareLauncher( share( logsDirectory = logsDirectory, flushLogs = flushLogs, - intent = { archive -> context.logsSharingIntent(archive) } + shareArchive = { archive -> + context.startActivity(context.externalShareChooserIntent(context.logsSharingIntent(archive))) + } + ) + } + + fun shareLogsViaWire( + logsDirectory: File, + onShareUri: (Uri) -> Unit, + flushLogs: suspend () -> Unit = {} + ) { + share( + logsDirectory = logsDirectory, + flushLogs = flushLogs, + shareArchive = { archive -> onShareUri(context.logsSharingUri(archive)) } ) } @@ -75,20 +89,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: (File) -> Intent + shareArchive: (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) @@ -114,8 +128,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 @@ -137,7 +153,7 @@ 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..b3093c81be3 --- /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..0c8727ff602 100644 --- a/app/src/main/res/drawable/ic_share.xml +++ b/app/src/main/res/drawable/ic_share.xml @@ -17,11 +17,41 @@ --> + android:width="16dp" + android:height="16dp" + android:viewportWidth="16" + android:viewportHeight="16"> + android:fillColor="@android:color/transparent" + android:pathData="M12,5.334C13.105,5.334 14,4.439 14,3.334C14,2.229 13.105,1.334 12,1.334C10.895,1.334 10,2.229 10,3.334C10,4.439 10.895,5.334 12,5.334Z" + android:strokeWidth="1.75" + android:strokeColor="#000000" + android:strokeLineCap="round" + android:strokeLineJoin="round"/> + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a32965013d3..a96582dc055 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -794,6 +794,7 @@ Message Details Copy text Share + Forward Edit text Delete Message copied @@ -1462,6 +1463,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/ui/WireActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt index 670f490f15c..32302cb1a34 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt @@ -21,6 +21,8 @@ package com.wire.android.ui import android.content.Intent +import android.net.Uri +import android.os.Bundle import androidx.work.Operation import androidx.work.WorkManager import app.cash.turbine.test @@ -958,6 +960,71 @@ class WireActivityViewModelTest { } } + @Test + fun `given untrusted sharing intent with mixed Wire and external provider uris, when handling deep link, then show ignored toast`() = + runTest { + val (_, viewModel) = Arrangement() + .withDeepLinkResult(DeepLinkResult.SharingIntent) + .arrange() + val intent = sharingIntent( + wireProviderUri(), + externalProviderUri("com.android.providers.media.documents") + ) + + viewModel.actions.test { + viewModel.handleDeepLink( + intent = intent, + providerAuthority = WIRE_PROVIDER_AUTHORITY, + hasTrustedWireShareCaller = false + ) + advanceUntilIdle() + assertEquals(ShowToast(R.string.public_share_ignored_wire_internal_files), expectMostRecentItem()) + expectNoEvents() + } + } + + @Test + fun `given trusted sharing intent with mixed Wire and external provider uris, when handling deep link, then import media screen is shown`() = + runTest { + val (_, viewModel) = Arrangement() + .withDeepLinkResult(DeepLinkResult.SharingIntent) + .arrange() + val intent = sharingIntent( + wireProviderUri(), + externalProviderUri("com.android.providers.media.documents") + ) + + viewModel.actions.test { + viewModel.handleDeepLink( + intent = intent, + providerAuthority = WIRE_PROVIDER_AUTHORITY, + hasTrustedWireShareCaller = true + ) + assertEquals(OnShowImportMediaScreen, expectMostRecentItem()) + } + } + + @Test + fun `given untrusted sharing intent with external provider uris, when handling deep link, then import media screen is shown`() = + runTest { + val (_, viewModel) = Arrangement() + .withDeepLinkResult(DeepLinkResult.SharingIntent) + .arrange() + val intent = sharingIntent( + externalProviderUri("com.android.providers.media.documents"), + externalProviderUri("com.google.android.apps.photos.contentprovider") + ) + + viewModel.actions.test { + viewModel.handleDeepLink( + intent = intent, + providerAuthority = WIRE_PROVIDER_AUTHORITY, + hasTrustedWireShareCaller = false + ) + assertEquals(OnShowImportMediaScreen, expectMostRecentItem()) + } + } + @Test fun `given no valid session, when checking number of sessions, then return true`() = runTest { // given @@ -1375,6 +1442,7 @@ class WireActivityViewModelTest { companion object { val USER_ID = UserId("user_id", "domain.de") val TEST_ACCOUNT_INFO = AccountInfo.Valid(USER_ID) + private const val WIRE_PROVIDER_AUTHORITY = "com.wire.android.provider" private fun mockedTestAccounts(count: Int) = List(count) { i -> TEST_ACCOUNT_INFO.copy(userId = USER_ID.copy("user_$i")) @@ -1388,6 +1456,29 @@ class WireActivityViewModelTest { } } + private fun sharingIntent(vararg uris: Uri): Intent { + val extrasBundle = mockk { + every { this@mockk.get(Intent.EXTRA_STREAM) } returns uris.toList() + } + return mockk { + every { data } returns null + every { action } returns Intent.ACTION_SEND_MULTIPLE + every { extras } returns extrasBundle + } + } + + private fun wireProviderUri(): Uri = + sharingUri(WIRE_PROVIDER_AUTHORITY) + + private fun externalProviderUri(authority: String): Uri = + sharingUri(authority) + + private fun sharingUri(authority: String): Uri = + mockk { + every { scheme } returns "content" + every { this@mockk.authority } returns authority + } + val ongoingCall = Call( CommonTopAppBarViewModelTest.conversationId, CallStatus.ESTABLISHED, diff --git a/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt index 38f1065163f..3ad57a665a3 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt @@ -270,7 +270,8 @@ class MediaGalleryViewModelTest { assertEquals( listOf( MediaGalleryMenuItem.DOWNLOAD, - MediaGalleryMenuItem.SHARE, + MediaGalleryMenuItem.SHARE_VIA_WIRE, + MediaGalleryMenuItem.SHARE_EXTERNALLY, MediaGalleryMenuItem.DELETE, ), state.menuItems @@ -371,7 +372,8 @@ class MediaGalleryViewModelTest { MediaGalleryMenuItem.SHOW_DETAILS, MediaGalleryMenuItem.REPLY, MediaGalleryMenuItem.DOWNLOAD, - MediaGalleryMenuItem.SHARE, + MediaGalleryMenuItem.SHARE_VIA_WIRE, + MediaGalleryMenuItem.SHARE_EXTERNALLY, MediaGalleryMenuItem.DELETE, ), state.menuItems diff --git a/app/src/test/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModelTest.kt index d244a9b55e5..fe262b9774b 100644 --- a/app/src/test/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModelTest.kt @@ -17,6 +17,8 @@ */ package com.wire.android.ui.sharing +import android.content.Context +import android.net.Uri import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.paging.PagingData import app.cash.turbine.test @@ -26,19 +28,27 @@ import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri import com.wire.android.framework.TestConversationItem import com.wire.android.framework.TestUser +import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.usecase.GetConversationsFromSearchUseCase import com.wire.android.ui.home.conversations.usecase.HandleUriAssetUseCase +import com.wire.kalium.logic.data.asset.AttachmentType import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTimerSettingsForConversationUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.PersistNewSelfDeletionTimerUseCase import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toPath +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -70,7 +80,329 @@ class ImportMediaAuthenticatedViewModelTest { } } + @Test + fun `given content uri from Wire file provider, when checking provider uri, then match it`() { + val uri = testUri(authority = "com.wire.android.provider") + + assertTrue(uri.isWireFileProviderUri("com.wire.android.provider")) + } + + @Test + fun `given uppercase content scheme from Wire file provider, when checking provider uri, then match it`() { + val uri = testUri(scheme = "CONTENT", authority = "com.wire.android.provider") + + assertTrue(uri.isWireFileProviderUri("com.wire.android.provider")) + } + + @Test + fun `given file uri with Wire authority, when checking provider uri, then reject it`() { + val uri = testUri(scheme = "file", authority = "com.wire.android.provider") + + assertFalse(uri.isWireFileProviderUri("com.wire.android.provider")) + } + + @Test + fun `given uri from provider with Wire authority prefix, when checking provider uri, then reject it`() { + val uri = testUri(authority = "com.wire.android.provider.evil") + + assertFalse(uri.isWireFileProviderUri("com.wire.android.provider")) + } + + @Test + fun `given uri from another content provider, when checking provider uri, then reject it`() { + val uri = testUri(authority = "com.android.providers.media.documents") + + assertFalse(uri.isWireFileProviderUri("com.wire.android.provider")) + } + + @Test + fun `given Wire provider uri from trusted Wire caller, when checking public share rejection, then allow it`() { + val uri = testUri(authority = "com.wire.android.provider") + + assertFalse( + uri.shouldRejectWireFileProviderShare( + providerAuthority = "com.wire.android.provider", + hasTrustedWireCaller = true + ) + ) + } + + @Test + fun `given Wire provider uri without trusted Wire caller, when checking public share rejection, then reject it`() { + val uri = testUri(authority = "com.wire.android.provider") + + assertTrue( + uri.shouldRejectWireFileProviderShare( + providerAuthority = "com.wire.android.provider", + hasTrustedWireCaller = false + ) + ) + } + + @Test + fun `given external provider uri without trusted Wire caller, when checking public share rejection, then allow it`() { + val uri = testUri(authority = "com.android.providers.media.documents") + + assertFalse( + uri.shouldRejectWireFileProviderShare( + providerAuthority = "com.wire.android.provider", + hasTrustedWireCaller = false + ) + ) + } + + @Test + fun `given untrusted public share with Wire provider uri, when handling sharing uris, then reject whole intent`() = + runTest(dispatcherProvider.main()) { + val wireUri = testUri(authority = "com.wire.android.provider") + val externalUri = testUri(authority = "com.android.providers.media.documents") + val (arrangement, viewModel) = Arrangement() + .withHandleUriAsset(HandleUriAssetUseCase.Result.Success(assetBundle("external-file.zip"))) + .arrange() + + viewModel.handleReceivedUrisFromSharingIntent( + providerAuthority = "com.wire.android.provider", + hasTrustedWireCaller = false, + uris = listOf(wireUri, externalUri) + ) + + assertTrue(viewModel.importMediaState.importedAssets.isEmpty()) + coVerify(exactly = 0) { + arrangement.handleUriAssetUseCase.invoke(any(), any()) + } + } + + @Test + fun `given trusted public share with Wire provider uri, when handling sharing uris, then import it`() = + runTest(dispatcherProvider.main()) { + val wireUri = testUri(authority = "com.wire.android.provider") + val assetBundle = assetBundle("wire-file.zip") + val (arrangement, viewModel) = Arrangement() + .withHandleUriAsset(HandleUriAssetUseCase.Result.Success(assetBundle)) + .arrange() + + viewModel.handleReceivedUrisFromSharingIntent( + providerAuthority = "com.wire.android.provider", + hasTrustedWireCaller = true, + uris = listOf(wireUri) + ) + + assertEquals(assetBundle, viewModel.importMediaState.importedAssets.single().assetBundle) + coVerify(exactly = 1) { + arrangement.handleUriAssetUseCase.invoke(wireUri, saveToDeviceIfInvalid = false) + } + } + + @Test + fun `given trusted public share with mixed Wire and external provider uris, when handling sharing uris, then import all`() = + runTest(dispatcherProvider.main()) { + val wireUri = testUri(authority = "com.wire.android.provider") + val externalUri = testUri(authority = "com.android.providers.media.documents") + val wireAssetBundle = assetBundle("wire-file.zip") + val externalAssetBundle = assetBundle("external-file.zip") + val (arrangement, viewModel) = Arrangement() + .withHandleUriAsset(wireUri, HandleUriAssetUseCase.Result.Success(wireAssetBundle)) + .withHandleUriAsset(externalUri, HandleUriAssetUseCase.Result.Success(externalAssetBundle)) + .arrange() + + viewModel.handleReceivedUrisFromSharingIntent( + providerAuthority = "com.wire.android.provider", + hasTrustedWireCaller = true, + uris = listOf(wireUri, externalUri) + ) + + assertEquals( + listOf(wireAssetBundle, externalAssetBundle), + viewModel.importMediaState.importedAssets.map { it.assetBundle } + ) + coVerify(exactly = 1) { + arrangement.handleUriAssetUseCase.invoke(wireUri, saveToDeviceIfInvalid = false) + } + coVerify(exactly = 1) { + arrangement.handleUriAssetUseCase.invoke(externalUri, saveToDeviceIfInvalid = false) + } + } + + @Test + fun `given untrusted public share with only external provider uris, when handling sharing uris, then import all`() = + runTest(dispatcherProvider.main()) { + val firstExternalUri = testUri(authority = "com.android.providers.media.documents") + val secondExternalUri = testUri(authority = "com.google.android.apps.photos.contentprovider") + val firstAssetBundle = assetBundle("first-external-file.zip") + val secondAssetBundle = assetBundle("second-external-file.zip") + val (arrangement, viewModel) = Arrangement() + .withHandleUriAsset(firstExternalUri, HandleUriAssetUseCase.Result.Success(firstAssetBundle)) + .withHandleUriAsset(secondExternalUri, HandleUriAssetUseCase.Result.Success(secondAssetBundle)) + .arrange() + + viewModel.handleReceivedUrisFromSharingIntent( + providerAuthority = "com.wire.android.provider", + hasTrustedWireCaller = false, + uris = listOf(firstExternalUri, secondExternalUri) + ) + + assertEquals( + listOf(firstAssetBundle, secondAssetBundle), + viewModel.importMediaState.importedAssets.map { it.assetBundle } + ) + coVerify(exactly = 1) { + arrangement.handleUriAssetUseCase.invoke(firstExternalUri, saveToDeviceIfInvalid = false) + } + coVerify(exactly = 1) { + arrangement.handleUriAssetUseCase.invoke(secondExternalUri, saveToDeviceIfInvalid = false) + } + } + + @Test + fun `given Wire provider uri under shared files, when checking internal share uri, then allow it`() { + val uri = testUri(pathSegments = listOf("shared_files", "wire-logs.zip")) + + assertTrue(uri.isWireInternalShareUri("com.wire.android.provider")) + } + + @Test + fun `given uri from another provider under shared files, when checking internal share uri, then reject it`() { + val uri = testUri( + authority = "com.android.providers.media.documents", + pathSegments = listOf("shared_files", "wire-logs.zip") + ) + + assertFalse(uri.isWireInternalShareUri("com.wire.android.provider")) + } + + @Test + fun `given Wire provider uri outside shared files, when checking internal share uri, then reject it`() { + val uri = testUri(pathSegments = listOf("cached_files", "private-file.zip")) + + assertFalse(uri.isWireInternalShareUri("com.wire.android.provider")) + } + + @Test + fun `given Wire provider uri with shared files prefix confusion, when checking internal share uri, then reject it`() { + val uri = testUri(pathSegments = listOf("shared_files_evil", "private-file.zip")) + + assertFalse(uri.isWireInternalShareUri("com.wire.android.provider")) + } + + @Test + fun `given Wire provider uri with only shared files root, when checking internal share uri, then reject it`() { + val uri = testUri(pathSegments = listOf("shared_files")) + + assertFalse(uri.isWireInternalShareUri("com.wire.android.provider")) + } + + @Test + fun `given Wire provider uri with traversal segment, when checking internal share uri, then reject it`() { + val uri = testUri(pathSegments = listOf("shared_files", "..", "cached_files", "private-file.zip")) + + assertFalse(uri.isWireInternalShareUri("com.wire.android.provider")) + } + + @Test + fun `given internal Wire file provider uri, when handling internal share, then import it`() = runTest(dispatcherProvider.main()) { + val uri = testUri(pathSegments = listOf("shared_files", "wire-logs.zip")) + val assetBundle = AssetBundle( + key = "key", + mimeType = "application/zip", + dataPath = "/tmp/wire-logs.zip".toPath(), + dataSize = 100L, + fileName = "wire-logs.zip", + assetType = AttachmentType.GENERIC_FILE + ) + val (arrangement, viewModel) = Arrangement() + .withHandleUriAsset(HandleUriAssetUseCase.Result.Success(assetBundle)) + .arrange() + + viewModel.handleReceivedDataFromInternalShare(listOf(uri)) + + assertEquals(assetBundle, viewModel.importMediaState.importedAssets.single().assetBundle) + coVerify(exactly = 1) { + arrangement.handleUriAssetUseCase.invoke(uri, saveToDeviceIfInvalid = false) + } + } + + @Test + fun `given mixed internal share uris, when handling internal share, then import only allowed Wire shared files`() = + runTest(dispatcherProvider.main()) { + val validUri = testUri(pathSegments = listOf("shared_files", "wire-logs.zip")) + val wrongRootUri = testUri(pathSegments = listOf("cached_files", "private-file.zip")) + val wrongProviderUri = testUri( + authority = "com.android.providers.media.documents", + pathSegments = listOf("shared_files", "wire-logs.zip") + ) + val assetBundle = assetBundle(fileName = "wire-logs.zip") + val (arrangement, viewModel) = Arrangement() + .withHandleUriAsset(HandleUriAssetUseCase.Result.Success(assetBundle)) + .arrange() + + viewModel.handleReceivedDataFromInternalShare(listOf(validUri, wrongRootUri, wrongProviderUri)) + + assertEquals(listOf(assetBundle), viewModel.importMediaState.importedAssets.map { it.assetBundle }) + coVerify(exactly = 1) { + arrangement.handleUriAssetUseCase.invoke(validUri, saveToDeviceIfInvalid = false) + } + coVerify(exactly = 0) { + arrangement.handleUriAssetUseCase.invoke(wrongRootUri, any()) + } + coVerify(exactly = 0) { + arrangement.handleUriAssetUseCase.invoke(wrongProviderUri, any()) + } + } + + @Test + fun `given internal Wire provider uri outside shared files, when handling internal share, then reject it`() = + runTest(dispatcherProvider.main()) { + val uri = testUri(pathSegments = listOf("cached_files", "private-file.zip")) + val (arrangement, viewModel) = Arrangement().arrange() + + viewModel.handleReceivedDataFromInternalShare(listOf(uri)) + + assertTrue(viewModel.importMediaState.importedAssets.isEmpty()) + coVerify(exactly = 0) { + arrangement.handleUriAssetUseCase.invoke(any(), any()) + } + } + + @Test + fun `given internal uri from another provider, when handling internal share, then reject it`() = + runTest(dispatcherProvider.main()) { + val uri = testUri( + authority = "com.android.providers.media.documents", + pathSegments = listOf("shared_files", "wire-logs.zip") + ) + val (arrangement, viewModel) = Arrangement().arrange() + + viewModel.handleReceivedDataFromInternalShare(listOf(uri)) + + assertTrue(viewModel.importMediaState.importedAssets.isEmpty()) + coVerify(exactly = 0) { + arrangement.handleUriAssetUseCase.invoke(any(), any()) + } + } + + private fun testUri( + scheme: String = "content", + authority: String = "com.wire.android.provider", + pathSegments: List = emptyList() + ): Uri = mockk { + every { this@mockk.scheme } returns scheme + every { this@mockk.authority } returns authority + every { this@mockk.pathSegments } returns pathSegments + } + + private fun assetBundle(fileName: String) = AssetBundle( + key = "key", + mimeType = "application/zip", + dataPath = "/tmp/$fileName".toPath(), + dataSize = 100L, + fileName = fileName, + assetType = AttachmentType.GENERIC_FILE + ) + inner class Arrangement { + val context = mockk { + every { packageName } returns "com.wire.android" + } @MockK lateinit var getSelfUser: ObserveSelfUserUseCase @@ -100,7 +432,16 @@ class ImportMediaAuthenticatedViewModelTest { mockUri() } + fun withHandleUriAsset(result: HandleUriAssetUseCase.Result) = apply { + coEvery { handleUriAssetUseCase.invoke(any(), any()) } returns result + } + + fun withHandleUriAsset(uri: Uri, result: HandleUriAssetUseCase.Result) = apply { + coEvery { handleUriAssetUseCase.invoke(uri, any()) } returns result + } + fun arrange() = this to ImportMediaAuthenticatedViewModel( + context = context, getSelf = getSelfUser, getConversationsPaginated = getConversationsPaginated, handleUriAsset = handleUriAssetUseCase, diff --git a/core/search/stability/search-debug.stability b/core/search/stability/search-debug.stability index d722f82b059..011f9cbe9a6 100644 --- a/core/search/stability/search-debug.stability +++ b/core/search/stability/search-debug.stability @@ -180,13 +180,14 @@ private fun com.wire.android.search.users.ShowButton(isShownAll: kotlin.Boolean, - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.search.widget.HighlightName(name: kotlin.String, searchQuery: kotlin.String, modifier: androidx.compose.ui.Modifier): kotlin.Unit +public fun com.wire.android.search.widget.HighlightName(name: kotlin.String, searchQuery: kotlin.String, modifier: androidx.compose.ui.Modifier, maxLines: kotlin.Int): kotlin.Unit skippable: true restartable: true params: - name: STABLE (String is immutable) - searchQuery: STABLE (String is immutable) - modifier: STABLE (marked @Stable or @Immutable) + - maxLines: STABLE (primitive type) @Composable public fun com.wire.android.search.widget.HighlightSubtitle(subTitle: kotlin.String, modifier: androidx.compose.ui.Modifier, searchQuery: kotlin.String, prefix: kotlin.String): kotlin.Unit diff --git a/core/ui-common/stability/ui-common-debug.stability b/core/ui-common/stability/ui-common-debug.stability index 287cd99ef1b..66cb41fea70 100644 --- a/core/ui-common/stability/ui-common-debug.stability +++ b/core/ui-common/stability/ui-common-debug.stability @@ -907,11 +907,12 @@ public fun com.wire.android.ui.common.button.WireButton(onClick: kotlin.Function - description: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.ui.common.button.WireButtonColors.containerColor(state: com.wire.android.ui.common.button.WireButtonState): androidx.compose.runtime.State +public fun com.wire.android.ui.common.button.WireButtonColors.containerColor(state: com.wire.android.ui.common.button.WireButtonState, isFocused: kotlin.Boolean): androidx.compose.runtime.State skippable: true restartable: true params: - state: STABLE (class with no mutable properties) + - isFocused: STABLE (primitive type) @Composable public fun com.wire.android.ui.common.button.WireButtonColors.contentColor(state: com.wire.android.ui.common.button.WireButtonState): androidx.compose.runtime.State @@ -921,11 +922,12 @@ public fun com.wire.android.ui.common.button.WireButtonColors.contentColor(state - state: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.ui.common.button.WireButtonColors.outlineColor(state: com.wire.android.ui.common.button.WireButtonState): androidx.compose.runtime.State +public fun com.wire.android.ui.common.button.WireButtonColors.outlineColor(state: com.wire.android.ui.common.button.WireButtonState, isFocused: kotlin.Boolean): androidx.compose.runtime.State skippable: true restartable: true params: - state: STABLE (class with no mutable properties) + - isFocused: STABLE (primitive type) @Composable public fun com.wire.android.ui.common.button.WireButtonColors.rippleColor(state: com.wire.android.ui.common.button.WireButtonState): androidx.compose.runtime.State @@ -1106,7 +1108,7 @@ public fun com.wire.android.ui.common.button.secondaryButtonColors(): com.wire.a params: @Composable -private fun com.wire.android.ui.common.button.wireButtonColors(enabled: androidx.compose.ui.graphics.Color, onEnabled: androidx.compose.ui.graphics.Color, enabledOutline: androidx.compose.ui.graphics.Color, enabledRipple: androidx.compose.ui.graphics.Color, disabled: androidx.compose.ui.graphics.Color, onDisabled: androidx.compose.ui.graphics.Color, disabledOutline: androidx.compose.ui.graphics.Color, disabledRipple: androidx.compose.ui.graphics.Color, selected: androidx.compose.ui.graphics.Color, onSelected: androidx.compose.ui.graphics.Color, selectedOutline: androidx.compose.ui.graphics.Color, selectedRipple: androidx.compose.ui.graphics.Color, error: androidx.compose.ui.graphics.Color, onError: androidx.compose.ui.graphics.Color, errorOutline: androidx.compose.ui.graphics.Color, errorRipple: androidx.compose.ui.graphics.Color, positive: androidx.compose.ui.graphics.Color, onPositive: androidx.compose.ui.graphics.Color, positiveOutline: androidx.compose.ui.graphics.Color, positiveRipple: androidx.compose.ui.graphics.Color): com.wire.android.ui.common.button.WireButtonColors +private fun com.wire.android.ui.common.button.wireButtonColors(enabled: androidx.compose.ui.graphics.Color, onEnabled: androidx.compose.ui.graphics.Color, enabledOutline: androidx.compose.ui.graphics.Color, enabledRipple: androidx.compose.ui.graphics.Color, disabled: androidx.compose.ui.graphics.Color, onDisabled: androidx.compose.ui.graphics.Color, disabledOutline: androidx.compose.ui.graphics.Color, disabledRipple: androidx.compose.ui.graphics.Color, selected: androidx.compose.ui.graphics.Color, onSelected: androidx.compose.ui.graphics.Color, selectedOutline: androidx.compose.ui.graphics.Color, selectedRipple: androidx.compose.ui.graphics.Color, error: androidx.compose.ui.graphics.Color, onError: androidx.compose.ui.graphics.Color, errorOutline: androidx.compose.ui.graphics.Color, errorRipple: androidx.compose.ui.graphics.Color, positive: androidx.compose.ui.graphics.Color, onPositive: androidx.compose.ui.graphics.Color, positiveOutline: androidx.compose.ui.graphics.Color, positiveRipple: androidx.compose.ui.graphics.Color, focused: androidx.compose.ui.graphics.Color, focusedOutline: androidx.compose.ui.graphics.Color): com.wire.android.ui.common.button.WireButtonColors skippable: true restartable: true params: @@ -1130,6 +1132,8 @@ private fun com.wire.android.ui.common.button.wireButtonColors(enabled: androidx - onPositive: STABLE (marked @Stable or @Immutable) - positiveOutline: STABLE (marked @Stable or @Immutable) - positiveRipple: STABLE (marked @Stable or @Immutable) + - focused: STABLE (marked @Stable or @Immutable) + - focusedOutline: STABLE (marked @Stable or @Immutable) @Composable public fun com.wire.android.ui.common.button.wireCheckBoxColors(): androidx.compose.material3.CheckboxColors @@ -1382,6 +1386,13 @@ public fun com.wire.android.ui.common.fixedColorsScheme(): com.wire.android.ui.t restartable: true params: +@Composable +public fun com.wire.android.ui.common.focusedBorder(isFocused: kotlin.Boolean): androidx.compose.ui.Modifier + skippable: true + restartable: true + params: + - isFocused: STABLE (primitive type) + @Composable private fun com.wire.android.ui.common.getButton(modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true @@ -1437,6 +1448,12 @@ public fun com.wire.android.ui.common.lightColorsScheme(): com.wire.android.ui.t restartable: true params: +@Composable +public fun com.wire.android.ui.common.maxTitleLines(): kotlin.Int + skippable: true + restartable: true + params: + @Composable public fun com.wire.android.ui.common.preview.EdgeToEdgePreview(useDarkIcons: kotlin.Boolean, content: @[Composable] androidx.compose.runtime.internal.ComposableFunction0): kotlin.Unit skippable: true diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt b/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt index b2a6c81e298..dc8ed9a308d 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt @@ -18,9 +18,11 @@ package com.wire.android.feature.cells.util import android.content.ActivityNotFoundException +import android.content.ComponentName import android.content.ContentValues import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.os.Environment import android.provider.MediaStore @@ -116,7 +118,9 @@ class FileHelper @Inject constructor( putExtra(Intent.EXTRA_STREAM, assetUri) } val chooserIntent = Intent.createChooser(intent, null).apply { + excludeOwnShareTargets(intent) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(chooserIntent) } catch (e: java.lang.IllegalArgumentException) { @@ -134,6 +138,7 @@ class FileHelper @Inject constructor( putExtra(Intent.EXTRA_TEXT, url) } val chooserIntent = Intent.createChooser(intent, null).apply { + excludeOwnShareTargets(intent) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(chooserIntent) @@ -156,4 +161,22 @@ class FileHelper @Inject constructor( private fun Context.pathToUri(assetDataPath: Path, assetName: String?): Uri = FileProvider.getUriForFile(this, getProviderAuthority(), assetDataPath.toFile(), assetName ?: assetDataPath.name) + + private fun Intent.excludeOwnShareTargets(sendIntent: Intent) { + val ownShareComponents = context.packageManager.queryIntentActivitiesCompat(sendIntent) + .map { ComponentName(it.activityInfo.packageName, it.activityInfo.name) } + .filter { it.packageName == context.packageName } + .toTypedArray() + if (ownShareComponents.isNotEmpty()) { + putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, ownShareComponents) + } + } + + private fun PackageManager.queryIntentActivitiesCompat(intent: Intent) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong())) + } else { + @Suppress("DEPRECATION") + queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + } } diff --git a/features/cells/src/main/res/drawable/ic_share.xml b/features/cells/src/main/res/drawable/ic_share.xml index 590b9033eaf..134df61d0ee 100644 --- a/features/cells/src/main/res/drawable/ic_share.xml +++ b/features/cells/src/main/res/drawable/ic_share.xml @@ -4,7 +4,36 @@ android:viewportWidth="16" android:viewportHeight="16"> + android:fillColor="@android:color/transparent" + android:pathData="M12,5.334C13.105,5.334 14,4.439 14,3.334C14,2.229 13.105,1.334 12,1.334C10.895,1.334 10,2.229 10,3.334C10,4.439 10.895,5.334 12,5.334Z" + android:strokeWidth="1.75" + android:strokeColor="#000000" + android:strokeLineCap="round" + android:strokeLineJoin="round"/> + + + + diff --git a/features/meetings/stability/meetings-debug.stability b/features/meetings/stability/meetings-debug.stability index f4552016c6b..bfb567cc999 100644 --- a/features/meetings/stability/meetings-debug.stability +++ b/features/meetings/stability/meetings-debug.stability @@ -288,6 +288,13 @@ internal fun com.wire.android.feature.meetings.ui.list.VideoCallIcon(tint: andro - tint: STABLE (marked @Stable or @Immutable) - modifier: STABLE (marked @Stable or @Immutable) +@Composable +private fun com.wire.android.feature.meetings.ui.list.audioPermissionCheckFlow(onPermissionGranted: kotlin.Function0): com.wire.android.util.permission.RequestLauncher + skippable: true + restartable: true + params: + - onPermissionGranted: STABLE (function type) + @Composable private fun com.wire.android.feature.meetings.ui.list.getDateHeaderString(time: kotlinx.datetime.Instant): kotlin.String skippable: true diff --git a/kalium b/kalium index fe5d4317394..402f0d2ac84 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit fe5d4317394e67dbbbf5cdabd9869e3211b692ab +Subproject commit 402f0d2ac84424d5caa040cc02284ca2cd83da2c