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

* Report shortcut usage for outgoing messages

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

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

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

* Add initial avatar icons to shortcuts

* Remove room shortcuts when they're no longer joined

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

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

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

* Make `DefaultNotificationConversationService` a singleton.

---------

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

View file

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

View file

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

View file

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

View file

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