From 9bc2c4a776a6c8e2228424704cbefa04f4dce999 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 19 Aug 2025 16:02:51 +0200 Subject: [PATCH] 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 Co-authored-by: ElementBot --- .../android/appnav/LoggedInFlowNode.kt | 8 + features/leaveroom/impl/build.gradle.kts | 2 + .../leaveroom/impl/LeaveRoomPresenter.kt | 3 + .../impl/LeaveBaseRoomPresenterTest.kt | 2 + features/messages/impl/build.gradle.kts | 2 + .../MessageComposerPresenter.kt | 17 ++ .../MessageComposerPresenterTest.kt | 3 + .../libraries/matrix/api/MatrixClient.kt | 1 + .../matrix/api/room/RoomMembersState.kt | 7 + .../libraries/matrix/impl/RustMatrixClient.kt | 11 + .../libraries/matrix/test/FakeMatrixClient.kt | 5 + .../ui/media/InitialsAvatarBitmapGenerator.kt | 137 ++++++++++++ .../matrix/ui/room/MatrixRoomMembers.kt | 9 +- .../matrix/ui/room/RoomMembersTest.kt | 2 +- libraries/push/api/build.gradle.kts | 1 + .../notifications/NotificationBitmapLoader.kt | 8 +- .../NotificationConversationService.kt | 40 ++++ libraries/push/impl/build.gradle.kts | 2 + .../DefaultNotificationBitmapLoader.kt | 19 +- .../DefaultNotificationConversationService.kt | 197 ++++++++++++++++++ ...aultNotificationConversationServiceTest.kt | 190 +++++++++++++++++ .../factories/FakeIntentProvider.kt | 2 +- .../FakeNotificationConversationService.kt | 26 +++ .../push/FakeNotificationBitmapLoader.kt | 6 +- .../src/main/res/values/localazy.xml | 2 + ...InitialsAvatarBitmapGenerator_Day_0_en.png | 3 + ...itialsAvatarBitmapGenerator_Night_0_en.png | 3 + 27 files changed, 681 insertions(+), 27 deletions(-) create mode 100644 libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/InitialsAvatarBitmapGenerator.kt create mode 100644 libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt create mode 100644 libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt create mode 100644 tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media_InitialsAvatarBitmapGenerator_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media_InitialsAvatarBitmapGenerator_Night_0_en.png diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index ae22e5aa97..f621c7a090 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -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( 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) diff --git a/features/leaveroom/impl/build.gradle.kts b/features/leaveroom/impl/build.gradle.kts index 1a31023f04..ff9eb5de95 100644 --- a/features/leaveroom/impl/build.gradle.kts +++ b/features/leaveroom/impl/build.gradle.kts @@ -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) } diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt index 2f1bab248f..d1575aac91 100644 --- a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt @@ -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 { @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() } diff --git a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt index 498edc801a..9e8c4113ae 100644 --- a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt +++ b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt @@ -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(), ) diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 00d8e3f765..1519f204f6 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -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) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index e68dc3410f..2da723746c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -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 { @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, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index d52cd2e291..84aeb2ce28 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -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 diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 46814f4800..3c0f95745e 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -53,6 +53,7 @@ interface MatrixClient { suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom? suspend fun getRoom(roomId: RoomId): BaseRoom? suspend fun findDM(userId: UserId): Result + suspend fun getJoinedRoomIds(): Result> suspend fun ignoreUser(userId: UserId): Result suspend fun unignoreUser(userId: UserId): Result suspend fun createRoom(createRoomParams: CreateRoomParameters): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembersState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembersState.kt index ab857e5d63..c83b74ac70 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembersState.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembersState.kt @@ -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 { fun RoomMembersState.activeRoomMembers(): List { 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() } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index b0ac21b19d..85c1dde7e2 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -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> = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.rooms() + .filter { it.membership() == Membership.JOINED } + .map { RoomId(it.id()) } + .toSet() + } + } + override suspend fun ignoreUser(userId: UserId): Result = withContext(sessionDispatcher) { runCatchingExceptions { innerClient.ignoreUser(userId.value) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 13b26c8137..dfc82e132d 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -94,6 +94,7 @@ class FakeMatrixClient( private val isLivekitRtcSupportedLambda: () -> Boolean = { false }, override val ignoredUsersFlow: StateFlow> = MutableStateFlow(persistentListOf()), private val getMaxUploadSizeResult: () -> Result = { lambdaError() }, + private val getJoinedRoomIdsResult: () -> Result> = { Result.success(emptySet()) }, ) : MatrixClient { var setDisplayNameCalled: Boolean = false private set @@ -141,6 +142,10 @@ class FakeMatrixClient( return findDmResult } + override suspend fun getJoinedRoomIds(): Result> { + return getJoinedRoomIdsResult() + } + override suspend fun ignoreUser(userId: UserId): Result = simulateLongTask { return ignoreUserResult(userId) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/InitialsAvatarBitmapGenerator.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/InitialsAvatarBitmapGenerator.kt new file mode 100644 index 0000000000..e0e09a07e7 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/InitialsAvatarBitmapGenerator.kt @@ -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 = 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") + } + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomMembers.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomMembers.kt index 8631066482..445fa156a1 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomMembers.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomMembers.kt @@ -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 { - 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) } } } diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/room/RoomMembersTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/room/RoomMembersTest.kt index 87deb9c323..6bf081cf1f 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/room/RoomMembersTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/room/RoomMembersTest.kt @@ -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( diff --git a/libraries/push/api/build.gradle.kts b/libraries/push/api/build.gradle.kts index 481b9e1d2b..48df34053a 100644 --- a/libraries/push/api/build.gradle.kts +++ b/libraries/push/api/build.gradle.kts @@ -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) } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt index 541e7ae2e7..d0fa4c418a 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt @@ -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. diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt new file mode 100644 index 0000000000..586aea0fae --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt @@ -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) +} diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index dbf3bbcf5a..d256666156 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -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) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt index 7aef28df67..d7d02f765c 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt @@ -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 diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt new file mode 100644 index 0000000000..9fbb67c08c --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt @@ -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) { + runCatchingExceptions { + val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context) + + val shortcutsToRemove = mutableListOf() + 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") + } + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt new file mode 100644 index 0000000000..92adfd7812 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt @@ -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, + ) +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/FakeIntentProvider.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/FakeIntentProvider.kt index 2d2fea78d4..29aade1753 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/FakeIntentProvider.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/FakeIntentProvider.kt @@ -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) } diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt new file mode 100644 index 0000000000..4076171ca6 --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt @@ -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) = Unit +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/push/FakeNotificationBitmapLoader.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/push/FakeNotificationBitmapLoader.kt index d8551dd1e1..c44837b230 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/push/FakeNotificationBitmapLoader.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/push/FakeNotificationBitmapLoader.kt @@ -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? { diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 242e7b67fa..70531b5a27 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -167,6 +167,8 @@ "Advanced settings" "an image" "Analytics" + "You left the room" + "You were logged out of the session" "Appearance" "Audio" "Blocked users" diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media_InitialsAvatarBitmapGenerator_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media_InitialsAvatarBitmapGenerator_Day_0_en.png new file mode 100644 index 0000000000..a277935539 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media_InitialsAvatarBitmapGenerator_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66bb008da1a5e258a021eb669d76b26c03217bd4cd3a09aeef7a6ab3247caab1 +size 6782 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media_InitialsAvatarBitmapGenerator_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media_InitialsAvatarBitmapGenerator_Night_0_en.png new file mode 100644 index 0000000000..9c96549e0d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.media_InitialsAvatarBitmapGenerator_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d29041afd1cbb103c5d426c7ea8a74d00fd86646c862800426bf37ed0befd59 +size 6776