Add shortcut suggestions for rooms, remove then when leaving (#5180)

* Report shortcut usage for outgoing messages

This patch adds support for creating and pushing dynamic
long-lived shortcuts for outgoing messages. This together
with an existing reference to the roomId used by the
shortcuts as an identifer allows conversations to be
prioritized.

See https://developer.android.com/training/sharing/direct-share-targets#report-usage-outgoing

* Simplify how to get the other user in a DM room

* Add initial avatar icons to shortcuts

* Remove room shortcuts when they're no longer joined

* Try using API 33 for the new tests. They worked locally with API 30, so it's weird the CI asks for a higher API version.

* Add observers for the pin code and session logout states. With this we can prevent new shortcuts from being created and remove existing ones when needed.

* Wrap all calls to `ShortcutManagerCompat` with `runCatchingExceptions` to avoid crashes

* Make `DefaultNotificationConversationService` a singleton.

---------

Co-authored-by: networkException <git@nwex.de>
Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2025-08-19 16:02:51 +02:00 committed by GitHub
parent 35928e3630
commit 9bc2c4a776
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 681 additions and 27 deletions

View file

@ -81,6 +81,7 @@ import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
@ -121,6 +122,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
private val sessionEnterpriseService: SessionEnterpriseService,
private val networkMonitor: NetworkMonitor,
private val notificationConversationService: NotificationConversationService,
snackbarDispatcher: SnackbarDispatcher,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
@ -206,6 +208,12 @@ class LoggedInFlowNode @AssistedInject constructor(
}
.launchIn(lifecycleScope)
},
onResume = {
lifecycleScope.launch {
val availableRoomIds = matrixClient.getJoinedRoomIds().getOrNull() ?: return@launch
notificationConversationService.onAvailableRoomsChanged(sessionId = matrixClient.sessionId, roomIds = availableRoomIds)
}
},
onDestroy = {
appNavigationStateService.onLeavingSpace(id)
appNavigationStateService.onLeavingSession(id)

View file

@ -24,6 +24,7 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.push.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
@ -32,5 +33,6 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.tests.testutils)
}

View file

