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
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue