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
This commit is contained in:
Jorge Martin Espinosa
2026-03-03 14:12:33 +01:00
committed by GitHub
parent 7c97ec1155
commit 9c8757e38b
20 changed files with 285 additions and 141 deletions

View File

@@ -57,6 +57,7 @@ dependencies {
testCommonDependencies(libs) testCommonDependencies(libs)
testImplementation(projects.features.login.test) testImplementation(projects.features.login.test)
testImplementation(projects.features.share.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oidc.test) testImplementation(projects.libraries.oidc.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)

View File

@@ -8,7 +8,6 @@
package io.element.android.appnav package io.element.android.appnav
import android.content.Intent
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable 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.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.share.api.ShareEntryPoint 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.startchat.api.StartChatEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
@@ -307,7 +307,7 @@ class LoggedInFlowNode(
data object RoomDirectory : NavTarget data object RoomDirectory : NavTarget
@Parcelize @Parcelize
data class IncomingShare(val intent: Intent) : NavTarget data class IncomingShare(val shareIntentData: ShareIntentData) : NavTarget
@Parcelize @Parcelize
data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget
@@ -570,7 +570,7 @@ class LoggedInFlowNode(
shareEntryPoint.createNode( shareEntryPoint.createNode(
parentNode = this, parentNode = this,
buildContext = buildContext, buildContext = buildContext,
params = ShareEntryPoint.Params(intent = navTarget.intent), params = ShareEntryPoint.Params(shareIntentData = navTarget.shareIntentData),
callback = object : ShareEntryPoint.Callback { callback = object : ShareEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) { override fun onDone(roomIds: List<RoomId>) {
// Remove the incoming share screen // 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 -> waitForNavTargetAttached { navTarget ->
navTarget is NavTarget.Home navTarget is NavTarget.Home
} }
attachChild<Node> { attachChild<Node> {
backstack.push( backstack.push(
NavTarget.IncomingShare(intent) NavTarget.IncomingShare(shareIntentData)
) )
} }
} }

View File

@@ -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.LoginParams
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint 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.features.signedout.api.SignedOutEntryPoint
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
@@ -265,7 +266,7 @@ class RootFlowNode(
@Parcelize data class AccountSelect( @Parcelize data class AccountSelect(
val currentSessionId: SessionId, val currentSessionId: SessionId,
val intent: Intent?, val shareIntentData: ShareIntentData?,
val permalinkData: PermalinkData?, val permalinkData: PermalinkData?,
) : NavTarget ) : NavTarget
@@ -357,8 +358,8 @@ class RootFlowNode(
backstack.pop() backstack.pop()
} }
attachSession(sessionId).apply { attachSession(sessionId).apply {
if (navTarget.intent != null) { if (navTarget.shareIntentData != null) {
attachIncomingShare(navTarget.intent) attachIncomingShare(navTarget.shareIntentData)
} else if (navTarget.permalinkData != null) { } else if (navTarget.permalinkData != null) {
attachPermalinkData(navTarget.permalinkData) attachPermalinkData(navTarget.permalinkData)
} }
@@ -392,7 +393,7 @@ class RootFlowNode(
is ResolvedIntent.Login -> onLoginLink(resolvedIntent.params) is ResolvedIntent.Login -> onLoginLink(resolvedIntent.params)
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction) is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData) 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? // Is there a session already?
val latestSessionId = sessionStore.getLatestSessionId() val latestSessionId = sessionStore.getLatestSessionId()
if (latestSessionId == null) { if (latestSessionId == null) {
@@ -437,13 +438,13 @@ class RootFlowNode(
backstack.push( backstack.push(
NavTarget.AccountSelect( NavTarget.AccountSelect(
currentSessionId = latestSessionId, currentSessionId = latestSessionId,
intent = intent, shareIntentData = shareIntentData,
permalinkData = null, permalinkData = null,
) )
) )
} else { } else {
// Only one account, directly attach the incoming share node. // Only one account, directly attach the incoming share node.
loggedInFlowNode.attachIncomingShare(intent) loggedInFlowNode.attachIncomingShare(shareIntentData)
} }
} }
} }
@@ -467,7 +468,7 @@ class RootFlowNode(
backstack.push( backstack.push(
NavTarget.AccountSelect( NavTarget.AccountSelect(
currentSessionId = latestSessionId, currentSessionId = latestSessionId,
intent = null, shareIntentData = null,
permalinkData = permalinkData, permalinkData = permalinkData,
) )
) )

View File

@@ -12,6 +12,8 @@ import android.content.Intent
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.login.api.LoginIntentResolver import io.element.android.features.login.api.LoginIntentResolver
import io.element.android.features.login.api.LoginParams 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.DeeplinkData
import io.element.android.libraries.deeplink.api.DeeplinkParser import io.element.android.libraries.deeplink.api.DeeplinkParser
import io.element.android.libraries.matrix.api.permalink.PermalinkData 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 Oidc(val oidcAction: OidcAction) : ResolvedIntent
data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent
data class Login(val params: LoginParams) : ResolvedIntent data class Login(val params: LoginParams) : ResolvedIntent
data class IncomingShare(val intent: Intent) : ResolvedIntent data class IncomingShare(val shareIntentData: ShareIntentData) : ResolvedIntent
} }
@Inject @Inject
@@ -34,6 +36,7 @@ class IntentResolver(
private val loginIntentResolver: LoginIntentResolver, private val loginIntentResolver: LoginIntentResolver,
private val oidcIntentResolver: OidcIntentResolver, private val oidcIntentResolver: OidcIntentResolver,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val shareIntentHandler: ShareIntentHandler,
) { ) {
fun resolve(intent: Intent): ResolvedIntent? { fun resolve(intent: Intent): ResolvedIntent? {
if (intent.canBeIgnored()) return null if (intent.canBeIgnored()) return null
@@ -62,7 +65,8 @@ class IntentResolver(
if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData) if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData)
if (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) { 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 // Unknown intent

View File

@@ -15,6 +15,9 @@ import androidx.core.net.toUri
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.test.FakeLoginIntentResolver 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.deeplink.api.DeeplinkData
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
@@ -239,26 +242,34 @@ class IntentResolverTest {
@Test @Test
fun `test incoming share simple`() { fun `test incoming share simple`() {
val shareIntentData = ShareIntentData.PlainText("Hello")
val sut = createIntentResolver( val sut = createIntentResolver(
oidcIntentResolverResult = { null }, oidcIntentResolverResult = { null },
onIncomingShareIntent = { shareIntentData },
) )
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_SEND action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, "Hello")
} }
val result = sut.resolve(intent) val result = sut.resolve(intent)
assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(intent = intent)) assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(shareIntentData))
} }
@Test @Test
fun `test incoming share multiple`() { 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( val sut = createIntentResolver(
oidcIntentResolverResult = { null }, oidcIntentResolverResult = { null },
onIncomingShareIntent = { shareIntentData },
) )
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_SEND_MULTIPLE action = Intent.ACTION_SEND_MULTIPLE
putExtra(Intent.EXTRA_TEXT, "Hello")
data = fileUri
} }
val result = sut.resolve(intent) val result = sut.resolve(intent)
assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(intent = intent)) assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(shareIntentData))
} }
@Test @Test
@@ -296,6 +307,7 @@ class IntentResolverTest {
permalinkParserResult: (String) -> PermalinkData = { lambdaError() }, permalinkParserResult: (String) -> PermalinkData = { lambdaError() },
loginIntentResolverResult: (String) -> LoginParams? = { lambdaError() }, loginIntentResolverResult: (String) -> LoginParams? = { lambdaError() },
oidcIntentResolverResult: (Intent) -> OidcAction? = { lambdaError() }, oidcIntentResolverResult: (Intent) -> OidcAction? = { lambdaError() },
onIncomingShareIntent: (Intent) -> ShareIntentData? = { null },
): IntentResolver { ): IntentResolver {
return IntentResolver( return IntentResolver(
deeplinkParser = { deeplinkParserResult }, deeplinkParser = { deeplinkParserResult },
@@ -308,6 +320,9 @@ class IntentResolverTest {
permalinkParser = FakePermalinkParser( permalinkParser = FakePermalinkParser(
result = permalinkParserResult result = permalinkParserResult
), ),
shareIntentHandler = FakeShareIntentHandler(
onIncomingShareIntent = onIncomingShareIntent,
),
) )
} }
} }

