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")
}
}
}