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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:66bb008da1a5e258a021eb669d76b26c03217bd4cd3a09aeef7a6ab3247caab1
|
||||
size 6782
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0d29041afd1cbb103c5d426c7ea8a74d00fd86646c862800426bf37ed0befd59
|
||||
size 6776
|
||||
Loading…
Add table
Add a link
Reference in a new issue