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:
parent
35928e3630
commit
9bc2c4a776
27 changed files with 681 additions and 27 deletions
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue