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

@ -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(