From e5697489cebf26f33c26850c320e2d0c2b055dcf Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Fri, 22 May 2026 15:04:21 +0200 Subject: [PATCH 1/2] feat: enhance external sharing functionality by adding chooser intent and excluding own share targets --- .../mapper/SystemMessageContentMapper.kt | 24 +++++++---- .../android/navigation/OtherDestinations.kt | 3 +- .../ImportMediaAuthenticatedViewModel.kt | 41 ++++++++++++------- .../ui/userprofile/qr/QRCodeIntents.kt | 7 ++-- .../kotlin/com/wire/android/util/FileUtil.kt | 27 +++++++++++- .../ImportMediaAuthenticatedViewModelTest.kt | 25 +++++++++++ .../android/feature/cells/util/FileHelper.kt | 23 +++++++++++ 7 files changed, 121 insertions(+), 29 deletions(-) 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 e942f2d0a03..a64b811cb07 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/sharing/ImportMediaAuthenticatedViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt index b3277ca18cd..1bf8e2ea5f3 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,6 +17,7 @@ */ package com.wire.android.ui.sharing +import android.content.ContentResolver import android.content.Intent import android.net.Uri import android.os.Parcelable @@ -44,6 +45,7 @@ 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.dispatchers.DispatcherProvider +import com.wire.android.util.getProviderAuthority import com.wire.android.util.parcelableArrayList import com.wire.kalium.logic.data.message.SelfDeletionTimer import com.wire.kalium.logic.data.message.SelfDeletionTimer.Companion.SELF_DELETION_LOG_TAG @@ -156,7 +158,7 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( } else { if (incomingIntent.isSingleShare) { // ACTION_SEND - handleSingleIntent(incomingIntent) + handleSingleIntent(activity, incomingIntent) } else { // ACTION_SEND_MULTIPLE handleMultipleActionIntent(activity) @@ -170,10 +172,10 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( importMediaState = importMediaState.copy(importedText = text) } - private suspend fun handleSingleIntent(incomingIntent: ShareCompat.IntentReader) { + private suspend fun handleSingleIntent(activity: AppCompatActivity, incomingIntent: ShareCompat.IntentReader) { incomingIntent.stream?.let { uri -> appLogger.d("$TAG: handleSingleIntent") - handleImportedAsset(uri)?.let { importedAsset -> + handleImportedAsset(activity, uri)?.let { importedAsset -> if (importedAsset.assetSizeExceeded != null) { onSnackbarMessage( SendMessagesSnackbarMessages.MaxAssetSizeExceeded(importedAsset.assetSizeExceeded) @@ -188,7 +190,7 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( appLogger.d("$TAG: handleMultipleActionIntent") val importedMediaAssets = activity.intent.parcelableArrayList(Intent.EXTRA_STREAM)?.mapNotNull { val fileUri = it.toString().toUri() - handleImportedAsset(fileUri) + handleImportedAsset(activity, fileUri) } ?: listOf() importMediaState = importMediaState.copy(importedAssets = importedMediaAssets.toPersistentList()) @@ -215,19 +217,25 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( } } - 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(activity: AppCompatActivity, uri: Uri): ImportedMediaAsset? { + if (uri.isWireFileProviderUri(activity.getProviderAuthority())) { + 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) + } } } @@ -239,3 +247,6 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( private const val TAG = "[ImportMediaAuthenticatedViewModel]" } } + +internal fun Uri.isWireFileProviderUri(providerAuthority: String): Boolean = + scheme.equals(ContentResolver.SCHEME_CONTENT, ignoreCase = true) && authority == providerAuthority 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/FileUtil.kt b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt index d00a5590135..62a7756e528 100644 --- a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt +++ b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt @@ -22,10 +22,12 @@ package com.wire.android.util 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 @@ -280,7 +282,7 @@ 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 saveFileToDownloadsFolder(assetName: String, assetDataPath: Path, assetDataSize: Long, context: Context): Uri? = @@ -377,7 +379,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 +389,27 @@ fun shareAssetFileWithExternalApp(assetDataPath: Path, context: Context, assetNa } } +fun Context.externalShareChooserIntent(sendIntent: Intent, title: CharSequence? = null): Intent = + Intent.createChooser(sendIntent, title).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + 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) + } + } + +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 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..bfc400a468d 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,7 @@ */ package com.wire.android.ui.sharing +import android.net.Uri import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.paging.PagingData import app.cash.turbine.test @@ -34,11 +35,15 @@ 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 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,6 +75,26 @@ class ImportMediaAuthenticatedViewModelTest { } } + @Test + fun `given uri from Wire file provider, when checking imported uri, then reject it`() { + val uri = mockk { + every { scheme } returns "content" + every { authority } returns "com.wire.android.provider" + } + + assertTrue(uri.isWireFileProviderUri("com.wire.android.provider")) + } + + @Test + fun `given uri from another content provider, when checking imported uri, then allow it`() { + val uri = mockk { + every { scheme } returns "content" + every { authority } returns "com.android.providers.media.documents" + } + + assertFalse(uri.isWireFileProviderUri("com.wire.android.provider")) + } + inner class Arrangement { @MockK 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 8d6dfa14980..c2e8c4c626e 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) @@ -150,4 +155,22 @@ class FileHelper @Inject constructor( private fun Context.pathToUri(assetDataPath: Path, assetName: String?): Uri = FileProvider.getUriForFile(this, getProviderAuthority(), assetDataPath.toFile(), assetName ?: assetDataPath.name) + + private fun Intent.excludeOwnShareTargets(sendIntent: Intent) { + val ownShareComponents = context.packageManager.queryIntentActivitiesCompat(sendIntent) + .map { ComponentName(it.activityInfo.packageName, it.activityInfo.name) } + .filter { it.packageName == context.packageName } + .toTypedArray() + if (ownShareComponents.isNotEmpty()) { + putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, ownShareComponents) + } + } + + private fun PackageManager.queryIntentActivitiesCompat(intent: Intent) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong())) + } else { + @Suppress("DEPRECATION") + queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + } } From ad612e03f05d2f8d250cda1f93202ee594e521c1 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Fri, 22 May 2026 16:39:49 +0200 Subject: [PATCH 2/2] feat: implement sharing options for assets via Wire and externally --- .../android/ui/WireActivityActionsHandler.kt | 9 +- .../com/wire/android/ui/debug/DebugScreen.kt | 32 ++- .../android/ui/debug/LogManagementScreen.kt | 14 +- .../com/wire/android/ui/debug/LogOptions.kt | 75 ++++++- .../android/ui/edit/ShareAssetMenuOption.kt | 21 +- .../home/conversations/ConversationScreen.kt | 26 ++- .../edit/AssetOptionsMenuItems.kt | 17 +- .../edit/MessageOptionsMenuItems.kt | 6 +- .../edit/MessageOptionsModalSheetLayout.kt | 23 ++- .../media/ConversationMediaScreen.kt | 25 ++- .../messages/ConversationMessagesViewModel.kt | 8 + .../ui/home/gallery/MediaGalleryScreen.kt | 25 ++- .../ui/home/gallery/MediaGalleryViewModel.kt | 30 ++- .../sharing/ImportMediaAuthenticatedState.kt | 4 +- .../ImportMediaAuthenticatedViewModel.kt | 40 +++- .../android/ui/sharing/ImportMediaNavArgs.kt | 24 +++ .../android/ui/sharing/ImportMediaScreen.kt | 33 ++- .../wire/android/util/AvatarImageManager.kt | 3 +- .../kotlin/com/wire/android/util/FileUtil.kt | 69 +++++-- .../wire/android/util/logging/LogSharing.kt | 32 ++- app/src/main/res/values/strings.xml | 4 + app/src/main/res/xml/provider_paths.xml | 21 +- .../home/gallery/MediaGalleryViewModelTest.kt | 6 +- .../ImportMediaAuthenticatedViewModelTest.kt | 194 +++++++++++++++++- 24 files changed, 639 insertions(+), 102 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaNavArgs.kt diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityActionsHandler.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityActionsHandler.kt index 6e1c9f0328a..6cbe34eac9b 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/debug/DebugScreen.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt index b09a38aadef..58bd338e8ef 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 @@ -53,10 +54,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 @@ -82,7 +85,14 @@ fun DebugScreen( }, onShowCryptoStats = { navigator.navigate(NavigationCommand(ConversationCryptoStatsScreenDestination)) - } + }, + onShareLogsViaWire = { uri -> + navigator.navigate( + NavigationCommand( + ImportMediaScreenDestination(ImportMediaNavArgs(arrayListOf(uri))) + ) + ) + }, ) } @@ -94,6 +104,7 @@ internal fun UserDebugContent( onDatabaseLoggerEnabledChanged: (Boolean) -> Unit, onDeleteLogs: () -> Unit, onFlushLogs: () -> Deferred, + onShareLogsViaWire: (Uri) -> Unit, onShowFeatureFlags: () -> Unit, onShowCryptoStats: () -> Unit, ) { @@ -120,7 +131,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, @@ -226,7 +238,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) { @@ -235,6 +247,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) @@ -249,6 +274,7 @@ internal fun PreviewUserDebugContent() = WireTheme { onLoggingEnabledChange = {}, onDeleteLogs = {}, onFlushLogs = { CompletableDeferred(Unit) }, + onShareLogsViaWire = {}, onDatabaseLoggerEnabledChanged = {}, onShowFeatureFlags = {}, onShowCryptoStats = {}, 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 f07a839b6c3..934edcc5a46 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 @@ -27,11 +28,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel 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 @@ -64,7 +67,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..665216afe29 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,13 @@ fun LogOptions( isDBLoggerEnabled: Boolean, onDBLoggerEnabledChange: (Boolean) -> Unit, onDeleteLogs: () -> Unit, - onShareLogs: () -> Unit, + onShareLogsExternally: () -> Unit, + onShareLogsViaWire: () -> Unit, isPrivateBuild: Boolean, modifier: Modifier = Modifier ) { + val shareLogsSheetState = rememberWireModalSheetState() + Column(modifier = modifier) { SectionHeader(stringResource(R.string.label_logs_option_title)) EnableLoggingSwitch( @@ -74,9 +84,13 @@ fun LogOptions( SettingsItem( text = stringResource(R.string.label_share_logs), trailingIcon = R.drawable.ic_entypo_share, + onRowPressed = Clickable( + enabled = true, + onClick = { shareLogsSheetState.show() } + ), onIconPressed = Clickable( enabled = true, - onClick = onShareLogs + onClick = { shareLogsSheetState.show() } ) ) @@ -90,6 +104,57 @@ fun LogOptions( ) } } + + WireModalSheetLayout( + sheetState = shareLogsSheetState, + sheetContent = { + WireMenuModalSheetContent( + header = MenuModalSheetHeader.Visible( + title = stringResource(R.string.label_share_logs) + ), + menuItems = listOf( + { + ShareLogsInWireOption { + shareLogsSheetState.hide { onShareLogsViaWire() } + } + }, + { + ShareLogsExternallyOption { + shareLogsSheetState.hide { onShareLogsExternally() } + } + } + ) + ) + } + ) +} + +@Composable +private fun ShareLogsInWireOption(onClick: () -> Unit) { + MenuBottomSheetItem( + leading = { + MenuItemIcon( + id = R.drawable.ic_share_file, + contentDescription = stringResource(R.string.content_description_share_the_file), + ) + }, + title = stringResource(R.string.label_share_logs_via_wire), + onItemClick = onClick + ) +} + +@Composable +private fun ShareLogsExternallyOption(onClick: () -> Unit) { + MenuBottomSheetItem( + leading = { + MenuItemIcon( + id = R.drawable.ic_entypo_share, + contentDescription = stringResource(R.string.content_description_share_the_file), + ) + }, + title = stringResource(R.string.label_share_logs_externally), + onItemClick = onClick + ) } @Composable @@ -172,7 +237,8 @@ fun PreviewLoggingOptionsPublicBuild() { isDBLoggerEnabled = true, onDBLoggerEnabledChange = {}, onDeleteLogs = {}, - onShareLogs = {}, + onShareLogsExternally = {}, + onShareLogsViaWire = {}, isPrivateBuild = false, ) } @@ -186,7 +252,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..8e2cee8c5d2 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 @@ -25,6 +25,25 @@ import com.wire.android.ui.common.bottomsheet.MenuItemIcon @Composable fun ShareAssetMenuOption(onShareAsset: () -> Unit) { + ShareAssetExternallyMenuOption(onShareAsset) +} + +@Composable +fun ShareAssetViaWireMenuOption(onShareAsset: () -> Unit) { + MenuBottomSheetItem( + leading = { + MenuItemIcon( + id = R.drawable.ic_share_file, + contentDescription = stringResource(R.string.content_description_share_the_file), + ) + }, + title = stringResource(R.string.label_share_via_wire), + onItemClick = onShareAsset + ) +} + +@Composable +fun ShareAssetExternallyMenuOption(onShareAsset: () -> Unit) { MenuBottomSheetItem( leading = { MenuItemIcon( @@ -32,7 +51,7 @@ fun ShareAssetMenuOption(onShareAsset: () -> Unit) { contentDescription = stringResource(R.string.content_description_share_the_file), ) }, - title = stringResource(R.string.label_share), + title = stringResource(R.string.label_share_externally), onItemClick = onShareAsset ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 507195c12a8..55c2baa67b7 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 @@ -88,6 +88,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 @@ -192,12 +193,14 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography 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.common.error.NetworkFailure import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.Conversation.TypingIndicatorMode @@ -619,7 +622,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, @@ -934,7 +949,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, @@ -1075,7 +1091,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, ) @@ -1910,7 +1927,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..f853fe020de 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,8 @@ 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.ShareAssetExternallyMenuOption +import com.wire.android.ui.edit.ShareAssetViaWireMenuOption // menu items with both asset options enabled (like share, download, etc.) and message options enabled (like reply, reaction, etc.) @Composable @@ -33,7 +34,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 +61,8 @@ fun assetMessageOptionsMenuItems( add { MessageDetailsMenuOption(onDetailsClick) } add { ReplyMessageOption(onReplyClick) } add { DownloadAssetExternallyOption(onDownloadAsset) } - add { ShareAssetMenuOption(onShareAsset) } + add { ShareAssetViaWireMenuOption(onShareAssetViaWire) } + add { ShareAssetExternallyMenuOption(onShareAssetExternally) } if (isOpenable) add { OpenAssetExternallyOption(onOpenAsset) } add { DeleteItemMenuOption(onDeleteClick) } } @@ -72,7 +75,8 @@ fun assetMessageOptionsMenuItems( fun assetOptionsMenuItems( isEphemeral: Boolean, onDeleteClick: () -> Unit, - onShareAsset: () -> Unit, + onShareAssetExternally: () -> Unit, + onShareAssetViaWire: () -> Unit, onDownloadAsset: () -> Unit, isOpenable: Boolean = false, onOpenAsset: () -> Unit = {}, @@ -80,7 +84,10 @@ fun assetOptionsMenuItems( ): List<@Composable () -> Unit> = buildList { if (!isUploading) { add { DownloadAssetExternallyOption(onDownloadAsset) } - if (!isEphemeral) add { ShareAssetMenuOption(onShareAsset) } + if (!isEphemeral) { + add { ShareAssetViaWireMenuOption(onShareAssetViaWire) } + add { ShareAssetExternallyMenuOption(onShareAssetExternally) } + } 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 05f0ef49ce2..eb5037ddd94 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 @@ -58,7 +58,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 { @@ -117,7 +119,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, ) { @@ -206,10 +209,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) } } }, @@ -261,7 +271,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 972009eefbb..8481bc7e14c 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 @@ -70,8 +71,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 @@ -125,7 +128,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, ) @@ -246,7 +260,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( @@ -257,7 +272,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) } } }, ) ) @@ -319,7 +335,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 1de13e8ec59..fc9036f15b9 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 @@ -402,6 +402,14 @@ class ConversationMessagesViewModel @Inject constructor( } } + 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 44e8f6d56c4..7ac73f4b0bb 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 coil3.annotation.ExperimentalCoilApi import com.ramcosta.composedestinations.result.ResultBackNavigator import com.wire.android.R 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,13 +56,16 @@ 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.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 @@ -135,7 +139,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( @@ -255,11 +267,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 818c275701f..6b6a778ce53 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 @@ -124,7 +124,7 @@ class MediaGalleryViewModel @Inject constructor( 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) @@ -192,6 +192,14 @@ class MediaGalleryViewModel @Inject constructor( } } + 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) @@ -230,7 +238,8 @@ class MediaGalleryViewModel @Inject constructor( MenuIntent.Download -> sendAction(MediaGalleryAction.Download) - MenuIntent.Share -> shareAsset() + MenuIntent.ShareExternally -> shareAsset() + MenuIntent.ShareViaWire -> shareAssetViaWire() MenuIntent.Delete -> { deleteMessageDialogState.show( @@ -275,13 +284,17 @@ class MediaGalleryViewModel @Inject constructor( 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) } } @@ -301,7 +314,8 @@ class MediaGalleryViewModel @Inject constructor( 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 @@ -315,7 +329,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 } @@ -324,7 +339,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 1bf8e2ea5f3..85b2b142180 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 @@ -18,6 +18,7 @@ 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 @@ -44,6 +45,7 @@ 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.getProviderAuthority import com.wire.android.util.parcelableArrayList @@ -53,6 +55,7 @@ import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTim import com.wire.kalium.logic.feature.selfDeletingMessages.PersistNewSelfDeletionTimerUseCase import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.FlowPreview @@ -74,6 +77,7 @@ import javax.inject.Inject @OptIn(FlowPreview::class) @Suppress("LongParameterList", "TooManyFunctions") class ImportMediaAuthenticatedViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, private val getSelf: ObserveSelfUserUseCase, private val getConversationsPaginated: GetConversationsFromSearchUseCase, private val handleUriAsset: HandleUriAssetUseCase, @@ -167,6 +171,27 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( 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) @@ -217,8 +242,11 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( } } - private suspend fun handleImportedAsset(activity: AppCompatActivity, uri: Uri): ImportedMediaAsset? { - if (uri.isWireFileProviderUri(activity.getProviderAuthority())) { + private suspend fun handleImportedAsset(activity: AppCompatActivity, uri: Uri): ImportedMediaAsset? = + handleImportedAsset(uri, rejectOwnFileProviderUri = uri.isWireFileProviderUri(activity.getProviderAuthority())) + + 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 } @@ -250,3 +278,11 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( internal fun Uri.isWireFileProviderUri(providerAuthority: String): Boolean = scheme.equals(ContentResolver.SCHEME_CONTENT, ignoreCase = true) && authority == providerAuthority + +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 == ".." } + } 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 d6c3e9511a1..3a5b3f04309 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 @@ -119,24 +119,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 = hiltViewModel(), ) { + 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)) @@ -148,13 +151,15 @@ fun ImportMediaScreen( FeatureFlagState.FileSharingState.AllowAll, is FeatureFlagState.FileSharingState.AllowSome -> { ImportMediaAuthenticatedContent( + navArgs = navArgs, navigator = navigator, isRestrictedInTeam = fileSharingRestrictedState == FeatureFlagState.FileSharingState.DisabledByTeam, + navigateBack = navigateBack, ) } } - BackHandler { navigator.finish() } + BackHandler { navigateBack() } } @Composable @@ -190,8 +195,10 @@ private fun ImportMediaLoadingContent(navigateBack: () -> Unit) { @Composable private fun ImportMediaAuthenticatedContent( + navArgs: ImportMediaNavArgs, navigator: Navigator, isRestrictedInTeam: Boolean, + navigateBack: () -> Unit, checkAssetRestrictionsViewModel: CheckAssetRestrictionsViewModel = hiltViewModel(), importMediaViewModel: ImportMediaAuthenticatedViewModel = hiltViewModel(), ) { @@ -199,7 +206,7 @@ private fun ImportMediaAuthenticatedContent( ImportMediaRestrictedContent( importMediaAuthenticatedState = importMediaViewModel.importMediaState, avatarAsset = null, - navigateBack = navigator.finish + navigateBack = navigateBack ) } else { LaunchedEffect(checkAssetRestrictionsViewModel.state) { @@ -234,7 +241,7 @@ private fun ImportMediaAuthenticatedContent( }, onNewSelfDeletionTimerPicked = importMediaViewModel::onNewSelfDeletionTimerPicked, infoMessage = importMediaViewModel.infoMessage, - navigateBack = navigator.finish, + navigateBack = navigateBack, onRemoveAsset = importMediaViewModel::onRemove ) AssetTooLargeDialog( @@ -244,16 +251,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/util/AvatarImageManager.kt b/app/src/main/kotlin/com/wire/android/util/AvatarImageManager.kt index 27bff86691b..874d6211314 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 javax.inject.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 62a7756e528..8ba32d30a15 100644 --- a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt +++ b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt @@ -64,6 +64,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 @@ -75,9 +76,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( @@ -193,7 +194,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) { @@ -266,13 +267,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) @@ -285,6 +280,9 @@ fun Context.startFileShareIntent(path: Path, assetName: String?) { 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) @@ -304,11 +302,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) } @@ -402,6 +396,48 @@ fun Context.externalShareChooserIntent(sendIntent: Intent, title: CharSequence? } } +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())) @@ -521,5 +557,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/values/strings.xml b/app/src/main/res/values/strings.xml index 9f0885b6c5e..5a94b05010a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -811,6 +811,8 @@ Message Details Copy text Share + Forward + Share externally Edit text Delete Message copied @@ -1396,6 +1398,8 @@ In group conversations, the group admin can overwrite this setting. Logs Share Logs Could not prepare logs for sharing + Share in Wire + Share externally Delete All Logs Restart slow sync Restart 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/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 bfc400a468d..7ee4a7ae4b8 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,7 @@ */ 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 @@ -27,8 +28,10 @@ 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 @@ -42,6 +45,8 @@ 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 @@ -76,26 +81,190 @@ class ImportMediaAuthenticatedViewModelTest { } @Test - fun `given uri from Wire file provider, when checking imported uri, then reject it`() { - val uri = mockk { - every { scheme } returns "content" - every { authority } returns "com.wire.android.provider" - } + 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 uri from another content provider, when checking imported uri, then allow it`() { - val uri = mockk { - every { scheme } returns "content" - every { authority } returns "com.android.providers.media.documents" - } + 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 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 @@ -125,7 +294,12 @@ class ImportMediaAuthenticatedViewModelTest { mockUri() } + fun withHandleUriAsset(result: HandleUriAssetUseCase.Result) = apply { + coEvery { handleUriAssetUseCase.invoke(any(), any()) } returns result + } + fun arrange() = this to ImportMediaAuthenticatedViewModel( + context = context, getSelf = getSelfUser, getConversationsPaginated = getConversationsPaginated, handleUriAsset = handleUriAssetUseCase,