From 4abe3955caf328c4c56d4b1dd869d32d3932fdb7 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/9] 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 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/sharing/ImportMediaAuthenticatedViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt index ba6bc0cde9f..7dc39ed4632 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 @@ -153,7 +155,7 @@ class ImportMediaAuthenticatedViewModel( } else { if (incomingIntent.isSingleShare) { // ACTION_SEND - handleSingleIntent(incomingIntent) + handleSingleIntent(activity, incomingIntent) } else { // ACTION_SEND_MULTIPLE handleMultipleActionIntent(activity) @@ -167,10 +169,10 @@ class ImportMediaAuthenticatedViewModel( 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) @@ -185,7 +187,7 @@ class ImportMediaAuthenticatedViewModel( 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()) @@ -212,19 +214,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(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) + } } } @@ -236,3 +244,6 @@ class ImportMediaAuthenticatedViewModel( 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 b2a6c81e298..dc8ed9a308d 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt @@ -18,9 +18,11 @@ package com.wire.android.feature.cells.util import android.content.ActivityNotFoundException +import android.content.ComponentName import android.content.ContentValues import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.os.Environment import android.provider.MediaStore @@ -116,7 +118,9 @@ class FileHelper @Inject constructor( putExtra(Intent.EXTRA_STREAM, assetUri) } val chooserIntent = Intent.createChooser(intent, null).apply { + excludeOwnShareTargets(intent) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(chooserIntent) } catch (e: java.lang.IllegalArgumentException) { @@ -134,6 +138,7 @@ class FileHelper @Inject constructor( putExtra(Intent.EXTRA_TEXT, url) } val chooserIntent = Intent.createChooser(intent, null).apply { + excludeOwnShareTargets(intent) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(chooserIntent) @@ -156,4 +161,22 @@ class FileHelper @Inject constructor( private fun Context.pathToUri(assetDataPath: Path, assetName: String?): Uri = FileProvider.getUriForFile(this, getProviderAuthority(), assetDataPath.toFile(), assetName ?: assetDataPath.name) + + private fun Intent.excludeOwnShareTargets(sendIntent: Intent) { + val ownShareComponents = context.packageManager.queryIntentActivitiesCompat(sendIntent) + .map { ComponentName(it.activityInfo.packageName, it.activityInfo.name) } + .filter { it.packageName == context.packageName } + .toTypedArray() + if (ownShareComponents.isNotEmpty()) { + putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, ownShareComponents) + } + } + + private fun PackageManager.queryIntentActivitiesCompat(intent: Intent) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong())) + } else { + @Suppress("DEPRECATION") + queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + } } From 6232808d6e1d8ba4aef6283317a3af1b2ebb918f 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/9] feat: implement sharing options for assets via Wire and externally --- .../wire/android/ui/MiscViewModelFactory.kt | 4 + .../android/ui/WireActivityActionsHandler.kt | 9 +- .../com/wire/android/ui/debug/DebugScreen.kt | 30 ++- .../android/ui/debug/LogManagementScreen.kt | 14 +- .../com/wire/android/ui/debug/LogOptions.kt | 75 ++++++- .../android/ui/edit/ShareAssetMenuOption.kt | 21 +- .../home/conversations/ConversationScreen.kt | 28 ++- .../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 | 35 +++- .../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 +++++++++++++++++- 25 files changed, 645 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/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/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/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..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 6816949e7bd..5dc37062861 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,16 @@ 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.common.error.NetworkFailure +import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.Conversation.TypingIndicatorMode import com.wire.kalium.logic.data.conversation.InteractionAvailability import com.wire.kalium.logic.data.id.ConversationId @@ -535,7 +540,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 +817,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 +961,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 +1829,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 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 7dc39ed4632..d195d2576c8 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 @@ -34,6 +35,7 @@ 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 @@ -44,6 +46,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 @@ -71,6 +74,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, @@ -164,6 +168,27 @@ class ImportMediaAuthenticatedViewModel( 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) @@ -214,8 +239,11 @@ class ImportMediaAuthenticatedViewModel( } } - 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 } @@ -247,3 +275,11 @@ class ImportMediaAuthenticatedViewModel( 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 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/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 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 a32965013d3..bb08e2230f9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -794,6 +794,8 @@ Message Details Copy text Share + Forward + Share externally Edit text Delete Message copied @@ -1342,6 +1344,8 @@ In group conversations, the group admin can overwrite this setting. Logs Share Logs Could not prepare logs for sharing + Share in Wire + Share externally Delete All Logs Restart slow sync Restart 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, From 4d5a60ddc061f8f470fbd6b49f437d661ae1b083 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:43:29 +0200 Subject: [PATCH 3/9] feat: unify share asset options and enhance sharing intent handling --- .../com/wire/android/ui/WireActivity.kt | 12 ++- .../com/wire/android/ui/debug/LogOptions.kt | 42 +++++++--- .../android/ui/edit/ShareAssetMenuOption.kt | 25 +++++- .../edit/AssetOptionsMenuItems.kt | 9 +-- .../ui/home/gallery/MediaGalleryScreen.kt | 17 +++- .../ImportMediaAuthenticatedViewModel.kt | 69 +++++++++++----- .../kotlin/com/wire/android/util/FileUtil.kt | 31 +++++++- .../wire/android/util/logging/LogSharing.kt | 3 +- .../ImportMediaAuthenticatedViewModelTest.kt | 78 +++++++++++++++++++ 9 files changed, 244 insertions(+), 42 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index 1ca67d69ea8..d2b1d56d25b 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 @@ -287,10 +288,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) } 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 665216afe29..9ba9bd3687b 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 @@ -51,6 +51,7 @@ import com.wire.android.ui.home.settings.SettingsItem import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.supportsTrustedWireShareCaller import com.wire.android.util.ui.PreviewMultipleThemes @Composable @@ -112,16 +113,12 @@ fun LogOptions( header = MenuModalSheetHeader.Visible( title = stringResource(R.string.label_share_logs) ), - menuItems = listOf( - { - ShareLogsInWireOption { - shareLogsSheetState.hide { onShareLogsViaWire() } - } + menuItems = shareLogsMenuItems( + onShareLogsExternally = { + shareLogsSheetState.hide { onShareLogsExternally() } }, - { - ShareLogsExternallyOption { - shareLogsSheetState.hide { onShareLogsExternally() } - } + onShareLogsViaWire = { + shareLogsSheetState.hide { onShareLogsViaWire() } } ) ) @@ -129,6 +126,33 @@ fun LogOptions( ) } +private fun shareLogsMenuItems( + onShareLogsExternally: () -> Unit, + onShareLogsViaWire: () -> Unit +): List<@Composable () -> Unit> = + if (supportsTrustedWireShareCaller()) { + listOf({ ShareLogsOption(onShareLogsExternally) }) + } else { + listOf( + { ShareLogsInWireOption(onShareLogsViaWire) }, + { ShareLogsExternallyOption(onShareLogsExternally) } + ) + } + +@Composable +private fun ShareLogsOption(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), + onItemClick = onClick + ) +} + @Composable private fun ShareLogsInWireOption(onClick: () -> Unit) { MenuBottomSheetItem( 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 8e2cee8c5d2..ab6f8c63b4b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/edit/ShareAssetMenuOption.kt +++ b/app/src/main/kotlin/com/wire/android/ui/edit/ShareAssetMenuOption.kt @@ -22,12 +22,35 @@ import androidx.compose.ui.res.stringResource import com.wire.android.R import com.wire.android.ui.common.bottomsheet.MenuBottomSheetItem import com.wire.android.ui.common.bottomsheet.MenuItemIcon +import com.wire.android.util.supportsTrustedWireShareCaller @Composable fun ShareAssetMenuOption(onShareAsset: () -> Unit) { - ShareAssetExternallyMenuOption(onShareAsset) + MenuBottomSheetItem( + leading = { + MenuItemIcon( + id = R.drawable.ic_share_file, + contentDescription = stringResource(R.string.content_description_share_the_file), + ) + }, + title = stringResource(R.string.label_share), + onItemClick = onShareAsset + ) } +fun shareAssetMenuOptions( + onShareAssetExternally: () -> Unit, + onShareAssetViaWire: () -> Unit +): List<@Composable () -> Unit> = + if (supportsTrustedWireShareCaller()) { + listOf({ ShareAssetMenuOption(onShareAssetExternally) }) + } else { + listOf( + { ShareAssetViaWireMenuOption(onShareAssetViaWire) }, + { ShareAssetExternallyMenuOption(onShareAssetExternally) } + ) + } + @Composable fun ShareAssetViaWireMenuOption(onShareAsset: () -> Unit) { MenuBottomSheetItem( 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 f853fe020de..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,8 +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.ShareAssetExternallyMenuOption -import com.wire.android.ui.edit.ShareAssetViaWireMenuOption +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 @@ -61,8 +60,7 @@ fun assetMessageOptionsMenuItems( add { MessageDetailsMenuOption(onDetailsClick) } add { ReplyMessageOption(onReplyClick) } add { DownloadAssetExternallyOption(onDownloadAsset) } - add { ShareAssetViaWireMenuOption(onShareAssetViaWire) } - add { ShareAssetExternallyMenuOption(onShareAssetExternally) } + addAll(shareAssetMenuOptions(onShareAssetExternally, onShareAssetViaWire)) if (isOpenable) add { OpenAssetExternallyOption(onOpenAsset) } add { DeleteItemMenuOption(onDeleteClick) } } @@ -85,8 +83,7 @@ fun assetOptionsMenuItems( if (!isUploading) { add { DownloadAssetExternallyOption(onDownloadAsset) } if (!isEphemeral) { - add { ShareAssetViaWireMenuOption(onShareAssetViaWire) } - add { ShareAssetExternallyMenuOption(onShareAssetExternally) } + addAll(shareAssetMenuOptions(onShareAssetExternally, onShareAssetViaWire)) } if (isOpenable) add { OpenAssetExternallyOption(onOpenAsset) } } 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 74065a40b3a..ccb0fd8821a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt @@ -56,6 +56,7 @@ 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 @@ -69,6 +70,7 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.util.fileShareUri import com.wire.android.util.permission.rememberWriteStoragePermissionFlow import com.wire.android.util.startFileShareIntent +import com.wire.android.util.supportsTrustedWireShareCaller import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.SnackBarMessageHandler import com.wire.android.util.openDownloadFolder @@ -252,6 +254,9 @@ private fun MediaGalleryOptionsBottomSheetLayout( val sheetState: WireModalSheetState = rememberWireModalSheetState(WireSheetValue.Expanded(Unit)) val onOptionsClick: (MenuIntent) -> Unit = remember { { sheetState.hide { onMenuIntent(it) } } } + val collapseShareOptions = supportsTrustedWireShareCaller() && + menuItems.contains(MediaGalleryMenuItem.SHARE_VIA_WIRE) && + menuItems.contains(MediaGalleryMenuItem.SHARE_EXTERNALLY) val menuItems: List<@Composable () -> Unit> = buildList { menuItems.forEach { item -> @@ -268,11 +273,17 @@ private fun MediaGalleryOptionsBottomSheetLayout( MediaGalleryMenuItem.DOWNLOAD -> add { DownloadAssetExternallyOption { onOptionsClick(MenuIntent.Download) } } - MediaGalleryMenuItem.SHARE_EXTERNALLY -> add { - ShareAssetExternallyMenuOption { onOptionsClick(MenuIntent.ShareExternally) } + MediaGalleryMenuItem.SHARE_EXTERNALLY -> if (!collapseShareOptions) { + add { + ShareAssetExternallyMenuOption { onOptionsClick(MenuIntent.ShareExternally) } + } } MediaGalleryMenuItem.SHARE_VIA_WIRE -> add { - ShareAssetViaWireMenuOption { onOptionsClick(MenuIntent.ShareViaWire) } + if (collapseShareOptions) { + ShareAssetMenuOption { onOptionsClick(MenuIntent.ShareExternally) } + } else { + ShareAssetViaWireMenuOption { onOptionsClick(MenuIntent.ShareViaWire) } + } } MediaGalleryMenuItem.SHARE_PUBLIC_LINK -> add { SharePublicLinkMenuOption { onOptionsClick(MenuIntent.ShareExternally) } 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 d195d2576c8..03d204c5b02 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 @@ -21,7 +21,9 @@ import android.content.ContentResolver import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Build import android.os.Parcelable +import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.getValue @@ -55,7 +57,6 @@ import com.wire.kalium.logic.data.message.SelfDeletionTimer.Companion.SELF_DELET 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 @@ -157,12 +158,14 @@ 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(activity, incomingIntent) + handleSingleIntent(providerAuthority, hasTrustedWireCaller, incomingIntent) } else { // ACTION_SEND_MULTIPLE - handleMultipleActionIntent(activity) + handleMultipleActionIntent(activity, providerAuthority, hasTrustedWireCaller) } } importMediaState = importMediaState.copy(isImporting = false) @@ -194,26 +197,41 @@ class ImportMediaAuthenticatedViewModel( importMediaState = importMediaState.copy(importedText = text) } - private suspend fun handleSingleIntent(activity: AppCompatActivity, incomingIntent: ShareCompat.IntentReader) { + private suspend fun handleSingleIntent( + providerAuthority: String, + hasTrustedWireCaller: Boolean, + incomingIntent: ShareCompat.IntentReader + ) { incomingIntent.stream?.let { uri -> appLogger.d("$TAG: handleSingleIntent") - handleImportedAsset(activity, 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(activity, fileUri) + val fileUris = activity.intent.parcelableArrayList(Intent.EXTRA_STREAM)?.map { + it.toString().toUri() } ?: listOf() + handleReceivedUrisFromSharingIntent(providerAuthority, hasTrustedWireCaller, fileUris) + } + + 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()) @@ -239,9 +257,6 @@ class ImportMediaAuthenticatedViewModel( } } - 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") @@ -276,6 +291,12 @@ class 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 Uri.isWireInternalShareUri(providerAuthority: String): Boolean = isWireFileProviderUri(providerAuthority) && pathSegments.let { segments -> @@ -283,3 +304,13 @@ internal fun Uri.isWireInternalShareUri(providerAuthority: String): Boolean = segments.firstOrNull() == FILE_PROVIDER_SHARED_FILES_ROOT && segments.none { it == ".." } } + +private 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/util/FileUtil.kt b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt index 8ba32d30a15..2ab209eefaa 100644 --- a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt +++ b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt @@ -20,6 +20,7 @@ package com.wire.android.util +import android.app.ActivityOptions import android.app.DownloadManager import android.content.ActivityNotFoundException import android.content.ComponentName @@ -33,6 +34,7 @@ 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 @@ -277,7 +279,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(externalShareChooserIntent(shareIntent)) + startShareIntentWithTrustedWireTarget(shareIntent) } fun Context.fileShareUri(path: Path, assetName: String?): Uri = @@ -383,10 +385,15 @@ fun shareAssetFileWithExternalApp(assetDataPath: Path, context: Context, assetNa } } -fun Context.externalShareChooserIntent(sendIntent: Intent, title: CharSequence? = null): Intent = +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 } @@ -396,6 +403,26 @@ fun Context.externalShareChooserIntent(sendIntent: Intent, title: CharSequence? } } +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) 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 a6317e44bd8..11869938d83 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 @@ -29,6 +29,7 @@ import com.wire.android.util.getDeviceIdString import com.wire.android.util.getGitBuildId import com.wire.android.util.sha256 import com.wire.android.util.shareableFileProviderUri +import com.wire.android.util.startShareIntentWithTrustedWireTarget import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -66,7 +67,7 @@ class LogShareLauncher( logsDirectory = logsDirectory, flushLogs = flushLogs, shareArchive = { archive -> - context.startActivity(context.externalShareChooserIntent(context.logsSharingIntent(archive))) + context.startShareIntentWithTrustedWireTarget(context.logsSharingIntent(archive)) } ) } 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 7ee4a7ae4b8..1c840676ba9 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 @@ -115,6 +115,84 @@ class ImportMediaAuthenticatedViewModelTest { 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 Wire provider uri under shared files, when checking internal share uri, then allow it`() { val uri = testUri(pathSegments = listOf("shared_files", "wire-logs.zip")) From 9317f0903f062695e1e88315b0d7acaa42329397 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:59:23 +0200 Subject: [PATCH 4/9] feat: enhance log sharing functionality with direct sharing option --- .../com/wire/android/ui/debug/LogOptions.kt | 72 +++++++++---------- 1 file changed, 32 insertions(+), 40 deletions(-) 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 9ba9bd3687b..f66e223b0f2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/LogOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/LogOptions.kt @@ -67,6 +67,14 @@ fun LogOptions( modifier: Modifier = Modifier ) { val shareLogsSheetState = rememberWireModalSheetState() + val shareLogsDirectly = supportsTrustedWireShareCaller() + val onShareLogsClick: () -> Unit = { + if (shareLogsDirectly) { + onShareLogsExternally() + } else { + shareLogsSheetState.show() + } + } Column(modifier = modifier) { SectionHeader(stringResource(R.string.label_logs_option_title)) @@ -87,11 +95,11 @@ fun LogOptions( trailingIcon = R.drawable.ic_entypo_share, onRowPressed = Clickable( enabled = true, - onClick = { shareLogsSheetState.show() } + onClick = onShareLogsClick ), onIconPressed = Clickable( enabled = true, - onClick = { shareLogsSheetState.show() } + onClick = onShareLogsClick ) ) @@ -106,52 +114,36 @@ 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() } - } + if (!shareLogsDirectly) { + WireModalSheetLayout( + sheetState = shareLogsSheetState, + sheetContent = { + WireMenuModalSheetContent( + header = MenuModalSheetHeader.Visible( + title = stringResource(R.string.label_share_logs) + ), + menuItems = shareLogsMenuItems( + onShareLogsExternally = { + shareLogsSheetState.hide { onShareLogsExternally() } + }, + onShareLogsViaWire = { + shareLogsSheetState.hide { onShareLogsViaWire() } + } + ) ) - ) - } - ) + } + ) + } } private fun shareLogsMenuItems( onShareLogsExternally: () -> Unit, onShareLogsViaWire: () -> Unit ): List<@Composable () -> Unit> = - if (supportsTrustedWireShareCaller()) { - listOf({ ShareLogsOption(onShareLogsExternally) }) - } else { - listOf( - { ShareLogsInWireOption(onShareLogsViaWire) }, - { ShareLogsExternallyOption(onShareLogsExternally) } - ) - } - -@Composable -private fun ShareLogsOption(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), - onItemClick = onClick + listOf( + { ShareLogsInWireOption(onShareLogsViaWire) }, + { ShareLogsExternallyOption(onShareLogsExternally) } ) -} @Composable private fun ShareLogsInWireOption(onClick: () -> Unit) { From 716e6e81adddaada601ac8b8b96887c7c10255b1 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:44:13 +0200 Subject: [PATCH 5/9] do not open the share screen for intents with internal files --- .../com/wire/android/ui/WireActivity.kt | 8 +- .../wire/android/ui/WireActivityViewModel.kt | 37 +++++++- .../ImportMediaAuthenticatedViewModel.kt | 23 +++-- app/src/main/res/values/strings.xml | 1 + .../android/ui/WireActivityViewModelTest.kt | 91 +++++++++++++++++++ .../ImportMediaAuthenticatedViewModelTest.kt | 64 +++++++++++++ 6 files changed, 213 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index d2b1d56d25b..f928d0643a3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -154,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 @@ -1193,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/WireActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt index 459a01b407c..5bdbbc73665 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt @@ -50,6 +50,8 @@ import com.wire.android.ui.common.dialogs.CustomServerDetailsDialogState import com.wire.android.ui.common.dialogs.CustomServerDialogState import com.wire.android.ui.common.dialogs.CustomServerNoNetworkDialogState import com.wire.android.ui.joinConversation.JoinConversationViaCodeState +import com.wire.android.ui.sharing.sharingUris +import com.wire.android.ui.sharing.shouldRejectSharingIntent import com.wire.android.ui.theme.Accent import com.wire.android.ui.theme.ThemeOption import com.wire.android.util.CurrentScreen @@ -110,6 +112,8 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import java.io.InputStream import java.io.InputStreamReader import dev.zacsweers.metro.Inject @@ -413,7 +417,11 @@ class WireActivityViewModel @Inject constructor( } @Suppress("ComplexMethod") - fun handleDeepLink(intent: Intent?) { + fun handleDeepLink( + intent: Intent?, + providerAuthority: String? = null, + hasTrustedWireShareCaller: Boolean = false + ) { viewModelScope.launch(dispatchers.io()) { when (val result = deepLinkProcessor.value.invoke(intent?.data, intent?.action)) { DeepLinkResult.AuthorizationNeeded -> sendAction(OnAuthorizationNeeded) @@ -442,7 +450,16 @@ class WireActivityViewModel @Inject constructor( is DeepLinkResult.OpenConversation -> sendAction(OpenConversation(result)) is DeepLinkResult.OpenOtherUserProfile -> onOpenUserProfileDeepLink(result) - DeepLinkResult.SharingIntent -> sendAction(OnShowImportMediaScreen) + DeepLinkResult.SharingIntent -> { + val shouldRejectSharingIntent = providerAuthority != null && + intent?.shouldRejectSharingIntent(providerAuthority, hasTrustedWireShareCaller) == true + if (shouldRejectSharingIntent) { + logRejectedSharingIntent(intent, providerAuthority, hasTrustedWireShareCaller) + sendAction(ShowToast(R.string.public_share_ignored_wire_internal_files)) + return@launch + } + sendAction(OnShowImportMediaScreen) + } DeepLinkResult.Unknown -> { sendAction(OnUnknownDeepLink) appLogger.e("unknown deeplink result $result") @@ -451,6 +468,22 @@ class WireActivityViewModel @Inject constructor( } } + private fun logRejectedSharingIntent( + intent: Intent?, + providerAuthority: String?, + hasTrustedWireShareCaller: Boolean + ) { + val logMap = mapOf( + "event" to "public_share_rejected", + "reason" to "wire_file_provider_uri", + "action" to intent?.action.orEmpty(), + "providerAuthority" to providerAuthority.orEmpty(), + "hasTrustedWireShareCaller" to hasTrustedWireShareCaller.toString(), + "uriCount" to (intent?.sharingUris()?.size ?: 0).toString() + ) + appLogger.w("Rejected public share intent: ${Json.encodeToString(logMap)}") + } + // Returns whether an intent was handled, or if there was nothing to do @Suppress("ReturnCount") suspend fun handleIntentsThatAreNotDeepLinks(intent: Intent?): Boolean { diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt index 03d204c5b02..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 @@ -22,7 +22,6 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build -import android.os.Parcelable import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.text.input.TextFieldState @@ -30,7 +29,6 @@ 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 @@ -51,7 +49,6 @@ 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 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 @@ -214,10 +211,7 @@ class ImportMediaAuthenticatedViewModel( hasTrustedWireCaller: Boolean ) { appLogger.d("$TAG: handleMultipleActionIntent") - val fileUris = activity.intent.parcelableArrayList(Intent.EXTRA_STREAM)?.map { - it.toString().toUri() - } ?: listOf() - handleReceivedUrisFromSharingIntent(providerAuthority, hasTrustedWireCaller, fileUris) + handleReceivedUrisFromSharingIntent(providerAuthority, hasTrustedWireCaller, activity.intent.sharingUris()) } internal suspend fun handleReceivedUrisFromSharingIntent( @@ -297,6 +291,19 @@ internal fun Uri.shouldRejectWireFileProviderShare(providerAuthority: String, ha 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 -> @@ -305,7 +312,7 @@ internal fun Uri.isWireInternalShareUri(providerAuthority: String): Boolean = segments.none { it == ".." } } -private fun AppCompatActivity.hasTrustedWireShareCaller(): Boolean = +internal fun AppCompatActivity.hasTrustedWireShareCaller(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && getTrustedShareCallerPackageName() == packageName diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bb08e2230f9..990759a6e2b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1466,6 +1466,7 @@ In group conversations, the group admin can overwrite this setting. Start You can not share this file because this feature is disabled for this account. You need to be logged in to Wire before you can share anything + This share was ignored because it contains internal Wire files. Open Wire Join conversation? diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt index 670f490f15c..32302cb1a34 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt @@ -21,6 +21,8 @@ package com.wire.android.ui import android.content.Intent +import android.net.Uri +import android.os.Bundle import androidx.work.Operation import androidx.work.WorkManager import app.cash.turbine.test @@ -958,6 +960,71 @@ class WireActivityViewModelTest { } } + @Test + fun `given untrusted sharing intent with mixed Wire and external provider uris, when handling deep link, then show ignored toast`() = + runTest { + val (_, viewModel) = Arrangement() + .withDeepLinkResult(DeepLinkResult.SharingIntent) + .arrange() + val intent = sharingIntent( + wireProviderUri(), + externalProviderUri("com.android.providers.media.documents") + ) + + viewModel.actions.test { + viewModel.handleDeepLink( + intent = intent, + providerAuthority = WIRE_PROVIDER_AUTHORITY, + hasTrustedWireShareCaller = false + ) + advanceUntilIdle() + assertEquals(ShowToast(R.string.public_share_ignored_wire_internal_files), expectMostRecentItem()) + expectNoEvents() + } + } + + @Test + fun `given trusted sharing intent with mixed Wire and external provider uris, when handling deep link, then import media screen is shown`() = + runTest { + val (_, viewModel) = Arrangement() + .withDeepLinkResult(DeepLinkResult.SharingIntent) + .arrange() + val intent = sharingIntent( + wireProviderUri(), + externalProviderUri("com.android.providers.media.documents") + ) + + viewModel.actions.test { + viewModel.handleDeepLink( + intent = intent, + providerAuthority = WIRE_PROVIDER_AUTHORITY, + hasTrustedWireShareCaller = true + ) + assertEquals(OnShowImportMediaScreen, expectMostRecentItem()) + } + } + + @Test + fun `given untrusted sharing intent with external provider uris, when handling deep link, then import media screen is shown`() = + runTest { + val (_, viewModel) = Arrangement() + .withDeepLinkResult(DeepLinkResult.SharingIntent) + .arrange() + val intent = sharingIntent( + externalProviderUri("com.android.providers.media.documents"), + externalProviderUri("com.google.android.apps.photos.contentprovider") + ) + + viewModel.actions.test { + viewModel.handleDeepLink( + intent = intent, + providerAuthority = WIRE_PROVIDER_AUTHORITY, + hasTrustedWireShareCaller = false + ) + assertEquals(OnShowImportMediaScreen, expectMostRecentItem()) + } + } + @Test fun `given no valid session, when checking number of sessions, then return true`() = runTest { // given @@ -1375,6 +1442,7 @@ class WireActivityViewModelTest { companion object { val USER_ID = UserId("user_id", "domain.de") val TEST_ACCOUNT_INFO = AccountInfo.Valid(USER_ID) + private const val WIRE_PROVIDER_AUTHORITY = "com.wire.android.provider" private fun mockedTestAccounts(count: Int) = List(count) { i -> TEST_ACCOUNT_INFO.copy(userId = USER_ID.copy("user_$i")) @@ -1388,6 +1456,29 @@ class WireActivityViewModelTest { } } + private fun sharingIntent(vararg uris: Uri): Intent { + val extrasBundle = mockk { + every { this@mockk.get(Intent.EXTRA_STREAM) } returns uris.toList() + } + return mockk { + every { data } returns null + every { action } returns Intent.ACTION_SEND_MULTIPLE + every { extras } returns extrasBundle + } + } + + private fun wireProviderUri(): Uri = + sharingUri(WIRE_PROVIDER_AUTHORITY) + + private fun externalProviderUri(authority: String): Uri = + sharingUri(authority) + + private fun sharingUri(authority: String): Uri = + mockk { + every { scheme } returns "content" + every { this@mockk.authority } returns authority + } + val ongoingCall = Call( CommonTopAppBarViewModelTest.conversationId, CallStatus.ESTABLISHED, diff --git a/app/src/test/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModelTest.kt index 1c840676ba9..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 @@ -193,6 +193,66 @@ class ImportMediaAuthenticatedViewModelTest { } } + @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")) @@ -376,6 +436,10 @@ class ImportMediaAuthenticatedViewModelTest { 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, From dae7bae87ff56c7aa8e1e9dd2cc9db70bf4a45b5 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:04:51 +0200 Subject: [PATCH 6/9] detekt --- .../wire/android/ui/home/conversations/ConversationScreen.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 5dc37062861..c6f94769604 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -195,8 +195,6 @@ 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 import com.wire.kalium.logic.data.conversation.InteractionAvailability import com.wire.kalium.logic.data.id.ConversationId From de959376a23faeebe8a0c8bed341f277b03cb0bb Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:17:52 +0200 Subject: [PATCH 7/9] fix tests --- .../kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt b/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt index 25b453687d3..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 = {}, ) } } From 3b4136bfd747775af5b89402851d2100f7457f90 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:09:38 +0200 Subject: [PATCH 8/9] adapt design changes --- .../com/wire/android/ui/debug/LogOptions.kt | 60 ++++++++----------- .../android/ui/edit/ShareAssetMenuOption.kt | 37 +++--------- .../home/conversations/ConversationScreen.kt | 3 +- .../ui/home/gallery/MediaGalleryScreen.kt | 17 +----- .../kotlin/com/wire/android/util/FileUtil.kt | 2 +- .../wire/android/util/logging/LogSharing.kt | 3 +- app/src/main/res/drawable/ic_forward.xml | 26 ++++++++ app/src/main/res/drawable/ic_share.xml | 42 +++++++++++-- app/src/main/res/values/strings.xml | 3 - .../cells/src/main/res/drawable/ic_share.xml | 35 ++++++++++- kalium | 2 +- 11 files changed, 136 insertions(+), 94 deletions(-) create mode 100644 app/src/main/res/drawable/ic_forward.xml diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/LogOptions.kt b/app/src/main/kotlin/com/wire/android/ui/debug/LogOptions.kt index f66e223b0f2..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 @@ -51,7 +51,6 @@ import com.wire.android.ui.home.settings.SettingsItem import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography -import com.wire.android.util.supportsTrustedWireShareCaller import com.wire.android.util.ui.PreviewMultipleThemes @Composable @@ -67,14 +66,7 @@ fun LogOptions( modifier: Modifier = Modifier ) { val shareLogsSheetState = rememberWireModalSheetState() - val shareLogsDirectly = supportsTrustedWireShareCaller() - val onShareLogsClick: () -> Unit = { - if (shareLogsDirectly) { - onShareLogsExternally() - } else { - shareLogsSheetState.show() - } - } + val onShareLogsClick: () -> Unit = { shareLogsSheetState.show() } Column(modifier = modifier) { SectionHeader(stringResource(R.string.label_logs_option_title)) @@ -92,7 +84,7 @@ fun LogOptions( if (isLoggingEnabled) { SettingsItem( text = stringResource(R.string.label_share_logs), - trailingIcon = R.drawable.ic_entypo_share, + trailingIcon = R.drawable.ic_share, onRowPressed = Clickable( enabled = true, onClick = onShareLogsClick @@ -114,26 +106,24 @@ fun LogOptions( } } - if (!shareLogsDirectly) { - WireModalSheetLayout( - sheetState = shareLogsSheetState, - sheetContent = { - WireMenuModalSheetContent( - header = MenuModalSheetHeader.Visible( - title = stringResource(R.string.label_share_logs) - ), - menuItems = shareLogsMenuItems( - onShareLogsExternally = { - shareLogsSheetState.hide { onShareLogsExternally() } - }, - onShareLogsViaWire = { - shareLogsSheetState.hide { onShareLogsViaWire() } - } - ) + 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( @@ -150,11 +140,11 @@ private fun ShareLogsInWireOption(onClick: () -> 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_logs_via_wire), + title = stringResource(R.string.label_share_via_wire), onItemClick = onClick ) } @@ -164,11 +154,11 @@ private fun ShareLogsExternallyOption(onClick: () -> Unit) { MenuBottomSheetItem( leading = { MenuItemIcon( - id = R.drawable.ic_entypo_share, - contentDescription = stringResource(R.string.content_description_share_the_file), + id = R.drawable.ic_share, + contentDescription = stringResource(R.string.label_share), ) }, - title = stringResource(R.string.label_share_logs_externally), + title = stringResource(R.string.label_share), onItemClick = onClick ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/edit/ShareAssetMenuOption.kt b/app/src/main/kotlin/com/wire/android/ui/edit/ShareAssetMenuOption.kt index ab6f8c63b4b..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 @@ -22,42 +22,23 @@ import androidx.compose.ui.res.stringResource import com.wire.android.R import com.wire.android.ui.common.bottomsheet.MenuBottomSheetItem import com.wire.android.ui.common.bottomsheet.MenuItemIcon -import com.wire.android.util.supportsTrustedWireShareCaller - -@Composable -fun ShareAssetMenuOption(onShareAsset: () -> Unit) { - MenuBottomSheetItem( - leading = { - MenuItemIcon( - id = R.drawable.ic_share_file, - contentDescription = stringResource(R.string.content_description_share_the_file), - ) - }, - title = stringResource(R.string.label_share), - onItemClick = onShareAsset - ) -} fun shareAssetMenuOptions( onShareAssetExternally: () -> Unit, onShareAssetViaWire: () -> Unit ): List<@Composable () -> Unit> = - if (supportsTrustedWireShareCaller()) { - listOf({ ShareAssetMenuOption(onShareAssetExternally) }) - } else { - listOf( - { ShareAssetViaWireMenuOption(onShareAssetViaWire) }, - { ShareAssetExternallyMenuOption(onShareAssetExternally) } - ) - } + listOf( + { ShareAssetViaWireMenuOption(onShareAssetViaWire) }, + { ShareAssetExternallyMenuOption(onShareAssetExternally) } + ) @Composable fun ShareAssetViaWireMenuOption(onShareAsset: () -> Unit) { MenuBottomSheetItem( leading = { MenuItemIcon( - id = R.drawable.ic_share_file, - contentDescription = stringResource(R.string.content_description_share_the_file), + id = R.drawable.ic_forward, + contentDescription = stringResource(R.string.label_share_via_wire), ) }, title = stringResource(R.string.label_share_via_wire), @@ -70,11 +51,11 @@ fun ShareAssetExternallyMenuOption(onShareAsset: () -> Unit) { MenuBottomSheetItem( leading = { MenuItemIcon( - id = R.drawable.ic_share_file, - contentDescription = stringResource(R.string.content_description_share_the_file), + id = R.drawable.ic_share, + contentDescription = stringResource(R.string.label_share), ) }, - title = stringResource(R.string.label_share_externally), + title = stringResource(R.string.label_share), onItemClick = onShareAsset ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index c6f94769604..14180e062b6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -256,7 +256,8 @@ fun ConversationScreen( ) { val coroutineScope = rememberCoroutineScope() val uriHandler = LocalUriHandler.current - val resources = LocalContext.current.resources + val context = LocalContext.current + val resources = context.resources val showDialog = remember { mutableStateOf(ConversationScreenDialogType.NONE) } val messageComposerViewState = messageComposerViewModel.messageComposerViewState val messageComposerStateHolder = rememberMessageComposerStateHolder( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt index ccb0fd8821a..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 @@ -56,7 +56,6 @@ 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 @@ -70,7 +69,6 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.util.fileShareUri import com.wire.android.util.permission.rememberWriteStoragePermissionFlow import com.wire.android.util.startFileShareIntent -import com.wire.android.util.supportsTrustedWireShareCaller import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.SnackBarMessageHandler import com.wire.android.util.openDownloadFolder @@ -254,9 +252,6 @@ private fun MediaGalleryOptionsBottomSheetLayout( val sheetState: WireModalSheetState = rememberWireModalSheetState(WireSheetValue.Expanded(Unit)) val onOptionsClick: (MenuIntent) -> Unit = remember { { sheetState.hide { onMenuIntent(it) } } } - val collapseShareOptions = supportsTrustedWireShareCaller() && - menuItems.contains(MediaGalleryMenuItem.SHARE_VIA_WIRE) && - menuItems.contains(MediaGalleryMenuItem.SHARE_EXTERNALLY) val menuItems: List<@Composable () -> Unit> = buildList { menuItems.forEach { item -> @@ -273,17 +268,11 @@ private fun MediaGalleryOptionsBottomSheetLayout( MediaGalleryMenuItem.DOWNLOAD -> add { DownloadAssetExternallyOption { onOptionsClick(MenuIntent.Download) } } - MediaGalleryMenuItem.SHARE_EXTERNALLY -> if (!collapseShareOptions) { - add { - ShareAssetExternallyMenuOption { onOptionsClick(MenuIntent.ShareExternally) } - } + MediaGalleryMenuItem.SHARE_EXTERNALLY -> add { + ShareAssetExternallyMenuOption { onOptionsClick(MenuIntent.ShareExternally) } } MediaGalleryMenuItem.SHARE_VIA_WIRE -> add { - if (collapseShareOptions) { - ShareAssetMenuOption { onOptionsClick(MenuIntent.ShareExternally) } - } else { - ShareAssetViaWireMenuOption { onOptionsClick(MenuIntent.ShareViaWire) } - } + ShareAssetViaWireMenuOption { onOptionsClick(MenuIntent.ShareViaWire) } } MediaGalleryMenuItem.SHARE_PUBLIC_LINK -> add { SharePublicLinkMenuOption { onOptionsClick(MenuIntent.ShareExternally) } diff --git a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt index 2ab209eefaa..ebf0b16be87 100644 --- a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt +++ b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt @@ -279,7 +279,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) - startShareIntentWithTrustedWireTarget(shareIntent) + startActivity(externalShareChooserIntent(shareIntent)) } fun Context.fileShareUri(path: Path, assetName: String?): Uri = 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 11869938d83..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 @@ -29,7 +29,6 @@ import com.wire.android.util.getDeviceIdString import com.wire.android.util.getGitBuildId import com.wire.android.util.sha256 import com.wire.android.util.shareableFileProviderUri -import com.wire.android.util.startShareIntentWithTrustedWireTarget import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -67,7 +66,7 @@ class LogShareLauncher( logsDirectory = logsDirectory, flushLogs = flushLogs, shareArchive = { archive -> - context.startShareIntentWithTrustedWireTarget(context.logsSharingIntent(archive)) + context.startActivity(context.externalShareChooserIntent(context.logsSharingIntent(archive))) } ) } 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 990759a6e2b..a96582dc055 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -795,7 +795,6 @@ Copy text Share Forward - Share externally Edit text Delete Message copied @@ -1344,8 +1343,6 @@ 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/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/kalium b/kalium index fe5d4317394..402f0d2ac84 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit fe5d4317394e67dbbbf5cdabd9869e3211b692ab +Subproject commit 402f0d2ac84424d5caa040cc02284ca2cd83da2c From e88c975e3c3688ead59921f62057bf5bb2b8505d Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:24:24 +0200 Subject: [PATCH 9/9] debugStabilityDump --- core/search/stability/search-debug.stability | 3 ++- .../stability/ui-common-debug.stability | 23 ++++++++++++++++--- .../stability/meetings-debug.stability | 7 ++++++ 3 files changed, 29 insertions(+), 4 deletions(-) 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/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