forked from dsutanto/bChot-android
First Commit
This commit is contained in:
24
libraries/push/api/build.gradle.kts
Normal file
24
libraries/push/api/build.gradle.kts
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.push.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.corektx)
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixmedia.api)
|
||||
implementation(projects.libraries.pushproviders.api)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
interface GetCurrentPushProvider {
|
||||
suspend fun getCurrentPushProvider(sessionId: SessionId): String?
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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
|
||||
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.push.api.history.PushHistoryItem
|
||||
import io.element.android.libraries.pushproviders.api.Distributor
|
||||
import io.element.android.libraries.pushproviders.api.PushProvider
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface PushService {
|
||||
/**
|
||||
* Return the current push provider, or null if none.
|
||||
*/
|
||||
suspend fun getCurrentPushProvider(sessionId: SessionId): PushProvider?
|
||||
|
||||
/**
|
||||
* Return the list of push providers, available at compile time, sorted by index.
|
||||
*/
|
||||
fun getAvailablePushProviders(): List<PushProvider>
|
||||
|
||||
/**
|
||||
* Will unregister any previous pusher and register a new one with the provided [PushProvider].
|
||||
*
|
||||
* The method has effect only if the [PushProvider] is different than the current one.
|
||||
*/
|
||||
suspend fun registerWith(
|
||||
matrixClient: MatrixClient,
|
||||
pushProvider: PushProvider,
|
||||
distributor: Distributor,
|
||||
): Result<Unit>
|
||||
|
||||
/**
|
||||
* Ensure that the pusher with the current push provider and distributor is registered.
|
||||
* If there is no current config, the default push provider with the default distributor will be used.
|
||||
* Error can be [PusherRegistrationFailure].
|
||||
*/
|
||||
suspend fun ensurePusherIsRegistered(
|
||||
matrixClient: MatrixClient,
|
||||
): Result<Unit>
|
||||
|
||||
/**
|
||||
* Store the given push provider as the current one, but do not register.
|
||||
* To be used when there is no distributor available.
|
||||
*/
|
||||
suspend fun selectPushProvider(
|
||||
sessionId: SessionId,
|
||||
pushProvider: PushProvider,
|
||||
)
|
||||
|
||||
fun ignoreRegistrationError(sessionId: SessionId): Flow<Boolean>
|
||||
suspend fun setIgnoreRegistrationError(sessionId: SessionId, ignore: Boolean)
|
||||
|
||||
/**
|
||||
* Return false in case of early error.
|
||||
*/
|
||||
suspend fun testPush(sessionId: SessionId): Boolean
|
||||
|
||||
/**
|
||||
* Get a flow of total number of received Push.
|
||||
*/
|
||||
val pushCounter: Flow<Int>
|
||||
|
||||
/**
|
||||
* Get a flow of list of [PushHistoryItem].
|
||||
*/
|
||||
fun getPushHistoryItemsFlow(): Flow<List<PushHistoryItem>>
|
||||
|
||||
/**
|
||||
* Reset the push history, including the push counter.
|
||||
*/
|
||||
suspend fun resetPushHistory()
|
||||
|
||||
/**
|
||||
* Reset the battery optimization state.
|
||||
*/
|
||||
suspend fun resetBatteryOptimizationState()
|
||||
|
||||
/**
|
||||
* Notify the user that the service is un-registered.
|
||||
*/
|
||||
suspend fun onServiceUnregistered(userId: UserId)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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
|
||||
|
||||
import io.element.android.libraries.matrix.api.exception.ClientException
|
||||
|
||||
sealed class PusherRegistrationFailure : Exception() {
|
||||
class AccountNotVerified : PusherRegistrationFailure()
|
||||
class NoProvidersAvailable : PusherRegistrationFailure()
|
||||
class NoDistributorsAvailable : PusherRegistrationFailure()
|
||||
|
||||
/**
|
||||
* @param clientException the failure that occurred.
|
||||
* @param isRegisteringAgain true if the server should already have a the same pusher registered.
|
||||
*/
|
||||
class RegistrationFailure(
|
||||
val clientException: ClientException,
|
||||
val isRegisteringAgain: Boolean,
|
||||
) : PusherRegistrationFailure()
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.battery
|
||||
|
||||
sealed interface BatteryOptimizationEvents {
|
||||
data object Dismiss : BatteryOptimizationEvents
|
||||
data object RequestDisableOptimizations : BatteryOptimizationEvents
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.battery
|
||||
|
||||
data class BatteryOptimizationState(
|
||||
val shouldDisplayBanner: Boolean,
|
||||
val eventSink: (BatteryOptimizationEvents) -> Unit,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.battery
|
||||
|
||||
fun aBatteryOptimizationState(
|
||||
shouldDisplayBanner: Boolean = false,
|
||||
eventSink: (BatteryOptimizationEvents) -> Unit = {},
|
||||
) = BatteryOptimizationState(
|
||||
shouldDisplayBanner = shouldDisplayBanner,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.gateway
|
||||
|
||||
sealed class PushGatewayFailure : Exception() {
|
||||
class PusherRejected : PushGatewayFailure()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.history
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
/**
|
||||
* Data class representing a push history item.
|
||||
* @property pushDate Date (timestamp).
|
||||
* @property formattedDate Formatted date.
|
||||
* @property providerInfo Push provider name / info
|
||||
* @property eventId EventId from the push, can be null if the received data are not correct.
|
||||
* @property roomId RoomId from the push, can be null if the received data are not correct.
|
||||
* @property sessionId The session Id, can be null if the session cannot be retrieved
|
||||
* @property hasBeenResolved Result of resolving the event
|
||||
* @property comment Comment. Can contains an error message if the event could not be resolved, or other any information.
|
||||
*/
|
||||
data class PushHistoryItem(
|
||||
val pushDate: Long,
|
||||
val formattedDate: String,
|
||||
val providerInfo: String,
|
||||
val eventId: EventId?,
|
||||
val roomId: RoomId?,
|
||||
val sessionId: SessionId?,
|
||||
val hasBeenResolved: Boolean,
|
||||
val comment: String?,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import coil3.ImageLoader
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL
|
||||
|
||||
interface NotificationBitmapLoader {
|
||||
/**
|
||||
* Get icon of a room.
|
||||
* @param avatarData the data related to the Avatar
|
||||
* @param imageLoader Coil image loader
|
||||
* @param targetSize The size we want the bitmap to be resized to
|
||||
*/
|
||||
suspend fun getRoomBitmap(
|
||||
avatarData: AvatarData,
|
||||
imageLoader: ImageLoader,
|
||||
targetSize: Long = AVATAR_THUMBNAIL_SIZE_IN_PIXEL,
|
||||
): Bitmap?
|
||||
|
||||
/**
|
||||
* Get icon of a user.
|
||||
* Before Android P, this does nothing because the icon won't be used
|
||||
* @param avatarData the data related to the Avatar
|
||||
* @param imageLoader Coil image loader
|
||||
*/
|
||||
suspend fun getUserIcon(
|
||||
avatarData: AvatarData,
|
||||
imageLoader: ImageLoader,
|
||||
): IconCompat?
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
|
||||
interface NotificationCleaner {
|
||||
fun clearAllMessagesEvents(sessionId: SessionId)
|
||||
fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId)
|
||||
fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId)
|
||||
fun clearEvent(sessionId: SessionId, eventId: EventId)
|
||||
|
||||
fun clearMembershipNotificationForSession(sessionId: SessionId)
|
||||
fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import kotlin.math.abs
|
||||
|
||||
object NotificationIdProvider {
|
||||
fun getSummaryNotificationId(sessionId: SessionId): Int {
|
||||
return getOffset(sessionId) + SUMMARY_NOTIFICATION_ID
|
||||
}
|
||||
|
||||
fun getRoomMessagesNotificationId(sessionId: SessionId): Int {
|
||||
return getOffset(sessionId) + ROOM_MESSAGES_NOTIFICATION_ID
|
||||
}
|
||||
|
||||
fun getRoomEventNotificationId(sessionId: SessionId): Int {
|
||||
return getOffset(sessionId) + ROOM_EVENT_NOTIFICATION_ID
|
||||
}
|
||||
|
||||
fun getRoomInvitationNotificationId(sessionId: SessionId): Int {
|
||||
return getOffset(sessionId) + ROOM_INVITATION_NOTIFICATION_ID
|
||||
}
|
||||
|
||||
fun getFallbackNotificationId(sessionId: SessionId): Int {
|
||||
return getOffset(sessionId) + FALLBACK_NOTIFICATION_ID
|
||||
}
|
||||
|
||||
fun getForegroundServiceNotificationId(type: ForegroundServiceType): Int {
|
||||
return type.ordinal * 10 + FOREGROUND_SERVICE_NOTIFICATION_ID
|
||||
}
|
||||
|
||||
private fun getOffset(sessionId: SessionId): Int {
|
||||
// Compute a int from a string with a low risk of collision.
|
||||
return abs(sessionId.value.hashCode() % 100_000) * 10
|
||||
}
|
||||
|
||||
private const val FALLBACK_NOTIFICATION_ID = -1
|
||||
private const val SUMMARY_NOTIFICATION_ID = 0
|
||||
private const val ROOM_MESSAGES_NOTIFICATION_ID = 1
|
||||
private const val ROOM_EVENT_NOTIFICATION_ID = 2
|
||||
private const val ROOM_INVITATION_NOTIFICATION_ID = 3
|
||||
|
||||
private const val FOREGROUND_SERVICE_NOTIFICATION_ID = 4
|
||||
}
|
||||
|
||||
enum class ForegroundServiceType {
|
||||
INCOMING_CALL,
|
||||
ONGOING_CALL,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
/**
|
||||
* Handles missed calls by creating a new notification.
|
||||
*/
|
||||
interface OnMissedCallNotificationHandler {
|
||||
/**
|
||||
* Adds a missed call notification.
|
||||
*/
|
||||
suspend fun addMissedCallNotification(
|
||||
sessionId: SessionId,
|
||||
roomId: RoomId,
|
||||
eventId: EventId,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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>)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.push
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
data class NotificationEventRequest(
|
||||
val sessionId: SessionId,
|
||||
val roomId: RoomId,
|
||||
val eventId: EventId,
|
||||
val providerInfo: String,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.push
|
||||
|
||||
fun interface SyncOnNotifiableEvent {
|
||||
suspend operator fun invoke(requests: List<NotificationEventRequest>)
|
||||
}
|
||||
101
libraries/push/impl/build.gradle.kts
Normal file
101
libraries/push/impl/build.gradle.kts
Normal file
@@ -0,0 +1,101 @@
|
||||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023, 2024 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.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.sqldelight)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.push.impl"
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupDependencyInjection()
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.corektx)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(platform(libs.network.retrofit.bom))
|
||||
implementation(libs.network.retrofit)
|
||||
implementation(libs.serialization.json)
|
||||
implementation(libs.coil)
|
||||
|
||||
implementation(libs.sqldelight.driver.android)
|
||||
implementation(libs.sqlcipher)
|
||||
implementation(libs.sqlite)
|
||||
implementation(libs.sqldelight.coroutines)
|
||||
implementation(projects.libraries.encryptedDb)
|
||||
|
||||
implementation(projects.appconfig)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.dateformatter.api)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.di)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.network)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.matrixmedia.api)
|
||||
implementation(projects.features.networkmonitor.api)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.sessionStorage.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.troubleshoot.api)
|
||||
implementation(projects.libraries.workmanager.api)
|
||||
implementation(projects.features.call.api)
|
||||
implementation(projects.features.enterprise.api)
|
||||
implementation(projects.features.lockscreen.api)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
api(projects.libraries.pushproviders.api)
|
||||
api(projects.libraries.pushstore.api)
|
||||
api(projects.libraries.push.api)
|
||||
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.services.appnavstate.api)
|
||||
implementation(projects.services.toolbox.api)
|
||||
|
||||
testCommonDependencies(libs)
|
||||
testImplementation(libs.coil.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.matrixmedia.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.libraries.sessionStorage.test)
|
||||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.libraries.pushproviders.test)
|
||||
testImplementation(projects.libraries.pushstore.test)
|
||||
testImplementation(projects.libraries.troubleshoot.test)
|
||||
testImplementation(projects.libraries.workmanager.test)
|
||||
testImplementation(projects.features.call.test)
|
||||
testImplementation(projects.features.enterprise.test)
|
||||
testImplementation(projects.features.lockscreen.test)
|
||||
testImplementation(projects.features.networkmonitor.test)
|
||||
testImplementation(projects.services.appnavstate.impl)
|
||||
testImplementation(projects.services.appnavstate.test)
|
||||
testImplementation(projects.services.toolbox.impl)
|
||||
testImplementation(projects.services.toolbox.test)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
testImplementation(libs.kotlinx.collections.immutable)
|
||||
}
|
||||
|
||||
sqldelight {
|
||||
databases {
|
||||
create("PushDatabase") {
|
||||
schemaOutputDirectory = File("src/main/sqldelight/databases")
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
libraries/push/impl/src/debug/res/raw/message.mp3
Normal file
BIN
libraries/push/impl/src/debug/res/raw/message.mp3
Normal file
Binary file not shown.
34
libraries/push/impl/src/main/AndroidManifest.xml
Normal file
34
libraries/push/impl/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright (c) 2025 Element Creations Ltd.
|
||||
~ Copyright 2023 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.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<application>
|
||||
<receiver
|
||||
android:name=".notifications.TestNotificationReceiver"
|
||||
android:exported="false" />
|
||||
<receiver
|
||||
android:name=".notifications.NotificationBroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name=".notifications.NotificationsFileProvider"
|
||||
android:authorities="${applicationId}.notifications.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/notifications_provider_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.push.api.GetCurrentPushProvider
|
||||
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultGetCurrentPushProvider(
|
||||
private val pushStoreFactory: UserPushStoreFactory,
|
||||
) : GetCurrentPushProvider {
|
||||
override suspend fun getCurrentPushProvider(sessionId: SessionId): String? {
|
||||
return pushStoreFactory.getOrCreate(sessionId).getPushProviderName()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import dev.zacsweers.metro.binding
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.push.api.GetCurrentPushProvider
|
||||
import io.element.android.libraries.push.api.PushService
|
||||
import io.element.android.libraries.push.api.PusherRegistrationFailure
|
||||
import io.element.android.libraries.push.api.history.PushHistoryItem
|
||||
import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore
|
||||
import io.element.android.libraries.push.impl.store.PushDataStore
|
||||
import io.element.android.libraries.push.impl.test.TestPush
|
||||
import io.element.android.libraries.push.impl.unregistration.ServiceUnregisteredHandler
|
||||
import io.element.android.libraries.pushproviders.api.Distributor
|
||||
import io.element.android.libraries.pushproviders.api.PushProvider
|
||||
import io.element.android.libraries.pushproviders.api.RegistrationFailure
|
||||
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
|
||||
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore
|
||||
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
||||
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import timber.log.Timber
|
||||
|
||||
@ContributesBinding(AppScope::class, binding = binding<PushService>())
|
||||
@SingleIn(AppScope::class)
|
||||
class DefaultPushService(
|
||||
private val testPush: TestPush,
|
||||
private val userPushStoreFactory: UserPushStoreFactory,
|
||||
private val pushProviders: Set<@JvmSuppressWildcards PushProvider>,
|
||||
private val getCurrentPushProvider: GetCurrentPushProvider,
|
||||
private val sessionObserver: SessionObserver,
|
||||
private val pushClientSecretStore: PushClientSecretStore,
|
||||
private val pushDataStore: PushDataStore,
|
||||
private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore,
|
||||
private val serviceUnregisteredHandler: ServiceUnregisteredHandler,
|
||||
) : PushService, SessionListener {
|
||||
init {
|
||||
observeSessions()
|
||||
}
|
||||
|
||||
override suspend fun getCurrentPushProvider(sessionId: SessionId): PushProvider? {
|
||||
val currentPushProvider = getCurrentPushProvider.getCurrentPushProvider(sessionId)
|
||||
return pushProviders.find { it.name == currentPushProvider }
|
||||
}
|
||||
|
||||
override fun getAvailablePushProviders(): List<PushProvider> {
|
||||
return pushProviders
|
||||
.sortedBy { it.index }
|
||||
}
|
||||
|
||||
override suspend fun registerWith(
|
||||
matrixClient: MatrixClient,
|
||||
pushProvider: PushProvider,
|
||||
distributor: Distributor,
|
||||
): Result<Unit> {
|
||||
Timber.d("Registering with ${pushProvider.name}/${distributor.name}")
|
||||
val userPushStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId)
|
||||
val currentPushProviderName = userPushStore.getPushProviderName()
|
||||
val currentPushProvider = pushProviders.find { it.name == currentPushProviderName }
|
||||
val currentDistributorValue = currentPushProvider?.getCurrentDistributor(matrixClient.sessionId)?.value
|
||||
if (currentPushProviderName != pushProvider.name || currentDistributorValue != distributor.value) {
|
||||
// Unregister previous one if any
|
||||
currentPushProvider
|
||||
?.also { Timber.d("Unregistering previous push provider $currentPushProviderName/$currentDistributorValue") }
|
||||
?.unregister(matrixClient)
|
||||
?.onFailure {
|
||||
Timber.w(it, "Failed to unregister previous push provider")
|
||||
return Result.failure(it)
|
||||
}
|
||||
}
|
||||
// Store new value
|
||||
userPushStore.setPushProviderName(pushProvider.name)
|
||||
// Then try to register
|
||||
return pushProvider.registerWith(matrixClient, distributor)
|
||||
}
|
||||
|
||||
override suspend fun ensurePusherIsRegistered(matrixClient: MatrixClient): Result<Unit> {
|
||||
val verificationStatus = matrixClient.sessionVerificationService.sessionVerifiedStatus.first()
|
||||
if (verificationStatus != SessionVerifiedStatus.Verified) {
|
||||
return Result.failure<Unit>(PusherRegistrationFailure.AccountNotVerified())
|
||||
.also { Timber.w("Account is not verified") }
|
||||
}
|
||||
Timber.d("Ensure pusher is registered")
|
||||
val currentPushProvider = getCurrentPushProvider(matrixClient.sessionId)
|
||||
val result = if (currentPushProvider == null) {
|
||||
Timber.d("Register with the first available push provider with at least one distributor")
|
||||
val pushProvider = getAvailablePushProviders()
|
||||
.firstOrNull { it.getDistributors().isNotEmpty() }
|
||||
// Else fallback to the first available push provider (the list should never be empty)
|
||||
?: getAvailablePushProviders().firstOrNull()
|
||||
?: return Result.failure<Unit>(PusherRegistrationFailure.NoProvidersAvailable())
|
||||
.also { Timber.w("No push providers available") }
|
||||
val distributor = pushProvider.getDistributors().firstOrNull()
|
||||
?: return Result.failure<Unit>(PusherRegistrationFailure.NoDistributorsAvailable())
|
||||
.also { Timber.w("No distributors available") }
|
||||
.also {
|
||||
// In this case, consider the push provider is chosen.
|
||||
selectPushProvider(matrixClient.sessionId, pushProvider)
|
||||
}
|
||||
registerWith(matrixClient, pushProvider, distributor)
|
||||
} else {
|
||||
val currentPushDistributor = currentPushProvider.getCurrentDistributor(matrixClient.sessionId)
|
||||
if (currentPushDistributor == null) {
|
||||
Timber.d("Register with the first available distributor")
|
||||
val distributor = currentPushProvider.getDistributors().firstOrNull()
|
||||
?: return Result.failure<Unit>(PusherRegistrationFailure.NoDistributorsAvailable())
|
||||
.also { Timber.w("No distributors available") }
|
||||
registerWith(matrixClient, currentPushProvider, distributor)
|
||||
} else {
|
||||
Timber.d("Re-register with the current distributor")
|
||||
registerWith(matrixClient, currentPushProvider, currentPushDistributor)
|
||||
}
|
||||
}
|
||||
return result.fold(
|
||||
onSuccess = {
|
||||
Timber.d("Pusher registered")
|
||||
Result.success(Unit)
|
||||
},
|
||||
onFailure = {
|
||||
Timber.e(it, "Failed to register pusher")
|
||||
if (it is RegistrationFailure) {
|
||||
Result.failure(PusherRegistrationFailure.RegistrationFailure(it.clientException, it.isRegisteringAgain))
|
||||
} else {
|
||||
Result.failure(it)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun selectPushProvider(
|
||||
sessionId: SessionId,
|
||||
pushProvider: PushProvider,
|
||||
) {
|
||||
Timber.d("Select ${pushProvider.name}")
|
||||
val userPushStore = userPushStoreFactory.getOrCreate(sessionId)
|
||||
userPushStore.setPushProviderName(pushProvider.name)
|
||||
}
|
||||
|
||||
override fun ignoreRegistrationError(sessionId: SessionId): Flow<Boolean> {
|
||||
return userPushStoreFactory.getOrCreate(sessionId).ignoreRegistrationError()
|
||||
}
|
||||
|
||||
override suspend fun setIgnoreRegistrationError(sessionId: SessionId, ignore: Boolean) {
|
||||
userPushStoreFactory.getOrCreate(sessionId).setIgnoreRegistrationError(ignore)
|
||||
}
|
||||
|
||||
override suspend fun testPush(sessionId: SessionId): Boolean {
|
||||
val pushProvider = getCurrentPushProvider(sessionId) ?: return false
|
||||
val config = pushProvider.getPushConfig(sessionId) ?: return false
|
||||
testPush.execute(config)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun observeSessions() {
|
||||
sessionObserver.addListener(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* The session has been deleted.
|
||||
* In this case, this is not necessary to unregister the pusher from the homeserver,
|
||||
* but we need to do some cleanup locally.
|
||||
* The current push provider may want to take action, and we need to
|
||||
* cleanup the stores.
|
||||
*/
|
||||
override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {
|
||||
val sessionId = SessionId(userId)
|
||||
val userPushStore = userPushStoreFactory.getOrCreate(sessionId)
|
||||
val currentPushProviderName = userPushStore.getPushProviderName()
|
||||
val currentPushProvider = pushProviders.find { it.name == currentPushProviderName }
|
||||
// Cleanup the current push provider. They may need the client secret, so delete the secret after.
|
||||
currentPushProvider?.onSessionDeleted(sessionId)
|
||||
// Now we can safely reset the stores.
|
||||
pushClientSecretStore.resetSecret(sessionId)
|
||||
userPushStore.reset()
|
||||
}
|
||||
|
||||
override val pushCounter: Flow<Int> = pushDataStore.pushCounterFlow
|
||||
|
||||
override fun getPushHistoryItemsFlow(): Flow<List<PushHistoryItem>> {
|
||||
return pushDataStore.getPushHistoryItemsFlow()
|
||||
}
|
||||
|
||||
override suspend fun resetPushHistory() {
|
||||
pushDataStore.reset()
|
||||
}
|
||||
|
||||
override suspend fun resetBatteryOptimizationState() {
|
||||
mutableBatteryOptimizationStore.reset()
|
||||
}
|
||||
|
||||
override suspend fun onServiceUnregistered(userId: UserId) {
|
||||
serviceUnregisteredHandler.handle(userId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.appconfig.PushConfig
|
||||
import io.element.android.libraries.core.extensions.mapFailure
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.exception.ClientException
|
||||
import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData
|
||||
import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData
|
||||
import io.element.android.libraries.pushproviders.api.PusherSubscriber
|
||||
import io.element.android.libraries.pushproviders.api.RegistrationFailure
|
||||
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
|
||||
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
|
||||
import timber.log.Timber
|
||||
|
||||
internal const val DEFAULT_PUSHER_FILE_TAG = "mobile"
|
||||
|
||||
private val loggerTag = LoggerTag("DefaultPusherSubscriber", LoggerTag.PushLoggerTag)
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultPusherSubscriber(
|
||||
private val buildMeta: BuildMeta,
|
||||
private val pushClientSecret: PushClientSecret,
|
||||
private val userPushStoreFactory: UserPushStoreFactory,
|
||||
) : PusherSubscriber {
|
||||
/**
|
||||
* Register a pusher to the server if not done yet.
|
||||
*/
|
||||
override suspend fun registerPusher(
|
||||
matrixClient: MatrixClient,
|
||||
pushKey: String,
|
||||
gateway: String,
|
||||
): Result<Unit> {
|
||||
val userDataStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId)
|
||||
val isRegisteringAgain = userDataStore.getCurrentRegisteredPushKey() == pushKey
|
||||
if (isRegisteringAgain) {
|
||||
Timber.tag(loggerTag.value)
|
||||
.d("Unnecessary to register again the same pusher, but do it in case the pusher has been removed from the server")
|
||||
}
|
||||
return matrixClient.pushersService
|
||||
.setHttpPusher(
|
||||
createHttpPusher(pushKey, gateway, matrixClient.sessionId)
|
||||
)
|
||||
.onSuccess {
|
||||
userDataStore.setCurrentRegisteredPushKey(pushKey)
|
||||
}
|
||||
.mapFailure { throwable ->
|
||||
Timber.tag(loggerTag.value).e(throwable, "Unable to register the pusher")
|
||||
if (throwable is ClientException) {
|
||||
// It should always be the case.
|
||||
RegistrationFailure(throwable, isRegisteringAgain = isRegisteringAgain)
|
||||
} else {
|
||||
throwable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createHttpPusher(
|
||||
pushKey: String,
|
||||
gateway: String,
|
||||
userId: SessionId,
|
||||
): SetHttpPusherData =
|
||||
SetHttpPusherData(
|
||||
pushKey = pushKey,
|
||||
appId = PushConfig.PUSHER_APP_ID,
|
||||
// TODO + abs(activeSessionHolder.getActiveSession().myUserId.hashCode())
|
||||
profileTag = DEFAULT_PUSHER_FILE_TAG + "_",
|
||||
// TODO localeProvider.current().language
|
||||
lang = "en",
|
||||
appDisplayName = buildMeta.applicationName,
|
||||
// TODO getDeviceInfoUseCase.execute().displayName().orEmpty()
|
||||
deviceDisplayName = "MyDevice",
|
||||
url = gateway,
|
||||
defaultPayload = createDefaultPayload(pushClientSecret.getSecretForUser(userId))
|
||||
)
|
||||
|
||||
/**
|
||||
* Ex: {"cs":"sfvsdv"}.
|
||||
*/
|
||||
private fun createDefaultPayload(secretForUser: String): String {
|
||||
return "{\"cs\":\"$secretForUser\"}"
|
||||
}
|
||||
|
||||
override suspend fun unregisterPusher(
|
||||
matrixClient: MatrixClient,
|
||||
pushKey: String,
|
||||
gateway: String,
|
||||
): Result<Unit> {
|
||||
val userDataStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId)
|
||||
return matrixClient.pushersService
|
||||
.unsetHttpPusher(
|
||||
unsetHttpPusherData = UnsetHttpPusherData(
|
||||
pushKey = pushKey,
|
||||
appId = PushConfig.PUSHER_APP_ID
|
||||
)
|
||||
)
|
||||
.onSuccess {
|
||||
userDataStore.setCurrentRegisteredPushKey(null)
|
||||
}
|
||||
.onFailure { throwable ->
|
||||
Timber.tag(loggerTag.value).e(throwable, "Unable to unregister the pusher")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.battery
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.net.toUri
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher
|
||||
import timber.log.Timber
|
||||
|
||||
interface BatteryOptimization {
|
||||
/**
|
||||
* Tells if the application ignores battery optimizations.
|
||||
*
|
||||
* Ignoring them allows the app to run in background to make background sync with the homeserver.
|
||||
* This user option appears on Android M but Android O enforces its usage and kills apps not
|
||||
* authorised by the user to run in background.
|
||||
*
|
||||
* @return true if battery optimisations are ignored
|
||||
*/
|
||||
fun isIgnoringBatteryOptimizations(): Boolean
|
||||
|
||||
/**
|
||||
* Request the user to disable battery optimizations for this app.
|
||||
* This will open the system settings where the user can disable battery optimizations.
|
||||
* See https://developer.android.com/training/monitoring-device-state/doze-standby#exemption-cases
|
||||
*
|
||||
* @return true if the intent was successfully started, false if the activity was not found
|
||||
*/
|
||||
fun requestDisablingBatteryOptimization(): Boolean
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class AndroidBatteryOptimization(
|
||||
@ApplicationContext
|
||||
private val context: Context,
|
||||
private val externalIntentLauncher: ExternalIntentLauncher,
|
||||
) : BatteryOptimization {
|
||||
override fun isIgnoringBatteryOptimizations(): Boolean {
|
||||
return context.getSystemService<PowerManager>()
|
||||
?.isIgnoringBatteryOptimizations(context.packageName) == true
|
||||
}
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
override fun requestDisablingBatteryOptimization(): Boolean {
|
||||
val ignoreBatteryOptimizationsResult = launchAction(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, withData = true)
|
||||
if (ignoreBatteryOptimizationsResult) {
|
||||
return true
|
||||
}
|
||||
// Open settings as a fallback if the first attempt fails
|
||||
return launchAction(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS, withData = false)
|
||||
}
|
||||
|
||||
private fun launchAction(
|
||||
action: String,
|
||||
withData: Boolean,
|
||||
): Boolean {
|
||||
val intent = Intent()
|
||||
intent.action = action
|
||||
if (withData) {
|
||||
intent.data = ("package:" + context.packageName).toUri()
|
||||
}
|
||||
return try {
|
||||
externalIntentLauncher.launch(intent)
|
||||
true
|
||||
} catch (exception: ActivityNotFoundException) {
|
||||
Timber.w(exception, "Cannot launch intent with action $action.")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.battery
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.push.api.battery.BatteryOptimizationEvents
|
||||
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
|
||||
import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore
|
||||
import io.element.android.libraries.push.impl.store.PushDataStore
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Inject
|
||||
class BatteryOptimizationPresenter(
|
||||
private val pushDataStore: PushDataStore,
|
||||
private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore,
|
||||
private val batteryOptimization: BatteryOptimization,
|
||||
) : Presenter<BatteryOptimizationState> {
|
||||
@Composable
|
||||
override fun present(): BatteryOptimizationState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var isRequestSent by remember { mutableStateOf(false) }
|
||||
var localShouldDisplayBanner by remember { mutableStateOf(true) }
|
||||
val storeShouldDisplayBanner by pushDataStore.shouldDisplayBatteryOptimizationBannerFlow.collectAsState(initial = false)
|
||||
var isSystemIgnoringBatteryOptimizations by remember {
|
||||
mutableStateOf(batteryOptimization.isIgnoringBatteryOptimizations())
|
||||
}
|
||||
|
||||
LifecycleResumeEffect(Unit) {
|
||||
isSystemIgnoringBatteryOptimizations = batteryOptimization.isIgnoringBatteryOptimizations()
|
||||
if (isRequestSent) {
|
||||
localShouldDisplayBanner = false
|
||||
}
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
fun handleEvent(event: BatteryOptimizationEvents) {
|
||||
when (event) {
|
||||
BatteryOptimizationEvents.Dismiss -> coroutineScope.launch {
|
||||
mutableBatteryOptimizationStore.onOptimizationBannerDismissed()
|
||||
}
|
||||
BatteryOptimizationEvents.RequestDisableOptimizations -> {
|
||||
isRequestSent = true
|
||||
if (batteryOptimization.requestDisablingBatteryOptimization().not()) {
|
||||
// If not able to perform the request, ensure that we do not display the banner again
|
||||
coroutineScope.launch {
|
||||
mutableBatteryOptimizationStore.onOptimizationBannerDismissed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BatteryOptimizationState(
|
||||
shouldDisplayBanner = localShouldDisplayBanner && storeShouldDisplayBanner && !isSystemIgnoringBatteryOptimizations,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.BindingContainer
|
||||
import dev.zacsweers.metro.Binds
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import dev.zacsweers.metro.Provides
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
|
||||
import io.element.android.libraries.push.impl.battery.BatteryOptimizationPresenter
|
||||
|
||||
@BindingContainer
|
||||
@ContributesTo(AppScope::class)
|
||||
interface PushModule {
|
||||
companion object {
|
||||
@Provides
|
||||
fun provideNotificationCompatManager(@ApplicationContext context: Context): NotificationManagerCompat {
|
||||
return NotificationManagerCompat.from(context)
|
||||
}
|
||||
}
|
||||
|
||||
@Binds
|
||||
fun bindBatteryOptimizationPresenter(presenter: BatteryOptimizationPresenter): Presenter<BatteryOptimizationState>
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.history
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import androidx.core.content.getSystemService
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.push.impl.PushDatabase
|
||||
import io.element.android.libraries.push.impl.db.PushHistory
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultPushHistoryService(
|
||||
private val pushDatabase: PushDatabase,
|
||||
private val systemClock: SystemClock,
|
||||
@ApplicationContext context: Context,
|
||||
) : PushHistoryService {
|
||||
private val powerManager = context.getSystemService<PowerManager>()
|
||||
private val packageName = context.packageName
|
||||
|
||||
override fun onPushReceived(
|
||||
providerInfo: String,
|
||||
eventId: EventId?,
|
||||
roomId: RoomId?,
|
||||
sessionId: SessionId?,
|
||||
hasBeenResolved: Boolean,
|
||||
includeDeviceState: Boolean,
|
||||
comment: String?,
|
||||
) {
|
||||
val finalComment = buildString {
|
||||
append(comment.orEmpty())
|
||||
if (includeDeviceState && powerManager != null) {
|
||||
// Add info about device state
|
||||
append("\n")
|
||||
append(" - Idle: ${powerManager.isDeviceIdleMode}\n")
|
||||
append(" - Power Save Mode: ${powerManager.isPowerSaveMode}\n")
|
||||
append(" - Ignoring Battery Optimizations: ${powerManager.isIgnoringBatteryOptimizations(packageName)}\n")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
append(" - Device Light Idle Mode: ${powerManager.isDeviceLightIdleMode}\n")
|
||||
append(" - Low Power Standby Enabled: ${powerManager.isLowPowerStandbyEnabled}\n")
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
append(" - Exempt from Low Power Standby: ${powerManager.isExemptFromLowPowerStandby}\n")
|
||||
}
|
||||
}
|
||||
}.takeIf { it.isNotEmpty() }
|
||||
|
||||
pushDatabase.pushHistoryQueries.insertPushHistory(
|
||||
PushHistory(
|
||||
pushDate = systemClock.epochMillis(),
|
||||
providerInfo = providerInfo,
|
||||
eventId = eventId?.value,
|
||||
roomId = roomId?.value,
|
||||
sessionId = sessionId?.value,
|
||||
hasBeenResolved = if (hasBeenResolved) 1 else 0,
|
||||
comment = finalComment,
|
||||
)
|
||||
)
|
||||
|
||||
// Keep only the last 1_000 events
|
||||
pushDatabase.pushHistoryQueries.removeOldest(1_000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.history
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
interface PushHistoryService {
|
||||
/**
|
||||
* Create a new push history entry.
|
||||
* Do not use directly, prefer using the extension functions.
|
||||
*/
|
||||
fun onPushReceived(
|
||||
providerInfo: String,
|
||||
eventId: EventId?,
|
||||
roomId: RoomId?,
|
||||
sessionId: SessionId?,
|
||||
hasBeenResolved: Boolean,
|
||||
includeDeviceState: Boolean,
|
||||
comment: String?,
|
||||
)
|
||||
}
|
||||
|
||||
fun PushHistoryService.onInvalidPushReceived(
|
||||
providerInfo: String,
|
||||
data: String,
|
||||
) = onPushReceived(
|
||||
providerInfo = providerInfo,
|
||||
eventId = null,
|
||||
roomId = null,
|
||||
sessionId = null,
|
||||
hasBeenResolved = false,
|
||||
includeDeviceState = false,
|
||||
comment = "Invalid or ignored push data:\n$data",
|
||||
)
|
||||
|
||||
fun PushHistoryService.onUnableToRetrieveSession(
|
||||
providerInfo: String,
|
||||
eventId: EventId,
|
||||
roomId: RoomId,
|
||||
reason: String,
|
||||
) = onPushReceived(
|
||||
providerInfo = providerInfo,
|
||||
eventId = eventId,
|
||||
roomId = roomId,
|
||||
sessionId = null,
|
||||
hasBeenResolved = false,
|
||||
includeDeviceState = true,
|
||||
comment = "Unable to retrieve session: $reason",
|
||||
)
|
||||
|
||||
fun PushHistoryService.onUnableToResolveEvent(
|
||||
providerInfo: String,
|
||||
eventId: EventId,
|
||||
roomId: RoomId,
|
||||
sessionId: SessionId,
|
||||
reason: String,
|
||||
) = onPushReceived(
|
||||
providerInfo = providerInfo,
|
||||
eventId = eventId,
|
||||
roomId = roomId,
|
||||
sessionId = sessionId,
|
||||
hasBeenResolved = false,
|
||||
includeDeviceState = true,
|
||||
comment = "Unable to resolve event: $reason",
|
||||
)
|
||||
|
||||
fun PushHistoryService.onSuccess(
|
||||
providerInfo: String,
|
||||
eventId: EventId,
|
||||
roomId: RoomId,
|
||||
sessionId: SessionId,
|
||||
comment: String?,
|
||||
) = onPushReceived(
|
||||
providerInfo = providerInfo,
|
||||
eventId = eventId,
|
||||
roomId = roomId,
|
||||
sessionId = sessionId,
|
||||
hasBeenResolved = true,
|
||||
includeDeviceState = false,
|
||||
comment = buildString {
|
||||
append("Success")
|
||||
if (comment.isNullOrBlank().not()) {
|
||||
append(" - $comment")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
fun PushHistoryService.onDiagnosticPush(
|
||||
providerInfo: String,
|
||||
) = onPushReceived(
|
||||
providerInfo = providerInfo,
|
||||
eventId = null,
|
||||
roomId = null,
|
||||
sessionId = null,
|
||||
hasBeenResolved = true,
|
||||
includeDeviceState = false,
|
||||
comment = "Diagnostic push",
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.history.di
|
||||
|
||||
import android.content.Context
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.BindingContainer
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import dev.zacsweers.metro.Provides
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.push.impl.PushDatabase
|
||||
import io.element.encrypteddb.SqlCipherDriverFactory
|
||||
import io.element.encrypteddb.passphrase.RandomSecretPassphraseProvider
|
||||
|
||||
@BindingContainer
|
||||
@ContributesTo(AppScope::class)
|
||||
object PushHistoryModule {
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun providePushDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
): PushDatabase {
|
||||
val name = "push_database"
|
||||
val secretFile = context.getDatabasePath("$name.key")
|
||||
|
||||
// Make sure the parent directory of the key file exists, otherwise it will crash in older Android versions
|
||||
val parentDir = secretFile.parentFile
|
||||
if (parentDir != null && !parentDir.exists()) {
|
||||
parentDir.mkdirs()
|
||||
}
|
||||
|
||||
val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile)
|
||||
val driver = SqlCipherDriverFactory(passphraseProvider)
|
||||
.create(PushDatabase.Schema, "$name.db", context)
|
||||
return PushDatabase(driver)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.intent
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
|
||||
interface IntentProvider {
|
||||
/**
|
||||
* Provide an intent to start the application on a room or thread.
|
||||
*/
|
||||
fun getViewRoomIntent(
|
||||
sessionId: SessionId,
|
||||
roomId: RoomId?,
|
||||
threadId: ThreadId?,
|
||||
eventId: EventId?,
|
||||
extras: Bundle? = null,
|
||||
): Intent
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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
|
||||
|
||||
import android.service.notification.StatusBarNotification
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
|
||||
import timber.log.Timber
|
||||
|
||||
interface ActiveNotificationsProvider {
|
||||
/**
|
||||
* Gets the displayed notifications for the combination of [sessionId], [roomId] and [threadId].
|
||||
*/
|
||||
fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List<StatusBarNotification>
|
||||
|
||||
/**
|
||||
* Gets all displayed notifications associated to [sessionId] and [roomId]. These will include all thread notifications as well.
|
||||
*/
|
||||
fun getAllMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
|
||||
fun getNotificationsForSession(sessionId: SessionId): List<StatusBarNotification>
|
||||
fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification>
|
||||
fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
|
||||
fun getSummaryNotification(sessionId: SessionId): StatusBarNotification?
|
||||
fun count(sessionId: SessionId): Int
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultActiveNotificationsProvider(
|
||||
private val notificationManager: NotificationManagerCompat,
|
||||
) : ActiveNotificationsProvider {
|
||||
override fun getNotificationsForSession(sessionId: SessionId): List<StatusBarNotification> {
|
||||
return runCatchingExceptions { notificationManager.activeNotifications }
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to get active notifications")
|
||||
}
|
||||
.getOrElse { emptyList() }
|
||||
.filter { it.notification.group == sessionId.value }
|
||||
}
|
||||
|
||||
override fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification> {
|
||||
val notificationId = NotificationIdProvider.getRoomInvitationNotificationId(sessionId)
|
||||
return getNotificationsForSession(sessionId).filter { it.id == notificationId }
|
||||
}
|
||||
|
||||
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List<StatusBarNotification> {
|
||||
val notificationId = NotificationIdProvider.getRoomMessagesNotificationId(sessionId)
|
||||
val expectedTag = NotificationCreator.messageTag(roomId, threadId)
|
||||
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == expectedTag }
|
||||
}
|
||||
|
||||
override fun getAllMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
|
||||
val notificationId = NotificationIdProvider.getRoomMessagesNotificationId(sessionId)
|
||||
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag.startsWith(roomId.value) }
|
||||
}
|
||||
|
||||
override fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
|
||||
val notificationId = NotificationIdProvider.getRoomInvitationNotificationId(sessionId)
|
||||
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == roomId.value }
|
||||
}
|
||||
|
||||
override fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? {
|
||||
val summaryId = NotificationIdProvider.getSummaryNotificationId(sessionId)
|
||||
return getNotificationsForSession(sessionId).find { it.id == summaryId }
|
||||
}
|
||||
|
||||
override fun count(sessionId: SessionId): Int {
|
||||
return getNotificationsForSession(sessionId).size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationContent
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationData
|
||||
import io.element.android.libraries.matrix.api.notification.RtcNotificationType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
|
||||
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Helper to resolve a valid [NotifiableEvent] from a [NotificationData].
|
||||
*/
|
||||
interface CallNotificationEventResolver {
|
||||
/**
|
||||
* Resolve a call notification event from a notification data depending on whether it should be a ringing one or not.
|
||||
* @param sessionId the current session id
|
||||
* @param notificationData the notification data
|
||||
* @param forceNotify `true` to force the notification to be non-ringing, `false` to use the default behaviour. Default is `false`.
|
||||
* @return a [NotifiableEvent] if the notification data is a call notification, null otherwise
|
||||
*/
|
||||
suspend fun resolveEvent(
|
||||
sessionId: SessionId,
|
||||
notificationData: NotificationData,
|
||||
forceNotify: Boolean = false,
|
||||
): Result<NotifiableEvent>
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultCallNotificationEventResolver(
|
||||
private val stringProvider: StringProvider,
|
||||
private val appForegroundStateService: AppForegroundStateService,
|
||||
private val clientProvider: MatrixClientProvider,
|
||||
) : CallNotificationEventResolver {
|
||||
override suspend fun resolveEvent(
|
||||
sessionId: SessionId,
|
||||
notificationData: NotificationData,
|
||||
forceNotify: Boolean
|
||||
): Result<NotifiableEvent> = runCatchingExceptions {
|
||||
val content = notificationData.content as? NotificationContent.MessageLike.RtcNotification
|
||||
?: throw NotificationResolverException.UnknownError("content is not a call notify")
|
||||
|
||||
val previousRingingCallStatus = appForegroundStateService.hasRingingCall.value
|
||||
// We need the sync service working to get the updated room info
|
||||
val isRoomCallActive = runCatchingExceptions {
|
||||
if (content.type == RtcNotificationType.RING) {
|
||||
appForegroundStateService.updateHasRingingCall(true)
|
||||
|
||||
val client = clientProvider.getOrRestore(
|
||||
sessionId
|
||||
).getOrNull() ?: throw NotificationResolverException.UnknownError("Session $sessionId not found")
|
||||
val room = client.getRoom(
|
||||
notificationData.roomId
|
||||
) ?: throw NotificationResolverException.UnknownError("Room ${notificationData.roomId} not found")
|
||||
// Give a few seconds for the room info flow to catch up with the sync, if needed - this is usually instant
|
||||
val isActive = withTimeoutOrNull(3.seconds) { room.roomInfoFlow.firstOrNull { it.hasRoomCall } }?.hasRoomCall ?: false
|
||||
|
||||
// We no longer need the sync service to be active because of a call notification.
|
||||
appForegroundStateService.updateHasRingingCall(previousRingingCallStatus)
|
||||
|
||||
isActive
|
||||
} else {
|
||||
// If the call notification is not of ringing type, we don't need to check if the call is active
|
||||
false
|
||||
}
|
||||
}.onFailure {
|
||||
// Make sure to reset the hasRingingCall state in case of failure
|
||||
appForegroundStateService.updateHasRingingCall(previousRingingCallStatus)
|
||||
}.getOrDefault(false)
|
||||
|
||||
notificationData.run {
|
||||
if (content.type == RtcNotificationType.RING && isRoomCallActive && !forceNotify) {
|
||||
NotifiableRingingCallEvent(
|
||||
sessionId = sessionId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
roomName = roomDisplayName,
|
||||
editedEventId = null,
|
||||
canBeReplaced = true,
|
||||
timestamp = this.timestamp,
|
||||
isRedacted = false,
|
||||
isUpdated = false,
|
||||
description = stringProvider.getString(R.string.notification_incoming_call),
|
||||
senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId),
|
||||
roomAvatarUrl = roomAvatarUrl,
|
||||
rtcNotificationType = content.type,
|
||||
senderId = content.senderId,
|
||||
senderAvatarUrl = senderAvatarUrl,
|
||||
expirationTimestamp = content.expirationTimestampMillis,
|
||||
)
|
||||
} else {
|
||||
Timber.d("Event $eventId is call notify but should not ring: $isRoomCallActive, notify: ${content.type}")
|
||||
// Create a simple message notification event
|
||||
buildNotifiableMessageEvent(
|
||||
sessionId = sessionId,
|
||||
senderId = content.senderId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
noisy = true,
|
||||
timestamp = this.timestamp,
|
||||
senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId),
|
||||
body = stringProvider.getString(R.string.notification_incoming_call),
|
||||
roomName = roomDisplayName,
|
||||
roomIsDm = isDm,
|
||||
roomAvatarPath = roomAvatarUrl,
|
||||
senderAvatarPath = senderAvatarUrl,
|
||||
type = EventType.RTC_NOTIFICATION,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.core.extensions.flatMap
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
|
||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||
import io.element.android.libraries.matrix.api.media.getMediaPreviewValue
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationContent
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationData
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import timber.log.Timber
|
||||
|
||||
private val loggerTag = LoggerTag("DefaultNotifiableEventResolver", LoggerTag.NotificationLoggerTag)
|
||||
|
||||
/**
|
||||
* Result of resolving a batch of push events.
|
||||
* The outermost [Result] indicates whether the setup to resolve the events was successful.
|
||||
* The results for each push notification will be a map of [NotificationEventRequest] to [Result] of [ResolvedPushEvent].
|
||||
* If the resolution of a specific event fails, the innermost [Result] will contain an exception.
|
||||
*/
|
||||
typealias ResolvePushEventsResult = Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>
|
||||
|
||||
/**
|
||||
* The notifiable event resolver is able to create a NotifiableEvent (view model for notifications) from an sdk Event.
|
||||
* It is used as a bridge between the Event Thread and the NotificationDrawerManager.
|
||||
* The NotifiableEventResolver is the only aware of session/store, the NotificationDrawerManager has no knowledge of that,
|
||||
* this pattern allow decoupling between the object responsible of displaying notifications and the matrix sdk.
|
||||
*/
|
||||
interface NotifiableEventResolver {
|
||||
suspend fun resolveEvents(
|
||||
sessionId: SessionId,
|
||||
notificationEventRequests: List<NotificationEventRequest>
|
||||
): ResolvePushEventsResult
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@SingleIn(AppScope::class)
|
||||
class DefaultNotifiableEventResolver(
|
||||
private val stringProvider: StringProvider,
|
||||
private val matrixClientProvider: MatrixClientProvider,
|
||||
private val notificationMediaRepoFactory: NotificationMediaRepo.Factory,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val callNotificationEventResolver: CallNotificationEventResolver,
|
||||
private val fallbackNotificationFactory: FallbackNotificationFactory,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : NotifiableEventResolver {
|
||||
override suspend fun resolveEvents(
|
||||
sessionId: SessionId,
|
||||
notificationEventRequests: List<NotificationEventRequest>
|
||||
): ResolvePushEventsResult {
|
||||
Timber.d("Queueing notifications: $notificationEventRequests")
|
||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrElse {
|
||||
return Result.failure(IllegalStateException("Couldn't get or restore client for session $sessionId"))
|
||||
}
|
||||
val ids = notificationEventRequests.groupBy { it.roomId }
|
||||
.mapValues { (_, requests) ->
|
||||
requests.map { it.eventId }
|
||||
}
|
||||
|
||||
// TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event
|
||||
val notificationsResult = client.notificationService.getNotifications(ids)
|
||||
|
||||
if (notificationsResult.isFailure) {
|
||||
val exception = notificationsResult.exceptionOrNull()
|
||||
Timber.tag(loggerTag.value).e(exception, "Failed to get notifications for $ids")
|
||||
return Result.failure(exception ?: NotificationResolverException.UnknownError("Unknown error while fetching notifications"))
|
||||
}
|
||||
|
||||
// The null check is done above
|
||||
val notificationDataMap = notificationsResult.getOrNull()!!.mapValues { (_, notificationData) ->
|
||||
notificationData.flatMap { data ->
|
||||
data.asNotifiableEvent(client, sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
return Result.success(
|
||||
notificationEventRequests.associate { request ->
|
||||
val notificationDataResult = notificationDataMap[request.eventId]
|
||||
if (notificationDataResult == null) {
|
||||
request to Result.failure(NotificationResolverException.UnknownError("No notification data for ${request.roomId} - ${request.eventId}"))
|
||||
} else {
|
||||
request to notificationDataResult
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun NotificationData.asNotifiableEvent(
|
||||
client: MatrixClient,
|
||||
userId: SessionId,
|
||||
): Result<ResolvedPushEvent> = runCatchingExceptions {
|
||||
when (val content = this.content) {
|
||||
is NotificationContent.MessageLike.RoomMessage -> {
|
||||
val showMediaPreview = client.mediaPreviewService.getMediaPreviewValue() == MediaPreviewValue.On
|
||||
val senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId)
|
||||
val imageMimeType = if (showMediaPreview) content.getImageMimetype() else null
|
||||
val imageUriString = imageMimeType?.let { content.fetchImageIfPresent(client, imageMimeType)?.toString() }
|
||||
val messageBody = descriptionFromMessageContent(
|
||||
content = content,
|
||||
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
|
||||
hasImageUri = imageUriString != null,
|
||||
)
|
||||
val notifiableMessageEvent = buildNotifiableMessageEvent(
|
||||
sessionId = userId,
|
||||
senderId = content.senderId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
threadId = threadId.takeIf { featureFlagService.isFeatureEnabled(FeatureFlags.Threads) },
|
||||
noisy = isNoisy,
|
||||
timestamp = this.timestamp,
|
||||
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
|
||||
body = messageBody,
|
||||
imageUriString = imageUriString,
|
||||
imageMimeType = imageMimeType.takeIf { imageUriString != null },
|
||||
roomName = roomDisplayName,
|
||||
roomIsDm = isDm,
|
||||
roomAvatarPath = roomAvatarUrl,
|
||||
senderAvatarPath = senderAvatarUrl,
|
||||
hasMentionOrReply = hasMention,
|
||||
)
|
||||
ResolvedPushEvent.Event(notifiableMessageEvent)
|
||||
}
|
||||
is NotificationContent.Invite -> {
|
||||
val senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId)
|
||||
val inviteNotifiableEvent = InviteNotifiableEvent(
|
||||
sessionId = userId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
editedEventId = null,
|
||||
canBeReplaced = true,
|
||||
roomName = roomDisplayName,
|
||||
noisy = isNoisy,
|
||||
timestamp = this.timestamp,
|
||||
soundName = null,
|
||||
isRedacted = false,
|
||||
isUpdated = false,
|
||||
description = descriptionFromRoomMembershipInvite(senderDisambiguatedDisplayName, isDirect),
|
||||
// TODO check if type is needed anymore
|
||||
type = null,
|
||||
// TODO check if title is needed anymore
|
||||
title = null,
|
||||
)
|
||||
ResolvedPushEvent.Event(inviteNotifiableEvent)
|
||||
}
|
||||
NotificationContent.MessageLike.CallAnswer,
|
||||
NotificationContent.MessageLike.CallCandidates,
|
||||
NotificationContent.MessageLike.CallHangup -> {
|
||||
Timber.tag(loggerTag.value).d("Ignoring notification for call ${content.javaClass.simpleName}")
|
||||
throw NotificationResolverException.EventFilteredOut
|
||||
}
|
||||
is NotificationContent.MessageLike.CallInvite -> {
|
||||
val notifiableMessageEvent = buildNotifiableMessageEvent(
|
||||
sessionId = userId,
|
||||
senderId = content.senderId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
noisy = isNoisy,
|
||||
timestamp = this.timestamp,
|
||||
senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId),
|
||||
body = stringProvider.getString(CommonStrings.common_unsupported_call),
|
||||
roomName = roomDisplayName,
|
||||
roomIsDm = isDm,
|
||||
roomAvatarPath = roomAvatarUrl,
|
||||
senderAvatarPath = senderAvatarUrl,
|
||||
)
|
||||
ResolvedPushEvent.Event(notifiableMessageEvent)
|
||||
}
|
||||
is NotificationContent.MessageLike.RtcNotification -> {
|
||||
val notifiableEvent = callNotificationEventResolver.resolveEvent(userId, this).getOrThrow()
|
||||
ResolvedPushEvent.Event(notifiableEvent)
|
||||
}
|
||||
NotificationContent.MessageLike.KeyVerificationAccept,
|
||||
NotificationContent.MessageLike.KeyVerificationCancel,
|
||||
NotificationContent.MessageLike.KeyVerificationDone,
|
||||
NotificationContent.MessageLike.KeyVerificationKey,
|
||||
NotificationContent.MessageLike.KeyVerificationMac,
|
||||
NotificationContent.MessageLike.KeyVerificationReady,
|
||||
NotificationContent.MessageLike.KeyVerificationStart -> {
|
||||
Timber.tag(loggerTag.value).d("Ignoring notification for verification ${content.javaClass.simpleName}")
|
||||
throw NotificationResolverException.EventFilteredOut
|
||||
}
|
||||
is NotificationContent.MessageLike.Poll -> {
|
||||
val notifiableEventMessage = buildNotifiableMessageEvent(
|
||||
sessionId = userId,
|
||||
senderId = content.senderId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
noisy = isNoisy,
|
||||
timestamp = this.timestamp,
|
||||
senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId),
|
||||
body = stringProvider.getString(CommonStrings.common_poll_summary, content.question),
|
||||
imageUriString = null,
|
||||
roomName = roomDisplayName,
|
||||
roomIsDm = isDm,
|
||||
roomAvatarPath = roomAvatarUrl,
|
||||
senderAvatarPath = senderAvatarUrl,
|
||||
)
|
||||
ResolvedPushEvent.Event(notifiableEventMessage)
|
||||
}
|
||||
is NotificationContent.MessageLike.ReactionContent -> {
|
||||
Timber.tag(loggerTag.value).d("Ignoring notification for reaction")
|
||||
throw NotificationResolverException.EventFilteredOut
|
||||
}
|
||||
NotificationContent.MessageLike.RoomEncrypted -> {
|
||||
Timber.tag(loggerTag.value).w("Notification with encrypted content -> fallback")
|
||||
val fallbackNotifiableEvent = fallbackNotificationFactory.create(
|
||||
sessionId = userId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
cause = "Unable to decrypt event content",
|
||||
)
|
||||
ResolvedPushEvent.Event(fallbackNotifiableEvent)
|
||||
}
|
||||
is NotificationContent.MessageLike.RoomRedaction -> {
|
||||
// Note: this case will be handled below
|
||||
val redactedEventId = content.redactedEventId
|
||||
if (redactedEventId == null) {
|
||||
Timber.tag(loggerTag.value).d("redactedEventId is null.")
|
||||
throw NotificationResolverException.UnknownError("redactedEventId is null")
|
||||
} else {
|
||||
ResolvedPushEvent.Redaction(
|
||||
sessionId = userId,
|
||||
roomId = roomId,
|
||||
redactedEventId = redactedEventId,
|
||||
reason = content.reason,
|
||||
)
|
||||
}
|
||||
}
|
||||
NotificationContent.MessageLike.Sticker -> {
|
||||
Timber.tag(loggerTag.value).d("Ignoring notification for sticker")
|
||||
throw NotificationResolverException.EventFilteredOut
|
||||
}
|
||||
is NotificationContent.StateEvent.RoomMemberContent,
|
||||
NotificationContent.StateEvent.PolicyRuleRoom,
|
||||
NotificationContent.StateEvent.PolicyRuleServer,
|
||||
NotificationContent.StateEvent.PolicyRuleUser,
|
||||
NotificationContent.StateEvent.RoomAliases,
|
||||
NotificationContent.StateEvent.RoomAvatar,
|
||||
NotificationContent.StateEvent.RoomCanonicalAlias,
|
||||
NotificationContent.StateEvent.RoomCreate,
|
||||
NotificationContent.StateEvent.RoomEncryption,
|
||||
NotificationContent.StateEvent.RoomGuestAccess,
|
||||
NotificationContent.StateEvent.RoomHistoryVisibility,
|
||||
NotificationContent.StateEvent.RoomJoinRules,
|
||||
NotificationContent.StateEvent.RoomName,
|
||||
NotificationContent.StateEvent.RoomPinnedEvents,
|
||||
NotificationContent.StateEvent.RoomPowerLevels,
|
||||
NotificationContent.StateEvent.RoomServerAcl,
|
||||
NotificationContent.StateEvent.RoomThirdPartyInvite,
|
||||
NotificationContent.StateEvent.RoomTombstone,
|
||||
is NotificationContent.StateEvent.RoomTopic,
|
||||
NotificationContent.StateEvent.SpaceChild,
|
||||
NotificationContent.StateEvent.SpaceParent -> {
|
||||
Timber.tag(loggerTag.value).d("Ignoring notification for state event ${content.javaClass.simpleName}")
|
||||
throw NotificationResolverException.EventFilteredOut
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun descriptionFromMessageContent(
|
||||
content: NotificationContent.MessageLike.RoomMessage,
|
||||
senderDisambiguatedDisplayName: String,
|
||||
hasImageUri: Boolean,
|
||||
): String? {
|
||||
return when (val messageType = content.messageType) {
|
||||
is AudioMessageType -> messageType.bestDescription
|
||||
is VoiceMessageType -> stringProvider.getString(CommonStrings.common_voice_message)
|
||||
is EmoteMessageType -> "* $senderDisambiguatedDisplayName ${messageType.body}"
|
||||
is FileMessageType -> messageType.bestDescription
|
||||
is ImageMessageType -> if (hasImageUri) {
|
||||
messageType.caption
|
||||
} else {
|
||||
messageType.bestDescription
|
||||
}
|
||||
is StickerMessageType -> messageType.bestDescription
|
||||
is NoticeMessageType -> messageType.body
|
||||
is TextMessageType -> messageType.toPlainText(permalinkParser = permalinkParser)
|
||||
is VideoMessageType -> messageType.bestDescription
|
||||
is LocationMessageType -> messageType.body
|
||||
is OtherMessageType -> messageType.body
|
||||
}
|
||||
}
|
||||
|
||||
private fun descriptionFromRoomMembershipInvite(
|
||||
senderDisambiguatedDisplayName: String,
|
||||
isDirectRoom: Boolean
|
||||
): String {
|
||||
return if (isDirectRoom) {
|
||||
stringProvider.getString(R.string.notification_invite_body_with_sender, senderDisambiguatedDisplayName)
|
||||
} else {
|
||||
stringProvider.getString(R.string.notification_room_invite_body_with_sender, senderDisambiguatedDisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the image for message type, only if the mime type is supported, as recommended
|
||||
* per [NotificationCompat.MessagingStyle.Message.setData] documentation.
|
||||
* Then convert to a [Uri] accessible to the Notification Service.
|
||||
*/
|
||||
private suspend fun NotificationContent.MessageLike.RoomMessage.fetchImageIfPresent(
|
||||
client: MatrixClient,
|
||||
mimeType: String,
|
||||
): Uri? {
|
||||
val fileResult = when (val messageType = messageType) {
|
||||
is ImageMessageType -> {
|
||||
val isMimeTypeSupported = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ImageDecoder.isMimeTypeSupported(mimeType)
|
||||
} else {
|
||||
// Assume it's supported on old systems...
|
||||
true
|
||||
}
|
||||
if (isMimeTypeSupported) {
|
||||
notificationMediaRepoFactory.create(client).getMediaFile(
|
||||
mediaSource = messageType.source,
|
||||
mimeType = messageType.info?.mimetype,
|
||||
filename = messageType.filename,
|
||||
)
|
||||
} else {
|
||||
Timber.tag(loggerTag.value).d("Mime type $mimeType not supported by the system")
|
||||
null
|
||||
}
|
||||
}
|
||||
is VideoMessageType -> null // Use the thumbnail here?
|
||||
else -> null
|
||||
}
|
||||
?: return null
|
||||
|
||||
return fileResult
|
||||
.onFailure {
|
||||
Timber.tag(loggerTag.value).e(it, "Failed to download image for notification")
|
||||
}
|
||||
.map { mediaFile ->
|
||||
val authority = "${context.packageName}.notifications.fileprovider"
|
||||
FileProvider.getUriForFile(context, authority, mediaFile)
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
private fun NotificationContent.MessageLike.RoomMessage.getImageMimetype(): String? {
|
||||
return when (val messageType = messageType) {
|
||||
is ImageMessageType -> messageType.info?.mimetype
|
||||
is VideoMessageType -> null // Use the thumbnail here?
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal fun buildNotifiableMessageEvent(
|
||||
sessionId: SessionId,
|
||||
senderId: UserId,
|
||||
roomId: RoomId,
|
||||
eventId: EventId,
|
||||
editedEventId: EventId? = null,
|
||||
canBeReplaced: Boolean = false,
|
||||
noisy: Boolean,
|
||||
timestamp: Long,
|
||||
senderDisambiguatedDisplayName: String?,
|
||||
body: String?,
|
||||
// We cannot use Uri? type here, as that could trigger a
|
||||
// NotSerializableException when persisting this to storage
|
||||
imageUriString: String? = null,
|
||||
imageMimeType: String? = null,
|
||||
threadId: ThreadId? = null,
|
||||
roomName: String? = null,
|
||||
roomIsDm: Boolean = false,
|
||||
roomAvatarPath: String? = null,
|
||||
senderAvatarPath: String? = null,
|
||||
soundName: String? = null,
|
||||
// This is used for >N notification, as the result of a smart reply
|
||||
outGoingMessage: Boolean = false,
|
||||
outGoingMessageFailed: Boolean = false,
|
||||
isRedacted: Boolean = false,
|
||||
isUpdated: Boolean = false,
|
||||
type: String = EventType.MESSAGE,
|
||||
hasMentionOrReply: Boolean = false,
|
||||
) = NotifiableMessageEvent(
|
||||
sessionId = sessionId,
|
||||
senderId = senderId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
editedEventId = editedEventId,
|
||||
canBeReplaced = canBeReplaced,
|
||||
noisy = noisy,
|
||||
timestamp = timestamp,
|
||||
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
|
||||
body = body,
|
||||
imageUriString = imageUriString,
|
||||
imageMimeType = imageMimeType,
|
||||
threadId = threadId,
|
||||
roomName = roomName,
|
||||
roomIsDm = roomIsDm,
|
||||
roomAvatarPath = roomAvatarPath,
|
||||
senderAvatarPath = senderAvatarPath,
|
||||
soundName = soundName,
|
||||
outGoingMessage = outGoingMessage,
|
||||
outGoingMessageFailed = outGoingMessageFailed,
|
||||
isRedacted = isRedacted,
|
||||
isUpdated = isUpdated,
|
||||
type = type,
|
||||
hasMentionOrReply = hasMentionOrReply,
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.transformations
|
||||
import coil3.toBitmap
|
||||
import coil3.transform.CircleCropTransformation
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL
|
||||
import io.element.android.libraries.matrix.ui.media.InitialsAvatarBitmapGenerator
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
|
||||
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
import timber.log.Timber
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultNotificationBitmapLoader(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val sdkIntProvider: BuildVersionSdkIntProvider,
|
||||
private val initialsAvatarBitmapGenerator: InitialsAvatarBitmapGenerator,
|
||||
) : NotificationBitmapLoader {
|
||||
override suspend fun getRoomBitmap(
|
||||
avatarData: AvatarData,
|
||||
imageLoader: ImageLoader,
|
||||
targetSize: Long,
|
||||
): Bitmap? {
|
||||
return try {
|
||||
loadBitmap(
|
||||
avatarData = avatarData,
|
||||
imageLoader = imageLoader,
|
||||
targetSize = targetSize,
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
Timber.e(e, "Unable to load room bitmap")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getUserIcon(
|
||||
avatarData: AvatarData,
|
||||
imageLoader: ImageLoader,
|
||||
): IconCompat? {
|
||||
if (sdkIntProvider.get() < Build.VERSION_CODES.P) {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
loadBitmap(
|
||||
avatarData = avatarData,
|
||||
imageLoader = imageLoader,
|
||||
targetSize = AVATAR_THUMBNAIL_SIZE_IN_PIXEL,
|
||||
)
|
||||
?.let { IconCompat.createWithBitmap(it) }
|
||||
} catch (e: Throwable) {
|
||||
Timber.e(e, "Unable to load user bitmap")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDarkTheme(): Boolean {
|
||||
return context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
|
||||
}
|
||||
|
||||
private suspend fun loadBitmap(
|
||||
avatarData: AvatarData,
|
||||
imageLoader: ImageLoader,
|
||||
targetSize: Long
|
||||
): Bitmap? {
|
||||
val path = avatarData.url
|
||||
val data = if (path != null) {
|
||||
MediaRequestData(
|
||||
source = MediaSource(path),
|
||||
kind = MediaRequestData.Kind.Thumbnail(targetSize),
|
||||
)
|
||||
} else {
|
||||
initialsAvatarBitmapGenerator.generateBitmap(
|
||||
size = targetSize.toInt(),
|
||||
avatarData = avatarData,
|
||||
useDarkTheme = isDarkTheme(),
|
||||
)
|
||||
}
|
||||
val imageRequest = ImageRequest.Builder(context)
|
||||
.data(data)
|
||||
.transformations(CircleCropTransformation())
|
||||
.build()
|
||||
return imageLoader.execute(imageRequest).image?.toBitmap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.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.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
|
||||
import io.element.android.libraries.push.api.notifications.NotificationCleaner
|
||||
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
|
||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||
import io.element.android.services.appnavstate.api.NavigationState
|
||||
import io.element.android.services.appnavstate.api.currentSessionId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* This class receives notification events as they arrive from the PushHandler calling [onNotifiableEventReceived] and
|
||||
* organise them in order to display them in the notification drawer.
|
||||
* Events can be grouped into the same notification, old (already read) events can be removed to do some cleaning.
|
||||
*/
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultNotificationDrawerManager(
|
||||
private val notificationDisplayer: NotificationDisplayer,
|
||||
private val notificationRenderer: NotificationRenderer,
|
||||
private val appNavigationStateService: AppNavigationStateService,
|
||||
@AppCoroutineScope
|
||||
coroutineScope: CoroutineScope,
|
||||
private val matrixClientProvider: MatrixClientProvider,
|
||||
private val imageLoaderHolder: ImageLoaderHolder,
|
||||
private val activeNotificationsProvider: ActiveNotificationsProvider,
|
||||
) : NotificationCleaner {
|
||||
// TODO EAx add a setting per user for this
|
||||
private var useCompleteNotificationFormat = true
|
||||
|
||||
init {
|
||||
// Observe application state
|
||||
coroutineScope.launch {
|
||||
appNavigationStateService.appNavigationState
|
||||
.collect { onAppNavigationStateChange(it.navigationState) }
|
||||
}
|
||||
}
|
||||
|
||||
private var currentAppNavigationState: NavigationState? = null
|
||||
|
||||
private fun onAppNavigationStateChange(navigationState: NavigationState) {
|
||||
when (navigationState) {
|
||||
NavigationState.Root -> {
|
||||
currentAppNavigationState?.currentSessionId()?.let { sessionId ->
|
||||
// User signed out, clear all notifications related to the session.
|
||||
clearAllEvents(sessionId)
|
||||
}
|
||||
}
|
||||
is NavigationState.Session -> {}
|
||||
is NavigationState.Space -> {}
|
||||
is NavigationState.Room -> {
|
||||
// Cleanup notification for current room
|
||||
clearMessagesForRoom(
|
||||
sessionId = navigationState.parentSpace.parentSession.sessionId,
|
||||
roomId = navigationState.roomId,
|
||||
)
|
||||
}
|
||||
is NavigationState.Thread -> {
|
||||
clearMessagesForThread(
|
||||
sessionId = navigationState.parentRoom.parentSpace.parentSession.sessionId,
|
||||
roomId = navigationState.parentRoom.roomId,
|
||||
threadId = navigationState.threadId,
|
||||
)
|
||||
}
|
||||
}
|
||||
currentAppNavigationState = navigationState
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called as soon as a new event is ready to be displayed, filtering out notifications that shouldn't be displayed.
|
||||
* Events might be grouped and there might not be one notification per event!
|
||||
*/
|
||||
suspend fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
|
||||
if (notifiableEvent.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value)) {
|
||||
return
|
||||
}
|
||||
renderEvents(listOf(notifiableEvent))
|
||||
}
|
||||
|
||||
suspend fun onNotifiableEventsReceived(notifiableEvents: List<NotifiableEvent>) {
|
||||
val eventsToNotify = notifiableEvents.filter { !it.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value) }
|
||||
renderEvents(eventsToNotify)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all known message events for a [sessionId].
|
||||
*/
|
||||
override fun clearAllMessagesEvents(sessionId: SessionId) {
|
||||
notificationDisplayer.cancelNotification(null, NotificationIdProvider.getRoomMessagesNotificationId(sessionId))
|
||||
clearSummaryNotificationIfNeeded(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all notifications related to the session.
|
||||
*/
|
||||
fun clearAllEvents(sessionId: SessionId) {
|
||||
activeNotificationsProvider.getNotificationsForSession(sessionId)
|
||||
.forEach { notificationDisplayer.cancelNotification(it.tag, it.id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when the application is currently opened and showing timeline for the given [roomId].
|
||||
* Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room.
|
||||
* Can also be called when a notification for this room is dismissed by the user.
|
||||
*/
|
||||
override fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) {
|
||||
notificationDisplayer.cancelNotification(roomId.value, NotificationIdProvider.getRoomMessagesNotificationId(sessionId))
|
||||
clearSummaryNotificationIfNeeded(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when the application is currently opened and showing timeline for the given threadId.
|
||||
* Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room.
|
||||
*/
|
||||
override fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) {
|
||||
val tag = NotificationCreator.messageTag(roomId, threadId)
|
||||
notificationDisplayer.cancelNotification(tag, NotificationIdProvider.getRoomMessagesNotificationId(sessionId))
|
||||
clearSummaryNotificationIfNeeded(sessionId)
|
||||
}
|
||||
|
||||
override fun clearMembershipNotificationForSession(sessionId: SessionId) {
|
||||
activeNotificationsProvider.getMembershipNotificationForSession(sessionId)
|
||||
.forEach { notificationDisplayer.cancelNotification(it.tag, it.id) }
|
||||
clearSummaryNotificationIfNeeded(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear invitation notification for the provided room.
|
||||
*/
|
||||
override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
|
||||
activeNotificationsProvider.getMembershipNotificationForRoom(sessionId, roomId)
|
||||
.forEach { notificationDisplayer.cancelNotification(it.tag, it.id) }
|
||||
clearSummaryNotificationIfNeeded(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the notifications for a single event.
|
||||
*/
|
||||
override fun clearEvent(sessionId: SessionId, eventId: EventId) {
|
||||
val id = NotificationIdProvider.getRoomEventNotificationId(sessionId)
|
||||
notificationDisplayer.cancelNotification(eventId.value, id)
|
||||
clearSummaryNotificationIfNeeded(sessionId)
|
||||
}
|
||||
|
||||
private fun clearSummaryNotificationIfNeeded(sessionId: SessionId) {
|
||||
val summaryNotification = activeNotificationsProvider.getSummaryNotification(sessionId)
|
||||
if (summaryNotification != null && activeNotificationsProvider.count(sessionId) == 1) {
|
||||
notificationDisplayer.cancelNotification(null, summaryNotification.id)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun renderEvents(eventsToRender: List<NotifiableEvent>) {
|
||||
// Group by sessionId
|
||||
val eventsForSessions = eventsToRender.groupBy {
|
||||
it.sessionId
|
||||
}
|
||||
|
||||
for ((sessionId, notifiableEvents) in eventsForSessions) {
|
||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow()
|
||||
val imageLoader = imageLoaderHolder.get(client)
|
||||
val userFromCache = client.userProfile.value
|
||||
val currentUser = if (userFromCache.avatarUrl != null && userFromCache.displayName.isNullOrEmpty().not()) {
|
||||
// We have an avatar and a display name, use it
|
||||
userFromCache
|
||||
} else {
|
||||
client.getUserProfile().getOrNull() ?: MatrixUser(sessionId)
|
||||
}
|
||||
notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents, imageLoader)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
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.OnMissedCallNotificationHandler
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultOnMissedCallNotificationHandler(
|
||||
private val matrixClientProvider: MatrixClientProvider,
|
||||
private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager,
|
||||
private val callNotificationEventResolver: CallNotificationEventResolver,
|
||||
) : OnMissedCallNotificationHandler {
|
||||
override suspend fun addMissedCallNotification(
|
||||
sessionId: SessionId,
|
||||
roomId: RoomId,
|
||||
eventId: EventId,
|
||||
) {
|
||||
// Resolve the event and add a notification for it, at this point it should no longer be a ringing one
|
||||
val notificationData = matrixClientProvider.getOrRestore(sessionId).getOrNull()
|
||||
?.notificationService
|
||||
?.getNotifications(mapOf(roomId to listOf(eventId)))
|
||||
?.getOrNull()
|
||||
?.get(eventId)
|
||||
?.getOrNull()
|
||||
?: return
|
||||
|
||||
val notifiableEvent = callNotificationEventResolver.resolveEvent(
|
||||
sessionId = sessionId,
|
||||
notificationData = notificationData,
|
||||
// Make sure the notifiable event is not a ringing one
|
||||
forceNotify = true,
|
||||
).getOrNull()
|
||||
notifiableEvent?.let { defaultNotificationDrawerManager.onNotifiableEventReceived(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
|
||||
@Inject
|
||||
class FallbackNotificationFactory(
|
||||
private val clock: SystemClock,
|
||||
private val stringProvider: StringProvider,
|
||||
) {
|
||||
fun create(
|
||||
sessionId: SessionId,
|
||||
roomId: RoomId,
|
||||
eventId: EventId,
|
||||
cause: String?,
|
||||
): FallbackNotifiableEvent = FallbackNotifiableEvent(
|
||||
sessionId = sessionId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
editedEventId = null,
|
||||
canBeReplaced = true,
|
||||
isRedacted = false,
|
||||
isUpdated = false,
|
||||
timestamp = clock.epochMillis(),
|
||||
description = stringProvider.getString(R.string.notification_fallback_content),
|
||||
cause = cause,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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
|
||||
|
||||
data class NotificationAction(
|
||||
val shouldNotify: Boolean,
|
||||
val highlight: Boolean,
|
||||
val soundName: String?
|
||||
)
|
||||
|
||||
/*
|
||||
fun List<Action>.toNotificationAction(): NotificationAction {
|
||||
var shouldNotify = false
|
||||
var highlight = false
|
||||
var sound: String? = null
|
||||
forEach { action ->
|
||||
when (action) {
|
||||
is Action.Notify -> shouldNotify = true
|
||||
is Action.DoNotNotify -> shouldNotify = false
|
||||
is Action.Highlight -> highlight = action.highlight
|
||||
is Action.Sound -> sound = action.sound
|
||||
}
|
||||
}
|
||||
return NotificationAction(shouldNotify, highlight, sound)
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2022-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
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
|
||||
/**
|
||||
* Util class for creating notifications action Ids, using the application id.
|
||||
*/
|
||||
@Inject data class NotificationActionIds(
|
||||
private val buildMeta: BuildMeta,
|
||||
) {
|
||||
val join = "${buildMeta.applicationId}.NotificationActions.JOIN_ACTION"
|
||||
val reject = "${buildMeta.applicationId}.NotificationActions.REJECT_ACTION"
|
||||
val markRoomRead = "${buildMeta.applicationId}.NotificationActions.MARK_ROOM_READ_ACTION"
|
||||
val smartReply = "${buildMeta.applicationId}.NotificationActions.SMART_REPLY_ACTION"
|
||||
val dismissSummary = "${buildMeta.applicationId}.NotificationActions.DISMISS_SUMMARY_ACTION"
|
||||
val dismissRoom = "${buildMeta.applicationId}.NotificationActions.DISMISS_ROOM_NOTIF_ACTION"
|
||||
val dismissInvite = "${buildMeta.applicationId}.NotificationActions.DISMISS_INVITE_NOTIF_ACTION"
|
||||
val dismissEvent = "${buildMeta.applicationId}.NotificationActions.DISMISS_EVENT_NOTIF_ACTION"
|
||||
val diagnostic = "${buildMeta.applicationId}.NotificationActions.DIAGNOSTIC"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
|
||||
/**
|
||||
* Receives actions broadcast by notification (on click, on dismiss, inline replies, etc.).
|
||||
*/
|
||||
class NotificationBroadcastReceiver : BroadcastReceiver() {
|
||||
@Inject lateinit var notificationBroadcastReceiverHandler: NotificationBroadcastReceiverHandler
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent == null || context == null) return
|
||||
context.bindings<NotificationBroadcastReceiverBindings>().inject(this)
|
||||
notificationBroadcastReceiverHandler.onReceive(intent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_SESSION_ID = "sessionID"
|
||||
const val KEY_ROOM_ID = "roomID"
|
||||
const val KEY_THREAD_ID = "threadID"
|
||||
const val KEY_EVENT_ID = "eventID"
|
||||
const val KEY_TEXT_REPLY = "key_text_reply"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
|
||||
@ContributesTo(AppScope::class)
|
||||
interface NotificationBroadcastReceiverBindings {
|
||||
fun inject(receiver: NotificationBroadcastReceiver)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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
|
||||
|
||||
import android.content.Intent
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.isDm
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
|
||||
import io.element.android.libraries.push.api.notifications.NotificationCleaner
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived
|
||||
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
private val loggerTag = LoggerTag("NotificationBroadcastReceiverHandler", LoggerTag.NotificationLoggerTag)
|
||||
|
||||
@Inject
|
||||
class NotificationBroadcastReceiverHandler(
|
||||
@AppCoroutineScope
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
private val matrixClientProvider: MatrixClientProvider,
|
||||
private val sessionPreferencesStore: SessionPreferencesStoreFactory,
|
||||
private val notificationCleaner: NotificationCleaner,
|
||||
private val actionIds: NotificationActionIds,
|
||||
private val systemClock: SystemClock,
|
||||
private val onNotifiableEventReceived: OnNotifiableEventReceived,
|
||||
private val stringProvider: StringProvider,
|
||||
private val replyMessageExtractor: ReplyMessageExtractor,
|
||||
private val activeRoomsHolder: ActiveRoomsHolder,
|
||||
) {
|
||||
fun onReceive(intent: Intent) {
|
||||
val sessionId = intent.getStringExtra(NotificationBroadcastReceiver.KEY_SESSION_ID)?.let(::SessionId) ?: return
|
||||
val roomId = intent.getStringExtra(NotificationBroadcastReceiver.KEY_ROOM_ID)?.let(::RoomId)
|
||||
val threadId = intent.getStringExtra(NotificationBroadcastReceiver.KEY_THREAD_ID)?.let(::ThreadId)
|
||||
val eventId = intent.getStringExtra(NotificationBroadcastReceiver.KEY_EVENT_ID)?.let(::EventId)
|
||||
|
||||
Timber.tag(loggerTag.value).d("onReceive: ${intent.action} ${intent.data} for: ${roomId?.value}/${eventId?.value}")
|
||||
when (intent.action) {
|
||||
actionIds.smartReply -> if (roomId != null) {
|
||||
handleSmartReply(sessionId, roomId, eventId, threadId, intent)
|
||||
}
|
||||
actionIds.dismissRoom -> if (roomId != null) {
|
||||
notificationCleaner.clearMessagesForRoom(sessionId, roomId)
|
||||
}
|
||||
actionIds.dismissSummary ->
|
||||
notificationCleaner.clearAllMessagesEvents(sessionId)
|
||||
actionIds.dismissInvite -> if (roomId != null) {
|
||||
notificationCleaner.clearMembershipNotificationForRoom(sessionId, roomId)
|
||||
}
|
||||
actionIds.dismissEvent -> if (eventId != null) {
|
||||
notificationCleaner.clearEvent(sessionId, eventId)
|
||||
}
|
||||
actionIds.markRoomRead -> if (roomId != null) {
|
||||
if (threadId == null) {
|
||||
notificationCleaner.clearMessagesForRoom(sessionId, roomId)
|
||||
} else {
|
||||
notificationCleaner.clearMessagesForThread(sessionId, roomId, threadId)
|
||||
}
|
||||
handleMarkAsRead(sessionId, roomId, threadId)
|
||||
}
|
||||
actionIds.join -> if (roomId != null) {
|
||||
notificationCleaner.clearMembershipNotificationForRoom(sessionId, roomId)
|
||||
handleJoinRoom(sessionId, roomId)
|
||||
}
|
||||
actionIds.reject -> if (roomId != null) {
|
||||
notificationCleaner.clearMembershipNotificationForRoom(sessionId, roomId)
|
||||
handleRejectRoom(sessionId, roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleJoinRoom(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch {
|
||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
|
||||
client.joinRoom(roomId)
|
||||
}
|
||||
|
||||
private fun handleRejectRoom(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch {
|
||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
|
||||
client.getRoom(roomId)?.leave()
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?) = appCoroutineScope.launch {
|
||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
|
||||
val isSendPublicReadReceiptsEnabled = sessionPreferencesStore.get(sessionId, this).isSendPublicReadReceiptsEnabled().first()
|
||||
val receiptType = if (isSendPublicReadReceiptsEnabled) {
|
||||
ReceiptType.READ
|
||||
} else {
|
||||
ReceiptType.READ_PRIVATE
|
||||
}
|
||||
val room = client.getJoinedRoom(roomId) ?: return@launch
|
||||
val timeline = if (threadId != null) {
|
||||
room.createTimeline(CreateTimelineParams.Threaded(threadId)).getOrNull()
|
||||
} else {
|
||||
room.liveTimeline
|
||||
}
|
||||
timeline?.markAsRead(receiptType)
|
||||
?.onSuccess {
|
||||
if (threadId != null) {
|
||||
Timber.d("Marked thread $threadId in room $roomId as read with receipt type $receiptType")
|
||||
} else {
|
||||
Timber.d("Marked room $roomId as read with receipt type $receiptType")
|
||||
}
|
||||
}
|
||||
?.onFailure {
|
||||
Timber.e(it, "Fails to mark as read with receipt type $receiptType")
|
||||
}
|
||||
if (timeline?.mode != Timeline.Mode.Live) {
|
||||
timeline?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSmartReply(
|
||||
sessionId: SessionId,
|
||||
roomId: RoomId,
|
||||
replyToEventId: EventId?,
|
||||
threadId: ThreadId?,
|
||||
intent: Intent,
|
||||
) = appCoroutineScope.launch {
|
||||
val message = replyMessageExtractor.getReplyMessage(intent)
|
||||
if (message.isNullOrBlank()) {
|
||||
// ignore this event
|
||||
// Can this happen? should we update notification?
|
||||
return@launch
|
||||
}
|
||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
|
||||
val room = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId) ?: client.getJoinedRoom(roomId)
|
||||
|
||||
room?.let {
|
||||
sendMatrixEvent(
|
||||
sessionId = sessionId,
|
||||
roomId = roomId,
|
||||
replyToEventId = replyToEventId,
|
||||
threadId = threadId,
|
||||
room = it,
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendMatrixEvent(
|
||||
sessionId: SessionId,
|
||||
roomId: RoomId,
|
||||
threadId: ThreadId?,
|
||||
replyToEventId: EventId?,
|
||||
room: JoinedRoom,
|
||||
message: String,
|
||||
) {
|
||||
// Create a new event to be displayed in the notification drawer, right now
|
||||
val notifiableMessageEvent = NotifiableMessageEvent(
|
||||
sessionId = sessionId,
|
||||
roomId = roomId,
|
||||
// Generate a Fake event id
|
||||
eventId = EventId("\$" + UUID.randomUUID().toString()),
|
||||
editedEventId = null,
|
||||
canBeReplaced = false,
|
||||
senderId = sessionId,
|
||||
noisy = false,
|
||||
timestamp = systemClock.epochMillis(),
|
||||
senderDisambiguatedDisplayName = room.getUpdatedMember(sessionId).getOrNull()
|
||||
?.disambiguatedDisplayName
|
||||
?: stringProvider.getString(R.string.notification_sender_me),
|
||||
body = message,
|
||||
imageUriString = null,
|
||||
imageMimeType = null,
|
||||
threadId = threadId,
|
||||
roomName = room.info().name,
|
||||
roomIsDm = room.isDm(),
|
||||
outGoingMessage = true,
|
||||
)
|
||||
onNotifiableEventReceived.onNotifiableEventsReceived(listOf(notifiableMessageEvent))
|
||||
|
||||
if (threadId != null && replyToEventId != null) {
|
||||
room.liveTimeline.replyMessage(
|
||||
body = message,
|
||||
htmlBody = null,
|
||||
intentionalMentions = emptyList(),
|
||||
fromNotification = true,
|
||||
repliedToEventId = replyToEventId,
|
||||
)
|
||||
} else {
|
||||
room.liveTimeline.sendMessage(
|
||||
body = message,
|
||||
htmlBody = null,
|
||||
intentionalMentions = emptyList()
|
||||
)
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to send smart reply message")
|
||||
onNotifiableEventReceived.onNotifiableEventsReceived(
|
||||
listOf(
|
||||
notifiableMessageEvent.copy(
|
||||
outGoingMessageFailed = true
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2021-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
|
||||
|
||||
import android.app.Notification
|
||||
import android.graphics.Typeface
|
||||
import android.text.style.StyleSpan
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import coil3.ImageLoader
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
|
||||
interface NotificationDataFactory {
|
||||
suspend fun toNotifications(
|
||||
messages: List<NotifiableMessageEvent>,
|
||||
imageLoader: ImageLoader,
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
): List<RoomNotification>
|
||||
|
||||
@JvmName("toNotificationInvites")
|
||||
@Suppress("INAPPLICABLE_JVM_NAME")
|
||||
fun toNotifications(
|
||||
invites: List<InviteNotifiableEvent>,
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
): List<OneShotNotification>
|
||||
|
||||
@JvmName("toNotificationSimpleEvents")
|
||||
@Suppress("INAPPLICABLE_JVM_NAME")
|
||||
fun toNotifications(
|
||||
simpleEvents: List<SimpleNotifiableEvent>,
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
): List<OneShotNotification>
|
||||
|
||||
@JvmName("toNotificationFallbackEvents")
|
||||
@Suppress("INAPPLICABLE_JVM_NAME")
|
||||
fun toNotifications(
|
||||
fallback: List<FallbackNotifiableEvent>,
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
): List<OneShotNotification>
|
||||
|
||||
fun createSummaryNotification(
|
||||
roomNotifications: List<RoomNotification>,
|
||||
invitationNotifications: List<OneShotNotification>,
|
||||
simpleNotifications: List<OneShotNotification>,
|
||||
fallbackNotifications: List<OneShotNotification>,
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
): SummaryNotification
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultNotificationDataFactory(
|
||||
private val notificationCreator: NotificationCreator,
|
||||
private val roomGroupMessageCreator: RoomGroupMessageCreator,
|
||||
private val summaryGroupMessageCreator: SummaryGroupMessageCreator,
|
||||
private val activeNotificationsProvider: ActiveNotificationsProvider,
|
||||
private val stringProvider: StringProvider,
|
||||
) : NotificationDataFactory {
|
||||
override suspend fun toNotifications(
|
||||
messages: List<NotifiableMessageEvent>,
|
||||
imageLoader: ImageLoader,
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
): List<RoomNotification> {
|
||||
val messagesToDisplay = messages.filterNot { it.canNotBeDisplayed() }
|
||||
.groupBy { it.roomId }
|
||||
return messagesToDisplay.flatMap { (roomId, events) ->
|
||||
val roomName = events.lastOrNull()?.roomName ?: roomId.value
|
||||
val isDm = events.lastOrNull()?.roomIsDm ?: false
|
||||
val eventsByThreadId = events.groupBy { it.threadId }
|
||||
|
||||
eventsByThreadId.map { (threadId, events) ->
|
||||
val notification = roomGroupMessageCreator.createRoomMessage(
|
||||
events = events,
|
||||
roomId = roomId,
|
||||
threadId = threadId,
|
||||
imageLoader = imageLoader,
|
||||
existingNotification = getExistingNotificationForMessages(notificationAccountParams.user.userId, roomId, threadId),
|
||||
notificationAccountParams = notificationAccountParams,
|
||||
)
|
||||
RoomNotification(
|
||||
notification = notification,
|
||||
roomId = roomId,
|
||||
threadId = threadId,
|
||||
summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, isDm),
|
||||
messageCount = events.size,
|
||||
latestTimestamp = events.maxOf { it.timestamp },
|
||||
shouldBing = events.any { it.noisy }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted
|
||||
|
||||
private fun getExistingNotificationForMessages(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): Notification? {
|
||||
return activeNotificationsProvider.getMessageNotificationsForRoom(sessionId, roomId, threadId).firstOrNull()?.notification
|
||||
}
|
||||
|
||||
@JvmName("toNotificationInvites")
|
||||
@Suppress("INAPPLICABLE_JVM_NAME")
|
||||
override fun toNotifications(
|
||||
invites: List<InviteNotifiableEvent>,
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
): List<OneShotNotification> {
|
||||
return invites.map { event ->
|
||||
OneShotNotification(
|
||||
tag = event.roomId.value,
|
||||
notification = notificationCreator.createRoomInvitationNotification(notificationAccountParams, event),
|
||||
summaryLine = event.description,
|
||||
isNoisy = event.noisy,
|
||||
timestamp = event.timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmName("toNotificationSimpleEvents")
|
||||
@Suppress("INAPPLICABLE_JVM_NAME")
|
||||
override fun toNotifications(
|
||||
simpleEvents: List<SimpleNotifiableEvent>,
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
): List<OneShotNotification> {
|
||||
return simpleEvents.map { event ->
|
||||
OneShotNotification(
|
||||
tag = event.eventId.value,
|
||||
notification = notificationCreator.createSimpleEventNotification(notificationAccountParams, event),
|
||||
summaryLine = event.description,
|
||||
isNoisy = event.noisy,
|
||||
timestamp = event.timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmName("toNotificationFallbackEvents")
|
||||
@Suppress("INAPPLICABLE_JVM_NAME")
|
||||
override fun toNotifications(
|
||||
fallback: List<FallbackNotifiableEvent>,
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
): List<OneShotNotification> {
|
||||
return fallback.map { event ->
|
||||
OneShotNotification(
|
||||
tag = event.eventId.value,
|
||||
notification = notificationCreator.createFallbackNotification(notificationAccountParams, event),
|
||||
summaryLine = event.description.orEmpty(),
|
||||
isNoisy = false,
|
||||
timestamp = event.timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun createSummaryNotification(
|
||||
roomNotifications: List<RoomNotification>,
|
||||
invitationNotifications: List<OneShotNotification>,
|
||||
simpleNotifications: List<OneShotNotification>,
|
||||
fallbackNotifications: List<OneShotNotification>,
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
): SummaryNotification {
|
||||
return when {
|
||||
roomNotifications.isEmpty() && invitationNotifications.isEmpty() && simpleNotifications.isEmpty() -> SummaryNotification.Removed
|
||||
else -> SummaryNotification.Update(
|
||||
summaryGroupMessageCreator.createSummaryNotification(
|
||||
roomNotifications = roomNotifications,
|
||||
invitationNotifications = invitationNotifications,
|
||||
simpleNotifications = simpleNotifications,
|
||||
fallbackNotifications = fallbackNotifications,
|
||||
notificationAccountParams = notificationAccountParams,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRoomMessagesGroupSummaryLine(events: List<NotifiableMessageEvent>, roomName: String, roomIsDm: Boolean): CharSequence {
|
||||
return when (events.size) {
|
||||
1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDm)
|
||||
else -> {
|
||||
stringProvider.getQuantityString(
|
||||
R.plurals.notification_compat_summary_line_for_room,
|
||||
events.size,
|
||||
roomName,
|
||||
events.size
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDm: Boolean): CharSequence {
|
||||
return if (roomIsDm) {
|
||||
buildSpannedString {
|
||||
event.senderDisambiguatedDisplayName?.let {
|
||||
inSpans(StyleSpan(Typeface.BOLD)) {
|
||||
append(it)
|
||||
append(": ")
|
||||
}
|
||||
}
|
||||
append(event.description)
|
||||
}
|
||||
} else {
|
||||
buildSpannedString {
|
||||
inSpans(StyleSpan(Typeface.BOLD)) {
|
||||
append(roomName)
|
||||
append(": ")
|
||||
event.senderDisambiguatedDisplayName?.let {
|
||||
append(it)
|
||||
append(" ")
|
||||
}
|
||||
}
|
||||
append(event.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class RoomNotification(
|
||||
val notification: Notification,
|
||||
val roomId: RoomId,
|
||||
val threadId: ThreadId?,
|
||||
val summaryLine: CharSequence,
|
||||
val messageCount: Int,
|
||||
val latestTimestamp: Long,
|
||||
val shouldBing: Boolean,
|
||||
) {
|
||||
fun isDataEqualTo(other: RoomNotification): Boolean {
|
||||
return notification == other.notification &&
|
||||
roomId == other.roomId &&
|
||||
threadId == other.threadId &&
|
||||
summaryLine.toString() == other.summaryLine.toString() &&
|
||||
messageCount == other.messageCount &&
|
||||
latestTimestamp == other.latestTimestamp &&
|
||||
shouldBing == other.shouldBing
|
||||
}
|
||||
}
|
||||
|
||||
data class OneShotNotification(
|
||||
val notification: Notification,
|
||||
val tag: String,
|
||||
val summaryLine: CharSequence,
|
||||
val isNoisy: Boolean,
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
sealed interface SummaryNotification {
|
||||
data object Removed : SummaryNotification
|
||||
data class Update(val notification: Notification) : SummaryNotification
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2021-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
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import timber.log.Timber
|
||||
|
||||
interface NotificationDisplayer {
|
||||
fun showNotification(tag: String?, id: Int, notification: Notification): Boolean
|
||||
fun cancelNotification(tag: String?, id: Int)
|
||||
fun displayDiagnosticNotification(notification: Notification): Boolean
|
||||
fun dismissDiagnosticNotification()
|
||||
fun displayUnregistrationNotification(notification: Notification): Boolean
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultNotificationDisplayer(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val notificationManager: NotificationManagerCompat
|
||||
) : NotificationDisplayer {
|
||||
override fun showNotification(tag: String?, id: Int, notification: Notification): Boolean {
|
||||
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
Timber.w("Not allowed to notify.")
|
||||
return false
|
||||
}
|
||||
notificationManager.notify(tag, id, notification)
|
||||
Timber.d("Notifying with tag: $tag, id: $id")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun cancelNotification(tag: String?, id: Int) {
|
||||
notificationManager.cancel(tag, id)
|
||||
}
|
||||
|
||||
override fun displayDiagnosticNotification(notification: Notification): Boolean {
|
||||
return showNotification(
|
||||
tag = TAG_DIAGNOSTIC,
|
||||
id = NOTIFICATION_ID_DIAGNOSTIC,
|
||||
notification = notification
|
||||
)
|
||||
}
|
||||
|
||||
override fun dismissDiagnosticNotification() {
|
||||
cancelNotification(
|
||||
tag = TAG_DIAGNOSTIC,
|
||||
id = NOTIFICATION_ID_DIAGNOSTIC
|
||||
)
|
||||
}
|
||||
|
||||
override fun displayUnregistrationNotification(notification: Notification): Boolean {
|
||||
return showNotification(
|
||||
tag = TAG_DIAGNOSTIC,
|
||||
id = NOTIFICATION_ID_UNREGISTRATION,
|
||||
notification = notification,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG_DIAGNOSTIC = "DIAGNOSTIC"
|
||||
|
||||
/* ==========================================================================================
|
||||
* IDs for notifications
|
||||
* ========================================================================================== */
|
||||
private const val NOTIFICATION_ID_DIAGNOSTIC = 888
|
||||
private const val NOTIFICATION_ID_UNREGISTRATION = 889
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.core.extensions.mapCatchingExceptions
|
||||
import io.element.android.libraries.di.CacheDirectory
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.mxc.MxcTools
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Fetches the media file for a notification.
|
||||
*
|
||||
* Media is downloaded from the rust sdk and stored in the application's cache directory.
|
||||
* Media files are indexed by their Matrix Content (mxc://) URI and considered immutable.
|
||||
* Whenever a given mxc is found in the cache, it is returned immediately.
|
||||
*/
|
||||
interface NotificationMediaRepo {
|
||||
/**
|
||||
* Factory for [NotificationMediaRepo].
|
||||
*/
|
||||
fun interface Factory {
|
||||
/**
|
||||
* Creates a [NotificationMediaRepo].
|
||||
*
|
||||
*/
|
||||
fun create(
|
||||
client: MatrixClient
|
||||
): NotificationMediaRepo
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file.
|
||||
*
|
||||
* In case of a cache hit the file is returned immediately.
|
||||
* In case of a cache miss the file is downloaded and then returned.
|
||||
*
|
||||
* @param mediaSource the media source of the media.
|
||||
* @param mimeType the mime type of the media.
|
||||
* @param filename optional String which will be used to name the file.
|
||||
* @return A [Result] holding either the media [File] from the cache directory or an [Exception].
|
||||
*/
|
||||
suspend fun getMediaFile(
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
filename: String?,
|
||||
): Result<File>
|
||||
}
|
||||
|
||||
@AssistedInject
|
||||
class DefaultNotificationMediaRepo(
|
||||
@CacheDirectory private val cacheDir: File,
|
||||
private val mxcTools: MxcTools,
|
||||
@Assisted private val client: MatrixClient,
|
||||
) : NotificationMediaRepo {
|
||||
@ContributesBinding(AppScope::class)
|
||||
@AssistedFactory
|
||||
fun interface Factory : NotificationMediaRepo.Factory {
|
||||
override fun create(
|
||||
client: MatrixClient,
|
||||
): DefaultNotificationMediaRepo
|
||||
}
|
||||
|
||||
private val matrixMediaLoader = client.matrixMediaLoader
|
||||
|
||||
override suspend fun getMediaFile(
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
filename: String?,
|
||||
): Result<File> {
|
||||
val cachedFile = mediaSource.cachedFile()
|
||||
return when {
|
||||
cachedFile == null -> Result.failure(IllegalStateException("Invalid mxcUri."))
|
||||
cachedFile.exists() -> Result.success(cachedFile)
|
||||
else -> matrixMediaLoader.downloadMediaFile(
|
||||
source = mediaSource,
|
||||
mimeType = mimeType,
|
||||
filename = filename,
|
||||
).mapCatchingExceptions {
|
||||
it.use { mediaFile ->
|
||||
val dest = cachedFile.apply { parentFile?.mkdirs() }
|
||||
if (mediaFile.persist(dest.path)) {
|
||||
dest
|
||||
} else {
|
||||
error("Failed to move file to cache.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaSource.cachedFile(): File? = mxcTools.mxcUri2FilePath(url)?.let {
|
||||
File("${cacheDir.path}/$CACHE_NOTIFICATION_SUBDIR/$it")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subdirectory of the application's cache directory where file are stored.
|
||||
*/
|
||||
private const val CACHE_NOTIFICATION_SUBDIR = "temp/notif"
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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
|
||||
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import coil3.ImageLoader
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.appconfig.NotificationConfig
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import timber.log.Timber
|
||||
|
||||
private val loggerTag = LoggerTag("NotificationRenderer", LoggerTag.NotificationLoggerTag)
|
||||
|
||||
@Inject
|
||||
class NotificationRenderer(
|
||||
private val notificationDisplayer: NotificationDisplayer,
|
||||
private val notificationDataFactory: NotificationDataFactory,
|
||||
private val enterpriseService: EnterpriseService,
|
||||
private val sessionStore: SessionStore,
|
||||
) {
|
||||
suspend fun render(
|
||||
currentUser: MatrixUser,
|
||||
useCompleteNotificationFormat: Boolean,
|
||||
eventsToProcess: List<NotifiableEvent>,
|
||||
imageLoader: ImageLoader,
|
||||
) {
|
||||
val color = enterpriseService.brandColorsFlow(currentUser.userId).first()?.toArgb()
|
||||
?: NotificationConfig.NOTIFICATION_ACCENT_COLOR
|
||||
val numberOfAccounts = sessionStore.numberOfSessions()
|
||||
val notificationAccountParams = NotificationAccountParams(
|
||||
user = currentUser,
|
||||
color = color,
|
||||
showSessionId = numberOfAccounts > 1,
|
||||
)
|
||||
val groupedEvents = eventsToProcess.groupByType()
|
||||
val roomNotifications = notificationDataFactory.toNotifications(groupedEvents.roomEvents, imageLoader, notificationAccountParams)
|
||||
val invitationNotifications = notificationDataFactory.toNotifications(groupedEvents.invitationEvents, notificationAccountParams)
|
||||
val simpleNotifications = notificationDataFactory.toNotifications(groupedEvents.simpleEvents, notificationAccountParams)
|
||||
val fallbackNotifications = notificationDataFactory.toNotifications(groupedEvents.fallbackEvents, notificationAccountParams)
|
||||
val summaryNotification = notificationDataFactory.createSummaryNotification(
|
||||
roomNotifications = roomNotifications,
|
||||
invitationNotifications = invitationNotifications,
|
||||
simpleNotifications = simpleNotifications,
|
||||
fallbackNotifications = fallbackNotifications,
|
||||
notificationAccountParams = notificationAccountParams,
|
||||
)
|
||||
|
||||
// Remove summary first to avoid briefly displaying it after dismissing the last notification
|
||||
if (summaryNotification == SummaryNotification.Removed) {
|
||||
Timber.tag(loggerTag.value).d("Removing summary notification")
|
||||
notificationDisplayer.cancelNotification(
|
||||
tag = null,
|
||||
id = NotificationIdProvider.getSummaryNotificationId(currentUser.userId)
|
||||
)
|
||||
}
|
||||
|
||||
roomNotifications.forEach { notificationData ->
|
||||
val tag = NotificationCreator.messageTag(
|
||||
roomId = notificationData.roomId,
|
||||
threadId = notificationData.threadId
|
||||
)
|
||||
notificationDisplayer.showNotification(
|
||||
tag = tag,
|
||||
id = NotificationIdProvider.getRoomMessagesNotificationId(currentUser.userId),
|
||||
notification = notificationData.notification
|
||||
)
|
||||
}
|
||||
|
||||
invitationNotifications.forEach { notificationData ->
|
||||
if (useCompleteNotificationFormat) {
|
||||
Timber.tag(loggerTag.value).d("Updating invitation notification ${notificationData.tag}")
|
||||
notificationDisplayer.showNotification(
|
||||
tag = notificationData.tag,
|
||||
id = NotificationIdProvider.getRoomInvitationNotificationId(currentUser.userId),
|
||||
notification = notificationData.notification
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
simpleNotifications.forEach { notificationData ->
|
||||
if (useCompleteNotificationFormat) {
|
||||
Timber.tag(loggerTag.value).d("Updating simple notification ${notificationData.tag}")
|
||||
notificationDisplayer.showNotification(
|
||||
tag = notificationData.tag,
|
||||
id = NotificationIdProvider.getRoomEventNotificationId(currentUser.userId),
|
||||
notification = notificationData.notification
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Show only the first fallback notification
|
||||
if (fallbackNotifications.isNotEmpty()) {
|
||||
Timber.tag(loggerTag.value).d("Showing fallback notification")
|
||||
notificationDisplayer.showNotification(
|
||||
tag = "FALLBACK",
|
||||
id = NotificationIdProvider.getFallbackNotificationId(currentUser.userId),
|
||||
notification = fallbackNotifications.first().notification
|
||||
)
|
||||
}
|
||||
|
||||
// Update summary last to avoid briefly displaying it before other notifications
|
||||
if (summaryNotification is SummaryNotification.Update) {
|
||||
Timber.tag(loggerTag.value).d("Updating summary notification")
|
||||
notificationDisplayer.showNotification(
|
||||
tag = null,
|
||||
id = NotificationIdProvider.getSummaryNotificationId(currentUser.userId),
|
||||
notification = summaryNotification.notification
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<NotifiableEvent>.groupByType(): GroupedNotificationEvents {
|
||||
val roomEvents: MutableList<NotifiableMessageEvent> = mutableListOf()
|
||||
val simpleEvents: MutableList<SimpleNotifiableEvent> = mutableListOf()
|
||||
val invitationEvents: MutableList<InviteNotifiableEvent> = mutableListOf()
|
||||
val fallbackEvents: MutableList<FallbackNotifiableEvent> = mutableListOf()
|
||||
forEach { event ->
|
||||
when (event) {
|
||||
is InviteNotifiableEvent -> invitationEvents.add(event.castedToEventType())
|
||||
is NotifiableMessageEvent -> roomEvents.add(event.castedToEventType())
|
||||
is SimpleNotifiableEvent -> simpleEvents.add(event.castedToEventType())
|
||||
is FallbackNotifiableEvent -> fallbackEvents.add(event.castedToEventType())
|
||||
// Nothing should be done for ringing call events as they're not handled here
|
||||
is NotifiableRingingCallEvent -> {}
|
||||
}
|
||||
}
|
||||
return GroupedNotificationEvents(roomEvents, simpleEvents, invitationEvents, fallbackEvents)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T : NotifiableEvent> NotifiableEvent.castedToEventType(): T = this as T
|
||||
|
||||
data class GroupedNotificationEvents(
|
||||
val roomEvents: List<NotifiableMessageEvent>,
|
||||
val simpleEvents: List<SimpleNotifiableEvent>,
|
||||
val invitationEvents: List<InviteNotifiableEvent>,
|
||||
val fallbackEvents: List<FallbackNotifiableEvent>,
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||
import io.element.android.libraries.push.impl.workmanager.SyncNotificationWorkManagerRequest
|
||||
import io.element.android.libraries.push.impl.workmanager.WorkerDataConverter
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
|
||||
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
interface NotificationResolverQueue {
|
||||
val results: SharedFlow<Pair<List<NotificationEventRequest>, Map<NotificationEventRequest, Result<ResolvedPushEvent>>>>
|
||||
suspend fun enqueue(request: NotificationEventRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* This class is responsible for periodically batching notification requests and resolving them in a single call,
|
||||
* so that we can avoid having to resolve each notification individually in the SDK.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultNotificationResolverQueue(
|
||||
private val notifiableEventResolver: NotifiableEventResolver,
|
||||
@AppCoroutineScope
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
private val workManagerScheduler: WorkManagerScheduler,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val workerDataConverter: WorkerDataConverter,
|
||||
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
|
||||
) : NotificationResolverQueue {
|
||||
companion object {
|
||||
private const val BATCH_WINDOW_MS = 250L
|
||||
}
|
||||
|
||||
private val requestQueue = Channel<NotificationEventRequest>(capacity = 100)
|
||||
|
||||
private var currentProcessingJob: Job? = null
|
||||
|
||||
/**
|
||||
* A flow that emits pairs of a list of notification event requests and a map of the resolved events.
|
||||
* The map contains the original request as the key and the resolved event as the value.
|
||||
*/
|
||||
override val results = MutableSharedFlow<Pair<List<NotificationEventRequest>, Map<NotificationEventRequest, Result<ResolvedPushEvent>>>>()
|
||||
|
||||
/**
|
||||
* Enqueues a notification event request to be resolved.
|
||||
* The request will be processed in batches, so it may not be resolved immediately.
|
||||
*
|
||||
* @param request The notification event request to enqueue.
|
||||
*/
|
||||
override suspend fun enqueue(request: NotificationEventRequest) {
|
||||
// Cancel previous processing job if it exists, acting as a debounce operation
|
||||
Timber.d("Cancelling job: $currentProcessingJob")
|
||||
currentProcessingJob?.cancel()
|
||||
|
||||
// Enqueue the request and start a delayed processing job
|
||||
requestQueue.send(request)
|
||||
currentProcessingJob = processQueue()
|
||||
Timber.d("Starting processing job for request: $request")
|
||||
}
|
||||
|
||||
private fun processQueue() = appCoroutineScope.launch(SupervisorJob()) {
|
||||
delay(BATCH_WINDOW_MS.milliseconds)
|
||||
|
||||
// If this job is still active (so this is the latest job), we launch a separate one that won't be cancelled when enqueueing new items
|
||||
// to process the existing queued items.
|
||||
appCoroutineScope.launch {
|
||||
val groupedRequestsById = buildList {
|
||||
while (!requestQueue.isEmpty) {
|
||||
requestQueue.receiveCatching().getOrNull()?.let(::add)
|
||||
}
|
||||
}.groupBy { it.sessionId }
|
||||
|
||||
if (featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) {
|
||||
for ((sessionId, requests) in groupedRequestsById) {
|
||||
workManagerScheduler.submit(
|
||||
SyncNotificationWorkManagerRequest(
|
||||
sessionId = sessionId,
|
||||
notificationEventRequests = requests,
|
||||
workerDataConverter = workerDataConverter,
|
||||
buildVersionSdkIntProvider = buildVersionSdkIntProvider,
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val sessionIds = groupedRequestsById.keys
|
||||
for (sessionId in sessionIds) {
|
||||
val requests = groupedRequestsById[sessionId].orEmpty()
|
||||
Timber.d("Fetching notifications for $sessionId: $requests. Pending requests: ${!requestQueue.isEmpty}")
|
||||
// Resolving the events in parallel should improve performance since each session id will query a different Client
|
||||
launch {
|
||||
// No need for a Mutex since the SDK already has one internally
|
||||
val notifications = notifiableEventResolver.resolveEvents(sessionId, requests).getOrNull().orEmpty()
|
||||
results.emit(requests to notifications)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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
|
||||
|
||||
import androidx.core.content.FileProvider
|
||||
|
||||
/**
|
||||
* We have to declare our own file provider to avoid collision with other modules
|
||||
* having their own.
|
||||
*/
|
||||
class NotificationsFileProvider : FileProvider()
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.core.app.RemoteInput
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
|
||||
interface ReplyMessageExtractor {
|
||||
fun getReplyMessage(intent: Intent): String?
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class AndroidReplyMessageExtractor : ReplyMessageExtractor {
|
||||
override fun getReplyMessage(intent: Intent): String? {
|
||||
return RemoteInput.getResultsFromIntent(intent)
|
||||
?.getCharSequence(NotificationBroadcastReceiver.KEY_TEXT_REPLY)
|
||||
?.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
/**
|
||||
* Data class to hold information about a group of notifications for a room.
|
||||
*/
|
||||
data class RoomEventGroupInfo(
|
||||
val sessionId: SessionId,
|
||||
val roomId: RoomId,
|
||||
val roomDisplayName: String,
|
||||
val isDm: Boolean = false,
|
||||
// An event in the list has not yet been display
|
||||
val hasNewEvent: Boolean = false,
|
||||
// true if at least one on the not yet displayed event is noisy
|
||||
val shouldBing: Boolean = false,
|
||||
val customSound: String? = null,
|
||||
val hasSmartReplyError: Boolean = false,
|
||||
val isUpdated: Boolean = false,
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2021-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
|
||||
|
||||
import android.app.Notification
|
||||
import android.graphics.Bitmap
|
||||
import coil3.ImageLoader
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
|
||||
import io.element.android.libraries.push.impl.notifications.factories.isSmartReplyError
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
|
||||
interface RoomGroupMessageCreator {
|
||||
suspend fun createRoomMessage(
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
events: List<NotifiableMessageEvent>,
|
||||
roomId: RoomId,
|
||||
threadId: ThreadId?,
|
||||
imageLoader: ImageLoader,
|
||||
existingNotification: Notification?,
|
||||
): Notification
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultRoomGroupMessageCreator(
|
||||
private val bitmapLoader: NotificationBitmapLoader,
|
||||
private val stringProvider: StringProvider,
|
||||
private val notificationCreator: NotificationCreator,
|
||||
) : RoomGroupMessageCreator {
|
||||
override suspend fun createRoomMessage(
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
events: List<NotifiableMessageEvent>,
|
||||
roomId: RoomId,
|
||||
threadId: ThreadId?,
|
||||
imageLoader: ImageLoader,
|
||||
existingNotification: Notification?,
|
||||
): Notification {
|
||||
val lastKnownRoomEvent = events.last()
|
||||
val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderDisambiguatedDisplayName ?: "Room name (${roomId.value.take(8)}…)"
|
||||
val roomIsGroup = !lastKnownRoomEvent.roomIsDm
|
||||
|
||||
val tickerText = if (roomIsGroup) {
|
||||
stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderDisambiguatedDisplayName, events.last().description)
|
||||
} else {
|
||||
stringProvider.getString(R.string.notification_ticker_text_dm, events.last().senderDisambiguatedDisplayName, events.last().description)
|
||||
}
|
||||
|
||||
val largeBitmap = getRoomBitmap(events, imageLoader)
|
||||
|
||||
val lastMessageTimestamp = events.last().timestamp
|
||||
val smartReplyErrors = events.filter { it.isSmartReplyError() }
|
||||
val roomIsDm = !roomIsGroup
|
||||
return notificationCreator.createMessagesListNotification(
|
||||
notificationAccountParams = notificationAccountParams,
|
||||
RoomEventGroupInfo(
|
||||
sessionId = notificationAccountParams.user.userId,
|
||||
roomId = roomId,
|
||||
roomDisplayName = roomName,
|
||||
isDm = roomIsDm,
|
||||
hasSmartReplyError = smartReplyErrors.isNotEmpty(),
|
||||
shouldBing = events.any { it.noisy },
|
||||
customSound = events.last().soundName,
|
||||
isUpdated = events.last().isUpdated,
|
||||
),
|
||||
threadId = threadId,
|
||||
largeIcon = largeBitmap,
|
||||
lastMessageTimestamp = lastMessageTimestamp,
|
||||
tickerText = tickerText,
|
||||
existingNotification = existingNotification,
|
||||
imageLoader = imageLoader,
|
||||
events = events,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getRoomBitmap(
|
||||
events: List<NotifiableMessageEvent>,
|
||||
imageLoader: ImageLoader,
|
||||
): Bitmap? {
|
||||
// Use the last event (most recent?)
|
||||
val event = events.reversed().firstOrNull { it.roomAvatarPath != null }
|
||||
?: events.reversed().firstOrNull()
|
||||
return event?.let { event ->
|
||||
bitmapLoader.getRoomBitmap(
|
||||
avatarData = AvatarData(
|
||||
id = event.roomId.value,
|
||||
name = event.roomName,
|
||||
url = event.roomAvatarPath,
|
||||
size = AvatarSize.RoomDetailsHeader,
|
||||
),
|
||||
imageLoader = imageLoader,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2021-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
|
||||
|
||||
import android.app.Notification
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
|
||||
interface SummaryGroupMessageCreator {
|
||||
fun createSummaryNotification(
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
roomNotifications: List<RoomNotification>,
|
||||
invitationNotifications: List<OneShotNotification>,
|
||||
simpleNotifications: List<OneShotNotification>,
|
||||
fallbackNotifications: List<OneShotNotification>,
|
||||
): Notification
|
||||
}
|
||||
|
||||
/**
|
||||
* ======== Build summary notification =========
|
||||
* On Android 7.0 (API level 24) and higher, the system automatically builds a summary for
|
||||
* your group using snippets of text from each notification. The user can expand this
|
||||
* notification to see each separate notification.
|
||||
* The behavior of the group summary may vary on some device types such as wearables.
|
||||
* To ensure the best experience on all devices and versions, always include a group summary when you create a group
|
||||
* https://developer.android.com/training/notify-user/group
|
||||
*/
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultSummaryGroupMessageCreator(
|
||||
private val stringProvider: StringProvider,
|
||||
private val notificationCreator: NotificationCreator,
|
||||
) : SummaryGroupMessageCreator {
|
||||
override fun createSummaryNotification(
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
roomNotifications: List<RoomNotification>,
|
||||
invitationNotifications: List<OneShotNotification>,
|
||||
simpleNotifications: List<OneShotNotification>,
|
||||
fallbackNotifications: List<OneShotNotification>,
|
||||
): Notification {
|
||||
val summaryIsNoisy = roomNotifications.any { it.shouldBing } ||
|
||||
invitationNotifications.any { it.isNoisy } ||
|
||||
simpleNotifications.any { it.isNoisy }
|
||||
val lastMessageTimestamp = roomNotifications.lastOrNull()?.latestTimestamp
|
||||
?: invitationNotifications.lastOrNull()?.timestamp
|
||||
?: simpleNotifications.last().timestamp
|
||||
val nbEvents = roomNotifications.size + invitationNotifications.size + simpleNotifications.size
|
||||
val sumTitle = stringProvider.getQuantityString(R.plurals.notification_compat_summary_title, nbEvents, nbEvents)
|
||||
return notificationCreator.createSummaryListNotification(
|
||||
notificationAccountParams = notificationAccountParams,
|
||||
sumTitle,
|
||||
noisy = summaryIsNoisy,
|
||||
lastMessageTimestamp = lastMessageTimestamp,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.push.impl.troubleshoot.NotificationClickHandler
|
||||
|
||||
class TestNotificationReceiver : BroadcastReceiver() {
|
||||
@Inject lateinit var notificationClickHandler: NotificationClickHandler
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
context.bindings<TestNotificationReceiverBinding>().inject(this)
|
||||
notificationClickHandler.handleNotificationClick()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
|
||||
@ContributesTo(AppScope::class)
|
||||
interface TestNotificationReceiverBinding {
|
||||
fun inject(service: TestNotificationReceiver)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.channels
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioAttributes.USAGE_NOTIFICATION
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.ChecksSdkIntAtLeast
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.appconfig.NotificationConfig
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
|
||||
/* ==========================================================================================
|
||||
* IDs for channels
|
||||
* ========================================================================================== */
|
||||
internal const val SILENT_NOTIFICATION_CHANNEL_ID = "DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID_V2"
|
||||
internal const val NOISY_NOTIFICATION_CHANNEL_ID = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_V2"
|
||||
internal const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_NOTIFICATION_CHANNEL_ID_V3"
|
||||
internal const val RINGING_CALL_NOTIFICATION_CHANNEL_ID = "RINGING_CALL_NOTIFICATION_CHANNEL_ID"
|
||||
|
||||
/**
|
||||
* on devices >= android O, we need to define a channel for each notifications.
|
||||
*/
|
||||
interface NotificationChannels {
|
||||
/**
|
||||
* Get the channel for incoming call.
|
||||
* @param ring true if the device should ring when receiving the call.
|
||||
*/
|
||||
fun getChannelForIncomingCall(ring: Boolean): String
|
||||
|
||||
/**
|
||||
* Get the channel for messages.
|
||||
* @param noisy true if the notification should have sound and vibration.
|
||||
*/
|
||||
fun getChannelIdForMessage(noisy: Boolean): String
|
||||
|
||||
/**
|
||||
* Get the channel for test notifications.
|
||||
*/
|
||||
fun getChannelIdForTest(): String
|
||||
}
|
||||
|
||||
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
|
||||
private fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultNotificationChannels(
|
||||
private val notificationManager: NotificationManagerCompat,
|
||||
private val stringProvider: StringProvider,
|
||||
@ApplicationContext
|
||||
private val context: Context,
|
||||
) : NotificationChannels {
|
||||
init {
|
||||
createNotificationChannels()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create notification channels.
|
||||
*/
|
||||
private fun createNotificationChannels() {
|
||||
if (!supportNotificationChannels()) {
|
||||
return
|
||||
}
|
||||
|
||||
val accentColor = NotificationConfig.NOTIFICATION_ACCENT_COLOR
|
||||
|
||||
// Migration - the noisy channel was deleted and recreated when sound preference was changed (id was DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_BASE
|
||||
// + currentTimeMillis).
|
||||
// Now the sound can only be change directly in system settings, so for app upgrading we are deleting this former channel
|
||||
// Starting from this version the channel will not be dynamic
|
||||
for (channel in notificationManager.notificationChannels) {
|
||||
val channelId = channel.id
|
||||
val legacyBaseName = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_BASE"
|
||||
if (channelId.startsWith(legacyBaseName)) {
|
||||
notificationManager.deleteNotificationChannel(channelId)
|
||||
}
|
||||
}
|
||||
// Migration - Remove deprecated channels
|
||||
for (channelId in listOf(
|
||||
"DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID",
|
||||
"DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID",
|
||||
"CALL_NOTIFICATION_CHANNEL_ID",
|
||||
"CALL_NOTIFICATION_CHANNEL_ID_V2",
|
||||
"LISTEN_FOR_EVENTS_NOTIFICATION_CHANNEL_ID",
|
||||
)) {
|
||||
notificationManager.getNotificationChannel(channelId)?.let {
|
||||
notificationManager.deleteNotificationChannel(channelId)
|
||||
}
|
||||
}
|
||||
|
||||
// Default notification importance: shows everywhere, makes noise, but does not visually intrude.
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(
|
||||
NOISY_NOTIFICATION_CHANNEL_ID,
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT
|
||||
)
|
||||
.setSound(
|
||||
Uri.Builder()
|
||||
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
|
||||
// Strangely wwe have to provide a "//" before the package name
|
||||
.path("//" + context.packageName + "/" + R.raw.message)
|
||||
.build(),
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.setUsage(USAGE_NOTIFICATION)
|
||||
.build(),
|
||||
)
|
||||
.setName(stringProvider.getString(R.string.notification_channel_noisy).ifEmpty { "Noisy notifications" })
|
||||
.setDescription(stringProvider.getString(R.string.notification_channel_noisy))
|
||||
.setVibrationEnabled(true)
|
||||
.setLightsEnabled(true)
|
||||
.setLightColor(accentColor)
|
||||
.build()
|
||||
)
|
||||
|
||||
// Low notification importance: shows everywhere, but is not intrusive.
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(
|
||||
SILENT_NOTIFICATION_CHANNEL_ID,
|
||||
NotificationManagerCompat.IMPORTANCE_LOW
|
||||
)
|
||||
.setName(stringProvider.getString(R.string.notification_channel_silent).ifEmpty { "Silent notifications" })
|
||||
.setDescription(stringProvider.getString(R.string.notification_channel_silent))
|
||||
.setSound(null, null)
|
||||
.setLightsEnabled(true)
|
||||
.setLightColor(accentColor)
|
||||
.build()
|
||||
)
|
||||
|
||||
// Register a channel for incoming and in progress call notifications with no ringing
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(
|
||||
CALL_NOTIFICATION_CHANNEL_ID,
|
||||
NotificationManagerCompat.IMPORTANCE_HIGH
|
||||
)
|
||||
.setName(stringProvider.getString(R.string.notification_channel_call).ifEmpty { "Call" })
|
||||
.setDescription(stringProvider.getString(R.string.notification_channel_call))
|
||||
.setVibrationEnabled(true)
|
||||
.setLightsEnabled(true)
|
||||
.setLightColor(accentColor)
|
||||
.build()
|
||||
)
|
||||
|
||||
// Register a channel for incoming call notifications which will ring the device when received
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(
|
||||
RINGING_CALL_NOTIFICATION_CHANNEL_ID,
|
||||
NotificationManagerCompat.IMPORTANCE_MAX,
|
||||
)
|
||||
.setName(stringProvider.getString(R.string.notification_channel_ringing_calls).ifEmpty { "Ringing calls" })
|
||||
.setVibrationEnabled(true)
|
||||
.setSound(
|
||||
Settings.System.DEFAULT_RINGTONE_URI,
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.setLegacyStreamType(AudioManager.STREAM_RING)
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||
.build()
|
||||
)
|
||||
.setDescription(stringProvider.getString(R.string.notification_channel_ringing_calls))
|
||||
.setLightsEnabled(true)
|
||||
.setLightColor(accentColor)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
override fun getChannelForIncomingCall(ring: Boolean): String {
|
||||
return if (ring) RINGING_CALL_NOTIFICATION_CHANNEL_ID else CALL_NOTIFICATION_CHANNEL_ID
|
||||
}
|
||||
|
||||
override fun getChannelIdForMessage(noisy: Boolean): String {
|
||||
return if (noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
|
||||
}
|
||||
|
||||
override fun getChannelIdForTest(): String = NOISY_NOTIFICATION_CHANNEL_ID
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.os.Build
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
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.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
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.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.push.impl.notifications.shortcut.createShortcutId
|
||||
import io.element.android.libraries.push.impl.notifications.shortcut.filterBySession
|
||||
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
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultNotificationConversationService(
|
||||
@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 onSessionDeleted(userId: String, wasLastSession: Boolean) {
|
||||
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 icon = bitmapLoader.getRoomBitmap(
|
||||
avatarData = AvatarData(
|
||||
id = roomId.value,
|
||||
name = roomName,
|
||||
url = roomAvatarUrl,
|
||||
size = AvatarSize.RoomDetailsHeader,
|
||||
),
|
||||
imageLoader = imageLoader,
|
||||
targetSize = defaultShortcutIconSize.toLong()
|
||||
)?.let(IconCompat::createWithBitmap)
|
||||
|
||||
val shortcutInfo = ShortcutInfoCompat.Builder(context, createShortcutId(sessionId, roomId))
|
||||
.setShortLabel(roomName)
|
||||
.setIcon(icon)
|
||||
.setIntent(intentProvider.getViewRoomIntent(sessionId, roomId, threadId = null, eventId = 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(createShortcutId(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.filterBySession(sessionId).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,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.debug
|
||||
|
||||
fun CharSequence.annotateForDebug(@Suppress("UNUSED_PARAMETER") prefix: Int): CharSequence {
|
||||
return this // "$prefix-$this"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.factories
|
||||
|
||||
import androidx.annotation.ColorInt
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
data class NotificationAccountParams(
|
||||
val user: MatrixUser,
|
||||
@ColorInt val color: Int,
|
||||
val showSessionId: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,518 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.factories
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationCompat.MessagingStyle
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.os.bundleOf
|
||||
import coil3.ImageLoader
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.matrix.ui.model.getBestName
|
||||
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
|
||||
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
|
||||
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
|
||||
import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory
|
||||
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
|
||||
import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory
|
||||
import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.appnavstate.api.ROOM_OPENED_FROM_NOTIFICATION
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
|
||||
interface NotificationCreator {
|
||||
/**
|
||||
* Create a notification for a Room.
|
||||
*/
|
||||
suspend fun createMessagesListNotification(
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
roomInfo: RoomEventGroupInfo,
|
||||
threadId: ThreadId?,
|
||||
largeIcon: Bitmap?,
|
||||
lastMessageTimestamp: Long,
|
||||
tickerText: String,
|
||||
existingNotification: Notification?,
|
||||
imageLoader: ImageLoader,
|
||||
events: List<NotifiableMessageEvent>,
|
||||
): Notification
|
||||
|
||||
fun createRoomInvitationNotification(
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
inviteNotifiableEvent: InviteNotifiableEvent,
|
||||
): Notification
|
||||
|
||||
fun createSimpleEventNotification(
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
simpleNotifiableEvent: SimpleNotifiableEvent,
|
||||
): Notification
|
||||
|
||||
fun createFallbackNotification(
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
fallbackNotifiableEvent: FallbackNotifiableEvent,
|
||||
): Notification
|
||||
|
||||
/**
|
||||
* Create the summary notification.
|
||||
*/
|
||||
fun createSummaryListNotification(
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
compatSummary: String,
|
||||
noisy: Boolean,
|
||||
lastMessageTimestamp: Long,
|
||||
): Notification
|
||||
|
||||
fun createDiagnosticNotification(
|
||||
@ColorInt color: Int,
|
||||
): Notification
|
||||
|
||||
fun createUnregistrationNotification(
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
): Notification
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Creates a tag for a message notification given its [roomId] and optional [threadId].
|
||||
*/
|
||||
fun messageTag(roomId: RoomId, threadId: ThreadId?): String = if (threadId != null) {
|
||||
"$roomId|$threadId"
|
||||
} else {
|
||||
roomId.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultNotificationCreator(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val notificationChannels: NotificationChannels,
|
||||
private val stringProvider: StringProvider,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val pendingIntentFactory: PendingIntentFactory,
|
||||
private val markAsReadActionFactory: MarkAsReadActionFactory,
|
||||
private val quickReplyActionFactory: QuickReplyActionFactory,
|
||||
private val bitmapLoader: NotificationBitmapLoader,
|
||||
private val acceptInvitationActionFactory: AcceptInvitationActionFactory,
|
||||
private val rejectInvitationActionFactory: RejectInvitationActionFactory,
|
||||
) : NotificationCreator {
|
||||
/**
|
||||
* Create a notification for a Room.
|
||||
*/
|
||||
override suspend fun createMessagesListNotification(
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
roomInfo: RoomEventGroupInfo,
|
||||
threadId: ThreadId?,
|
||||
largeIcon: Bitmap?,
|
||||
lastMessageTimestamp: Long,
|
||||
tickerText: String,
|
||||
existingNotification: Notification?,
|
||||
imageLoader: ImageLoader,
|
||||
events: List<NotifiableMessageEvent>,
|
||||
): Notification {
|
||||
// Build the pending intent for when the notification is clicked
|
||||
val eventId = events.firstOrNull()?.eventId
|
||||
val openIntent = when {
|
||||
threadId != null -> pendingIntentFactory.createOpenThreadPendingIntent(roomInfo.sessionId, roomInfo.roomId, eventId, threadId)
|
||||
else -> pendingIntentFactory.createOpenRoomPendingIntent(
|
||||
sessionId = roomInfo.sessionId,
|
||||
roomId = roomInfo.roomId,
|
||||
eventId = eventId,
|
||||
extras = bundleOf(ROOM_OPENED_FROM_NOTIFICATION to true),
|
||||
)
|
||||
}
|
||||
val containsMissedCall = events.any { it.type == EventType.RTC_NOTIFICATION }
|
||||
val channelId = if (containsMissedCall) {
|
||||
notificationChannels.getChannelForIncomingCall(false)
|
||||
} else {
|
||||
notificationChannels.getChannelIdForMessage(noisy = roomInfo.shouldBing)
|
||||
}
|
||||
// A category allows groups of notifications to be ranked and filtered – per user or system settings.
|
||||
// For example, alarm notifications should display before promo notifications, or message from known contact
|
||||
// that can be displayed in not disturb mode if white listed (the later will need compat28.x)
|
||||
// If any of the events are of rtc notification type it means a missed call, set the category to the right value
|
||||
val category = if (containsMissedCall) {
|
||||
NotificationCompat.CATEGORY_MISSED_CALL
|
||||
} else {
|
||||
NotificationCompat.CATEGORY_MESSAGE
|
||||
}
|
||||
val builder = if (existingNotification != null) {
|
||||
NotificationCompat.Builder(context, existingNotification)
|
||||
// Clear existing actions
|
||||
.clearActions()
|
||||
} else {
|
||||
NotificationCompat.Builder(context, channelId)
|
||||
// ID of the corresponding shortcut, for conversation features under API 30+
|
||||
// Must match those created in the ShortcutInfoCompat.Builder()
|
||||
// for the notification to appear as a "Conversation":
|
||||
// https://developer.android.com/develop/ui/views/notifications/conversations
|
||||
.apply {
|
||||
if (threadId == null) {
|
||||
setShortcutId(createShortcutId(roomInfo.sessionId, roomInfo.roomId))
|
||||
}
|
||||
}
|
||||
.setGroupSummary(false)
|
||||
// In order to avoid notification making sound twice (due to the summary notification)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
// Remove notification after opening it or using an action
|
||||
.setAutoCancel(true)
|
||||
}
|
||||
val messagingStyle = existingNotification?.let {
|
||||
MessagingStyle.extractMessagingStyleFromNotification(it)
|
||||
} ?: createMessagingStyleFromCurrentUser(
|
||||
user = notificationAccountParams.user,
|
||||
imageLoader = imageLoader,
|
||||
roomName = roomInfo.roomDisplayName,
|
||||
isThread = threadId != null,
|
||||
roomIsGroup = !roomInfo.isDm,
|
||||
)
|
||||
messagingStyle.addMessagesFromEvents(events, imageLoader)
|
||||
return builder
|
||||
.setCategory(category)
|
||||
.setNumber(events.size)
|
||||
.setOnlyAlertOnce(roomInfo.isUpdated)
|
||||
.setWhen(lastMessageTimestamp)
|
||||
// MESSAGING_STYLE sets title and content for API 16 and above devices.
|
||||
.setStyle(messagingStyle)
|
||||
.configureWith(notificationAccountParams)
|
||||
// Mark room/thread as read
|
||||
.addAction(markAsReadActionFactory.create(roomInfo, threadId))
|
||||
.setContentIntent(openIntent)
|
||||
.setLargeIcon(largeIcon)
|
||||
.setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId))
|
||||
.apply {
|
||||
// Sets priority for 25 and below. For 26 and above, 'priority' is deprecated for
|
||||
// 'importance' which is set in the NotificationChannel. The integers representing
|
||||
// 'priority' are different from 'importance', so make sure you don't mix them.
|
||||
if (roomInfo.shouldBing) {
|
||||
priority = NotificationCompat.PRIORITY_DEFAULT
|
||||
setLights(notificationAccountParams.color, 500, 500)
|
||||
} else {
|
||||
priority = NotificationCompat.PRIORITY_LOW
|
||||
}
|
||||
// Quick reply
|
||||
if (!roomInfo.hasSmartReplyError) {
|
||||
val latestEventId = events.lastOrNull()?.eventId
|
||||
addAction(quickReplyActionFactory.create(roomInfo, latestEventId, threadId))
|
||||
}
|
||||
}
|
||||
.setTicker(tickerText)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun createRoomInvitationNotification(
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
inviteNotifiableEvent: InviteNotifiableEvent,
|
||||
): Notification {
|
||||
val channelId = notificationChannels.getChannelIdForMessage(inviteNotifiableEvent.noisy)
|
||||
return NotificationCompat.Builder(context, channelId)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentTitle((inviteNotifiableEvent.roomName ?: buildMeta.applicationName).annotateForDebug(5))
|
||||
.setContentText(inviteNotifiableEvent.description.annotateForDebug(6))
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
|
||||
.configureWith(notificationAccountParams)
|
||||
.addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
|
||||
.addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
|
||||
// Build the pending intent for when the notification is clicked
|
||||
.setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(
|
||||
sessionId = inviteNotifiableEvent.sessionId,
|
||||
roomId = inviteNotifiableEvent.roomId,
|
||||
eventId = null,
|
||||
))
|
||||
.apply {
|
||||
if (inviteNotifiableEvent.noisy) {
|
||||
// Compat
|
||||
priority = NotificationCompat.PRIORITY_DEFAULT
|
||||
setLights(notificationAccountParams.color, 500, 500)
|
||||
} else {
|
||||
priority = NotificationCompat.PRIORITY_LOW
|
||||
}
|
||||
}
|
||||
.setDeleteIntent(
|
||||
pendingIntentFactory.createDismissInvitePendingIntent(
|
||||
inviteNotifiableEvent.sessionId,
|
||||
inviteNotifiableEvent.roomId,
|
||||
)
|
||||
)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun createSimpleEventNotification(
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
simpleNotifiableEvent: SimpleNotifiableEvent,
|
||||
): Notification {
|
||||
val channelId = notificationChannels.getChannelIdForMessage(simpleNotifiableEvent.noisy)
|
||||
return NotificationCompat.Builder(context, channelId)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentTitle(buildMeta.applicationName.annotateForDebug(7))
|
||||
.setContentText(simpleNotifiableEvent.description.annotateForDebug(8))
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
|
||||
.configureWith(notificationAccountParams)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(
|
||||
sessionId = simpleNotifiableEvent.sessionId,
|
||||
roomId = simpleNotifiableEvent.roomId,
|
||||
eventId = null,
|
||||
extras = bundleOf(ROOM_OPENED_FROM_NOTIFICATION to true),
|
||||
))
|
||||
.apply {
|
||||
if (simpleNotifiableEvent.noisy) {
|
||||
// Compat
|
||||
priority = NotificationCompat.PRIORITY_DEFAULT
|
||||
setLights(notificationAccountParams.color, 500, 500)
|
||||
} else {
|
||||
priority = NotificationCompat.PRIORITY_LOW
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun createFallbackNotification(
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
fallbackNotifiableEvent: FallbackNotifiableEvent,
|
||||
): Notification {
|
||||
val channelId = notificationChannels.getChannelIdForMessage(false)
|
||||
return NotificationCompat.Builder(context, channelId)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentTitle(buildMeta.applicationName.annotateForDebug(7))
|
||||
.setContentText(fallbackNotifiableEvent.description.orEmpty().annotateForDebug(8))
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
|
||||
.configureWith(notificationAccountParams)
|
||||
.setAutoCancel(true)
|
||||
.setWhen(fallbackNotifiableEvent.timestamp)
|
||||
// Ideally we'd use `createOpenRoomPendingIntent` here, but the broken notification might apply to an invite
|
||||
// and the user won't have access to the room yet, resulting in an error screen.
|
||||
.setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(fallbackNotifiableEvent.sessionId))
|
||||
.setDeleteIntent(
|
||||
pendingIntentFactory.createDismissEventPendingIntent(
|
||||
fallbackNotifiableEvent.sessionId,
|
||||
fallbackNotifiableEvent.roomId,
|
||||
fallbackNotifiableEvent.eventId
|
||||
)
|
||||
)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the summary notification.
|
||||
*/
|
||||
override fun createSummaryListNotification(
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
compatSummary: String,
|
||||
noisy: Boolean,
|
||||
lastMessageTimestamp: Long,
|
||||
): Notification {
|
||||
val channelId = notificationChannels.getChannelIdForMessage(noisy)
|
||||
val userId = notificationAccountParams.user.userId
|
||||
return NotificationCompat.Builder(context, channelId)
|
||||
.setOnlyAlertOnce(true)
|
||||
// used in compat < N, after summary is built based on child notifications
|
||||
.setWhen(lastMessageTimestamp)
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
// set this notification as the summary for the group
|
||||
.setGroupSummary(true)
|
||||
.configureWith(notificationAccountParams)
|
||||
.apply {
|
||||
if (noisy) {
|
||||
// Compat
|
||||
priority = NotificationCompat.PRIORITY_DEFAULT
|
||||
setLights(notificationAccountParams.color, 500, 500)
|
||||
} else {
|
||||
// compat
|
||||
priority = NotificationCompat.PRIORITY_LOW
|
||||
}
|
||||
}
|
||||
.setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(userId))
|
||||
.setDeleteIntent(pendingIntentFactory.createDismissSummaryPendingIntent(userId))
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun createDiagnosticNotification(
|
||||
@ColorInt color: Int,
|
||||
): Notification {
|
||||
val intent = pendingIntentFactory.createTestPendingIntent()
|
||||
return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest())
|
||||
.setContentTitle(buildMeta.applicationName)
|
||||
.setContentText(stringProvider.getString(R.string.notification_test_push_notification_content))
|
||||
.setSmallIcon(CommonDrawables.ic_notification)
|
||||
.setColor(color)
|
||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(intent)
|
||||
.setDeleteIntent(intent)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun createUnregistrationNotification(
|
||||
notificationAccountParams: NotificationAccountParams,
|
||||
): Notification {
|
||||
val userId = notificationAccountParams.user.userId
|
||||
val text = stringProvider.getString(R.string.notification_error_unified_push_unregistered_android)
|
||||
return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest())
|
||||
.setSubText(userId.value)
|
||||
// The text is long and can be truncated so use BigTextStyle.
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setContentTitle(stringProvider.getString(CommonStrings.dialog_title_warning))
|
||||
.setContentText(text)
|
||||
.configureWith(notificationAccountParams)
|
||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(userId))
|
||||
.build()
|
||||
}
|
||||
|
||||
private suspend fun MessagingStyle.addMessagesFromEvents(
|
||||
events: List<NotifiableMessageEvent>,
|
||||
imageLoader: ImageLoader,
|
||||
) {
|
||||
events.forEach { event ->
|
||||
val senderPerson = if (event.outGoingMessage) {
|
||||
null
|
||||
} else {
|
||||
val senderName = event.senderDisambiguatedDisplayName.orEmpty()
|
||||
// If the notification is for a mention or reply, we create a fake `Person` with a custom name and key
|
||||
val displayName = if (event.hasMentionOrReply) {
|
||||
stringProvider.getString(R.string.notification_sender_mention_reply, senderName)
|
||||
} else {
|
||||
senderName
|
||||
}
|
||||
val key = if (event.hasMentionOrReply) {
|
||||
"mention-or-reply:${event.eventId.value}"
|
||||
} else {
|
||||
event.senderId.value
|
||||
}
|
||||
Person.Builder()
|
||||
.setName(displayName.annotateForDebug(70))
|
||||
.setIcon(
|
||||
bitmapLoader.getUserIcon(
|
||||
avatarData = AvatarData(
|
||||
id = event.senderId.value,
|
||||
name = senderName,
|
||||
url = event.senderAvatarPath,
|
||||
size = AvatarSize.UserHeader,
|
||||
),
|
||||
imageLoader = imageLoader,
|
||||
)
|
||||
)
|
||||
.setKey(key)
|
||||
.build()
|
||||
}
|
||||
when {
|
||||
event.isSmartReplyError() -> addMessage(
|
||||
stringProvider.getString(R.string.notification_inline_reply_failed),
|
||||
event.timestamp,
|
||||
senderPerson
|
||||
)
|
||||
else -> {
|
||||
if (event.imageMimeType != null && event.imageUri != null) {
|
||||
// Image case
|
||||
val message = MessagingStyle.Message(
|
||||
// This text will not be rendered, but some systems does not render the image
|
||||
// if the text is null
|
||||
stringProvider.getString(CommonStrings.common_image),
|
||||
event.timestamp,
|
||||
senderPerson,
|
||||
)
|
||||
.setData(event.imageMimeType, event.imageUri)
|
||||
message.extras.putString(MESSAGE_EVENT_ID, event.eventId.value)
|
||||
addMessage(message)
|
||||
// Add additional message for captions
|
||||
if (event.body != null) {
|
||||
addMessage(
|
||||
MessagingStyle.Message(
|
||||
event.body.annotateForDebug(72),
|
||||
event.timestamp,
|
||||
senderPerson,
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Text case
|
||||
val message = MessagingStyle.Message(
|
||||
event.body?.annotateForDebug(71),
|
||||
event.timestamp,
|
||||
senderPerson
|
||||
)
|
||||
message.extras.putString(MESSAGE_EVENT_ID, event.eventId.value)
|
||||
addMessage(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createMessagingStyleFromCurrentUser(
|
||||
user: MatrixUser,
|
||||
imageLoader: ImageLoader,
|
||||
roomName: String,
|
||||
isThread: Boolean,
|
||||
roomIsGroup: Boolean
|
||||
): MessagingStyle {
|
||||
return MessagingStyle(
|
||||
Person.Builder()
|
||||
// Note: name cannot be empty else NotificationCompat.MessagingStyle() will crash
|
||||
.setName(user.getBestName().annotateForDebug(50))
|
||||
.setIcon(
|
||||
bitmapLoader.getUserIcon(
|
||||
avatarData = user.getAvatarData(AvatarSize.UserHeader),
|
||||
imageLoader = imageLoader,
|
||||
)
|
||||
)
|
||||
.setKey(user.userId.value)
|
||||
.build()
|
||||
).also {
|
||||
it.conversationTitle = if (isThread) {
|
||||
stringProvider.getString(R.string.notification_thread_in_room, roomName)
|
||||
} else {
|
||||
roomName
|
||||
}
|
||||
// So the avatar is displayed even if they're part of a conversation
|
||||
it.isGroupConversation = roomIsGroup || isThread
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MESSAGE_EVENT_ID = "message_event_id"
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotificationCompat.Builder.configureWith(notificationAccountParams: NotificationAccountParams) = apply {
|
||||
setSmallIcon(CommonDrawables.ic_notification)
|
||||
setColor(notificationAccountParams.color)
|
||||
setGroup(notificationAccountParams.user.userId.value)
|
||||
if (notificationAccountParams.showSessionId) {
|
||||
setSubText(notificationAccountParams.user.userId.value)
|
||||
}
|
||||
}
|
||||
|
||||
fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.factories
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.androidutils.uri.createIgnoredUri
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.push.impl.intent.IntentProvider
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver
|
||||
import io.element.android.libraries.push.impl.notifications.TestNotificationReceiver
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
|
||||
@Inject
|
||||
class PendingIntentFactory(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val intentProvider: IntentProvider,
|
||||
private val clock: SystemClock,
|
||||
private val actionIds: NotificationActionIds,
|
||||
) {
|
||||
fun createOpenSessionPendingIntent(sessionId: SessionId, extras: Bundle? = null): PendingIntent? {
|
||||
return createRoomPendingIntent(sessionId = sessionId, roomId = null, eventId = null, threadId = null, extras = extras)
|
||||
}
|
||||
|
||||
fun createOpenRoomPendingIntent(sessionId: SessionId, roomId: RoomId, eventId: EventId?, extras: Bundle? = null): PendingIntent? {
|
||||
return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, eventId = eventId, threadId = null, extras = extras)
|
||||
}
|
||||
|
||||
fun createOpenThreadPendingIntent(sessionId: SessionId, roomId: RoomId, eventId: EventId?, threadId: ThreadId, extras: Bundle? = null): PendingIntent? {
|
||||
return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, eventId = eventId, threadId = threadId, extras = extras)
|
||||
}
|
||||
|
||||
private fun createRoomPendingIntent(sessionId: SessionId, roomId: RoomId?, eventId: EventId?, threadId: ThreadId?, extras: Bundle? = null): PendingIntent? {
|
||||
val intent = intentProvider.getViewRoomIntent(sessionId = sessionId, roomId = roomId, eventId = eventId, threadId = threadId, extras = extras)
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
clock.epochMillis().toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
fun createDismissSummaryPendingIntent(sessionId: SessionId): PendingIntent {
|
||||
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
|
||||
intent.action = actionIds.dismissSummary
|
||||
intent.data = createIgnoredUri("deleteSummary/$sessionId")
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
fun createDismissRoomPendingIntent(sessionId: SessionId, roomId: RoomId): PendingIntent {
|
||||
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
|
||||
intent.action = actionIds.dismissRoom
|
||||
intent.data = createIgnoredUri("deleteRoom/$sessionId/$roomId")
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value)
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
clock.epochMillis().toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
fun createDismissInvitePendingIntent(sessionId: SessionId, roomId: RoomId): PendingIntent {
|
||||
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
|
||||
intent.action = actionIds.dismissInvite
|
||||
intent.data = createIgnoredUri("deleteInvite/$sessionId/$roomId")
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value)
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
clock.epochMillis().toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
fun createDismissEventPendingIntent(sessionId: SessionId, roomId: RoomId, eventId: EventId): PendingIntent {
|
||||
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
|
||||
intent.action = actionIds.dismissEvent
|
||||
intent.data = createIgnoredUri("deleteEvent/$sessionId/$roomId/$eventId")
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value)
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_EVENT_ID, eventId.value)
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
clock.epochMillis().toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
fun createTestPendingIntent(): PendingIntent? {
|
||||
val testActionIntent = Intent(context, TestNotificationReceiver::class.java)
|
||||
testActionIntent.action = actionIds.diagnostic
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
testActionIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.factories.action
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.appconfig.NotificationConfig
|
||||
import io.element.android.libraries.androidutils.uri.createIgnoredUri
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver
|
||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
|
||||
@Inject
|
||||
class AcceptInvitationActionFactory(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val actionIds: NotificationActionIds,
|
||||
private val stringProvider: StringProvider,
|
||||
private val clock: SystemClock,
|
||||
) {
|
||||
fun create(inviteNotifiableEvent: InviteNotifiableEvent): NotificationCompat.Action? {
|
||||
if (!NotificationConfig.SHOW_ACCEPT_AND_DECLINE_INVITE_ACTIONS) return null
|
||||
val sessionId = inviteNotifiableEvent.sessionId.value
|
||||
val roomId = inviteNotifiableEvent.roomId.value
|
||||
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
|
||||
intent.action = actionIds.join
|
||||
intent.data = createIgnoredUri("acceptInvite/$sessionId/$roomId")
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId)
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
clock.epochMillis().toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return NotificationCompat.Action.Builder(
|
||||
CompoundDrawables.ic_compound_check,
|
||||
stringProvider.getString(CommonStrings.action_accept),
|
||||
pendingIntent
|
||||
).build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.factories.action
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.appconfig.NotificationConfig
|
||||
import io.element.android.libraries.androidutils.uri.createIgnoredUri
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver
|
||||
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
|
||||
@Inject
|
||||
class MarkAsReadActionFactory(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val actionIds: NotificationActionIds,
|
||||
private val stringProvider: StringProvider,
|
||||
private val clock: SystemClock,
|
||||
) {
|
||||
fun create(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): NotificationCompat.Action? {
|
||||
if (!NotificationConfig.SHOW_MARK_AS_READ_ACTION) return null
|
||||
val sessionId = roomInfo.sessionId.value
|
||||
val roomId = roomInfo.roomId.value
|
||||
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
|
||||
intent.action = actionIds.markRoomRead
|
||||
intent.data = createIgnoredUri("markRead/$sessionId/$roomId" + threadId?.let { "/$it" }.orEmpty())
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId)
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
|
||||
threadId?.let { intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, threadId.value) }
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
clock.epochMillis().toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
return NotificationCompat.Action.Builder(
|
||||
CompoundDrawables.ic_compound_mark_as_read,
|
||||
stringProvider.getString(R.string.notification_room_action_mark_as_read),
|
||||
pendingIntent
|
||||
)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
|
||||
.setShowsUserInterface(false)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.factories.action
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.RemoteInput
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.appconfig.NotificationConfig
|
||||
import io.element.android.libraries.androidutils.uri.createIgnoredUri
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver
|
||||
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
|
||||
@Inject
|
||||
class QuickReplyActionFactory(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val actionIds: NotificationActionIds,
|
||||
private val stringProvider: StringProvider,
|
||||
private val clock: SystemClock,
|
||||
) {
|
||||
fun create(roomInfo: RoomEventGroupInfo, eventId: EventId?, threadId: ThreadId?): NotificationCompat.Action? {
|
||||
if (!NotificationConfig.SHOW_QUICK_REPLY_ACTION) return null
|
||||
val sessionId = roomInfo.sessionId
|
||||
val roomId = roomInfo.roomId
|
||||
val replyPendingIntent = buildQuickReplyIntent(sessionId, roomId, eventId, threadId)
|
||||
val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY)
|
||||
.setLabel(stringProvider.getString(R.string.notification_room_action_quick_reply))
|
||||
.build()
|
||||
|
||||
return NotificationCompat.Action.Builder(
|
||||
CompoundDrawables.ic_compound_reply,
|
||||
stringProvider.getString(R.string.notification_room_action_quick_reply),
|
||||
replyPendingIntent
|
||||
)
|
||||
.addRemoteInput(remoteInput)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||
.setShowsUserInterface(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
/*
|
||||
* Direct reply is new in Android N, and Android already handles the UI, so the right pending intent
|
||||
* here will ideally be a Service/IntentService (for a long running background task) or a BroadcastReceiver,
|
||||
* which runs on the UI thread. It also works without unlocking, making the process really fluid for the user.
|
||||
* However, for Android devices running Marshmallow and below (API level 23 and below),
|
||||
* it will be more appropriate to use an activity. Since you have to provide your own UI.
|
||||
*/
|
||||
private fun buildQuickReplyIntent(
|
||||
sessionId: SessionId,
|
||||
roomId: RoomId,
|
||||
eventId: EventId?,
|
||||
threadId: ThreadId?,
|
||||
): PendingIntent {
|
||||
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
|
||||
intent.action = actionIds.smartReply
|
||||
intent.data = createIgnoredUri("quickReply/$sessionId/$roomId" + threadId?.let { "/$it" }.orEmpty())
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value)
|
||||
eventId?.let { intent.putExtra(NotificationBroadcastReceiver.KEY_EVENT_ID, it.value) }
|
||||
threadId?.let { intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it.value) }
|
||||
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
clock.epochMillis().toInt(),
|
||||
intent,
|
||||
// PendingIntents attached to actions with remote inputs must be mutable
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
PendingIntent.FLAG_MUTABLE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.factories.action
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.appconfig.NotificationConfig
|
||||
import io.element.android.libraries.androidutils.uri.createIgnoredUri
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver
|
||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
|
||||
@Inject
|
||||
class RejectInvitationActionFactory(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val actionIds: NotificationActionIds,
|
||||
private val stringProvider: StringProvider,
|
||||
private val clock: SystemClock,
|
||||
) {
|
||||
fun create(inviteNotifiableEvent: InviteNotifiableEvent): NotificationCompat.Action? {
|
||||
if (!NotificationConfig.SHOW_ACCEPT_AND_DECLINE_INVITE_ACTIONS) return null
|
||||
val sessionId = inviteNotifiableEvent.sessionId.value
|
||||
val roomId = inviteNotifiableEvent.roomId.value
|
||||
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
|
||||
intent.action = actionIds.reject
|
||||
intent.data = createIgnoredUri("rejectInvite/$sessionId/$roomId")
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId)
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
clock.epochMillis().toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return NotificationCompat.Action.Builder(
|
||||
CompoundDrawables.ic_compound_close,
|
||||
stringProvider.getString(CommonStrings.action_reject),
|
||||
pendingIntent
|
||||
).build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.model
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
/**
|
||||
* Used for notifications with events that couldn't be retrieved or decrypted, so we don't know their contents.
|
||||
* These are created separately from message notifications, so they can be displayed differently.
|
||||
*/
|
||||
data class FallbackNotifiableEvent(
|
||||
override val sessionId: SessionId,
|
||||
override val roomId: RoomId,
|
||||
override val eventId: EventId,
|
||||
override val editedEventId: EventId?,
|
||||
override val description: String?,
|
||||
override val canBeReplaced: Boolean,
|
||||
override val isRedacted: Boolean,
|
||||
override val isUpdated: Boolean,
|
||||
val timestamp: Long,
|
||||
val cause: String?,
|
||||
) : NotifiableEvent
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.model
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
data class InviteNotifiableEvent(
|
||||
override val sessionId: SessionId,
|
||||
override val roomId: RoomId,
|
||||
override val eventId: EventId,
|
||||
override val editedEventId: EventId?,
|
||||
override val canBeReplaced: Boolean,
|
||||
val roomName: String?,
|
||||
val noisy: Boolean,
|
||||
val title: String?,
|
||||
override val description: String,
|
||||
val type: String?,
|
||||
val timestamp: Long,
|
||||
val soundName: String?,
|
||||
override val isRedacted: Boolean = false,
|
||||
override val isUpdated: Boolean = false
|
||||
) : NotifiableEvent
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.model
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
/**
|
||||
* Parent interface for all events which can be displayed as a Notification.
|
||||
*/
|
||||
sealed interface NotifiableEvent {
|
||||
val sessionId: SessionId
|
||||
val roomId: RoomId
|
||||
val eventId: EventId
|
||||
val editedEventId: EventId?
|
||||
val description: String?
|
||||
|
||||
// Used to know if event should be replaced with the one coming from eventstream
|
||||
val canBeReplaced: Boolean
|
||||
val isRedacted: Boolean
|
||||
val isUpdated: Boolean
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.model
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
|
||||
import io.element.android.services.appnavstate.api.AppNavigationState
|
||||
import io.element.android.services.appnavstate.api.currentRoomId
|
||||
import io.element.android.services.appnavstate.api.currentSessionId
|
||||
import io.element.android.services.appnavstate.api.currentThreadId
|
||||
|
||||
data class NotifiableMessageEvent(
|
||||
override val sessionId: SessionId,
|
||||
override val roomId: RoomId,
|
||||
override val eventId: EventId,
|
||||
override val editedEventId: EventId?,
|
||||
override val canBeReplaced: Boolean,
|
||||
val senderId: UserId,
|
||||
val noisy: Boolean,
|
||||
val timestamp: Long,
|
||||
val senderDisambiguatedDisplayName: String?,
|
||||
val body: String?,
|
||||
// We cannot use Uri? type here, as that could trigger a
|
||||
// NotSerializableException when persisting this to storage
|
||||
private val imageUriString: String?,
|
||||
val imageMimeType: String?,
|
||||
val threadId: ThreadId?,
|
||||
val roomName: String?,
|
||||
val roomIsDm: Boolean = false,
|
||||
val roomAvatarPath: String? = null,
|
||||
val senderAvatarPath: String? = null,
|
||||
val soundName: String? = null,
|
||||
// This is used for >N notification, as the result of a smart reply
|
||||
val outGoingMessage: Boolean = false,
|
||||
val outGoingMessageFailed: Boolean = false,
|
||||
override val isRedacted: Boolean = false,
|
||||
override val isUpdated: Boolean = false,
|
||||
val type: String = EventType.MESSAGE,
|
||||
val hasMentionOrReply: Boolean = false,
|
||||
) : NotifiableEvent {
|
||||
override val description: String = body ?: ""
|
||||
|
||||
// Example of value:
|
||||
// content://io.element.android.x.debug.notifications.fileprovider/downloads/temp/notif/matrix.org/XGItzSDOnSyXjYtOPfiKexDJ
|
||||
val imageUri: Uri?
|
||||
get() = imageUriString?.toUri()
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to check if a notification should be ignored based on the current app and navigation state.
|
||||
*/
|
||||
fun NotifiableEvent.shouldIgnoreEventInRoom(appNavigationState: AppNavigationState): Boolean {
|
||||
val currentSessionId = appNavigationState.navigationState.currentSessionId() ?: return false
|
||||
return when (val currentRoomId = appNavigationState.navigationState.currentRoomId()) {
|
||||
null -> false
|
||||
else -> {
|
||||
// Never ignore ringing call notifications
|
||||
if (this is NotifiableRingingCallEvent) {
|
||||
false
|
||||
} else {
|
||||
appNavigationState.isInForeground &&
|
||||
sessionId == currentSessionId &&
|
||||
roomId == currentRoomId &&
|
||||
(this as? NotifiableMessageEvent)?.threadId == appNavigationState.navigationState.currentThreadId()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.model
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.notification.RtcNotificationType
|
||||
|
||||
data class NotifiableRingingCallEvent(
|
||||
override val sessionId: SessionId,
|
||||
override val roomId: RoomId,
|
||||
override val eventId: EventId,
|
||||
override val editedEventId: EventId?,
|
||||
override val description: String?,
|
||||
override val canBeReplaced: Boolean,
|
||||
override val isRedacted: Boolean,
|
||||
override val isUpdated: Boolean,
|
||||
val roomName: String?,
|
||||
val senderId: UserId,
|
||||
val senderDisambiguatedDisplayName: String?,
|
||||
val senderAvatarUrl: String?,
|
||||
val roomAvatarUrl: String? = null,
|
||||
val rtcNotificationType: RtcNotificationType,
|
||||
val timestamp: Long,
|
||||
val expirationTimestamp: Long,
|
||||
) : NotifiableEvent
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.model
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
sealed interface ResolvedPushEvent {
|
||||
val sessionId: SessionId
|
||||
val roomId: RoomId
|
||||
val eventId: EventId
|
||||
|
||||
data class Event(val notifiableEvent: NotifiableEvent) : ResolvedPushEvent {
|
||||
override val sessionId: SessionId = notifiableEvent.sessionId
|
||||
override val roomId: RoomId = notifiableEvent.roomId
|
||||
override val eventId: EventId = notifiableEvent.eventId
|
||||
}
|
||||
|
||||
data class Redaction(
|
||||
override val sessionId: SessionId,
|
||||
override val roomId: RoomId,
|
||||
val redactedEventId: EventId,
|
||||
val reason: String?,
|
||||
) : ResolvedPushEvent {
|
||||
override val eventId: EventId = redactedEventId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.model
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
data class SimpleNotifiableEvent(
|
||||
override val sessionId: SessionId,
|
||||
override val roomId: RoomId,
|
||||
override val eventId: EventId,
|
||||
override val editedEventId: EventId?,
|
||||
val noisy: Boolean,
|
||||
val title: String,
|
||||
override val description: String,
|
||||
val type: String?,
|
||||
val timestamp: Long,
|
||||
val soundName: String?,
|
||||
override val canBeReplaced: Boolean,
|
||||
override val isRedacted: Boolean = false,
|
||||
override val isUpdated: Boolean = false
|
||||
) : NotifiableEvent
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.shortcut
|
||||
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
internal fun createShortcutId(sessionId: SessionId, roomId: RoomId) = "$sessionId-$roomId"
|
||||
|
||||
internal fun Iterable<ShortcutInfoCompat>.filterBySession(sessionId: SessionId): Iterable<ShortcutInfoCompat> {
|
||||
val prefix = "$sessionId-"
|
||||
return filter { it.id.startsWith(prefix) }
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.push
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.api.ElementCallEntryPoint
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
|
||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||
import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.history.PushHistoryService
|
||||
import io.element.android.libraries.push.impl.history.onDiagnosticPush
|
||||
import io.element.android.libraries.push.impl.history.onInvalidPushReceived
|
||||
import io.element.android.libraries.push.impl.history.onSuccess
|
||||
import io.element.android.libraries.push.impl.history.onUnableToResolveEvent
|
||||
import io.element.android.libraries.push.impl.history.onUnableToRetrieveSession
|
||||
import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue
|
||||
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||
import io.element.android.libraries.push.impl.test.DefaultTestPush
|
||||
import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler
|
||||
import io.element.android.libraries.pushproviders.api.PushData
|
||||
import io.element.android.libraries.pushproviders.api.PushHandler
|
||||
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
|
||||
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag)
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultPushHandler(
|
||||
private val onNotifiableEventReceived: OnNotifiableEventReceived,
|
||||
private val onRedactedEventReceived: OnRedactedEventReceived,
|
||||
private val incrementPushDataStore: IncrementPushDataStore,
|
||||
private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore,
|
||||
private val userPushStoreFactory: UserPushStoreFactory,
|
||||
private val pushClientSecret: PushClientSecret,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val diagnosticPushHandler: DiagnosticPushHandler,
|
||||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||
private val notificationChannels: NotificationChannels,
|
||||
private val pushHistoryService: PushHistoryService,
|
||||
private val resolverQueue: NotificationResolverQueue,
|
||||
@AppCoroutineScope
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
private val fallbackNotificationFactory: FallbackNotificationFactory,
|
||||
private val syncOnNotifiableEvent: SyncOnNotifiableEvent,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : PushHandler {
|
||||
init {
|
||||
processPushEventResults()
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the push notification event results emitted by the [resolverQueue].
|
||||
*/
|
||||
private fun processPushEventResults() {
|
||||
resolverQueue.results
|
||||
.map { (requests, resolvedEvents) ->
|
||||
for (request in requests) {
|
||||
// Log the result of the push notification event
|
||||
val result = resolvedEvents[request]
|
||||
if (result == null) {
|
||||
pushHistoryService.onUnableToResolveEvent(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = request.eventId,
|
||||
roomId = request.roomId,
|
||||
sessionId = request.sessionId,
|
||||
reason = "Push not handled: no result found for request",
|
||||
)
|
||||
} else {
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
if (it is ResolvedPushEvent.Event && it.notifiableEvent is FallbackNotifiableEvent) {
|
||||
pushHistoryService.onUnableToResolveEvent(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = request.eventId,
|
||||
roomId = request.roomId,
|
||||
sessionId = request.sessionId,
|
||||
reason = it.notifiableEvent.cause.orEmpty(),
|
||||
)
|
||||
} else {
|
||||
pushHistoryService.onSuccess(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = request.eventId,
|
||||
roomId = request.roomId,
|
||||
sessionId = request.sessionId,
|
||||
comment = "Push handled successfully",
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { exception ->
|
||||
if (exception is NotificationResolverException.EventFilteredOut) {
|
||||
pushHistoryService.onSuccess(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = request.eventId,
|
||||
roomId = request.roomId,
|
||||
sessionId = request.sessionId,
|
||||
comment = "Push handled successfully but notification was filtered out",
|
||||
)
|
||||
} else {
|
||||
val reason = when (exception) {
|
||||
is NotificationResolverException.EventNotFound -> "Event not found"
|
||||
else -> "Unknown error: ${exception.message}"
|
||||
}
|
||||
pushHistoryService.onUnableToResolveEvent(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = request.eventId,
|
||||
roomId = request.roomId,
|
||||
sessionId = request.sessionId,
|
||||
reason = "$reason - Showing fallback notification",
|
||||
)
|
||||
mutableBatteryOptimizationStore.showBatteryOptimizationBanner()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val events = mutableListOf<NotifiableEvent>()
|
||||
val redactions = mutableListOf<ResolvedPushEvent.Redaction>()
|
||||
|
||||
@Suppress("LoopWithTooManyJumpStatements")
|
||||
for ((request, result) in resolvedEvents) {
|
||||
val event = result.recover { exception ->
|
||||
// If the event could not be resolved, we create a fallback notification
|
||||
when (exception) {
|
||||
is NotificationResolverException.EventFilteredOut -> {
|
||||
// Do nothing, we don't want to show a notification for filtered out events
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
Timber.tag(loggerTag.value).e(exception, "Failed to resolve push event")
|
||||
ResolvedPushEvent.Event(
|
||||
fallbackNotificationFactory.create(
|
||||
sessionId = request.sessionId,
|
||||
roomId = request.roomId,
|
||||
eventId = request.eventId,
|
||||
cause = exception.message,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}.getOrNull() ?: continue
|
||||
|
||||
val userPushStore = userPushStoreFactory.getOrCreate(event.sessionId)
|
||||
val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first()
|
||||
// If notifications are disabled for this session and device, we don't want to show the notification
|
||||
// But if it's a ringing call, we want to show it anyway
|
||||
val isRingingCall = (event as? ResolvedPushEvent.Event)?.notifiableEvent is NotifiableRingingCallEvent
|
||||
if (!areNotificationsEnabled && !isRingingCall) continue
|
||||
|
||||
// We categorise each result into either a NotifiableEvent or a Redaction
|
||||
when (event) {
|
||||
is ResolvedPushEvent.Event -> {
|
||||
events.add(event.notifiableEvent)
|
||||
}
|
||||
is ResolvedPushEvent.Redaction -> {
|
||||
redactions.add(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process redactions of messages in background to not block operations with higher priority
|
||||
if (redactions.isNotEmpty()) {
|
||||
appCoroutineScope.launch { onRedactedEventReceived.onRedactedEventsReceived(redactions) }
|
||||
}
|
||||
|
||||
// Find and process ringing call notifications separately
|
||||
val (ringingCallEvents, nonRingingCallEvents) = events.partition { it is NotifiableRingingCallEvent }
|
||||
for (ringingCallEvent in ringingCallEvents) {
|
||||
Timber.tag(loggerTag.value).d("Ringing call event: $ringingCallEvent")
|
||||
handleRingingCallEvent(ringingCallEvent as NotifiableRingingCallEvent)
|
||||
}
|
||||
|
||||
// Finally, process other notifications (messages, invites, generic notifications, etc.)
|
||||
if (nonRingingCallEvents.isNotEmpty()) {
|
||||
onNotifiableEventReceived.onNotifiableEventsReceived(nonRingingCallEvents)
|
||||
}
|
||||
|
||||
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) {
|
||||
syncOnNotifiableEvent(requests)
|
||||
}
|
||||
}
|
||||
.launchIn(appCoroutineScope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when message is received.
|
||||
*
|
||||
* @param pushData the data received in the push.
|
||||
* @param providerInfo the provider info.
|
||||
*/
|
||||
override suspend fun handle(pushData: PushData, providerInfo: String) {
|
||||
Timber.tag(loggerTag.value).d("## handling pushData: ${pushData.roomId}/${pushData.eventId}")
|
||||
if (buildMeta.lowPrivacyLoggingEnabled) {
|
||||
Timber.tag(loggerTag.value).d("## pushData: $pushData")
|
||||
}
|
||||
incrementPushDataStore.incrementPushCounter()
|
||||
// Diagnostic Push
|
||||
if (pushData.eventId == DefaultTestPush.TEST_EVENT_ID) {
|
||||
pushHistoryService.onDiagnosticPush(providerInfo)
|
||||
diagnosticPushHandler.handlePush()
|
||||
} else {
|
||||
handleInternal(pushData, providerInfo)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun handleInvalid(providerInfo: String, data: String) {
|
||||
incrementPushDataStore.incrementPushCounter()
|
||||
pushHistoryService.onInvalidPushReceived(providerInfo, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal receive method.
|
||||
*
|
||||
* @param pushData Object containing message data.
|
||||
* @param providerInfo the provider info.
|
||||
*/
|
||||
private suspend fun handleInternal(pushData: PushData, providerInfo: String) {
|
||||
try {
|
||||
if (buildMeta.lowPrivacyLoggingEnabled) {
|
||||
Timber.tag(loggerTag.value).d("## handleInternal() : $pushData")
|
||||
} else {
|
||||
Timber.tag(loggerTag.value).d("## handleInternal()")
|
||||
}
|
||||
// Get userId from client secret
|
||||
val userId = pushClientSecret.getUserIdFromSecret(pushData.clientSecret)
|
||||
if (userId == null) {
|
||||
Timber.w("Unable to get userId from client secret")
|
||||
pushHistoryService.onUnableToRetrieveSession(
|
||||
providerInfo = providerInfo,
|
||||
eventId = pushData.eventId,
|
||||
roomId = pushData.roomId,
|
||||
reason = "Unable to get userId from client secret",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
appCoroutineScope.launch {
|
||||
val notificationEventRequest = NotificationEventRequest(
|
||||
sessionId = userId,
|
||||
roomId = pushData.roomId,
|
||||
eventId = pushData.eventId,
|
||||
providerInfo = providerInfo,
|
||||
)
|
||||
Timber.d("Queueing notification: $notificationEventRequest")
|
||||
resolverQueue.enqueue(notificationEventRequest)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) {
|
||||
Timber.i("## handleInternal() : Incoming call.")
|
||||
elementCallEntryPoint.handleIncomingCall(
|
||||
callType = CallType.RoomCall(notifiableEvent.sessionId, notifiableEvent.roomId),
|
||||
eventId = notifiableEvent.eventId,
|
||||
senderId = notifiableEvent.senderId,
|
||||
roomName = notifiableEvent.roomName,
|
||||
senderName = notifiableEvent.senderDisambiguatedDisplayName,
|
||||
avatarUrl = notifiableEvent.roomAvatarUrl,
|
||||
timestamp = notifiableEvent.timestamp,
|
||||
expirationTimestamp = notifiableEvent.expirationTimestamp,
|
||||
notificationChannelId = notificationChannels.getChannelForIncomingCall(ring = true),
|
||||
textContent = notifiableEvent.description,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.push
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||
import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent
|
||||
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultSyncOnNotifiableEvent(
|
||||
private val matrixClientProvider: MatrixClientProvider,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val appForegroundStateService: AppForegroundStateService,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : SyncOnNotifiableEvent {
|
||||
override suspend operator fun invoke(requests: List<NotificationEventRequest>) = withContext(dispatchers.io) {
|
||||
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncOnPush)) {
|
||||
return@withContext
|
||||
}
|
||||
|
||||
try {
|
||||
val eventsBySession = requests.groupBy { it.sessionId }
|
||||
|
||||
appForegroundStateService.updateIsSyncingNotificationEvent(true)
|
||||
Timber.d("Starting opportunistic room list sync | In foreground: ${appForegroundStateService.isInForeground.value}")
|
||||
|
||||
for ((sessionId, events) in eventsBySession) {
|
||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: continue
|
||||
val roomIds = events.map { it.roomId }.distinct()
|
||||
|
||||
client.roomListService.subscribeToVisibleRooms(roomIds)
|
||||
|
||||
if (!appForegroundStateService.isInForeground.value) {
|
||||
// Give the sync some time to complete in background
|
||||
delay(10.seconds)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
Timber.d("Finished opportunistic room list sync")
|
||||
appForegroundStateService.updateIsSyncingNotificationEvent(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.push
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.push.impl.store.DefaultPushDataStore
|
||||
|
||||
interface IncrementPushDataStore {
|
||||
suspend fun incrementPushCounter()
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultIncrementPushDataStore(
|
||||
private val defaultPushDataStore: DefaultPushDataStore
|
||||
) : IncrementPushDataStore {
|
||||
override suspend fun incrementPushCounter() {
|
||||
defaultPushDataStore.incrementPushCounter()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.push
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.push.impl.store.DefaultPushDataStore
|
||||
|
||||
interface MutableBatteryOptimizationStore {
|
||||
suspend fun showBatteryOptimizationBanner()
|
||||
suspend fun onOptimizationBannerDismissed()
|
||||
suspend fun reset()
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultMutableBatteryOptimizationStore(
|
||||
private val defaultPushDataStore: DefaultPushDataStore,
|
||||
) : MutableBatteryOptimizationStore {
|
||||
override suspend fun showBatteryOptimizationBanner() {
|
||||
defaultPushDataStore.setBatteryOptimizationBannerState(DefaultPushDataStore.BATTERY_OPTIMIZATION_BANNER_STATE_SHOW)
|
||||
}
|
||||
|
||||
override suspend fun onOptimizationBannerDismissed() {
|
||||
defaultPushDataStore.setBatteryOptimizationBannerState(DefaultPushDataStore.BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED)
|
||||
}
|
||||
|
||||
override suspend fun reset() {
|
||||
defaultPushDataStore.setBatteryOptimizationBannerState(DefaultPushDataStore.BATTERY_OPTIMIZATION_BANNER_STATE_INIT)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.push
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
interface OnNotifiableEventReceived {
|
||||
fun onNotifiableEventsReceived(notifiableEvents: List<NotifiableEvent>)
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultOnNotifiableEventReceived(
|
||||
private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager,
|
||||
@AppCoroutineScope
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) : OnNotifiableEventReceived {
|
||||
override fun onNotifiableEventsReceived(notifiableEvents: List<NotifiableEvent>) {
|
||||
coroutineScope.launch {
|
||||
defaultNotificationDrawerManager.onNotifiableEventsReceived(notifiableEvents.filter { it !is NotifiableRingingCallEvent })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.push
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Typeface
|
||||
import android.text.style.StyleSpan
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationCompat.MessagingStyle
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.push.impl.notifications.ActiveNotificationsProvider
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationDisplayer
|
||||
import io.element.android.libraries.push.impl.notifications.factories.DefaultNotificationCreator
|
||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import timber.log.Timber
|
||||
|
||||
interface OnRedactedEventReceived {
|
||||
suspend fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>)
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultOnRedactedEventReceived(
|
||||
private val activeNotificationsProvider: ActiveNotificationsProvider,
|
||||
private val notificationDisplayer: NotificationDisplayer,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val stringProvider: StringProvider,
|
||||
) : OnRedactedEventReceived {
|
||||
override suspend fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>) {
|
||||
val redactionsBySessionIdAndRoom = redactions.groupBy { redaction ->
|
||||
redaction.sessionId to redaction.roomId
|
||||
}
|
||||
for ((keys, roomRedactions) in redactionsBySessionIdAndRoom) {
|
||||
val (sessionId, roomId) = keys
|
||||
// Get all notifications for the room, including those for threads
|
||||
val notifications = activeNotificationsProvider.getAllMessageNotificationsForRoom(sessionId, roomId)
|
||||
if (notifications.isEmpty()) {
|
||||
Timber.d("No notifications found for redacted event")
|
||||
}
|
||||
notifications.forEach { statusBarNotification ->
|
||||
val notification = statusBarNotification.notification
|
||||
val messagingStyle = MessagingStyle.extractMessagingStyleFromNotification(notification)
|
||||
if (messagingStyle == null) {
|
||||
Timber.w("Unable to retrieve messaging style from notification")
|
||||
return@forEach
|
||||
}
|
||||
val messageToRedactIndex = messagingStyle.messages.indexOfFirst { message ->
|
||||
roomRedactions.any { it.redactedEventId.value == message.extras.getString(DefaultNotificationCreator.MESSAGE_EVENT_ID) }
|
||||
}
|
||||
if (messageToRedactIndex == -1) {
|
||||
Timber.d("Unable to find the message to remove from notification")
|
||||
return@forEach
|
||||
}
|
||||
val oldMessage = messagingStyle.messages[messageToRedactIndex]
|
||||
val content = buildSpannedString {
|
||||
inSpans(StyleSpan(Typeface.ITALIC)) {
|
||||
append(stringProvider.getString(CommonStrings.common_message_removed))
|
||||
}
|
||||
}
|
||||
val newMessage = MessagingStyle.Message(
|
||||
content,
|
||||
oldMessage.timestamp,
|
||||
oldMessage.person
|
||||
)
|
||||
messagingStyle.messages[messageToRedactIndex] = newMessage
|
||||
notificationDisplayer.showNotification(
|
||||
statusBarNotification.tag,
|
||||
statusBarNotification.id,
|
||||
NotificationCompat.Builder(context, notification)
|
||||
.setStyle(messagingStyle)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.pushgateway
|
||||
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface PushGatewayAPI {
|
||||
/**
|
||||
* Ask the Push Gateway to send a push to the current device.
|
||||
*
|
||||
* Ref: https://matrix.org/docs/spec/push_gateway/r0.1.1#post-matrix-push-v1-notify
|
||||
*/
|
||||
@POST(PushGatewayConfig.URI_PUSH_GATEWAY_PREFIX_PATH + "notify")
|
||||
suspend fun notify(@Body body: PushGatewayNotifyBody): PushGatewayNotifyResponse
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.pushgateway
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.network.RetrofitFactory
|
||||
|
||||
interface PushGatewayApiFactory {
|
||||
fun create(baseUrl: String): PushGatewayAPI
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultPushGatewayApiFactory(
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
) : PushGatewayApiFactory {
|
||||
override fun create(baseUrl: String): PushGatewayAPI {
|
||||
return retrofitFactory.create(baseUrl)
|
||||
.create(PushGatewayAPI::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.pushgateway
|
||||
|
||||
object PushGatewayConfig {
|
||||
// Push Gateway
|
||||
const val URI_PUSH_GATEWAY_PREFIX_PATH = "_matrix/push/v1/"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.pushgateway
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PushGatewayDevice(
|
||||
/**
|
||||
* Required. The app_id given when the pusher was created.
|
||||
*/
|
||||
@SerialName("app_id")
|
||||
val appId: String,
|
||||
/**
|
||||
* Required. The pushkey given when the pusher was created.
|
||||
*/
|
||||
@SerialName("pushkey")
|
||||
val pushKey: String,
|
||||
/** Optional. Additional pusher data. */
|
||||
@SerialName("data")
|
||||
val data: PusherData? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PusherData(
|
||||
@SerialName("default_payload")
|
||||
val defaultPayload: Map<String, String>,
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.pushgateway
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PushGatewayNotification(
|
||||
@SerialName("event_id")
|
||||
val eventId: String,
|
||||
@SerialName("room_id")
|
||||
val roomId: String,
|
||||
/**
|
||||
* Required. This is an array of devices that the notification should be sent to.
|
||||
*/
|
||||
@SerialName("devices")
|
||||
val devices: List<PushGatewayDevice>,
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.pushgateway
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PushGatewayNotifyBody(
|
||||
/**
|
||||
* Required. Information about the push notification
|
||||
*/
|
||||
@SerialName("notification")
|
||||
val notification: PushGatewayNotification
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.pushgateway
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.push.api.gateway.PushGatewayFailure
|
||||
|
||||
interface PushGatewayNotifyRequest {
|
||||
data class Params(
|
||||
val url: String,
|
||||
val appId: String,
|
||||
val pushKey: String,
|
||||
val eventId: EventId,
|
||||
val roomId: RoomId,
|
||||
)
|
||||
|
||||
suspend fun execute(params: Params)
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultPushGatewayNotifyRequest(
|
||||
private val pushGatewayApiFactory: PushGatewayApiFactory,
|
||||
) : PushGatewayNotifyRequest {
|
||||
override suspend fun execute(params: PushGatewayNotifyRequest.Params) {
|
||||
val pushGatewayApi = pushGatewayApiFactory.create(
|
||||
params.url.substringBefore(PushGatewayConfig.URI_PUSH_GATEWAY_PREFIX_PATH)
|
||||
)
|
||||
val response = pushGatewayApi.notify(
|
||||
PushGatewayNotifyBody(
|
||||
PushGatewayNotification(
|
||||
eventId = params.eventId.value,
|
||||
roomId = params.roomId.value,
|
||||
devices = listOf(
|
||||
PushGatewayDevice(
|
||||
params.appId,
|
||||
params.pushKey,
|
||||
PusherData(mapOf(
|
||||
"cs" to "A_FAKE_SECRET",
|
||||
))
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (response.rejectedPushKeys.contains(params.pushKey)) {
|
||||
throw PushGatewayFailure.PusherRejected()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.pushgateway
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PushGatewayNotifyResponse(
|
||||
@SerialName("rejected")
|
||||
val rejectedPushKeys: List<String>
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.store
|
||||
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
|
||||
import io.element.android.libraries.push.api.history.PushHistoryItem
|
||||
import io.element.android.libraries.push.impl.PushDatabase
|
||||
import io.element.android.libraries.push.impl.store.DefaultPushDataStore.Companion.BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED
|
||||
import io.element.android.libraries.push.impl.store.DefaultPushDataStore.Companion.BATTERY_OPTIMIZATION_BANNER_STATE_INIT
|
||||
import io.element.android.libraries.push.impl.store.DefaultPushDataStore.Companion.BATTERY_OPTIMIZATION_BANNER_STATE_SHOW
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultPushDataStore(
|
||||
private val pushDatabase: PushDatabase,
|
||||
private val dateFormatter: DateFormatter,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
preferencesFactory: PreferenceDataStoreFactory,
|
||||
) : PushDataStore {
|
||||
private val pushCounter = intPreferencesKey("push_counter")
|
||||
|
||||
private val dataStore = preferencesFactory.create("push_store")
|
||||
|
||||
/**
|
||||
* Integer preference to track the state of the battery optimization banner.
|
||||
* Possible values:
|
||||
* [BATTERY_OPTIMIZATION_BANNER_STATE_INIT]: Should not show the banner
|
||||
* [BATTERY_OPTIMIZATION_BANNER_STATE_SHOW]: Should show the banner
|
||||
* [BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED]: Banner has been shown and user has dismissed it
|
||||
*/
|
||||
private val batteryOptimizationBannerState = intPreferencesKey("battery_optimization_banner_state")
|
||||
|
||||
override val pushCounterFlow: Flow<Int> = dataStore.data.map { preferences ->
|
||||
preferences[pushCounter] ?: 0
|
||||
}
|
||||
|
||||
@Suppress("UnnecessaryParentheses")
|
||||
override val shouldDisplayBatteryOptimizationBannerFlow: Flow<Boolean> = dataStore.data.map { preferences ->
|
||||
(preferences[batteryOptimizationBannerState] ?: BATTERY_OPTIMIZATION_BANNER_STATE_INIT) == BATTERY_OPTIMIZATION_BANNER_STATE_SHOW
|
||||
}
|
||||
|
||||
suspend fun incrementPushCounter() {
|
||||
dataStore.edit { settings ->
|
||||
val currentCounterValue = settings[pushCounter] ?: 0
|
||||
settings[pushCounter] = currentCounterValue + 1
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setBatteryOptimizationBannerState(newState: Int) {
|
||||
dataStore.edit { settings ->
|
||||
val currentValue = settings[batteryOptimizationBannerState] ?: BATTERY_OPTIMIZATION_BANNER_STATE_INIT
|
||||
settings[batteryOptimizationBannerState] = when (currentValue) {
|
||||
BATTERY_OPTIMIZATION_BANNER_STATE_INIT,
|
||||
BATTERY_OPTIMIZATION_BANNER_STATE_SHOW -> newState
|
||||
BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED -> currentValue
|
||||
else -> error("Invalid value for showBatteryOptimizationBanner: $currentValue")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPushHistoryItemsFlow(): Flow<List<PushHistoryItem>> {
|
||||
return pushDatabase.pushHistoryQueries.selectAll()
|
||||
.asFlow()
|
||||
.mapToList(dispatchers.io)
|
||||
.map { items ->
|
||||
items.map { pushHistory ->
|
||||
PushHistoryItem(
|
||||
pushDate = pushHistory.pushDate,
|
||||
formattedDate = dateFormatter.format(
|
||||
timestamp = pushHistory.pushDate,
|
||||
mode = DateFormatterMode.Full,
|
||||
useRelative = false,
|
||||
),
|
||||
providerInfo = pushHistory.providerInfo,
|
||||
eventId = pushHistory.eventId?.let { EventId(it) },
|
||||
roomId = pushHistory.roomId?.let { RoomId(it) },
|
||||
sessionId = pushHistory.sessionId?.let { SessionId(it) },
|
||||
hasBeenResolved = pushHistory.hasBeenResolved == 1L,
|
||||
comment = pushHistory.comment,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun reset() {
|
||||
pushDatabase.pushHistoryQueries.removeAll()
|
||||
dataStore.edit {
|
||||
it.clear()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BATTERY_OPTIMIZATION_BANNER_STATE_INIT = 0
|
||||
const val BATTERY_OPTIMIZATION_BANNER_STATE_SHOW = 1
|
||||
const val BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.store
|
||||
|
||||
import io.element.android.libraries.push.api.history.PushHistoryItem
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface PushDataStore {
|
||||
val shouldDisplayBatteryOptimizationBannerFlow: Flow<Boolean>
|
||||
val pushCounterFlow: Flow<Int>
|
||||
|
||||
/**
|
||||
* Get a flow of list of [PushHistoryItem].
|
||||
*/
|
||||
fun getPushHistoryItemsFlow(): Flow<List<PushHistoryItem>>
|
||||
|
||||
/**
|
||||
* Reset the push counter to 0, and clear the database.
|
||||
*/
|
||||
suspend fun reset()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.test
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.appconfig.PushConfig
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest
|
||||
import io.element.android.libraries.pushproviders.api.Config
|
||||
|
||||
interface TestPush {
|
||||
suspend fun execute(config: Config)
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultTestPush(
|
||||
private val pushGatewayNotifyRequest: PushGatewayNotifyRequest,
|
||||
) : TestPush {
|
||||
override suspend fun execute(config: Config) {
|
||||
pushGatewayNotifyRequest.execute(
|
||||
PushGatewayNotifyRequest.Params(
|
||||
url = config.url,
|
||||
appId = PushConfig.PUSHER_APP_ID,
|
||||
pushKey = config.pushKey,
|
||||
eventId = TEST_EVENT_ID,
|
||||
roomId = TEST_ROOM_ID,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TEST_EVENT_ID = EventId("\$THIS_IS_A_FAKE_EVENT_ID")
|
||||
val TEST_ROOM_ID = RoomId("!room:domain")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.troubleshoot
|
||||
|
||||
import dev.zacsweers.metro.ContributesIntoSet
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.push.api.PushService
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest
|
||||
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate
|
||||
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@ContributesIntoSet(SessionScope::class)
|
||||
@Inject
|
||||
class CurrentPushProviderTest(
|
||||
private val pushService: PushService,
|
||||
private val sessionId: SessionId,
|
||||
private val stringProvider: StringProvider,
|
||||
) : NotificationTroubleshootTest {
|
||||
override val order = 110
|
||||
private val delegate = NotificationTroubleshootTestDelegate(
|
||||
defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_current_push_provider_title),
|
||||
defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_current_push_provider_description),
|
||||
fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY,
|
||||
)
|
||||
override val state: StateFlow<NotificationTroubleshootTestState> = delegate.state
|
||||
|
||||
override suspend fun run(coroutineScope: CoroutineScope) {
|
||||
delegate.start()
|
||||
val pushProvider = pushService.getCurrentPushProvider(sessionId)
|
||||
if (pushProvider == null) {
|
||||
delegate.updateState(
|
||||
description = stringProvider.getString(R.string.troubleshoot_notifications_test_current_push_provider_failure),
|
||||
status = NotificationTroubleshootTestState.Status.Failure()
|
||||
)
|
||||
} else if (pushProvider.supportMultipleDistributors.not()) {
|
||||
delegate.updateState(
|
||||
description = stringProvider.getString(
|
||||
R.string.troubleshoot_notifications_test_current_push_provider_success,
|
||||
pushProvider.name
|
||||
),
|
||||
status = NotificationTroubleshootTestState.Status.Success
|
||||
)
|
||||
} else {
|
||||
val distributorValue = pushProvider.getCurrentDistributorValue(sessionId)
|
||||
if (distributorValue == null) {
|
||||
// No distributors configured
|
||||
delegate.updateState(
|
||||
description = stringProvider.getString(
|
||||
R.string.troubleshoot_notifications_test_current_push_provider_failure_no_distributor,
|
||||
pushProvider.name
|
||||
),
|
||||
status = NotificationTroubleshootTestState.Status.Failure(false)
|
||||
)
|
||||
} else {
|
||||
val distributor = pushProvider.getDistributors().find { it.value == distributorValue }
|
||||
if (distributor == null) {
|
||||
// Distributor has been uninstalled?
|
||||
delegate.updateState(
|
||||
description = stringProvider.getString(
|
||||
R.string.troubleshoot_notifications_test_current_push_provider_failure_distributor_not_found,
|
||||
pushProvider.name,
|
||||
distributorValue,
|
||||
distributorValue,
|
||||
),
|
||||
status = NotificationTroubleshootTestState.Status.Failure(false)
|
||||
)
|
||||
} else {
|
||||
delegate.updateState(
|
||||
description = stringProvider.getString(
|
||||
R.string.troubleshoot_notifications_test_current_push_provider_success_with_distributor,
|
||||
pushProvider.name,
|
||||
distributorValue,
|
||||
),
|
||||
status = NotificationTroubleshootTestState.Status.Success
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun reset() = delegate.reset()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.troubleshoot
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@Inject
|
||||
class DiagnosticPushHandler {
|
||||
private val _state = MutableSharedFlow<Unit>()
|
||||
val state: SharedFlow<Unit> = _state
|
||||
|
||||
suspend fun handlePush() {
|
||||
_state.emit(Unit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.troubleshoot
|
||||
|
||||
import dev.zacsweers.metro.ContributesIntoSet
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootNavigator
|
||||
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest
|
||||
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate
|
||||
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@ContributesIntoSet(SessionScope::class)
|
||||
@Inject
|
||||
class IgnoredUsersTest(
|
||||
private val matrixClient: MatrixClient,
|
||||
private val stringProvider: StringProvider,
|
||||
) : NotificationTroubleshootTest {
|
||||
override val order = 80
|
||||
private val delegate = NotificationTroubleshootTestDelegate(
|
||||
defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_blocked_users_title),
|
||||
defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_blocked_users_description),
|
||||
fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY,
|
||||
)
|
||||
override val state: StateFlow<NotificationTroubleshootTestState> = delegate.state
|
||||
|
||||
override suspend fun run(coroutineScope: CoroutineScope) {
|
||||
delegate.start()
|
||||
val ignorerUsers = matrixClient.ignoredUsersFlow.value
|
||||
if (ignorerUsers.isEmpty()) {
|
||||
delegate.updateState(
|
||||
description = stringProvider.getString(R.string.troubleshoot_notifications_test_blocked_users_result_none),
|
||||
status = NotificationTroubleshootTestState.Status.Success,
|
||||
)
|
||||
} else {
|
||||
delegate.updateState(
|
||||
description = stringProvider.getQuantityString(
|
||||
R.plurals.troubleshoot_notifications_test_blocked_users_result_some,
|
||||
ignorerUsers.size,
|
||||
ignorerUsers.size
|
||||
),
|
||||
status = NotificationTroubleshootTestState.Status.Failure(
|
||||
hasQuickFix = true,
|
||||
isCritical = false,
|
||||
quickFixButtonString = stringProvider.getString(R.string.troubleshoot_notifications_test_blocked_users_quick_fix),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun quickFix(
|
||||
coroutineScope: CoroutineScope,
|
||||
navigator: NotificationTroubleshootNavigator,
|
||||
) {
|
||||
navigator.navigateToBlockedUsers()
|
||||
}
|
||||
|
||||
override suspend fun reset() = delegate.reset()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.troubleshoot
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@Inject
|
||||
class NotificationClickHandler {
|
||||
private val _state = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val state: SharedFlow<Unit> = _state
|
||||
|
||||
fun handleNotificationClick() {
|
||||
_state.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.troubleshoot
|
||||
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import dev.zacsweers.metro.ContributesIntoSet
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.appconfig.NotificationConfig
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationDisplayer
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
|
||||
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest
|
||||
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate
|
||||
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@ContributesIntoSet(SessionScope::class)
|
||||
@Inject
|
||||
class NotificationTest(
|
||||
private val sessionId: SessionId,
|
||||
private val notificationCreator: NotificationCreator,
|
||||
private val notificationDisplayer: NotificationDisplayer,
|
||||
private val notificationClickHandler: NotificationClickHandler,
|
||||
private val stringProvider: StringProvider,
|
||||
private val enterpriseService: EnterpriseService,
|
||||
) : NotificationTroubleshootTest {
|
||||
override val order = 50
|
||||
private val delegate = NotificationTroubleshootTestDelegate(
|
||||
defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_display_notification_title),
|
||||
defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_display_notification_description),
|
||||
fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY,
|
||||
)
|
||||
override val state: StateFlow<NotificationTroubleshootTestState> = delegate.state
|
||||
|
||||
override suspend fun run(coroutineScope: CoroutineScope) {
|
||||
delegate.start()
|
||||
val color = enterpriseService.brandColorsFlow(sessionId).first()?.toArgb()
|
||||
?: NotificationConfig.NOTIFICATION_ACCENT_COLOR
|
||||
val notification = notificationCreator.createDiagnosticNotification(color)
|
||||
val result = notificationDisplayer.displayDiagnosticNotification(notification)
|
||||
if (result) {
|
||||
coroutineScope.listenToNotificationClick()
|
||||
delegate.updateState(
|
||||
description = stringProvider.getString(R.string.troubleshoot_notifications_test_display_notification_waiting),
|
||||
status = NotificationTroubleshootTestState.Status.WaitingForUser
|
||||
)
|
||||
} else {
|
||||
delegate.updateState(
|
||||
description = stringProvider.getString(R.string.troubleshoot_notifications_test_display_notification_permission_failure),
|
||||
status = NotificationTroubleshootTestState.Status.Failure()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.listenToNotificationClick() = launch {
|
||||
val job = launch {
|
||||
notificationClickHandler.state.first()
|
||||
Timber.d("Notification clicked!")
|
||||
}
|
||||
@Suppress("RunCatchingNotAllowed")
|
||||
runCatching {
|
||||
withTimeout(30.seconds) {
|
||||
job.join()
|
||||
}
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
delegate.updateState(
|
||||
description = stringProvider.getString(R.string.troubleshoot_notifications_test_display_notification_success),
|
||||
status = NotificationTroubleshootTestState.Status.Success
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
job.cancel()
|
||||
notificationDisplayer.dismissDiagnosticNotification()
|
||||
delegate.updateState(
|
||||
description = stringProvider.getString(R.string.troubleshoot_notifications_test_display_notification_failure),
|
||||
status = NotificationTroubleshootTestState.Status.Failure()
|
||||
)
|
||||
}
|
||||
)
|
||||
}.invokeOnCompletion {
|
||||
// Ensure that the notification is cancelled when the screen is left
|
||||
notificationDisplayer.dismissDiagnosticNotification()
|
||||
}
|
||||
|
||||
override suspend fun reset() = delegate.reset()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.troubleshoot
|
||||
|
||||
import dev.zacsweers.metro.ContributesIntoSet
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.push.api.PushService
|
||||
import io.element.android.libraries.push.api.gateway.PushGatewayFailure
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootNavigator
|
||||
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest
|
||||
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate
|
||||
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@ContributesIntoSet(SessionScope::class)
|
||||
@Inject
|
||||
class PushLoopbackTest(
|
||||
private val sessionId: SessionId,
|
||||
private val pushService: PushService,
|
||||
private val diagnosticPushHandler: DiagnosticPushHandler,
|
||||
private val clock: SystemClock,
|
||||
private val stringProvider: StringProvider,
|
||||
) : NotificationTroubleshootTest {
|
||||
override val order = 500
|
||||
private val delegate = NotificationTroubleshootTestDelegate(
|
||||
defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_title),
|
||||
defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_description),
|
||||
)
|
||||
override val state: StateFlow<NotificationTroubleshootTestState> = delegate.state
|
||||
|
||||
override suspend fun run(coroutineScope: CoroutineScope) {
|
||||
delegate.start()
|
||||
val startTime = clock.epochMillis()
|
||||
val completable = CompletableDeferred<Long>()
|
||||
val job = coroutineScope.launch {
|
||||
diagnosticPushHandler.state.first()
|
||||
completable.complete(clock.epochMillis() - startTime)
|
||||
}
|
||||
val testPushResult = try {
|
||||
pushService.testPush(sessionId)
|
||||
} catch (pusherRejected: PushGatewayFailure.PusherRejected) {
|
||||
val hasQuickFix = pushService.getCurrentPushProvider(sessionId)?.canRotateToken() == true
|
||||
delegate.updateState(
|
||||
description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_failure_1),
|
||||
status = NotificationTroubleshootTestState.Status.Failure(hasQuickFix = hasQuickFix)
|
||||
)
|
||||
job.cancel()
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to test push")
|
||||
delegate.updateState(
|
||||
description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_failure_2, e.message),
|
||||
status = NotificationTroubleshootTestState.Status.Failure()
|
||||
)
|
||||
job.cancel()
|
||||
return
|
||||
}
|
||||
if (!testPushResult) {
|
||||
delegate.updateState(
|
||||
description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_failure_3),
|
||||
status = NotificationTroubleshootTestState.Status.Failure()
|
||||
)
|
||||
job.cancel()
|
||||
return
|
||||
}
|
||||
@Suppress("RunCatchingNotAllowed")
|
||||
runCatching {
|
||||
withTimeout(10.seconds) {
|
||||
completable.await()
|
||||
}
|
||||
}.fold(
|
||||
onSuccess = { duration ->
|
||||
delegate.updateState(
|
||||
description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_success, duration),
|
||||
status = NotificationTroubleshootTestState.Status.Success
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
job.cancel()
|
||||
delegate.updateState(
|
||||
description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_failure_4),
|
||||
status = NotificationTroubleshootTestState.Status.Failure()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun quickFix(
|
||||
coroutineScope: CoroutineScope,
|
||||
navigator: NotificationTroubleshootNavigator,
|
||||
) {
|
||||
delegate.start()
|
||||
pushService.getCurrentPushProvider(sessionId)?.rotateToken()
|
||||
run(coroutineScope)
|
||||
}
|
||||
|
||||
override suspend fun reset() = delegate.reset()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.troubleshoot
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoSet
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.pushproviders.api.PushProvider
|
||||
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest
|
||||
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate
|
||||
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@ContributesIntoSet(AppScope::class)
|
||||
@Inject
|
||||
class PushProvidersTest(
|
||||
pushProviders: Set<@JvmSuppressWildcards PushProvider>,
|
||||
private val stringProvider: StringProvider,
|
||||
) : NotificationTroubleshootTest {
|
||||
private val sortedPushProvider = pushProviders.sortedBy { it.index }
|
||||
override val order = 100
|
||||
private val delegate = NotificationTroubleshootTestDelegate(
|
||||
defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_detect_push_provider_title),
|
||||
defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_detect_push_provider_description),
|
||||
fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY,
|
||||
)
|
||||
override val state: StateFlow<NotificationTroubleshootTestState> = delegate.state
|
||||
|
||||
override suspend fun run(coroutineScope: CoroutineScope) {
|
||||
delegate.start()
|
||||
val result = sortedPushProvider.isNotEmpty()
|
||||
if (result) {
|
||||
delegate.updateState(
|
||||
description = stringProvider.getString(
|
||||
resId = R.string.troubleshoot_notifications_test_detect_push_provider_success_2,
|
||||
sortedPushProvider.joinToString { it.name }
|
||||
),
|
||||
status = NotificationTroubleshootTestState.Status.Success
|
||||
)
|
||||
} else {
|
||||
delegate.updateState(
|
||||
description = stringProvider.getString(R.string.troubleshoot_notifications_test_detect_push_provider_failure),
|
||||
status = NotificationTroubleshootTestState.Status.Failure()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun reset() = delegate.reset()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations 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.unregistration
|
||||
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.appconfig.NotificationConfig
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationDisplayer
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
interface ServiceUnregisteredHandler {
|
||||
suspend fun handle(userId: UserId)
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultServiceUnregisteredHandler(
|
||||
private val enterpriseService: EnterpriseService,
|
||||
private val notificationCreator: NotificationCreator,
|
||||
private val notificationDisplayer: NotificationDisplayer,
|
||||
private val sessionStore: SessionStore,
|
||||
) : ServiceUnregisteredHandler {
|
||||
override suspend fun handle(userId: UserId) {
|
||||
val color = enterpriseService.brandColorsFlow(userId).first()?.toArgb()
|
||||
?: NotificationConfig.NOTIFICATION_ACCENT_COLOR
|
||||
val hasMultipleAccounts = sessionStore.numberOfSessions() > 1
|
||||
val notification = notificationCreator.createUnregistrationNotification(
|
||||
NotificationAccountParams(
|
||||
user = MatrixUser(userId),
|
||||
color = color,
|
||||
showSessionId = hasMultipleAccounts,
|
||||
)
|
||||
)
|
||||
notificationDisplayer.displayUnregistrationNotification(notification)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations 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.workmanager
|
||||
|
||||
class DataForWorkManagerIsTooBig : Exception()
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.workmanager
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import dev.zacsweers.metro.ContributesIntoMap
|
||||
import dev.zacsweers.metro.binding
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||
import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
|
||||
import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory
|
||||
import io.element.android.libraries.workmanager.api.di.WorkerKey
|
||||
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@AssistedInject
|
||||
class FetchNotificationsWorker(
|
||||
@Assisted workerParams: WorkerParameters,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val eventResolver: NotifiableEventResolver,
|
||||
private val queue: NotificationResolverQueue,
|
||||
private val workManagerScheduler: WorkManagerScheduler,
|
||||
private val syncOnNotifiableEvent: SyncOnNotifiableEvent,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
private val workerDataConverter: WorkerDataConverter,
|
||||
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
|
||||
) : CoroutineWorker(context, workerParams) {
|
||||
override suspend fun doWork(): Result = withContext(coroutineDispatchers.io) {
|
||||
Timber.d("FetchNotificationsWorker started")
|
||||
val requests = workerDataConverter.deserialize(inputData) ?: return@withContext Result.failure()
|
||||
// Wait for network to be available, but not more than 10 seconds
|
||||
val hasNetwork = withTimeoutOrNull(10.seconds) {
|
||||
networkMonitor.connectivity.first { it == NetworkStatus.Connected }
|
||||
} != null
|
||||
if (!hasNetwork) {
|
||||
Timber.w("No network, retrying later")
|
||||
return@withContext Result.retry()
|
||||
}
|
||||
|
||||
val failedSyncForSessions = mutableSetOf<SessionId>()
|
||||
|
||||
val groupedRequests = requests.groupBy { it.sessionId }
|
||||
for ((sessionId, notificationRequests) in groupedRequests) {
|
||||
Timber.d("Processing notification requests for session $sessionId")
|
||||
eventResolver.resolveEvents(sessionId, notificationRequests)
|
||||
.fold(
|
||||
onSuccess = { result ->
|
||||
// Update the resolved results in the queue
|
||||
(queue.results as MutableSharedFlow).emit(requests to result)
|
||||
},
|
||||
onFailure = {
|
||||
failedSyncForSessions += sessionId
|
||||
Timber.e(it, "Failed to resolve notification events for session $sessionId")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// If there were failures for whole sessions, we retry all their requests
|
||||
if (failedSyncForSessions.isNotEmpty()) {
|
||||
for (failedSessionId in failedSyncForSessions) {
|
||||
val requestsToRetry = groupedRequests[failedSessionId] ?: continue
|
||||
Timber.d("Re-scheduling ${requestsToRetry.size} failed notification requests for session $failedSessionId")
|
||||
workManagerScheduler.submit(
|
||||
SyncNotificationWorkManagerRequest(
|
||||
sessionId = failedSessionId,
|
||||
notificationEventRequests = requestsToRetry,
|
||||
workerDataConverter = workerDataConverter,
|
||||
buildVersionSdkIntProvider = buildVersionSdkIntProvider,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Timber.d("Notifications processed successfully")
|
||||
|
||||
performOpportunisticSyncIfNeeded(groupedRequests)
|
||||
|
||||
Result.success()
|
||||
}
|
||||
|
||||
private suspend fun performOpportunisticSyncIfNeeded(
|
||||
groupedRequests: Map<SessionId, List<NotificationEventRequest>>,
|
||||
) {
|
||||
for ((sessionId, notificationRequests) in groupedRequests) {
|
||||
runCatchingExceptions {
|
||||
syncOnNotifiableEvent(notificationRequests)
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to sync on notifiable events for session $sessionId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ContributesIntoMap(AppScope::class, binding = binding<MetroWorkerFactory.WorkerInstanceFactory<*>>())
|
||||
@WorkerKey(FetchNotificationsWorker::class)
|
||||
@AssistedFactory
|
||||
interface Factory : MetroWorkerFactory.WorkerInstanceFactory<FetchNotificationsWorker>
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.workmanager
|
||||
|
||||
import android.os.Build
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.WorkRequest
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequest
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
|
||||
import io.element.android.libraries.workmanager.api.workManagerTag
|
||||
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import timber.log.Timber
|
||||
import java.security.InvalidParameterException
|
||||
|
||||
class SyncNotificationWorkManagerRequest(
|
||||
private val sessionId: SessionId,
|
||||
private val notificationEventRequests: List<NotificationEventRequest>,
|
||||
private val workerDataConverter: WorkerDataConverter,
|
||||
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
|
||||
) : WorkManagerRequest {
|
||||
override fun build(): Result<List<WorkRequest>> {
|
||||
if (notificationEventRequests.isEmpty()) {
|
||||
return Result.failure(InvalidParameterException("notificationEventRequests cannot be empty"))
|
||||
}
|
||||
Timber.d("Scheduling ${notificationEventRequests.size} notification requests with WorkManager for $sessionId")
|
||||
return workerDataConverter.serialize(notificationEventRequests).map { dataList ->
|
||||
dataList.map { data ->
|
||||
OneTimeWorkRequestBuilder<FetchNotificationsWorker>()
|
||||
.setInputData(data)
|
||||
.apply {
|
||||
// Expedited workers aren't needed on Android 12 or lower:
|
||||
// They force displaying a foreground sync notification for no good reason, since they sync almost immediately anyway
|
||||
// See https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#backwards-compat
|
||||
if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
|
||||
setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
}
|
||||
}
|
||||
.setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC))
|
||||
// TODO investigate using this instead of the resolver queue
|
||||
// .setInputMerger()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Data(
|
||||
@SerialName("session_id")
|
||||
val sessionId: String,
|
||||
@SerialName("room_id")
|
||||
val roomId: String,
|
||||
@SerialName("event_id")
|
||||
val eventId: String,
|
||||
@SerialName("provider_info")
|
||||
val providerInfo: String,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.workmanager
|
||||
|
||||
import androidx.work.Data
|
||||
import androidx.work.workDataOf
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.core.extensions.mapCatchingExceptions
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
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.push.NotificationEventRequest
|
||||
import timber.log.Timber
|
||||
|
||||
@Inject
|
||||
class WorkerDataConverter(
|
||||
private val json: JsonProvider,
|
||||
) {
|
||||
fun serialize(notificationEventRequests: List<NotificationEventRequest>): Result<List<Data>> {
|
||||
// First try to serialize all requests at once. In the vast majority of cases this will work.
|
||||
return serializeRequests(notificationEventRequests)
|
||||
.map { listOf(it) }
|
||||
.recoverCatching { t ->
|
||||
if (t is DataForWorkManagerIsTooBig) {
|
||||
// Perform serialization on sublists, workDataOf have failed because of size limit
|
||||
Timber.w(t, "Failed to serialize ${notificationEventRequests.size} notification requests, split the requests per room.")
|
||||
// Group the requests per rooms
|
||||
val requestsSortedPerRoom = notificationEventRequests.groupBy { it.roomId }.values
|
||||
// Build a list of sublist with size at most CHUNK_SIZE, and with all rooms kept together
|
||||
buildList {
|
||||
val currentChunk = mutableListOf<NotificationEventRequest>()
|
||||
for (requests in requestsSortedPerRoom) {
|
||||
if (currentChunk.size + requests.size <= CHUNK_SIZE) {
|
||||
// Can add the whole room requests to the current chunk
|
||||
currentChunk.addAll(requests)
|
||||
} else {
|
||||
// Add the current chunk
|
||||
add(currentChunk.toList())
|
||||
// Start a new chunk with the current room requests
|
||||
currentChunk.clear()
|
||||
// If a room has more requests than CHUNK_SIZE, we need to split them
|
||||
requests.chunked(CHUNK_SIZE) { chunk ->
|
||||
if (chunk.size == CHUNK_SIZE) {
|
||||
add(chunk.toList())
|
||||
} else {
|
||||
currentChunk.addAll(chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add any remaining requests
|
||||
add(currentChunk.toList())
|
||||
}
|
||||
.filter { it.isNotEmpty() }
|
||||
.also {
|
||||
Timber.d("Split notification requests into ${it.size} chunks for WorkManager serialization")
|
||||
it.forEach { requests ->
|
||||
Timber.d(" - Chunk with ${requests.size} requests")
|
||||
}
|
||||
}
|
||||
.mapNotNull { serializeRequests(it).getOrNull() }
|
||||
} else {
|
||||
throw t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun serializeRequests(notificationEventRequests: List<NotificationEventRequest>): Result<Data> {
|
||||
return runCatchingExceptions { json().encodeToString(notificationEventRequests.map { it.toData() }) }
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to serialize notification requests")
|
||||
}
|
||||
.mapCatchingExceptions { str ->
|
||||
// Note: workDataOf can fail if the data is too large
|
||||
try {
|
||||
workDataOf(REQUESTS_KEY to str)
|
||||
} catch (_: IllegalStateException) {
|
||||
throw DataForWorkManagerIsTooBig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deserialize(data: Data): List<NotificationEventRequest>? {
|
||||
val rawRequestsJson = data.getString(REQUESTS_KEY) ?: return null
|
||||
return runCatchingExceptions {
|
||||
json().decodeFromString<List<SyncNotificationWorkManagerRequest.Data>>(rawRequestsJson).map { it.toRequest() }
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
Timber.d("Deserialized ${it.size} requests")
|
||||
it
|
||||
},
|
||||
onFailure = {
|
||||
Timber.e(it, "Failed to deserialize notification requests")
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REQUESTS_KEY = "requests"
|
||||
internal const val CHUNK_SIZE = 20
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotificationEventRequest.toData(): SyncNotificationWorkManagerRequest.Data {
|
||||
return SyncNotificationWorkManagerRequest.Data(
|
||||
sessionId = sessionId.value,
|
||||
roomId = roomId.value,
|
||||
eventId = eventId.value,
|
||||
providerInfo = providerInfo,
|
||||
)
|
||||
}
|
||||
|
||||
private fun SyncNotificationWorkManagerRequest.Data.toRequest(): NotificationEventRequest {
|
||||
return NotificationEventRequest(
|
||||
sessionId = SessionId(sessionId),
|
||||
roomId = RoomId(roomId),
|
||||
eventId = EventId(eventId),
|
||||
providerInfo = providerInfo,
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user