Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.oauth.api.OAuthAction
import io.element.android.libraries.oauth.api.OAuthActionFlow
import io.element.android.libraries.push.api.notifications.conversations.conversationShortcutRoomId
import io.element.android.libraries.sessionstorage.api.LoggedInState
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.ui.common.nodes.emptyNode
Expand Down Expand Up @@ -438,6 +439,16 @@ class RootFlowNode(
}

private suspend fun onIncomingShare(shareIntentData: ShareIntentData) {
// The share sheet named the conversation, so there is no account to choose.
val shortcutSessionId = shareIntentData.shortcutId?.let { shortcutId ->
sessionStore.getAllSessions()
.map { SessionId(it.userId) }
.firstOrNull { conversationShortcutRoomId(shortcutId, it) != null }
}
if (shortcutSessionId != null) {
attachSession(shortcutSessionId).attachIncomingShare(shareIntentData)
return
}
// Is there a session already?
val latestSessionId = sessionStore.getLatestSessionId()
if (latestSessionId == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,30 @@ import kotlinx.parcelize.Parcelize
* Share intent data, mapped from the original [android.content.Intent].
*/
sealed interface ShareIntentData : Parcelable {
/**
* The id of the shortcut the user picked in the Direct Share row of the system share sheet, or `null`
* when they shared to the app itself and still have to choose a conversation.
*/
val shortcutId: String?

/**
* 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
data class Uris(
val text: String?,
val uris: List<UriToShare>,
override val shortcutId: String? = null,
) : ShareIntentData

/**
* A plain text to share.
*/
@Parcelize
data class PlainText(val content: String) : ShareIntentData
data class PlainText(
val content: String,
override val shortcutId: String? = null,
) : ShareIntentData
}

/**
Expand Down
1 change: 1 addition & 0 deletions features/share/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.mediaupload.api)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ class DefaultShareIntentHandler(
): ShareIntentData? {
val type = intent.resolveType(context) ?: return null
val uris = getIncomingUris(intent, type)
val shortcutId = intent.getStringExtra(Intent.EXTRA_SHORTCUT_ID)
return when {
uris.isEmpty() && type == MimeTypes.PlainText -> handlePlainText(intent)
uris.isEmpty() && type == MimeTypes.PlainText -> handlePlainText(intent, shortcutId)
uris.isNotEmpty() ||
type.isMimeTypeImage() ||
type.isMimeTypeVideo() ||
Expand All @@ -53,16 +54,20 @@ class DefaultShareIntentHandler(
ShareIntentData.Uris(
text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.takeIf { it.isNotEmpty() },
uris = uris,
shortcutId = shortcutId,
)
}
else -> null
}
}

private fun handlePlainText(intent: Intent): ShareIntentData.PlainText? {
private fun handlePlainText(intent: Intent, shortcutId: String?): ShareIntentData.PlainText? {
val content = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()
return if (content?.isNotEmpty() == true) {
ShareIntentData.PlainText(content)
ShareIntentData.PlainText(
content = content,
shortcutId = shortcutId,
)
} else {
null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
package io.element.android.features.share.impl

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import dev.zacsweers.metro.Assisted
Expand All @@ -26,6 +27,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory
import io.element.android.libraries.push.api.notifications.conversations.conversationShortcutRoomId
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -55,6 +57,15 @@ class SharePresenter(

@Composable
override fun present(): ShareState {
val pickedRoomId = shareIntentData.shortcutId?.let { conversationShortcutRoomId(it, matrixClient.sessionId) }
LaunchedEffect(Unit) {
// Send to the room picked in the share sheet, unless a configuration change is replaying this
// effect over a send already in flight. Anything else falls back to room selection.
if (pickedRoomId != null && shareActionState.value is AsyncAction.Uninitialized) {
onRoomSelected(listOf(pickedRoomId))
}
}

fun handleEvent(event: ShareEvent) {
when (event) {
ShareEvent.ClearError -> shareActionState.value = AsyncAction.Uninitialized
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import com.google.common.truth.Truth.assertWithMessage
import io.element.android.features.share.api.ShareIntentData
import io.element.android.features.share.api.UriToShare
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.push.api.notifications.conversations.createConversationShortcutId
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
import org.robolectric.Robolectric
Expand Down Expand Up @@ -247,6 +250,42 @@ class DefaultShareIntentHandlerTest : RobolectricTest() {
)
}

@Test
fun `a conversation picked in the share sheet is carried on the shared text`() {
val intent = Intent(Intent.ACTION_SEND).apply {
type = MimeTypes.PlainText
putExtra(Intent.EXTRA_TEXT, "a message")
putExtra(Intent.EXTRA_SHORTCUT_ID, createConversationShortcutId(A_SESSION_ID, A_ROOM_ID))
}

val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent)

assertThat(result).isEqualTo(
ShareIntentData.PlainText(
content = "a message",
shortcutId = createConversationShortcutId(A_SESSION_ID, A_ROOM_ID),
)
)
}

@Test
fun `a conversation picked in the share sheet is carried on shared uris`() {
val uri = "content://sender/image.jpg".toUri()
val intent = aSendIntent(type = "image/jpeg", uri = uri).apply {
putExtra(Intent.EXTRA_SHORTCUT_ID, createConversationShortcutId(A_SESSION_ID, A_ROOM_ID))
}

val result = createDefaultShareIntentHandler().handleIncomingShareIntent(intent)

assertThat(result).isEqualTo(
ShareIntentData.Uris(
text = null,
uris = listOf(UriToShare(uri = uri, mimeType = "image/jpeg")),
shortcutId = createConversationShortcutId(A_SESSION_ID, A_ROOM_ID),
)
)
}

private fun aSendIntent(type: String, uri: Uri) = Intent(Intent.ACTION_SEND).apply {
this.type = type
putExtra(Intent.EXTRA_STREAM, uri)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.test.FakeMediaSender
import io.element.android.libraries.push.api.notifications.conversations.createConversationShortcutId
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
import io.element.android.tests.testutils.WarmUpRule
Expand Down Expand Up @@ -97,6 +100,58 @@ class SharePresenterTest : RobolectricTest() {
}
}

@Test
fun `present - a room picked in the share sheet is shared to without selecting one`() = runTest {
val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
},
)
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, joinedRoom)
}
val presenter = createSharePresenter(
matrixClient = matrixClient,
shareIntentData = ShareIntentData.PlainText(
content = A_MESSAGE,
shortcutId = createConversationShortcutId(A_SESSION_ID, A_ROOM_ID),
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
assertThat(awaitItem().shareAction.isUninitialized()).isTrue()
assertThat(awaitItem().shareAction.isLoading()).isTrue()
assertThat(awaitItem().shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
}
}

@Test
fun `present - a room picked for a session that is no longer attached is not shared to`() = runTest {
val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
},
)
val matrixClient = FakeMatrixClient(sessionId = A_SESSION_ID_2).apply {
givenGetRoomResult(A_ROOM_ID, joinedRoom)
}
val presenter = createSharePresenter(
matrixClient = matrixClient,
shareIntentData = ShareIntentData.PlainText(
content = A_MESSAGE,
// Picked for A_SESSION_ID, but A_SESSION_ID_2 is handling the share.
shortcutId = createConversationShortcutId(A_SESSION_ID, A_ROOM_ID),
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
assertThat(awaitItem().shareAction.isUninitialized()).isTrue()
expectNoEvents()
}
}

@Test
fun `present - send text ok`() = runTest {
val joinedRoom = FakeJoinedRoom(
Expand Down
4 changes: 4 additions & 0 deletions libraries/push/api/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import extension.testCommonDependencies

/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
Expand All @@ -22,4 +24,6 @@ dependencies {
implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.pushproviders.api)

testCommonDependencies(libs)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* 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.libraries.push.api.notifications.conversations

import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId

/**
* Build the id of the conversation shortcut for a room. The id is part of the contract with the system, which
* hands it back in [android.content.Intent.EXTRA_SHORTCUT_ID] when the user shares to that shortcut.
*/
fun createConversationShortcutId(sessionId: SessionId, roomId: RoomId) = "$sessionId-$roomId"

/**
* The room [shortcutId] was built for, or `null` when it is not the id of a conversation shortcut of [sessionId].
*/
fun conversationShortcutRoomId(shortcutId: String, sessionId: SessionId): RoomId? {
val prefix = "$sessionId-"
if (!shortcutId.startsWith(prefix)) return null
// Another app can send any id in the share intent, so what follows the prefix is not necessarily a room id.
return shortcutId.removePrefix(prefix).takeIf { MatrixPatterns.isRoomId(it) }?.let(::RoomId)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* 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.libraries.push.api.notifications.conversations

import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import org.junit.Test

class ConversationShortcutIdTest {
@Test
fun `roomId - resolves the room an id was built for, dashes in the domains included`() {
val sessionId = SessionId("@alice:my-server.example.org")
val roomId = RoomId("!a-room:other-server.example.org")

val result = conversationShortcutRoomId(createConversationShortcutId(sessionId, roomId), sessionId)

assertThat(result).isEqualTo(roomId)
}

@Test
fun `roomId - returns null for another session, or for an id the app did not build`() {
val sessionId = SessionId("@alice:example.org")

assertThat(conversationShortcutRoomId("", sessionId)).isNull()
assertThat(conversationShortcutRoomId("@bob:example.org-!aRoom:example.org", sessionId)).isNull()
assertThat(conversationShortcutRoomId("@alice:example.org.uk-!aRoom:example.org", sessionId)).isNull()
assertThat(conversationShortcutRoomId("@alice:example.org-aRoom:example.org", sessionId)).isNull()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
import io.element.android.libraries.push.api.notifications.conversations.conversationShortcutRoomId
import io.element.android.libraries.push.api.notifications.conversations.createConversationShortcutId
import io.element.android.libraries.push.impl.intent.IntentProvider
import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId
import io.element.android.libraries.push.impl.notifications.shortcut.filterBySession
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
Expand Down Expand Up @@ -112,7 +113,7 @@ class DefaultNotificationConversationService(
targetSize = defaultShortcutIconSize.toLong()
)?.let(IconCompat::createWithBitmap)

val shortcutInfo = ShortcutInfoCompat.Builder(context, createShortcutId(sessionId, roomId))
val shortcutInfo = ShortcutInfoCompat.Builder(context, createConversationShortcutId(sessionId, roomId))
.setShortLabel(name)
.setIcon(icon)
.setIntent(intentProvider.getViewRoomIntent(sessionId, roomId, threadId = null, eventId = null))
Expand All @@ -133,7 +134,7 @@ class DefaultNotificationConversationService(
}

override suspend fun onLeftRoom(sessionId: SessionId, roomId: RoomId) {
val shortcutsToRemove = listOf(createShortcutId(sessionId, roomId))
val shortcutsToRemove = listOf(createConversationShortcutId(sessionId, roomId))
runCatchingExceptions {
ShortcutManagerCompat.removeDynamicShortcuts(context, shortcutsToRemove)
if (isRequestPinShortcutSupported) {
Expand All @@ -150,16 +151,12 @@ class DefaultNotificationConversationService(

override suspend fun onAvailableRoomsChanged(sessionId: SessionId, roomIds: Set<RoomId>) {
runCatchingExceptions {
val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context)

val shortcutsToRemove = mutableListOf<String>()
shortcuts.filter { it.id.startsWith(sessionId.value) }
.forEach { shortcut ->
val roomId = RoomId(shortcut.id.removePrefix("$sessionId-"))
if (!roomIds.contains(roomId)) {
shortcutsToRemove.add(shortcut.id)
}
val shortcutsToRemove = ShortcutManagerCompat.getDynamicShortcuts(context)
.filter { shortcut ->
val roomId = conversationShortcutRoomId(shortcut.id, sessionId)
roomId != null && roomId !in roomIds
}
.map { it.id }

if (shortcutsToRemove.isNotEmpty()) {
ShortcutManagerCompat.removeDynamicShortcuts(context, shortcutsToRemove)
Expand Down
Loading
Loading