From 9c8757e38b76b9d2ef6e4daae9096af6df929f29 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 3 Mar 2026 14:12:33 +0100 Subject: [PATCH] Use `ShareIntentHandler` early to avoid distributing the whole intent (#6274) * Use `ShareIntentHandler` early to avoid distributing the whole intent This would make the intent be serialized as part of `NavTarget` and could potentially lead to `TransactionTooLargeException`s. We now pass a new `ShareIntentData` class around, containing the minimum amount of data needed. We also have a new `OnSharedData` post-processor to revoke uri access after they've been shared. * Move `UriToShare` next to `ShareIntentData` and add docs --- appnav/build.gradle.kts | 1 + .../android/appnav/LoggedInFlowNode.kt | 10 +-- .../io/element/android/appnav/RootFlowNode.kt | 17 ++--- .../android/appnav/intent/IntentResolver.kt | 8 ++- .../appnav/intent/IntentResolverTest.kt | 19 +++++- .../features/share/api/OnSharedData.kt | 15 ++++ .../features/share/api/ShareEntryPoint.kt | 3 +- .../features/share/api/ShareIntentData.kt | 38 +++++++++++ .../features/share/api/ShareIntentHandler.kt | 21 ++++++ features/share/impl/build.gradle.kts | 1 + .../share/impl/DefaultOnSharedData.kt | 50 ++++++++++++++ .../share/impl/DefaultShareEntryPoint.kt | 2 +- ...andler.kt => DefaultShareIntentHandler.kt} | 68 +++++-------------- .../android/features/share/impl/ShareNode.kt | 6 +- .../features/share/impl/SharePresenter.kt | 48 +++++++------ .../share/impl/DefaultShareEntryPointTest.kt | 7 +- .../share/impl/FakeShareIntentHandler.kt | 27 -------- .../features/share/impl/SharePresenterTest.kt | 42 +++++++----- features/share/test/build.gradle.kts | 21 ++++++ .../share/test/FakeShareIntentHandler.kt | 22 ++++++ 20 files changed, 285 insertions(+), 141 deletions(-) create mode 100644 features/share/api/src/main/kotlin/io/element/android/features/share/api/OnSharedData.kt create mode 100644 features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentData.kt create mode 100644 features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentHandler.kt create mode 100644 features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultOnSharedData.kt rename features/share/impl/src/main/kotlin/io/element/android/features/share/impl/{ShareIntentHandler.kt => DefaultShareIntentHandler.kt} (69%) delete mode 100644 features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareIntentHandler.kt create mode 100644 features/share/test/build.gradle.kts create mode 100644 features/share/test/src/main/kotlin/io/element/android/features/share/test/FakeShareIntentHandler.kt diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 8bc821f933..ecac391216 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { testCommonDependencies(libs) testImplementation(projects.features.login.test) + testImplementation(projects.features.share.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.oidc.test) testImplementation(projects.libraries.preferences.test) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 2fe9e970c4..a676df1d32 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -8,7 +8,6 @@ package io.element.android.appnav -import android.content.Intent import android.os.Parcelable import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable @@ -63,6 +62,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.share.api.ShareEntryPoint +import io.element.android.features.share.api.ShareIntentData import io.element.android.features.startchat.api.StartChatEntryPoint import io.element.android.features.userprofile.api.UserProfileEntryPoint import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint @@ -307,7 +307,7 @@ class LoggedInFlowNode( data object RoomDirectory : NavTarget @Parcelize - data class IncomingShare(val intent: Intent) : NavTarget + data class IncomingShare(val shareIntentData: ShareIntentData) : NavTarget @Parcelize data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget @@ -570,7 +570,7 @@ class LoggedInFlowNode( shareEntryPoint.createNode( parentNode = this, buildContext = buildContext, - params = ShareEntryPoint.Params(intent = navTarget.intent), + params = ShareEntryPoint.Params(shareIntentData = navTarget.shareIntentData), callback = object : ShareEntryPoint.Callback { override fun onDone(roomIds: List) { // Remove the incoming share screen @@ -649,13 +649,13 @@ class LoggedInFlowNode( } } - internal suspend fun attachIncomingShare(intent: Intent) { + internal suspend fun attachIncomingShare(shareIntentData: ShareIntentData) { waitForNavTargetAttached { navTarget -> navTarget is NavTarget.Home } attachChild { backstack.push( - NavTarget.IncomingShare(intent) + NavTarget.IncomingShare(shareIntentData) ) } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index f18fc138c1..745ab390b2 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -44,6 +44,7 @@ import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint +import io.element.android.features.share.api.ShareIntentData import io.element.android.features.signedout.api.SignedOutEntryPoint import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint import io.element.android.libraries.architecture.BackstackView @@ -265,7 +266,7 @@ class RootFlowNode( @Parcelize data class AccountSelect( val currentSessionId: SessionId, - val intent: Intent?, + val shareIntentData: ShareIntentData?, val permalinkData: PermalinkData?, ) : NavTarget @@ -357,8 +358,8 @@ class RootFlowNode( backstack.pop() } attachSession(sessionId).apply { - if (navTarget.intent != null) { - attachIncomingShare(navTarget.intent) + if (navTarget.shareIntentData != null) { + attachIncomingShare(navTarget.shareIntentData) } else if (navTarget.permalinkData != null) { attachPermalinkData(navTarget.permalinkData) } @@ -392,7 +393,7 @@ class RootFlowNode( is ResolvedIntent.Login -> onLoginLink(resolvedIntent.params) is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction) is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData) - is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.intent) + is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.shareIntentData) } } @@ -423,7 +424,7 @@ class RootFlowNode( } } - private suspend fun onIncomingShare(intent: Intent) { + private suspend fun onIncomingShare(shareIntentData: ShareIntentData) { // Is there a session already? val latestSessionId = sessionStore.getLatestSessionId() if (latestSessionId == null) { @@ -437,13 +438,13 @@ class RootFlowNode( backstack.push( NavTarget.AccountSelect( currentSessionId = latestSessionId, - intent = intent, + shareIntentData = shareIntentData, permalinkData = null, ) ) } else { // Only one account, directly attach the incoming share node. - loggedInFlowNode.attachIncomingShare(intent) + loggedInFlowNode.attachIncomingShare(shareIntentData) } } } @@ -467,7 +468,7 @@ class RootFlowNode( backstack.push( NavTarget.AccountSelect( currentSessionId = latestSessionId, - intent = null, + shareIntentData = null, permalinkData = permalinkData, ) ) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt index 3e26130c78..6844db3ed6 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt @@ -12,6 +12,8 @@ import android.content.Intent import dev.zacsweers.metro.Inject import io.element.android.features.login.api.LoginIntentResolver import io.element.android.features.login.api.LoginParams +import io.element.android.features.share.api.ShareIntentData +import io.element.android.features.share.api.ShareIntentHandler import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.deeplink.api.DeeplinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkData @@ -25,7 +27,7 @@ sealed interface ResolvedIntent { data class Oidc(val oidcAction: OidcAction) : ResolvedIntent data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent data class Login(val params: LoginParams) : ResolvedIntent - data class IncomingShare(val intent: Intent) : ResolvedIntent + data class IncomingShare(val shareIntentData: ShareIntentData) : ResolvedIntent } @Inject @@ -34,6 +36,7 @@ class IntentResolver( private val loginIntentResolver: LoginIntentResolver, private val oidcIntentResolver: OidcIntentResolver, private val permalinkParser: PermalinkParser, + private val shareIntentHandler: ShareIntentHandler, ) { fun resolve(intent: Intent): ResolvedIntent? { if (intent.canBeIgnored()) return null @@ -62,7 +65,8 @@ class IntentResolver( if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData) if (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) { - return ResolvedIntent.IncomingShare(intent) + val data = shareIntentHandler.handleIncomingShareIntent(intent) ?: return null + return ResolvedIntent.IncomingShare(data) } // Unknown intent diff --git a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt index bf673602c1..576e1aaea6 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt @@ -15,6 +15,9 @@ import androidx.core.net.toUri import com.google.common.truth.Truth.assertThat import io.element.android.features.login.api.LoginParams import io.element.android.features.login.test.FakeLoginIntentResolver +import io.element.android.features.share.api.ShareIntentData +import io.element.android.features.share.api.UriToShare +import io.element.android.features.share.test.FakeShareIntentHandler import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkData @@ -239,26 +242,34 @@ class IntentResolverTest { @Test fun `test incoming share simple`() { + val shareIntentData = ShareIntentData.PlainText("Hello") val sut = createIntentResolver( oidcIntentResolverResult = { null }, + onIncomingShareIntent = { shareIntentData }, ) val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, "Hello") } val result = sut.resolve(intent) - assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(intent = intent)) + assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(shareIntentData)) } @Test fun `test incoming share multiple`() { + val fileUri = "content://com.example.app/file1.jpg".toUri() + val shareIntentData = ShareIntentData.Uris(text = "Hello", uris = listOf(UriToShare(fileUri, "image/jpg"))) val sut = createIntentResolver( oidcIntentResolverResult = { null }, + onIncomingShareIntent = { shareIntentData }, ) val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { action = Intent.ACTION_SEND_MULTIPLE + putExtra(Intent.EXTRA_TEXT, "Hello") + data = fileUri } val result = sut.resolve(intent) - assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(intent = intent)) + assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(shareIntentData)) } @Test @@ -296,6 +307,7 @@ class IntentResolverTest { permalinkParserResult: (String) -> PermalinkData = { lambdaError() }, loginIntentResolverResult: (String) -> LoginParams? = { lambdaError() }, oidcIntentResolverResult: (Intent) -> OidcAction? = { lambdaError() }, + onIncomingShareIntent: (Intent) -> ShareIntentData? = { null }, ): IntentResolver { return IntentResolver( deeplinkParser = { deeplinkParserResult }, @@ -308,6 +320,9 @@ class IntentResolverTest { permalinkParser = FakePermalinkParser( result = permalinkParserResult ), + shareIntentHandler = FakeShareIntentHandler( + onIncomingShareIntent = onIncomingShareIntent, + ), ) } } diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/OnSharedData.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/OnSharedData.kt new file mode 100644 index 0000000000..d7985e8b06 --- /dev/null +++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/OnSharedData.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.api + +/** + * Post-processing to be done once a [ShareIntentData] has been consumed. + */ +fun interface OnSharedData { + operator fun invoke(data: ShareIntentData) +} diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt index 4d8eef9116..c83d4fbdc6 100644 --- a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt +++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt @@ -8,7 +8,6 @@ package io.element.android.features.share.api -import android.content.Intent import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin @@ -16,7 +15,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.matrix.api.core.RoomId interface ShareEntryPoint : FeatureEntryPoint { - data class Params(val intent: Intent) + data class Params(val shareIntentData: ShareIntentData) fun createNode( parentNode: Node, diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentData.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentData.kt new file mode 100644 index 0000000000..e5407c57d9 --- /dev/null +++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentData.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.api + +import android.net.Uri +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Share intent data, mapped from the original [android.content.Intent]. + */ +sealed interface ShareIntentData : Parcelable { + /** + * A list of [Uri]s to share and their mime types, with an optional [text] to be used as caption. + */ + @Parcelize + data class Uris(val text: String?, val uris: List) : ShareIntentData + + /** + * A plain text to share. + */ + @Parcelize + data class PlainText(val content: String) : ShareIntentData +} + +/** + * A [Uri] coming from an external share intent, with its associated [mimeType]. + */ +@Parcelize +data class UriToShare( + val uri: Uri, + val mimeType: String, +) : Parcelable diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentHandler.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentHandler.kt new file mode 100644 index 0000000000..d689c57398 --- /dev/null +++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentHandler.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.api + +import android.content.Intent + +interface ShareIntentHandler { + /** + * This methods aims to handle incoming share intents and parse its data. + * + * @return the [ShareIntentData] if it could be resolved, or null. + */ + fun handleIncomingShareIntent( + intent: Intent + ): ShareIntentData? +} diff --git a/features/share/impl/build.gradle.kts b/features/share/impl/build.gradle.kts index 73748095a9..255263cf02 100644 --- a/features/share/impl/build.gradle.kts +++ b/features/share/impl/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { api(projects.features.share.api) testCommonDependencies(libs, true) + testImplementation(projects.features.share.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.preferences.test) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultOnSharedData.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultOnSharedData.kt new file mode 100644 index 0000000000..3a71f02dc3 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultOnSharedData.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.share.api.OnSharedData +import io.element.android.features.share.api.ShareIntentData +import io.element.android.libraries.di.annotations.ApplicationContext +import timber.log.Timber +import kotlin.collections.forEach + +@ContributesBinding(AppScope::class) +class DefaultOnSharedData( + @ApplicationContext private val context: Context, +) : OnSharedData { + override fun invoke(data: ShareIntentData) { + when (data) { + is ShareIntentData.PlainText -> { + // No-op, there is nothing to do for plain text intents. + } + is ShareIntentData.Uris -> { + revokeUriPermissions(data.uris.map { it.uri }) + } + } + } + + private fun revokeUriPermissions(uris: List) { + uris.forEach { uri -> + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.revokeUriPermission(context.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } else { + context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } catch (e: Exception) { + Timber.w(e, "Unable to revoke Uri permission") + } + } + } +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt index a8ae4d71c6..98d4acc472 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt @@ -26,7 +26,7 @@ class DefaultShareEntryPoint : ShareEntryPoint { return parentNode.createNode( buildContext = buildContext, plugins = listOf( - ShareNode.Inputs(intent = params.intent), + ShareNode.Inputs(shareIntentData = params.shareIntentData), callback, ) ) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandler.kt similarity index 69% rename from features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt rename to features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandler.kt index 9342ef60d4..cfa06c3a97 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareIntentHandler.kt @@ -1,6 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. @@ -14,10 +13,12 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ResolveInfo import android.net.Uri -import android.os.Build import androidx.core.content.IntentCompat import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.share.api.ShareIntentData +import io.element.android.features.share.api.ShareIntentHandler +import io.element.android.features.share.api.UriToShare import io.element.android.libraries.androidutils.compat.queryIntentActivitiesCompat import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAny @@ -30,37 +31,17 @@ import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.di.annotations.ApplicationContext import timber.log.Timber -interface ShareIntentHandler { - data class UriToShare( - val uri: Uri, - val mimeType: String, - ) - - /** - * This methods aims to handle incoming share intents. - * - * @return true if it can handle the intent data, false otherwise - */ - suspend fun handleIncomingShareIntent( - intent: Intent, - onUris: suspend (List) -> Boolean, - onPlainText: suspend (String) -> Boolean, - ): Boolean -} - @ContributesBinding(AppScope::class) class DefaultShareIntentHandler( @ApplicationContext private val context: Context, ) : ShareIntentHandler { - override suspend fun handleIncomingShareIntent( + override fun handleIncomingShareIntent( intent: Intent, - onUris: suspend (List) -> Boolean, - onPlainText: suspend (String) -> Boolean, - ): Boolean { - val type = intent.resolveType(context) ?: return false + ): ShareIntentData? { + val type = intent.resolveType(context) ?: return null val uris = getIncomingUris(intent, type) return when { - uris.isEmpty() && type == MimeTypes.PlainText -> handlePlainText(intent, onPlainText) + uris.isEmpty() && type == MimeTypes.PlainText -> handlePlainText(intent) type.isMimeTypeImage() || type.isMimeTypeVideo() || type.isMimeTypeAudio() || @@ -68,20 +49,21 @@ class DefaultShareIntentHandler( type.isMimeTypeFile() || type.isMimeTypeText() || type.isMimeTypeAny() -> { - val result = onUris(uris) - revokeUriPermissions(uris.map { it.uri }) - result + ShareIntentData.Uris( + text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.takeIf { it.isNotEmpty() }, + uris = uris, + ) } - else -> false + else -> null } } - private suspend fun handlePlainText(intent: Intent, onPlainText: suspend (String) -> Boolean): Boolean { + private fun handlePlainText(intent: Intent): ShareIntentData.PlainText? { val content = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString() return if (content?.isNotEmpty() == true) { - onPlainText(content) + ShareIntentData.PlainText(content) } else { - false + null } } @@ -89,7 +71,7 @@ class DefaultShareIntentHandler( * Use this function to retrieve files which are shared from another application or internally * by using android.intent.action.SEND or android.intent.action.SEND_MULTIPLE actions. */ - private fun getIncomingUris(intent: Intent, fallbackMimeType: String): List { + private fun getIncomingUris(intent: Intent, fallbackMimeType: String): List { val uriList = mutableListOf() if (intent.action == Intent.ACTION_SEND) { IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) @@ -118,24 +100,10 @@ class DefaultShareIntentHandler( // The value in fallbackMimeType can be wrong, especially if several uris were received // in the same intent (i.e. 'image/*'). We need to check the mime type of each uri. val mimeType = context.contentResolver.getType(uri) ?: fallbackMimeType - ShareIntentHandler.UriToShare( + UriToShare( uri = uri, mimeType = mimeType, ) } } - - private fun revokeUriPermissions(uris: List) { - uris.forEach { uri -> - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.revokeUriPermission(context.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) - } else { - context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - } catch (e: Exception) { - Timber.w(e, "Unable to revoke Uri permission") - } - } - } } diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt index b91c484ef3..0597b3b678 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt @@ -8,7 +8,6 @@ package io.element.android.features.share.impl -import android.content.Intent import android.os.Parcelable import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable @@ -23,6 +22,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.features.share.api.ShareEntryPoint +import io.element.android.features.share.api.ShareIntentData import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs @@ -50,10 +50,10 @@ class ShareNode( @Parcelize object NavTarget : Parcelable - data class Inputs(val intent: Intent) : NodeInputs + data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs private val inputs = inputs() - private val presenter = presenterFactory.create(inputs.intent) + private val presenter = presenterFactory.create(inputs.shareIntentData) private val callback: ShareEntryPoint.Callback = callback() override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt index 4a4086ed87..93a9ae3bf4 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt @@ -8,13 +8,14 @@ package io.element.android.features.share.impl -import android.content.Intent import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import io.element.android.features.share.api.OnSharedData +import io.element.android.features.share.api.ShareIntentData import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState @@ -32,24 +33,24 @@ import kotlin.coroutines.cancellation.CancellationException @AssistedInject class SharePresenter( - @Assisted private val intent: Intent, + @Assisted private val shareIntentData: ShareIntentData, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, - private val shareIntentHandler: ShareIntentHandler, private val matrixClient: MatrixClient, private val mediaSenderRoomFactory: MediaSenderRoomFactory, private val activeRoomsHolder: ActiveRoomsHolder, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, + private val onSharedData: OnSharedData, ) : Presenter { @AssistedFactory fun interface Factory { - fun create(intent: Intent): SharePresenter + fun create(shareIntentData: ShareIntentData): SharePresenter } private val shareActionState: MutableState>> = mutableStateOf(AsyncAction.Uninitialized) fun onRoomSelected(roomIds: List) { - sessionCoroutineScope.share(intent, roomIds) + sessionCoroutineScope.share(shareIntentData, roomIds) } @Composable @@ -73,13 +74,24 @@ class SharePresenter( } private fun CoroutineScope.share( - intent: Intent, + shareIntentData: ShareIntentData, roomIds: List, ) = launch { suspend { - val result = shareIntentHandler.handleIncomingShareIntent( - intent, - onUris = { filesToShare -> + val result = when (shareIntentData) { + is ShareIntentData.PlainText -> { + roomIds + .map { roomId -> + getJoinedRoom(roomId)?.liveTimeline?.sendMessage( + body = shareIntentData.content, + htmlBody = null, + intentionalMentions = emptyList(), + )?.isSuccess.orFalse() + } + .all { it } + } + is ShareIntentData.Uris -> { + val filesToShare = shareIntentData.uris if (filesToShare.isEmpty()) { false } else { @@ -90,6 +102,7 @@ class SharePresenter( filesToShare .map { fileToShare -> val result = mediaSender.sendMedia( + caption = shareIntentData.text, uri = fileToShare.uri, mimeType = fileToShare.mimeType, mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), @@ -113,19 +126,12 @@ class SharePresenter( } .all { it } } - }, - onPlainText = { text -> - roomIds - .map { roomId -> - getJoinedRoom(roomId)?.liveTimeline?.sendMessage( - body = text, - htmlBody = null, - intentionalMentions = emptyList(), - )?.isSuccess.orFalse() - } - .all { it } } - ) + } + + // Handle post-processing of shared data + onSharedData(shareIntentData) + if (!result) { error("Failed to handle incoming share intent") } diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt index 83a32929ff..459fa423ec 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt @@ -8,13 +8,14 @@ package io.element.android.features.share.impl -import android.content.Intent import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.google.common.truth.Truth.assertThat import io.element.android.features.share.api.ShareEntryPoint +import io.element.android.features.share.api.ShareIntentData import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.node.TestParentNode @@ -44,7 +45,7 @@ class DefaultShareEntryPointTest { override fun onDone(roomIds: List) = lambdaError() } val params = ShareEntryPoint.Params( - intent = Intent(), + shareIntentData = ShareIntentData.PlainText(A_MESSAGE), ) val result = entryPoint.createNode( parentNode = parentNode, @@ -53,7 +54,7 @@ class DefaultShareEntryPointTest { callback = callback, ) assertThat(result).isInstanceOf(ShareNode::class.java) - assertThat(result.plugins).contains(ShareNode.Inputs(params.intent)) + assertThat(result.plugins).contains(ShareNode.Inputs(params.shareIntentData)) assertThat(result.plugins).contains(callback) } } diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareIntentHandler.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareIntentHandler.kt deleted file mode 100644 index dbb9d1c704..0000000000 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareIntentHandler.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.share.impl - -import android.content.Intent - -class FakeShareIntentHandler( - private val onIncomingShareIntent: suspend ( - Intent, - suspend (List) -> Boolean, - suspend (String) -> Boolean, - ) -> Boolean = { _, _, _ -> false }, -) : ShareIntentHandler { - override suspend fun handleIncomingShareIntent( - intent: Intent, - onUris: suspend (List) -> Boolean, - onPlainText: suspend (String) -> Boolean, - ): Boolean { - return onIncomingShareIntent(intent, onUris, onPlainText) - } -} diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt index 0df1ed7bab..fee1278fce 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt @@ -8,12 +8,14 @@ package io.element.android.features.share.impl -import android.content.Intent import android.net.Uri import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.share.api.OnSharedData +import io.element.android.features.share.api.ShareIntentData +import io.element.android.features.share.api.UriToShare import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.MatrixClient @@ -72,8 +74,17 @@ class SharePresenterTest { @Test fun `present - on room selected ok`() = runTest { + val joinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendMessageLambda = { _, _, _ -> Result.success(Unit) } + }, + ) + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, joinedRoom) + } val presenter = createSharePresenter( - shareIntentHandler = FakeShareIntentHandler { _, _, _ -> true } + matrixClient = matrixClient, + shareIntentData = ShareIntentData.PlainText(A_MESSAGE), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -100,9 +111,7 @@ class SharePresenterTest { } val presenter = createSharePresenter( matrixClient = matrixClient, - shareIntentHandler = FakeShareIntentHandler { _, _, onText -> - onText(A_MESSAGE) - } + shareIntentData = ShareIntentData.PlainText(A_MESSAGE), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -131,16 +140,15 @@ class SharePresenterTest { ) val presenter = createSharePresenter( matrixClient = matrixClient, - shareIntentHandler = FakeShareIntentHandler { _, onFile, _ -> - onFile( - listOf( - ShareIntentHandler.UriToShare( - uri = Uri.parse("content://image.jpg"), - mimeType = MimeTypes.Jpeg, - ) + shareIntentData = ShareIntentData.Uris( + text = A_MESSAGE, + listOf( + UriToShare( + uri = Uri.parse("content://image.jpg"), + mimeType = MimeTypes.Jpeg, ) ) - }, + ), mediaSenderRoomFactory = MediaSenderRoomFactory { mediaSender }, ) moleculeFlow(RecompositionMode.Immediate) { @@ -159,20 +167,20 @@ class SharePresenterTest { } internal fun TestScope.createSharePresenter( - intent: Intent = Intent(), - shareIntentHandler: ShareIntentHandler = FakeShareIntentHandler(), + shareIntentData: ShareIntentData = ShareIntentData.PlainText(A_MESSAGE), matrixClient: MatrixClient = FakeMatrixClient(), activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(), mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { FakeMediaSender() }, mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), + onSharedData: OnSharedData = OnSharedData {}, ): SharePresenter { return SharePresenter( - intent = intent, + shareIntentData = shareIntentData, sessionCoroutineScope = this, - shareIntentHandler = shareIntentHandler, matrixClient = matrixClient, activeRoomsHolder = activeRoomsHolder, mediaSenderRoomFactory = mediaSenderRoomFactory, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, + onSharedData = onSharedData, ) } diff --git a/features/share/test/build.gradle.kts b/features/share/test/build.gradle.kts new file mode 100644 index 0000000000..e4a987c113 --- /dev/null +++ b/features/share/test/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.share.test" +} + +dependencies { + implementation(projects.features.share.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.tests.testutils) +} diff --git a/features/share/test/src/main/kotlin/io/element/android/features/share/test/FakeShareIntentHandler.kt b/features/share/test/src/main/kotlin/io/element/android/features/share/test/FakeShareIntentHandler.kt new file mode 100644 index 0000000000..9fe04735fd --- /dev/null +++ b/features/share/test/src/main/kotlin/io/element/android/features/share/test/FakeShareIntentHandler.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.test + +import android.content.Intent +import io.element.android.features.share.api.ShareIntentData +import io.element.android.features.share.api.ShareIntentHandler + +class FakeShareIntentHandler( + private val onIncomingShareIntent: (Intent) -> ShareIntentData? = { null }, +) : ShareIntentHandler { + override fun handleIncomingShareIntent( + intent: Intent, + ): ShareIntentData? { + return onIncomingShareIntent(intent) + } +}