@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@ -33,6 +34,7 @@ import javax.inject.Inject
class LeaveRoomPresenter @Inject constructor(
private val client: MatrixClient,
private val dispatchers: CoroutineDispatchers,
private val notificationConversationService: NotificationConversationService,
) : Presenter<LeaveRoomState> {
@Composable
override fun present(): LeaveRoomState {
@ -78,6 +80,7 @@ class LeaveRoomPresenter @Inject constructor(
client.getRoom(roomId)!!.use { room ->
room
.leave()
.onSuccess { notificationConversationService.onLeftRoom(client.sessionId, roomId) }
.onFailure { Timber.e(it, "Error while leaving room ${room.roomId}") }
.getOrThrow()
}

View file

@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
@ -209,4 +210,5 @@ private fun TestScope.createLeaveRoomPresenter(
): LeaveRoomPresenter = LeaveRoomPresenter(
client = client,
dispatchers = testCoroutineDispatchers(false),
notificationConversationService = FakeNotificationConversationService(),
)

View file

@ -50,6 +50,7 @@ dependencies {
implementation(projects.libraries.voiceplayer.api)
implementation(projects.libraries.voicerecorder.api)
implementation(projects.libraries.mediaplayer.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.testtags)
implementation(projects.features.networkmonitor.api)
@ -76,6 +77,7 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.features.location.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.messages.test)

View file

@ -52,11 +52,14 @@ import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.getDirectRoomMember
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaSender
@ -64,6 +67,7 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
@ -118,6 +122,7 @@ class MessageComposerPresenter @AssistedInject constructor(
private val pillificationHelper: TextPillificationHelper,
private val suggestionsProcessor: SuggestionsProcessor,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val notificationConversationService: NotificationConversationService,
) : Presenter<MessageComposerState> {
@AssistedFactory
interface Factory {
@ -466,6 +471,18 @@ class MessageComposerPresenter @AssistedInject constructor(
}
}
}
val roomInfo = room.info()
val roomMembers = room.membersStateFlow.value
notificationConversationService.onSendMessage(
sessionId = room.sessionId,
roomId = roomInfo.id,
roomName = roomInfo.name ?: roomInfo.id.value,
roomIsDirect = roomInfo.isDm,
roomAvatarUrl = roomInfo.avatarUrl ?: roomMembers.getDirectRoomMember(roomInfo = roomInfo, sessionId = room.sessionId)?.avatarUrl,
)
analyticsService.capture(
Composer(
inThread = capturedMode.inThread,

View file

@ -85,6 +85,7 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenterFac
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
@ -128,6 +129,7 @@ class MessageComposerPresenterTest {
private val mockMediaUrl: Uri = mockk("localMediaUri")
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl)
private val analyticsService = FakeAnalyticsService()
private val notificationConversationService = FakeNotificationConversationService()
@Test
fun `present - initial state`() = runTest {
@ -1578,6 +1580,7 @@ class MessageComposerPresenterTest {
pillificationHelper = textPillificationHelper,
suggestionsProcessor = SuggestionsProcessor(),
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
notificationConversationService = notificationConversationService,
).apply {
isTesting = true
showTextFormatting = isRichTextEditorEnabled

View file

@ -53,6 +53,7 @@ interface MatrixClient {
suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom?
suspend fun getRoom(roomId: RoomId): BaseRoom?
suspend fun findDM(userId: UserId): Result<RoomId?>
suspend fun getJoinedRoomIds(): Result<Set<RoomId>>
suspend fun ignoreUser(userId: UserId): Result<Unit>
suspend fun unignoreUser(userId: UserId): Result<Unit>
suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId>

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.api.room
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.collections.immutable.ImmutableList
@Immutable
@ -34,3 +35,9 @@ fun RoomMembersState.joinedRoomMembers(): List<RoomMember> {
fun RoomMembersState.activeRoomMembers(): List<RoomMember> {
return roomMembers().orEmpty().filter { it.membership.isActive() }
}
fun RoomMembersState.getDirectRoomMember(roomInfo: RoomInfo, sessionId: SessionId): RoomMember? {
return roomMembers()
?.takeIf { roomInfo.isDm }
?.find { it.userId != sessionId && it.membership.isActive() }
}

View file

@ -107,6 +107,7 @@ import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientException
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.Membership
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.PowerLevels
import org.matrix.rustcomponents.sdk.RoomInfoListener
@ -277,6 +278,7 @@ class RustMatrixClient(
}
override suspend fun getRoom(roomId: RoomId): BaseRoom? = withContext(sessionDispatcher) {
innerClient.rooms()
roomFactory.getBaseRoom(roomId)
}
@ -311,6 +313,15 @@ class RustMatrixClient(
}
}
override suspend fun getJoinedRoomIds(): Result<Set<RoomId>> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.rooms()
.filter { it.membership() == Membership.JOINED }
.map { RoomId(it.id()) }
.toSet()
}
}
override suspend fun ignoreUser(userId: UserId): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.ignoreUser(userId.value)

View file

@ -94,6 +94,7 @@ class FakeMatrixClient(
private val isLivekitRtcSupportedLambda: () -> Boolean = { false },
override val ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()),
private val getMaxUploadSizeResult: () -> Result<Long> = { lambdaError() },
private val getJoinedRoomIdsResult: () -> Result<Set<RoomId>> = { Result.success(emptySet()) },
) : MatrixClient {
var setDisplayNameCalled: Boolean = false
private set
@ -141,6 +142,10 @@ class FakeMatrixClient(
return findDmResult
}
override suspend fun getJoinedRoomIds(): Result<Set<RoomId>> {
return getJoinedRoomIdsResult()
}
override suspend fun ignoreUser(userId: UserId): Result<Unit> = simulateLongTask {
return ignoreUserResult(userId)
}

View file

@ -0,0 +1,137 @@
/*
* Copyright 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.libraries.matrix.ui.media
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.Typeface
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import coil3.Bitmap
import io.element.android.compound.theme.AvatarColors
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.SemanticColors
import io.element.android.compound.tokens.generated.compoundColorsDark
import io.element.android.compound.tokens.generated.compoundColorsLight
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
/**
* Generates a bitmap for an initials avatar based on the provided [AvatarData].
*/
class InitialsAvatarBitmapGenerator(
useDarkTheme: Boolean = false,
private val fontSizePercentage: Float = 0.5f,
) {
private val compoundColors: SemanticColors = if (useDarkTheme) {
compoundColorsDark
} else {
compoundColorsLight
}
// List of predefined avatar colors to use for initials avatars, in light mode
private val allAvatarColors: List<AvatarColors> = listOf(
AvatarColors(
background = compoundColors.bgDecorative1,
foreground = compoundColors.textDecorative1,
),
AvatarColors(
background = compoundColors.bgDecorative2,
foreground = compoundColors.textDecorative2,
),
AvatarColors(
background = compoundColors.bgDecorative3,
foreground = compoundColors.textDecorative3,
),
AvatarColors(
background = compoundColors.bgDecorative4,
foreground = compoundColors.textDecorative4,
),
AvatarColors(
background = compoundColors.bgDecorative5,
foreground = compoundColors.textDecorative5,
),
AvatarColors(
background = compoundColors.bgDecorative6,
foreground = compoundColors.textDecorative6,
),
)
/**
* Generates a bitmap for an avatar with no URL, using the initials from the [AvatarData].
* @param size The size of the bitmap to generate, in pixels.
* @param avatarData The [AvatarData] containing the initials and other information.
*/
fun generateBitmap(size: Int, avatarData: AvatarData): Bitmap? {
if (avatarData.url != null) {
// This generator is only for initials avatars, not for avatars with URLs
return null
}
// Get the color pair to use for the initials avatar
val avatarColors = allAvatarColors[avatarData.id.sumOf { it.code } % allAvatarColors.size]
val bitmap = createBitmap(size, size)
Canvas(bitmap).run {
drawColor(avatarColors.background.toArgb())
val letter = avatarData.initialLetter
val textPaint = Paint().apply {
color = avatarColors.foreground.toArgb()
textSize = size * fontSizePercentage // Adjust text size relative to the avatar size
isAntiAlias = true
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
}
val bounds = Rect()
textPaint.getTextBounds(letter, 0, letter.length, bounds)
drawText(
letter,
size / 2f,
size.toFloat() / 2 - (textPaint.descent() + textPaint.ascent()) / 2,
textPaint
)
}
return bitmap
}
}
@Composable
@PreviewsDayNight
internal fun InitialsAvatarBitmapGeneratorPreview() = ElementPreview {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
repeat(6) { index ->
val avatarData = remember { AvatarData(id = index.toString(), name = Char('0'.code + index).toString(), size = AvatarSize.IncomingCall) }
val isLightTheme = ElementTheme.isLightTheme
val bitmap = remember(isLightTheme) {
val generator = InitialsAvatarBitmapGenerator(useDarkTheme = !isLightTheme)
generator.generateBitmap(512, avatarData)?.asImageBitmap()
}
bitmap?.let {
Image(bitmap = it, contentDescription = null, modifier = Modifier.size(48.dp))
} ?: Text("No avatar generated")
}
}
}

View file

@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.getDirectRoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
@Composable
@ -39,14 +40,10 @@ fun getRoomMemberAsState(roomMembersState: RoomMembersState, userId: UserId): St
@Composable
fun BaseRoom.getDirectRoomMember(roomMembersState: RoomMembersState): State<RoomMember?> {
val roomMembers = roomMembersState.roomMembers()
val roomInfo by roomInfoFlow.collectAsState()
return remember(roomMembersState, roomInfo.isDirect) {
return remember {
derivedStateOf {
roomMembers
?.filter { it.membership.isActive() }
?.takeIf { it.size == 2 && roomInfo.isDirect == true }
?.find { it.userId != sessionId }
roomMembersState.getDirectRoomMember(roomInfo, sessionId)
}
}
}

View file

@ -99,7 +99,7 @@ class RoomMembersTest {
val joinedRoom = FakeBaseRoom(
sessionId = A_USER_ID,
).apply {
givenRoomInfo(aRoomInfo(isDirect = true))
givenRoomInfo(aRoomInfo(isDirect = true, activeMembersCount = 3L))
}
moleculeFlow(RecompositionMode.Immediate) {
joinedRoom.getDirectRoomMember(

View file

@ -17,5 +17,6 @@ dependencies {
implementation(libs.coroutines.core)
implementation(libs.coil.compose)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.pushproviders.api)
}

View file

@ -10,14 +10,20 @@ package io.element.android.libraries.push.api.notifications
import android.graphics.Bitmap
import androidx.core.graphics.drawable.IconCompat
import coil3.ImageLoader
import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL
interface NotificationBitmapLoader {
/**
* Get icon of a room.
* @param path mxc url
* @param imageLoader Coil image loader
* @param targetSize The size we want the bitmap to be resized to
*/
suspend fun getRoomBitmap(path: String?, imageLoader: ImageLoader): Bitmap?
suspend fun getRoomBitmap(
path: String?,
imageLoader: ImageLoader,
targetSize: Long = AVATAR_THUMBNAIL_SIZE_IN_PIXEL,
): Bitmap?
/**
* Get icon of a user.

View file

@ -0,0 +1,40 @@
/*
* Copyright 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.libraries.push.api.notifications.conversations
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
/**
* Service to handle conversation-related notifications.
*/
interface NotificationConversationService {
/**
* Called when a new message is received in a room.
* It should create a new conversation shortcut for this room.
*/
suspend fun onSendMessage(
sessionId: SessionId,
roomId: RoomId,
roomName: String,
roomIsDirect: Boolean,
roomAvatarUrl: String?,
)
/**
* Called when a room is left.
* It should remove the conversation shortcut for this room.
*/
suspend fun onLeftRoom(sessionId: SessionId, roomId: RoomId)
/**
* Called when the list of available rooms changes.
* It should update the conversation shortcuts accordingly, removing shortcuts for no longer joined rooms.
*/
suspend fun onAvailableRoomsChanged(sessionId: SessionId, roomIds: Set<RoomId>)
}

View file

@ -54,6 +54,7 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.troubleshoot.api)
implementation(projects.features.call.api)
implementation(projects.features.lockscreen.api)
implementation(projects.libraries.featureflag.api)
api(projects.libraries.pushproviders.api)
api(projects.libraries.pushstore.api)
@ -78,6 +79,7 @@ dependencies {
testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.features.call.test)
testImplementation(projects.features.lockscreen.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.toolbox.impl)
testImplementation(projects.services.toolbox.test)

View file

@ -32,22 +32,17 @@ class DefaultNotificationBitmapLoader @Inject constructor(
@ApplicationContext private val context: Context,
private val sdkIntProvider: BuildVersionSdkIntProvider,
) : NotificationBitmapLoader {
/**
* Get icon of a room.
* @param path mxc url
* @param imageLoader Coil image loader
*/
override suspend fun getRoomBitmap(path: String?, imageLoader: ImageLoader): Bitmap? {
override suspend fun getRoomBitmap(path: String?, imageLoader: ImageLoader, targetSize: Long): Bitmap? {
if (path == null) {
return null
}
return loadRoomBitmap(path, imageLoader)
return loadRoomBitmap(path, imageLoader, targetSize)
}
private suspend fun loadRoomBitmap(path: String, imageLoader: ImageLoader): Bitmap? {
private suspend fun loadRoomBitmap(path: String, imageLoader: ImageLoader, targetSize: Long): Bitmap? {
return try {
val imageRequest = ImageRequest.Builder(context)
.data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL)))
.data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(targetSize)))
.transformations(CircleCropTransformation())
.build()
val result = imageLoader.execute(imageRequest)
@ -58,12 +53,6 @@ class DefaultNotificationBitmapLoader @Inject constructor(
}
}
/**
* Get icon of a user.
* Before Android P, this does nothing because the icon won't be used
* @param path mxc url
* @param imageLoader Coil image loader
*/
override suspend fun getUserIcon(path: String?, imageLoader: ImageLoader): IconCompat? {
if (path == null || sdkIntProvider.get() < Build.VERSION_CODES.P) {
return null

View file

@ -0,0 +1,197 @@
/*
* Copyright 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.libraries.push.impl.notifications.conversations
import android.content.Context
import android.content.pm.ShortcutInfo
import android.content.res.Configuration
import android.os.Build
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.libraries.core.coroutine.withPreviousValue
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.matrix.ui.media.InitialsAvatarBitmapGenerator
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.impl.intent.IntentProvider
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import javax.inject.Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultNotificationConversationService @Inject constructor(
@ApplicationContext private val context: Context,
private val intentProvider: IntentProvider,
private val bitmapLoader: NotificationBitmapLoader,
private val matrixClientProvider: MatrixClientProvider,
private val imageLoaderHolder: ImageLoaderHolder,
private val lockScreenService: LockScreenService,
sessionObserver: SessionObserver,
@AppCoroutineScope private val coroutineScope: CoroutineScope,
) : NotificationConversationService {
private val isRequestPinShortcutSupported = ShortcutManagerCompat.isRequestPinShortcutSupported(context)
init {
sessionObserver.addListener(object : SessionListener {
override suspend fun onSessionCreated(userId: String) = Unit
override suspend fun onSessionDeleted(userId: String) {
onSessionLogOut(SessionId(userId))
}
})
lockScreenService.isPinSetup()
.withPreviousValue()
.onEach { (hadPinCode, hasPinCode) ->
if (hadPinCode == false && hasPinCode) {
clearShortcuts()
}
}
.launchIn(coroutineScope)
}
override suspend fun onSendMessage(
sessionId: SessionId,
roomId: RoomId,
roomName: String,
roomIsDirect: Boolean,
roomAvatarUrl: String?,
) {
if (lockScreenService.isPinSetup().first()) {
// We don't create shortcuts when a pin code is set for privacy reasons
return
}
val categories = setOfNotNull(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION else null
)
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return
val imageLoader = imageLoaderHolder.get(client)
val defaultShortcutIconSize = ShortcutManagerCompat.getIconMaxWidth(context)
val useDarkTheme = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
val icon = bitmapLoader.getRoomBitmap(
path = roomAvatarUrl,
imageLoader = imageLoader,
targetSize = defaultShortcutIconSize.toLong()
)?.let(IconCompat::createWithBitmap)
?: InitialsAvatarBitmapGenerator(useDarkTheme = useDarkTheme)
.generateBitmap(defaultShortcutIconSize, AvatarData(id = roomId.value, name = roomName, size = AvatarSize.RoomHeader))
?.let(IconCompat::createWithAdaptiveBitmap)
val shortcutInfo = ShortcutInfoCompat.Builder(context, "$sessionId-$roomId")
.setShortLabel(roomName)
.setIcon(icon)
.setIntent(intentProvider.getViewRoomIntent(sessionId, roomId, threadId = null))
.setCategories(categories)
.setLongLived(true)
.let {
when (roomIsDirect) {
true -> it.addCapabilityBinding("actions.intent.SEND_MESSAGE")
false -> it.addCapabilityBinding("actions.intent.SEND_MESSAGE", "message.recipient.@type", listOf("Audience"))
}
}
.build()
runCatchingExceptions { ShortcutManagerCompat.pushDynamicShortcut(context, shortcutInfo) }
.onFailure {
Timber.e(it, "Failed to create shortcut for room $roomId in session $sessionId")
}
}
override suspend fun onLeftRoom(sessionId: SessionId, roomId: RoomId) {
val shortcutsToRemove = listOf("$sessionId-$roomId")
runCatchingExceptions {
ShortcutManagerCompat.removeDynamicShortcuts(context, shortcutsToRemove)
if (isRequestPinShortcutSupported) {
ShortcutManagerCompat.disableShortcuts(
context,
shortcutsToRemove,
context.getString(CommonStrings.common_android_shortcuts_remove_reason_left_room)
)
}
}.onFailure {
Timber.e(it, "Failed to remove shortcut for room $roomId in session $sessionId")
}
}
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)
}
}
if (shortcutsToRemove.isNotEmpty()) {
ShortcutManagerCompat.removeDynamicShortcuts(context, shortcutsToRemove)
if (isRequestPinShortcutSupported) {
ShortcutManagerCompat.disableShortcuts(
context,
shortcutsToRemove,
context.getString(CommonStrings.common_android_shortcuts_remove_reason_left_room)
)
}
}
}.onFailure {
Timber.e(it, "Failed to remove shortcuts for session $sessionId")
}
}
private fun clearShortcuts() {
runCatchingExceptions {
ShortcutManagerCompat.removeAllDynamicShortcuts(context)
}.onFailure {
Timber.e(it, "Failed to clear all shortcuts")
}
}
private fun onSessionLogOut(sessionId: SessionId) {
runCatchingExceptions {
val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context)
val shortcutIdsToRemove = shortcuts.filter { it.id.startsWith(sessionId.value) }.map { it.id }
ShortcutManagerCompat.removeDynamicShortcuts(context, shortcutIdsToRemove)
if (isRequestPinShortcutSupported) {
ShortcutManagerCompat.disableShortcuts(
context,
shortcutIdsToRemove,
context.getString(CommonStrings.common_android_shortcuts_remove_reason_session_logged_out)
)
}
}.onFailure {
Timber.e(it, "Failed to remove shortcuts for session $sessionId after logout")
}
}
}

View file

@ -0,0 +1,190 @@
/*
* Copyright 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.libraries.push.impl.notifications.conversations
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.test.FakeLockScreenService
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
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.FakeMatrixClientProvider
import io.element.android.libraries.push.impl.notifications.factories.FakeIntentProvider
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
class DefaultNotificationConversationServiceTest {
@Test
fun `onSendMessage adds a shortcut`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val service = createService(context)
service.onSendMessage(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
roomName = "Room title",
roomIsDirect = false,
roomAvatarUrl = null,
)
val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context)
assertThat(shortcuts).isNotEmpty()
}
@Test
fun `onLeftRoom removes a shortcut`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val service = createService(context)
val shortcutId = "$A_SESSION_ID-$A_ROOM_ID"
val shortcutInfo = ShortcutInfoCompat.Builder(context, shortcutId)
.setShortLabel("Room title")
.setIntent(Intent(Intent.ACTION_VIEW))
.build()
// First we add the shortcut
ShortcutManagerCompat.pushDynamicShortcut(context, shortcutInfo)
assertThat(ShortcutManagerCompat.getDynamicShortcuts(context).firstOrNull()?.id).isEqualTo(shortcutId)
service.onLeftRoom(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
)
// Then we check it's removed
val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context)
assertThat(shortcuts).isEmpty()
}
@Test
fun `onAvailableRoomsChanged keeps only the available rooms as shortcuts`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val service = createService(context)
// We add a couple of shortcuts
val shortcutInfoA = ShortcutInfoCompat.Builder(context, "$A_SESSION_ID-$A_ROOM_ID")
.setShortLabel("Room title")
.setIntent(Intent(Intent.ACTION_VIEW))
.build()
val shortcutInfoB = ShortcutInfoCompat.Builder(context, "$A_SESSION_ID-$A_ROOM_ID_2")
.setShortLabel("Room title")
.setIntent(Intent(Intent.ACTION_VIEW))
.build()
ShortcutManagerCompat.setDynamicShortcuts(context, listOf(shortcutInfoA, shortcutInfoB))
assertThat(ShortcutManagerCompat.getDynamicShortcuts(context)).hasSize(2)
service.onAvailableRoomsChanged(
sessionId = A_SESSION_ID,
roomIds = setOf(A_ROOM_ID),
)
// Then we check only the shortcuts for the matching rooms remain
val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context)
assertThat(shortcuts).hasSize(1)
assertThat(shortcuts.first().id).isEqualTo("$A_SESSION_ID-$A_ROOM_ID")
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `on pin code enabled, all shortcuts are cleared`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val lockScreenService = FakeLockScreenService()
createService(context, lockScreenService = lockScreenService)
// Make sure the pin is disabled
lockScreenService.setIsPinSetup(false)
// Give the test some time to save the pin setup value
runCurrent()
// We add a couple of shortcuts from different sessions
val shortcutInfoA = ShortcutInfoCompat.Builder(context, "$A_SESSION_ID-$A_ROOM_ID")
.setShortLabel("Room title")
.setIntent(Intent(Intent.ACTION_VIEW))
.build()
val shortcutInfoB = ShortcutInfoCompat.Builder(context, "$A_SESSION_ID_2-$A_ROOM_ID_2")
.setShortLabel("Room title")
.setIntent(Intent(Intent.ACTION_VIEW))
.build()
ShortcutManagerCompat.setDynamicShortcuts(context, listOf(shortcutInfoA, shortcutInfoB))
assertThat(ShortcutManagerCompat.getDynamicShortcuts(context)).hasSize(2)
// Enable the pin code
lockScreenService.setIsPinSetup(true)
// Give the test some time to save the new pin setup value
runCurrent()
// Then we check there are no shortcuts left from any session
val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context)
assertThat(shortcuts).isEmpty()
}
@Test
fun `on session logged out, all shortcuts for the session are cleared`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val sessionObserver = FakeSessionObserver()
createService(context, sessionObserver = sessionObserver)
// Set the initial session state
sessionObserver.onSessionCreated(A_SESSION_ID.value)
sessionObserver.onSessionCreated(A_SESSION_ID_2.value)
// We add a couple of shortcuts from different sessions
val shortcutInfoA = ShortcutInfoCompat.Builder(context, "$A_SESSION_ID-$A_ROOM_ID")
.setShortLabel("Room title")
.setIntent(Intent(Intent.ACTION_VIEW))
.build()
val shortcutInfoB = ShortcutInfoCompat.Builder(context, "$A_SESSION_ID_2-$A_ROOM_ID_2")
.setShortLabel("Room title")
.setIntent(Intent(Intent.ACTION_VIEW))
.build()
ShortcutManagerCompat.setDynamicShortcuts(context, listOf(shortcutInfoA, shortcutInfoB))
assertThat(ShortcutManagerCompat.getDynamicShortcuts(context)).hasSize(2)
// A session is logged out
sessionObserver.onSessionDeleted(A_SESSION_ID.value)
// Then we check the shortcuts for the logged out session are removed, but the rest remain
val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context)
assertThat(shortcuts).hasSize(1)
assertThat(shortcuts.first().id).startsWith(A_SESSION_ID_2.value)
}
private fun TestScope.createService(
context: Context = InstrumentationRegistry.getInstrumentation().context,
sessionObserver: FakeSessionObserver = FakeSessionObserver(),
lockScreenService: FakeLockScreenService = FakeLockScreenService(),
) = DefaultNotificationConversationService(
context = context,
intentProvider = FakeIntentProvider(),
bitmapLoader = FakeNotificationBitmapLoader(),
matrixClientProvider = FakeMatrixClientProvider(),
imageLoaderHolder = FakeImageLoaderHolder(),
sessionObserver = sessionObserver,
lockScreenService = lockScreenService,
coroutineScope = backgroundScope,
)
}

View file

@ -14,5 +14,5 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.intent.IntentProvider
class FakeIntentProvider : IntentProvider {
override fun getViewRoomIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?) = Intent()
override fun getViewRoomIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?) = Intent(Intent.ACTION_VIEW)
}

View file

@ -0,0 +1,26 @@
/*
* Copyright 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.libraries.push.test.notifications.conversations
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
class FakeNotificationConversationService : NotificationConversationService {
override suspend fun onSendMessage(
sessionId: SessionId,
roomId: RoomId,
roomName: String,
roomIsDirect: Boolean,
roomAvatarUrl: String?,
) = Unit
override suspend fun onLeftRoom(sessionId: SessionId, roomId: RoomId) = Unit
override suspend fun onAvailableRoomsChanged(sessionId: SessionId, roomIds: Set<RoomId>) = Unit
}

View file

@ -13,11 +13,11 @@ import coil3.ImageLoader
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
class FakeNotificationBitmapLoader(
var getRoomBitmapResult: (String?, ImageLoader) -> Bitmap? = { _, _ -> null },
var getRoomBitmapResult: (String?, ImageLoader, Long) -> Bitmap? = { _, _, _ -> null },
var getUserIconResult: (String?, ImageLoader) -> IconCompat? = { _, _ -> null },
) : NotificationBitmapLoader {
override suspend fun getRoomBitmap(path: String?, imageLoader: ImageLoader): Bitmap? {
return getRoomBitmapResult(path, imageLoader)
override suspend fun getRoomBitmap(path: String?, imageLoader: ImageLoader, targetSize: Long): Bitmap? {
return getRoomBitmapResult(path, imageLoader, targetSize)
}
override suspend fun getUserIcon(path: String?, imageLoader: ImageLoader): IconCompat? {

View file

@ -167,6 +167,8 @@
<string name="common_advanced_settings">"Advanced settings"</string>
<string name="common_an_image">"an image"</string>
<string name="common_analytics">"Analytics"</string>
<string name="common_android_shortcuts_remove_reason_left_room">"You left the room"</string>
<string name="common_android_shortcuts_remove_reason_session_logged_out">"You were logged out of the session"</string>
<string name="common_appearance">"Appearance"</string>
<string name="common_audio">"Audio"</string>
<string name="common_blocked_users">"Blocked users"</string>

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:66bb008da1a5e258a021eb669d76b26c03217bd4cd3a09aeef7a6ab3247caab1
size 6782

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0d29041afd1cbb103c5d426c7ea8a74d00fd86646c862800426bf37ed0befd59
size 6776