View File

@@ -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)
}

View File

@@ -8,7 +8,6 @@
package io.element.android.features.share.api package io.element.android.features.share.api
import android.content.Intent
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin 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 import io.element.android.libraries.matrix.api.core.RoomId
interface ShareEntryPoint : FeatureEntryPoint { interface ShareEntryPoint : FeatureEntryPoint {
data class Params(val intent: Intent) data class Params(val shareIntentData: ShareIntentData)
fun createNode( fun createNode(
parentNode: Node, parentNode: Node,

View File

@@ -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<UriToShare>) : 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

View File

@@ -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?
}

View File

@@ -44,6 +44,7 @@ dependencies {
api(projects.features.share.api) api(projects.features.share.api)
testCommonDependencies(libs, true) testCommonDependencies(libs, true)
testImplementation(projects.features.share.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)

View File

@@ -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<Uri>) {
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")
}
}
}
}

View File

@@ -26,7 +26,7 @@ class DefaultShareEntryPoint : ShareEntryPoint {
return parentNode.createNode<ShareNode>( return parentNode.createNode<ShareNode>(
buildContext = buildContext, buildContext = buildContext,
plugins = listOf( plugins = listOf(
ShareNode.Inputs(intent = params.intent), ShareNode.Inputs(shareIntentData = params.shareIntentData),
callback, callback,
) )
) )

View File

@@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2025 Element Creations Ltd. * Copyright (c) 2026 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details. * 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.PackageManager
import android.content.pm.ResolveInfo import android.content.pm.ResolveInfo
import android.net.Uri import android.net.Uri
import android.os.Build
import androidx.core.content.IntentCompat import androidx.core.content.IntentCompat
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding 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.androidutils.compat.queryIntentActivitiesCompat
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAny 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 io.element.android.libraries.di.annotations.ApplicationContext
import timber.log.Timber 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<UriToShare>) -> Boolean,
onPlainText: suspend (String) -> Boolean,
): Boolean
}
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultShareIntentHandler( class DefaultShareIntentHandler(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) : ShareIntentHandler { ) : ShareIntentHandler {
override suspend fun handleIncomingShareIntent( override fun handleIncomingShareIntent(
intent: Intent, intent: Intent,
onUris: suspend (List<ShareIntentHandler.UriToShare>) -> Boolean, ): ShareIntentData? {
onPlainText: suspend (String) -> Boolean, val type = intent.resolveType(context) ?: return null
): Boolean {
val type = intent.resolveType(context) ?: return false
val uris = getIncomingUris(intent, type) val uris = getIncomingUris(intent, type)
return when { return when {
uris.isEmpty() && type == MimeTypes.PlainText -> handlePlainText(intent, onPlainText) uris.isEmpty() && type == MimeTypes.PlainText -> handlePlainText(intent)
type.isMimeTypeImage() || type.isMimeTypeImage() ||
type.isMimeTypeVideo() || type.isMimeTypeVideo() ||
type.isMimeTypeAudio() || type.isMimeTypeAudio() ||
@@ -68,20 +49,21 @@ class DefaultShareIntentHandler(
type.isMimeTypeFile() || type.isMimeTypeFile() ||
type.isMimeTypeText() || type.isMimeTypeText() ||
type.isMimeTypeAny() -> { type.isMimeTypeAny() -> {
val result = onUris(uris) ShareIntentData.Uris(
revokeUriPermissions(uris.map { it.uri }) text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.takeIf { it.isNotEmpty() },
result 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() val content = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()
return if (content?.isNotEmpty() == true) { return if (content?.isNotEmpty() == true) {
onPlainText(content) ShareIntentData.PlainText(content)
} else { } else {
false null
} }
} }
@@ -89,7 +71,7 @@ class DefaultShareIntentHandler(
* Use this function to retrieve files which are shared from another application or internally * 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. * by using android.intent.action.SEND or android.intent.action.SEND_MULTIPLE actions.
*/ */
private fun getIncomingUris(intent: Intent, fallbackMimeType: String): List<ShareIntentHandler.UriToShare> { private fun getIncomingUris(intent: Intent, fallbackMimeType: String): List<UriToShare> {
val uriList = mutableListOf<Uri>() val uriList = mutableListOf<Uri>()
if (intent.action == Intent.ACTION_SEND) { if (intent.action == Intent.ACTION_SEND) {
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) 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 // 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. // in the same intent (i.e. 'image/*'). We need to check the mime type of each uri.
val mimeType = context.contentResolver.getType(uri) ?: fallbackMimeType val mimeType = context.contentResolver.getType(uri) ?: fallbackMimeType
ShareIntentHandler.UriToShare( UriToShare(
uri = uri, uri = uri,
mimeType = mimeType, mimeType = mimeType,
) )
} }
} }
private fun revokeUriPermissions(uris: List<Uri>) {
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")
}
}
}
} }

View File

@@ -8,7 +8,6 @@
package io.element.android.features.share.impl package io.element.android.features.share.impl
import android.content.Intent
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -23,6 +22,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.share.api.ShareEntryPoint 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.NodeInputs
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
@@ -50,10 +50,10 @@ class ShareNode(
@Parcelize @Parcelize
object NavTarget : Parcelable object NavTarget : Parcelable
data class Inputs(val intent: Intent) : NodeInputs data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs
private val inputs = inputs<Inputs>() private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.intent) private val presenter = presenterFactory.create(inputs.shareIntentData)
private val callback: ShareEntryPoint.Callback = callback() private val callback: ShareEntryPoint.Callback = callback()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {

View File

@@ -8,13 +8,14 @@
package io.element.android.features.share.impl package io.element.android.features.share.impl
import android.content.Intent
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject 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.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
@@ -32,24 +33,24 @@ import kotlin.coroutines.cancellation.CancellationException
@AssistedInject @AssistedInject
class SharePresenter( class SharePresenter(
@Assisted private val intent: Intent, @Assisted private val shareIntentData: ShareIntentData,
@SessionCoroutineScope @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
private val shareIntentHandler: ShareIntentHandler,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val mediaSenderRoomFactory: MediaSenderRoomFactory, private val mediaSenderRoomFactory: MediaSenderRoomFactory,
private val activeRoomsHolder: ActiveRoomsHolder, private val activeRoomsHolder: ActiveRoomsHolder,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val onSharedData: OnSharedData,
) : Presenter<ShareState> { ) : Presenter<ShareState> {
@AssistedFactory @AssistedFactory
fun interface Factory { fun interface Factory {
fun create(intent: Intent): SharePresenter fun create(shareIntentData: ShareIntentData): SharePresenter
} }
private val shareActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized) private val shareActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
fun onRoomSelected(roomIds: List<RoomId>) { fun onRoomSelected(roomIds: List<RoomId>) {
sessionCoroutineScope.share(intent, roomIds) sessionCoroutineScope.share(shareIntentData, roomIds)
} }
@Composable @Composable
@@ -73,13 +74,24 @@ class SharePresenter(
} }
private fun CoroutineScope.share( private fun CoroutineScope.share(
intent: Intent, shareIntentData: ShareIntentData,
roomIds: List<RoomId>, roomIds: List<RoomId>,
) = launch { ) = launch {
suspend { suspend {
val result = shareIntentHandler.handleIncomingShareIntent( val result = when (shareIntentData) {
intent, is ShareIntentData.PlainText -> {
onUris = { filesToShare -> 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()) { if (filesToShare.isEmpty()) {
false false
} else { } else {
@@ -90,6 +102,7 @@ class SharePresenter(
filesToShare filesToShare
.map { fileToShare -> .map { fileToShare ->
val result = mediaSender.sendMedia( val result = mediaSender.sendMedia(
caption = shareIntentData.text,
uri = fileToShare.uri, uri = fileToShare.uri,
mimeType = fileToShare.mimeType, mimeType = fileToShare.mimeType,
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
@@ -113,19 +126,12 @@ class SharePresenter(
} }
.all { it } .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) { if (!result) {
error("Failed to handle incoming share intent") error("Failed to handle incoming share intent")
} }

View File

@@ -8,13 +8,14 @@
package io.element.android.features.share.impl package io.element.android.features.share.impl
import android.content.Intent
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.share.api.ShareEntryPoint 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.api.core.RoomId
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode import io.element.android.tests.testutils.node.TestParentNode
@@ -44,7 +45,7 @@ class DefaultShareEntryPointTest {
override fun onDone(roomIds: List<RoomId>) = lambdaError() override fun onDone(roomIds: List<RoomId>) = lambdaError()
} }
val params = ShareEntryPoint.Params( val params = ShareEntryPoint.Params(
intent = Intent(), shareIntentData = ShareIntentData.PlainText(A_MESSAGE),
) )
val result = entryPoint.createNode( val result = entryPoint.createNode(
parentNode = parentNode, parentNode = parentNode,
@@ -53,7 +54,7 @@ class DefaultShareEntryPointTest {
callback = callback, callback = callback,
) )
assertThat(result).isInstanceOf(ShareNode::class.java) 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) assertThat(result.plugins).contains(callback)
} }
} }

View File

@@ -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<ShareIntentHandler.UriToShare>) -> Boolean,
suspend (String) -> Boolean,
) -> Boolean = { _, _, _ -> false },
) : ShareIntentHandler {
override suspend fun handleIncomingShareIntent(
intent: Intent,
onUris: suspend (List<ShareIntentHandler.UriToShare>) -> Boolean,
onPlainText: suspend (String) -> Boolean,
): Boolean {
return onIncomingShareIntent(intent, onUris, onPlainText)
}
}

View File

@@ -8,12 +8,14 @@
package io.element.android.features.share.impl package io.element.android.features.share.impl
import android.content.Intent
import android.net.Uri import android.net.Uri
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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.architecture.AsyncAction
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@@ -72,8 +74,17 @@ class SharePresenterTest {
@Test @Test
fun `present - on room selected ok`() = runTest { 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( val presenter = createSharePresenter(
shareIntentHandler = FakeShareIntentHandler { _, _, _ -> true } matrixClient = matrixClient,
shareIntentData = ShareIntentData.PlainText(A_MESSAGE),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@@ -100,9 +111,7 @@ class SharePresenterTest {
} }
val presenter = createSharePresenter( val presenter = createSharePresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
shareIntentHandler = FakeShareIntentHandler { _, _, onText -> shareIntentData = ShareIntentData.PlainText(A_MESSAGE),
onText(A_MESSAGE)
}
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@@ -131,16 +140,15 @@ class SharePresenterTest {
) )
val presenter = createSharePresenter( val presenter = createSharePresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
shareIntentHandler = FakeShareIntentHandler { _, onFile, _ -> shareIntentData = ShareIntentData.Uris(
onFile( text = A_MESSAGE,
listOf( listOf(
ShareIntentHandler.UriToShare( UriToShare(
uri = Uri.parse("content://image.jpg"), uri = Uri.parse("content://image.jpg"),
mimeType = MimeTypes.Jpeg, mimeType = MimeTypes.Jpeg,
)
) )
) )
}, ),
mediaSenderRoomFactory = MediaSenderRoomFactory { mediaSender }, mediaSenderRoomFactory = MediaSenderRoomFactory { mediaSender },
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@@ -159,20 +167,20 @@ class SharePresenterTest {
} }
internal fun TestScope.createSharePresenter( internal fun TestScope.createSharePresenter(
intent: Intent = Intent(), shareIntentData: ShareIntentData = ShareIntentData.PlainText(A_MESSAGE),
shareIntentHandler: ShareIntentHandler = FakeShareIntentHandler(),
matrixClient: MatrixClient = FakeMatrixClient(), matrixClient: MatrixClient = FakeMatrixClient(),
activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(), activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(),
mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { FakeMediaSender() }, mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { FakeMediaSender() },
mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
onSharedData: OnSharedData = OnSharedData {},
): SharePresenter { ): SharePresenter {
return SharePresenter( return SharePresenter(
intent = intent, shareIntentData = shareIntentData,
sessionCoroutineScope = this, sessionCoroutineScope = this,
shareIntentHandler = shareIntentHandler,
matrixClient = matrixClient, matrixClient = matrixClient,
activeRoomsHolder = activeRoomsHolder, activeRoomsHolder = activeRoomsHolder,
mediaSenderRoomFactory = mediaSenderRoomFactory, mediaSenderRoomFactory = mediaSenderRoomFactory,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
onSharedData = onSharedData,
) )
} }

View File

@@ -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)
}

View File

@@ -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)
}
}