First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
+101
View 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")
}
}
}
Binary file not shown.
@@ -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,
)
}
Binary file not shown.
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Пазваніць"</string>
<string name="notification_channel_listening_for_events">"Праслухоўванне падзей"</string>
<string name="notification_channel_noisy">"Шумныя апавяшчэнні"</string>
<string name="notification_channel_ringing_calls">"Званкі"</string>
<string name="notification_channel_silent">"Ціхія апавяшчэнні"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d паведамленне"</item>
<item quantity="few">"%1$s: %2$d паведамленні"</item>
<item quantity="many">"%1$s: %2$d паведамленняў"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"%d апавяшчэнне"</item>
<item quantity="few">"%d апавяшчэнні"</item>
<item quantity="many">"%d апавяшчэнняў"</item>
</plurals>
<string name="notification_incoming_call">"📹 Уваходны выклік"</string>
<string name="notification_inline_reply_failed">"** Не атрымалася даслаць - калі ласка, адкрыйце пакой"</string>
<string name="notification_invitation_action_join">"Далучыцца"</string>
<string name="notification_invitation_action_reject">"Адхіліць"</string>
<plurals name="notification_invitations">
<item quantity="one">"%d запрашэнне"</item>
<item quantity="few">"%d запрашэнні"</item>
<item quantity="many">"%d запрашэнняў"</item>
</plurals>
<string name="notification_invite_body">"Запрасіў(-ла) вас у чат"</string>
<string name="notification_invite_body_with_sender">"%1$s запрасіў(-ла) вас у чат"</string>
<string name="notification_mentioned_you_body">"Згадаў(-ла) вас: %1$s"</string>
<string name="notification_new_messages">"Новыя паведамленні"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d новае паведамленне"</item>
<item quantity="few">"%d новыя паведамленні"</item>
<item quantity="many">"%d новых паведамленняў"</item>
</plurals>
<string name="notification_reaction_body">"Адрэагаваў(-ла) на %1$s"</string>
<string name="notification_room_action_mark_as_read">"Пазначыць як прачытанае"</string>
<string name="notification_room_action_quick_reply">"Хуткі адказ"</string>
<string name="notification_room_invite_body">"Запрасіў(-ла) вас далучыцца да пакоя"</string>
<string name="notification_room_invite_body_with_sender">"%1$s запрасіў(-ла) вас далучыцца да пакоя"</string>
<string name="notification_sender_me">"Я"</string>
<string name="notification_sender_mention_reply">"%1$s згадаў ці адказаў"</string>
<string name="notification_test_push_notification_content">"Вы праглядаеце апавяшчэнне! Націсніце мяне!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d непрачытанае апавяшчэнне"</item>
<item quantity="few">"%d непрачытаныя апавяшчэнні"</item>
<item quantity="many">"%d непрачытаных апавяшчэнняў"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$s і %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s у %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s у %2$s і %3$s"</string>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d пакой"</item>
<item quantity="few">"%d пакоі"</item>
<item quantity="many">"%d пакояў"</item>
</plurals>
<string name="push_distributor_background_sync_android">"Фонавая сінхранізацыя"</string>
<string name="push_distributor_firebase_android">"Сэрвісы Google"</string>
<string name="push_no_valid_google_play_services_apk_android">"Службы Google Play не знойдзены. Апавяшчэнні могуць не працаваць належным чынам."</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Атрымаць назву бягучага пастаўшчыка."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Пастаўшчыкі push-апавяшчэнняў не выбраны."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Бягучы пастаўшчык push-апавяшчэнняў: %1$s."</string>
<string name="troubleshoot_notifications_test_current_push_provider_title">"Бягучы пастаўшчык push-апавяшчэнняў"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_description">"Пераканайцеся, што ў праграме ёсць хаця б адзін пастаўшчык push-апавяшчэнняў."</string>
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"Пастаўшчыкі push-апавяшчэнняў не знойдзены."</string>
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
<item quantity="one">"Знайшлі %1$d пастаўшчыка push-апавяшчэнняў: %2$s"</item>
<item quantity="few">"Знайшлі %1$d пастаўшчыкоў push-апавяшчэнняў: %2$s"</item>
<item quantity="many">"Знайшлі %1$d пастаўшчыкоў push-апавяшчэнняў: %2$s"</item>
</plurals>
<string name="troubleshoot_notifications_test_detect_push_provider_title">"Выяўленне пастаўшчыкоў push-паслуг"</string>
<string name="troubleshoot_notifications_test_display_notification_description">"Праверце, ці можа праграма паказваць апавяшчэнні."</string>
<string name="troubleshoot_notifications_test_display_notification_failure">"Апавяшчэнне не было націснута."</string>
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Немагчыма паказаць апавяшчэнне."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Апавяшчэнне было націснута!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Паказаць апавяшчэнне"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Націсніце на апавяшчэнне, каб працягнуць тэст."</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"Пераканайцеся, што праграма атрымлівае push-апавяшчэнні."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Памылка: pusher адхіліў запыт."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Памылка: %1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Памылка, немагчыма праверыць push-апавяшчэнне."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Памылка, тайм-аўт у чаканні push-апавяшчэння."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"Зварот цыклу назад заняў %1$d мс."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Тэст Націсніце кнопку вярнуцца"</string>
</resources>
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Обаждане"</string>
<string name="notification_channel_listening_for_events">"Слушане за събития"</string>
<string name="notification_channel_noisy">"Шумни известия"</string>
<string name="notification_channel_silent">"Безшумни известия"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d съобщение"</item>
<item quantity="other">"%1$s: %2$d съобщения"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"%d известие"</item>
<item quantity="other">"%d известия"</item>
</plurals>
<string name="notification_fallback_content">"Имате нови съобщения."</string>
<string name="notification_inline_reply_failed">"** Неуспешно изпращане - моля, отворете стаята"</string>
<string name="notification_invitation_action_join">"Присъединяване"</string>
<plurals name="notification_invitations">
<item quantity="one">"%d покана"</item>
<item quantity="other">"%d покани"</item>
</plurals>
<string name="notification_invite_body">"Поканиха ви за чат"</string>
<string name="notification_mentioned_you_body">"Ви спомена: %1$s"</string>
<string name="notification_new_messages">"Нови съобщения"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d ново съобщение"</item>
<item quantity="other">"%d нови съобщения"</item>
</plurals>
<string name="notification_reaction_body">"Реагира с %1$s"</string>
<string name="notification_room_action_mark_as_read">"Отбелязване като прочетено"</string>
<string name="notification_room_action_quick_reply">"Бърз отговор"</string>
<string name="notification_room_invite_body">"Ви покани да се присъедините към стаята"</string>
<string name="notification_sender_me">"Аз"</string>
<string name="notification_test_push_notification_content">"Преглеждате известието! Кликнете върху мен!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d непрочетено известено съобщение"</item>
<item quantity="other">"%d непрочетени известени съобщения"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$s и %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s в %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s в %2$s и %3$s"</string>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d стая"</item>
<item quantity="other">"%d стаи"</item>
</plurals>
<string name="push_distributor_background_sync_android">"Синхронизация на заден план"</string>
<string name="push_distributor_firebase_android">"Услуги на Google"</string>
<string name="push_no_valid_google_play_services_apk_android">"Не са намерени валидни услуги на Google Play. Известията може да не работят правилно."</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"Проверка на блокирани потребители"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"Преглед на блокираните потребители"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"Няма блокирани потребители."</string>
<string name="troubleshoot_notifications_test_blocked_users_title">"Блокирани потребители"</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Получаване на името на текущия доставчик."</string>
<string name="troubleshoot_notifications_test_detect_push_provider_success_2">"Приложението е изградено с поддръжка за: %1$s"</string>
<string name="troubleshoot_notifications_test_display_notification_description">"Проверка дали приложението може да показва известия."</string>
<string name="troubleshoot_notifications_test_display_notification_failure">"Известието не е било кликнато."</string>
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Не може да се покаже известието."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Известието беше натиснато!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Показване на известие"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Моля, натиснете известието, за да продължите теста."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Грешка: %1$s"</string>
</resources>
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Hovor"</string>
<string name="notification_channel_listening_for_events">"Naslouchání událostem"</string>
<string name="notification_channel_noisy">"Hlasitá oznámení"</string>
<string name="notification_channel_ringing_calls">"Vyzvánění hovorů"</string>
<string name="notification_channel_silent">"Tichá oznámení"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d zpráva"</item>
<item quantity="few">"%1$s: %2$d zprávy"</item>
<item quantity="other">"%1$s: %2$d zpráv"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"%d oznámení"</item>
<item quantity="few">"%d oznámení"</item>
<item quantity="other">"%d oznámení"</item>
</plurals>
<string name="notification_error_unified_push_unregistered_android">"Distributor oznámení UnifiedPush se nepodařilo zaregistrovat, takže již nebudete dostávat oznámení. Zkontrolujte nastavení oznámení v aplikaci a stav distributora push oznámění."</string>
<string name="notification_fallback_content">"Oznámení"</string>
<string name="notification_incoming_call">"📹 Příchozí hovor"</string>
<string name="notification_inline_reply_failed">"** Nepodařilo se odeslat - otevřete prosím místnost"</string>
<string name="notification_invitation_action_join">"Vstoupit"</string>
<string name="notification_invitation_action_reject">"Odmítnout"</string>
<plurals name="notification_invitations">
<item quantity="one">"%d pozvánka"</item>
<item quantity="few">"%d pozvánky"</item>
<item quantity="other">"%d pozvánek"</item>
</plurals>
<string name="notification_invite_body">"Vás pozval(a) do chatu"</string>
<string name="notification_invite_body_with_sender">"%1$s vás pozval(a) do chatu"</string>
<string name="notification_mentioned_you_body">"Zmínili vás: %1$s"</string>
<string name="notification_new_messages">"Nové zprávy"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d nová zpráva"</item>
<item quantity="few">"%d nové zprávy"</item>
<item quantity="other">"%d nových zpráv"</item>
</plurals>
<string name="notification_reaction_body">"Reagoval(a) s %1$s"</string>
<string name="notification_room_action_mark_as_read">"Označit jako přečtené"</string>
<string name="notification_room_action_quick_reply">"Rychlá odpověď"</string>
<string name="notification_room_invite_body">"Vás pozval(a) do místnosti"</string>
<string name="notification_room_invite_body_with_sender">"%1$s vás pozval(a) do místnosti"</string>
<string name="notification_sender_me">"Já"</string>
<string name="notification_sender_mention_reply">"%1$s zmínil(a) nebo odpověděl(a)"</string>
<string name="notification_test_push_notification_content">"Prohlížíte si oznámení! Klikněte na mě!"</string>
<string name="notification_thread_in_room">"Vlákno v %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d nepřečtená oznámená zpráva"</item>
<item quantity="few">"%d nepřečtené oznámené zprávy"</item>
<item quantity="other">"%d nepřečtených oznámených zpráv"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$s a %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s in %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s v %2$s a %3$s"</string>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d místnost"</item>
<item quantity="few">"%d místnosti"</item>
<item quantity="other">"%d místností"</item>
</plurals>
<string name="push_distributor_background_sync_android">"Synchronizace na pozadí"</string>
<string name="push_distributor_firebase_android">"Služby Google"</string>
<string name="push_no_valid_google_play_services_apk_android">"Nebyly nalezeny žádné funkční služby Google Play. Oznámení nemusí fungovat správně."</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"Kontrola blokovaných uživatelů"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"Zobrazit blokované uživatele"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"Žádní uživatelé nejsou blokováni."</string>
<plurals name="troubleshoot_notifications_test_blocked_users_result_some">
<item quantity="one">"Zablokovali jste %1$d uživatele. Nebudete dostávat oznámení od tohoto uživatele."</item>
<item quantity="few">"Zablokovali jste %1$d uživatele. Nebudete dostávat oznámení od těchto uživatelů."</item>
<item quantity="other">"Zablokovali jste %1$d uživatelů. Nebudete dostávat oznámení od těchto uživatelů."</item>
</plurals>
<string name="troubleshoot_notifications_test_blocked_users_title">"Blokovaní uživatelé"</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Získat název aktuálního poskytovatele."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Nebyli vybráni žádní push poskytovatelé."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure_distributor_not_found">"Aktuální poskytovatel push oznámení: %1$s a současný distributor: %2$s. Ale distributor %3$s nebyl nalezen. Možná byla aplikace odinstalována?"</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure_no_distributor">"Aktuální poskytovatel push oznámení: %1$s , ale nebyli nakonfigurováni žádní distributoři."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Aktuální push poskytovatel: %1$s."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success_with_distributor">"Aktuální poskytovatel push oznámení: %1$s (%2$s)"</string>
<string name="troubleshoot_notifications_test_current_push_provider_title">"Aktuální push poskytovatel"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_description">"Ujistěte se, že aplikace má alespoň jednoho push poskytovatele."</string>
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"Nebyli nalezeni žádní push poskytovatelé."</string>
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
<item quantity="one">"Nalezen %1$d push poskytovatel: %2$s"</item>
<item quantity="few">"Nalezeni %1$d push poskytovatelé: %2$s"</item>
<item quantity="other">"Nalezeno %1$d push poskytovatelů: %2$s"</item>
</plurals>
<string name="troubleshoot_notifications_test_detect_push_provider_success_2">"Aplikace byla vytvořena s podporou: %1$s"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_title">"Zjistit push poskytovatele"</string>
<string name="troubleshoot_notifications_test_display_notification_description">"Zkontrolujte, zda aplikace může zobrazit oznámení."</string>
<string name="troubleshoot_notifications_test_display_notification_failure">"Na oznámení nebylo kliknuto."</string>
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Oznámení nelze zobrazit."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Na oznámení bylo kliknuto!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Zobrazit oznámení"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Kliknutím na oznámení pokračujte v testu."</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"Ujistěte se, že aplikace přijímá push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Chyba: pusher odmítl požadavek."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Chyba: %1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Chyba, nelze otestovat push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Chyba, časový limit čekání na push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"Push zpětná smyčka trvala %1$d ms."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Otestovat push zpětnou smyčku"</string>
</resources>
@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Galw"</string>
<string name="notification_channel_listening_for_events">"Gwrando am ddigwyddiadau"</string>
<string name="notification_channel_noisy">"Hysbysiadau swnllyd"</string>
<string name="notification_channel_ringing_calls">"Galwadau\'n canu"</string>
<string name="notification_channel_silent">"Hysbysiadau tawel"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="zero">"%1$s: %2$d negeseuon"</item>
<item quantity="one">"%1$s: %2$d neges"</item>
<item quantity="two">"%1$s: %2$d neges"</item>
<item quantity="few">"%1$s: %2$d neges"</item>
<item quantity="many">"%1$s: %2$d neges"</item>
<item quantity="other">"%1$s: %2$d neges"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="zero">"%d hysbysiadau"</item>
<item quantity="one">"%d hysbysiad"</item>
<item quantity="two">"%d hysbysiad"</item>
<item quantity="few">"%d hysbysiad"</item>
<item quantity="many">"%d hysbysiad"</item>
<item quantity="other">"%d hysbysiad"</item>
</plurals>
<string name="notification_fallback_content">"Mae gennych chi negeseuon newydd."</string>
<string name="notification_incoming_call">"📹 Galwad i mewn"</string>
<string name="notification_inline_reply_failed">"** Wedi methu anfon - agorwch yr ystafell"</string>
<string name="notification_invitation_action_join">"Ymuno"</string>
<string name="notification_invitation_action_reject">"Gwrthod"</string>
<plurals name="notification_invitations">
<item quantity="zero">"%d gwahoddiadau"</item>
<item quantity="one">"%d gwahoddiadau"</item>
<item quantity="two">"%d gwahoddiadau"</item>
<item quantity="few">"%d gwahoddiadau"</item>
<item quantity="many">"%d gwahoddiadau"</item>
<item quantity="other">"%d gwahoddiadau"</item>
</plurals>
<string name="notification_invite_body">"Wedi eich gwahodd i sgwrsio"</string>
<string name="notification_invite_body_with_sender">"Mae %1$s wedi eich gwahodd i sgwrsio"</string>
<string name="notification_mentioned_you_body">"Wedi eich crybwyll: %1$s"</string>
<string name="notification_new_messages">"Negeseuon Newydd"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="zero">"%d negeseuon newydd"</item>
<item quantity="one">"%d neges newydd"</item>
<item quantity="two">"%d neges newydd"</item>
<item quantity="few">"%d neges newydd"</item>
<item quantity="many">"%d neges newydd"</item>
<item quantity="other">"%d neges newydd"</item>
</plurals>
<string name="notification_reaction_body">"Wedi ymateb gyda %1$s"</string>
<string name="notification_room_action_mark_as_read">"Marcio fel wedi\'i ddarllen"</string>
<string name="notification_room_action_quick_reply">"Ymateb cyflym"</string>
<string name="notification_room_invite_body">"Wedi\'ch gwahodd i ymuno â\'r ystafell"</string>
<string name="notification_room_invite_body_with_sender">"Mae %1$s wedi eich gwahodd i ymuno â\'r ystafell"</string>
<string name="notification_sender_me">"Fi"</string>
<string name="notification_sender_mention_reply">"Crybwyllodd neu atebodd %1$s"</string>
<string name="notification_test_push_notification_content">"Rydych chi\'n edrych ar yr hysbysiad! Cliciwch fi!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s : %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="zero">"%d negeseuon hysbyswyd heb eu darllen"</item>
<item quantity="one">"%d neges hysbyswyd heb ei ddarllen"</item>
<item quantity="two">"%d neges hysbyswyd heb eu darllen"</item>
<item quantity="few">"%d neges hysbyswyd heb eu darllen"</item>
<item quantity="many">"%d neges hysbyswyd heb eu darllen"</item>
<item quantity="other">"%d neges hysbyswyd heb eu darllen"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$s a %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s yn %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s yn %2$s a %3$s"</string>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="zero">"%d ystafelloedd"</item>
<item quantity="one">"%d ystafell"</item>
<item quantity="two">"%d ystafell"</item>
<item quantity="few">"%d ystafell"</item>
<item quantity="many">"%d ystafell"</item>
<item quantity="other">"%d ystafell"</item>
</plurals>
<string name="push_distributor_background_sync_android">"Cydweddu\'n y cefndir"</string>
<string name="push_distributor_firebase_android">"Gwasanaethau Google"</string>
<string name="push_no_valid_google_play_services_apk_android">"Heb ganfod Google Play Services dilys. Efallai fydd hysbysiadau ddim yn gweithio\'n iawn."</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"Gwirio defnyddwyr sydd wedi\'u rhwystro"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"Gweld defnyddwyr wedi\'u rhwystro"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"Does dim defnyddwyr wedi\'u rhwystro."</string>
<plurals name="troubleshoot_notifications_test_blocked_users_result_some">
<item quantity="zero">"Rydych wedi rhwystro %1$d defnyddiwr. Fyddwch chi ddim yn derbyn hysbysiadau ar gyfer y defnyddiwr hwn."</item>
<item quantity="one">"Rydych wedi rhwystro %1$d defnyddiwr. Fyddwch chi ddim yn derbyn hysbysiadau ar gyfer y defnyddiwr hwn."</item>
<item quantity="two">"Rydych wedi rhwystro %1$d defnyddiwr. Fyddwch chi ddim yn derbyn hysbysiadau ar gyfer y defnyddiwr hwn."</item>
<item quantity="few">"Rydych wedi rhwystro %1$d defnyddiwr. Fyddwch chi ddim yn derbyn hysbysiadau ar gyfer y defnyddiwr hwn."</item>
<item quantity="many">"Rydych wedi rhwystro %1$d defnyddiwr. Fyddwch chi ddim yn derbyn hysbysiadau ar gyfer y defnyddiwr hwn."</item>
<item quantity="other">"Rydych wedi rhwystro %1$d defnyddiwr. Fyddwch chi ddim yn derbyn hysbysiadau ar gyfer y defnyddiwr hwn."</item>
</plurals>
<string name="troubleshoot_notifications_test_blocked_users_title">"Defnyddwyr wedi\'u rhwystro"</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Cael enw\'r darparwr presennol."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Dim darparwyr gwthio wedi\'u dewis."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Darparwr gwthio presennol: %1$s."</string>
<string name="troubleshoot_notifications_test_current_push_provider_title">"Darparwr gwthio presennol"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_description">"Gwnewch yn siŵr fod y cais yn cefnogi o leiaf un darparwr gwthio."</string>
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"Heb ganfod cefnogaeth darparwr gwthio."</string>
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
<item quantity="zero">"Wedi canfod %1$d darparwyr gwthio: %2$s"</item>
<item quantity="one">"Wedi canfod %1$d darparwr gwthio: %2$s"</item>
<item quantity="two">"Wedi canfod %1$d darparwr gwthio: %2$s"</item>
<item quantity="few">"Wedi canfod %1$d darparwr gwthio: %2$s"</item>
<item quantity="many">"Wedi canfod %1$d darparwr gwthio: %2$s"</item>
<item quantity="other">"Wedi canfod %1$d darparwr gwthio: %2$s"</item>
</plurals>
<string name="troubleshoot_notifications_test_detect_push_provider_success_2">"Adeiladwyd y rhaglen gyda chefnogaeth ar gyfer: %1$s"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_title">"Cefnogaeth darparwr gwthio"</string>
<string name="troubleshoot_notifications_test_display_notification_description">"Gwiriwch y gall y cais ddangos hysbysiad."</string>
<string name="troubleshoot_notifications_test_display_notification_failure">"Nid yw\'r hysbysiad wedi\'i glicio."</string>
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Methu dangos yr hysbysiad."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Mae\'r hysbysiad wedi\'i glicio!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Hysbysiad dangos"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Cliciwch ar yr hysbysiad i barhau â\'r prawf."</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"Gwnewch yn siŵr fod y cais yn cael ei wthio."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Gwall: mae\'r gwthiwr wedi gwrthod y cais."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Gwall: %1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Gwall, methu profi\'r gwthio."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Gwall, terfyn amser wrth aros am y gwthio."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"Cymerodd dolen gwthio nôl %1$d ms."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Prawf dolen gwthio\'n ôl"</string>
</resources>
@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Opkald"</string>
<string name="notification_channel_listening_for_events">"Lytter efter begivenheder"</string>
<string name="notification_channel_noisy">"Lyd på notifikationer"</string>
<string name="notification_channel_ringing_calls">"Ringende opkald"</string>
<string name="notification_channel_silent">"Lydløse notifikationer"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d besked"</item>
<item quantity="other">"%1$s: %2$d beskeder"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"%d notifikation"</item>
<item quantity="other">"%d notifikationer"</item>
</plurals>
<string name="notification_fallback_content">"Du har nye beskeder."</string>
<string name="notification_incoming_call">"📹 Indgående opkald"</string>
<string name="notification_inline_reply_failed">"** Kunne ikke sende - åbn venligst rummet"</string>
<string name="notification_invitation_action_join">"Deltag"</string>
<string name="notification_invitation_action_reject">"Afvis"</string>
<plurals name="notification_invitations">
<item quantity="one">"%d invitation"</item>
<item quantity="other">"%d invitationer"</item>
</plurals>
<string name="notification_invite_body">"Inviterede dig til at samtale"</string>
<string name="notification_invite_body_with_sender">"%1$s inviterede dig til at samtale"</string>
<string name="notification_mentioned_you_body">"Nævnte dig: %1$s"</string>
<string name="notification_new_messages">"Nye beskeder"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d ny besked"</item>
<item quantity="other">"%d nye beskeder"</item>
</plurals>
<string name="notification_reaction_body">"Reagerede med%1$s"</string>
<string name="notification_room_action_mark_as_read">"Marker som læst"</string>
<string name="notification_room_action_quick_reply">"Hurtigt svar"</string>
<string name="notification_room_invite_body">"Inviterede dig til at deltage i rummet"</string>
<string name="notification_room_invite_body_with_sender">"%1$s inviterede dig til at deltage i rummet"</string>
<string name="notification_sender_me">"Mig"</string>
<string name="notification_sender_mention_reply">"%1$s nævnt eller besvaret"</string>
<string name="notification_test_push_notification_content">"Du ser notifikationen! Klik på mig!"</string>
<string name="notification_thread_in_room">"Tråd i %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d ulæst besked"</item>
<item quantity="other">"%d ulæste beskeder"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$s og %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s i %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s i %2$s og %3$s"</string>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d rum"</item>
<item quantity="other">"%d rum"</item>
</plurals>
<string name="push_distributor_background_sync_android">"Synkronisering i baggrunden"</string>
<string name="push_distributor_firebase_android">"Google-tjenester"</string>
<string name="push_no_valid_google_play_services_apk_android">"Der blev ikke fundet nogen gyldige Google Play-tjenester. Notifikationer fungerer muligvis ikke korrekt."</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"Kontrollerer blokerede brugere"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"Se blokerede brugere"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"Ingen brugere er blokeret."</string>
<plurals name="troubleshoot_notifications_test_blocked_users_result_some">
<item quantity="one">"Du har blokeret %1$d bruger. Du vil ikke modtage meddelelser fra denne bruger."</item>
<item quantity="other">"Du har blokeret %1$d brugere. Du vil ikke modtage meddelelser fra disse brugere."</item>
</plurals>
<string name="troubleshoot_notifications_test_blocked_users_title">"Blokerede brugere"</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Få navnet på den aktuelle udbyder."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Ingen push-udbydere valgt."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure_distributor_not_found">"Nuværende push-udbyder: %1$s og nuværende distributør: %2$s. Men distributøren %3$s kan ikke findes. Måske er applikationen blevet afinstalleret?"</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure_no_distributor">"Nuværende push-udbyder: %1$s, men der er ikke konfigureret nogen distributører."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Nuværende push-udbyder: %1$s."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success_with_distributor">"Nuværende push-udbyder: %1$s (%2$s)"</string>
<string name="troubleshoot_notifications_test_current_push_provider_title">"Nuværende push-udbyder"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_description">"Sørg for, at programmet understøtter mindst én push-udbyder."</string>
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"Ingen push-udbyder understøttelse fundet."</string>
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
<item quantity="one">"Fandt %1$d push-udbyder: %2$s"</item>
<item quantity="other">"Fandt %1$d push-udbydere: %2$s"</item>
</plurals>
<string name="troubleshoot_notifications_test_detect_push_provider_success_2">"Applikationen blev bygget med støtte til: %1$s"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_title">"Understøttelse af push-udbydere"</string>
<string name="troubleshoot_notifications_test_display_notification_description">"Kontrollér, at appen kan vise notifikationer."</string>
<string name="troubleshoot_notifications_test_display_notification_failure">"Der er ikke blevet klikket på meddelelsen."</string>
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Kan ikke vise notifikationen."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Der er blevet klikket på notifikationen!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Vis notifikation"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Klik venligst på notifikationen for at fortsætte testen."</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"Sørg for, at applikationen modtager push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Fejl: pusher har afvist anmodningen."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Fejl: %1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Fejl, kan ikke teste push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Fejl, timeout venter på push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"Push loop back tog %1$d ms."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Afprøv Push loop back"</string>
</resources>
@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Anruf"</string>
<string name="notification_channel_listening_for_events">"Auf Ereignisse achten"</string>
<string name="notification_channel_noisy">"Laute Benachrichtigungen"</string>
<string name="notification_channel_ringing_calls">"Klingelnde Anrufe"</string>
<string name="notification_channel_silent">"Stumme Benachrichtigungen"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d Nachricht"</item>
<item quantity="other">"%1$s: %2$d Nachrichten"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"%d Mitteilung"</item>
<item quantity="other">"%d Mitteilungen"</item>
</plurals>
<string name="notification_fallback_content">"Du hast neue Nachrichten."</string>
<string name="notification_incoming_call">"Eingehender Anruf"</string>
<string name="notification_inline_reply_failed">"** Fehler beim Senden - bitte Chat öffnen"</string>
<string name="notification_invitation_action_join">"Beitreten"</string>
<string name="notification_invitation_action_reject">"Ablehnen"</string>
<plurals name="notification_invitations">
<item quantity="one">"%d Einladung"</item>
<item quantity="other">"%d Einladungen"</item>
</plurals>
<string name="notification_invite_body">"Du wurdest zu einem Chat eingeladen"</string>
<string name="notification_invite_body_with_sender">"%1$s hat dich zum Chatten eingeladen"</string>
<string name="notification_mentioned_you_body">"Hat Dich erwähnt: %1$s"</string>
<string name="notification_new_messages">"Neue Nachrichten"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d neue Nachricht"</item>
<item quantity="other">"%d neue Nachrichten"</item>
</plurals>
<string name="notification_reaction_body">"Reagiert mit %1$s"</string>
<string name="notification_room_action_mark_as_read">"Als gelesen markieren"</string>
<string name="notification_room_action_quick_reply">"Schnelle Antwort"</string>
<string name="notification_room_invite_body">"Du wurdest eingeladen, den Chat zu betreten"</string>
<string name="notification_room_invite_body_with_sender">"%1$s hat dich eingeladen, dem Chat beizutreten"</string>
<string name="notification_sender_me">"Ich"</string>
<string name="notification_sender_mention_reply">"%1$s hat Dich erwähnt oder geantwortet"</string>
<string name="notification_test_push_notification_content">"Du siehst dir die Benachrichtigung an! Klicke hier!"</string>
<string name="notification_thread_in_room">"Thread in %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d ungelesene gemeldete Nachricht"</item>
<item quantity="other">"%d ungelesene gemeldete Nachrichten"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$s und %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s in %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s in %2$s und %3$s"</string>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d Chat"</item>
<item quantity="other">"%d Chats"</item>
</plurals>
<string name="push_distributor_background_sync_android">"Hintergrundsynchronisation"</string>
<string name="push_distributor_firebase_android">"Google-Dienste"</string>
<string name="push_no_valid_google_play_services_apk_android">"Keine gültigen Google Play-Dienste gefunden. Benachrichtigungen funktionieren möglicherweise nicht richtig."</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"Überprüfen von gesperrten Nutzern"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"Gesperrte Nutzer ansehen"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"Keine Nutzer sind gesperrt."</string>
<plurals name="troubleshoot_notifications_test_blocked_users_result_some">
<item quantity="one">"Du hast %1$d Nutzer gesperrt. Du wirst für diesen Nutzer keine Benachrichtigungen erhalten."</item>
<item quantity="other">"Du hast %1$d Nutzer gesperrt. Du wirst für diese Nutzer keine Benachrichtigungen erhalten."</item>
</plurals>
<string name="troubleshoot_notifications_test_blocked_users_title">"Gesperrte Nutzer"</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Ermittele den Namen des aktuellen Anbieters."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Kein Dienst für Push-Benachrichtigungen ausgewählt."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure_distributor_not_found">"Aktueller Push-Dienst: %1$s und aktueller UnifiedPush-Distributor: %2$s. Aber der Distributor %3$s kann nicht gefunden werden. Vielleicht wurde die App deinstalliert?"</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure_no_distributor">"Aktueller Push-Dienst: %1$s, aber kein UnifiedPush-Distributor konfiguriert."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Aktueller Push-Dienst: %1$s."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success_with_distributor">"Aktueller Push-Dienst: %1$s (%2$s)"</string>
<string name="troubleshoot_notifications_test_current_push_provider_title">"Aktueller Push-Dienst"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_description">"Stelle sicher, dass die Anwendung mindestens einen Push-Dienst hat."</string>
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"Keine Unterstützung für Push-Dienst gefunden."</string>
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
<item quantity="one">"%1$d Push-Dienst gefunden: %2$s"</item>
<item quantity="other">"%1$d Push-Dienst gefunden: %2$s"</item>
</plurals>
<string name="troubleshoot_notifications_test_detect_push_provider_success_2">"Die Anwendung bietet Unterstützung für: %1$s"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_title">"Unterstützung für Push-Dienst"</string>
<string name="troubleshoot_notifications_test_display_notification_description">"Prüfe, ob die Anwendung Benachrichtigungen anzeigen kann."</string>
<string name="troubleshoot_notifications_test_display_notification_failure">"Die Benachrichtigung wurde nicht angeklickt."</string>
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Die Benachrichtigung kann nicht angezeigt werden."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Die Benachrichtigung wurde angeklickt!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Benachrichtigung anzeigen"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Bitte klicke auf die Benachrichtigung, um den Test fortzusetzen."</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"Stelle sicher, dass die App Push-Benachrichtigungen empfängt."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Fehler: Der Pusher hat die Anfrage abgelehnt."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Fehler:%1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Fehler: Push kann nicht getestet werden."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Fehler: Timeout beim Warten auf Push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"Push-Loop-Back Dauer: %1$d ms."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Teste Push-Loop-Back"</string>
</resources>
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Κλήση"</string>
<string name="notification_channel_listening_for_events">"Ακρόαση για εκδηλώσεις"</string>
<string name="notification_channel_noisy">"Θορυβώδεις ειδοποιήσεις"</string>
<string name="notification_channel_ringing_calls">"Κουδούνισμα κλήσεων"</string>
<string name="notification_channel_silent">"Αθόρυβες ειδοποιήσεις"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d μήνυμα"</item>
<item quantity="other">"%1$s: %2$d μηνύματα"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"%d ειδοποίηση"</item>
<item quantity="other">"%d ειδοποιήσεις"</item>
</plurals>
<string name="notification_fallback_content">"Έχεις νέο(α) μήνυμα(τα)."</string>
<string name="notification_incoming_call">"📹 Εισερχόμενη κλήση"</string>
<string name="notification_inline_reply_failed">"** Αποτυχία αποστολής - παρακαλώ ανοίξτε την αίθουσα"</string>
<string name="notification_invitation_action_join">"Συμμετοχή"</string>
<string name="notification_invitation_action_reject">"Απόρριψη"</string>
<plurals name="notification_invitations">
<item quantity="one">"%d πρόσκληση"</item>
<item quantity="other">"%d προσκλήσεις"</item>
</plurals>
<string name="notification_invite_body">"Σε προσκάλεσε να συνομιλήσετε"</string>
<string name="notification_invite_body_with_sender">"Ο χρήστης %1$s σε προσκάλεσε σε συνομιλία"</string>
<string name="notification_mentioned_you_body">"Σέ ανέφερε: %1$s"</string>
<string name="notification_new_messages">"Νέα Μηνύματα"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d νέο μήνυμα"</item>
<item quantity="other">"%d νέα μηνύματα"</item>
</plurals>
<string name="notification_reaction_body">"Αντέδρασε με %1$s"</string>
<string name="notification_room_action_mark_as_read">"Επισήμανση ως αναγνωσμένου"</string>
<string name="notification_room_action_quick_reply">"Γρήγορη απάντηση"</string>
<string name="notification_room_invite_body">"Σας προσκάλεσε να ενταχθείτε στην αίθουσα"</string>
<string name="notification_room_invite_body_with_sender">"%1$s σας προσκάλεσε να συμμετάσχετε στην αίθουσα"</string>
<string name="notification_sender_me">"Εγώ"</string>
<string name="notification_sender_mention_reply">"Ο χρήστης %1$s αναφέρθηκε ή απάντησε"</string>
<string name="notification_test_push_notification_content">"Βλέπεις την ειδοποίηση! Κάνε μου κλικ!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d μη αναγνωσμένο ειδοποιημένο μήνυμα"</item>
<item quantity="other">"%d μη αναγνωσμένα ειδοποημένα μηνύματα"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$s και %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s σε %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s σε %2$s και %3$s"</string>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d αίθουσα"</item>
<item quantity="other">"%d αίθουσες"</item>
</plurals>
<string name="push_distributor_background_sync_android">"Συγχρονισμός στο παρασκήνιο"</string>
<string name="push_distributor_firebase_android">"Υπηρεσίες Google"</string>
<string name="push_no_valid_google_play_services_apk_android">"Δεν βρέθηκαν έγκυρες υπηρεσίες Google Play. Οι ειδοποιήσεις ενδέχεται να μην λειτουργούν σωστά."</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Λάβε το όνομα του τρέχοντος παρόχου."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Δεν έχουν επιλεγεί πάροχοι push."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Τρέχων πάροχος push: %1$s."</string>
<string name="troubleshoot_notifications_test_current_push_provider_title">"Τρέχων πάροχος push"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_description">"Βεβαιώσου ότι η εφαρμογή διαθέτει τουλάχιστον έναν πάροχο push."</string>
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"Δεν βρέθηκαν πάροχοι push."</string>
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
<item quantity="one">"Βρέθηκε %1$d πάροχος push: %2$s"</item>
<item quantity="other">"Βρέθηκαν %1$d πάροχοι push: %2$s"</item>
</plurals>
<string name="troubleshoot_notifications_test_detect_push_provider_success_2">"Η εφαρμογή δημιουργήθηκε με υποστήριξη για: %1$s"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_title">"Εντοπισμός παρόχων push"</string>
<string name="troubleshoot_notifications_test_display_notification_description">"Έλεγξε ότι η εφαρμογή μπορεί να εμφανίσει ειδοποίηση."</string>
<string name="troubleshoot_notifications_test_display_notification_failure">"Δεν έχει γίνει κλικ στην ειδοποίηση."</string>
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Δεν είναι δυνατή η εμφάνιση της ειδοποίησης."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Έγινε κλικ στην ειδοποίηση!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Εμφάνιση ειδοποίησης"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Κάνε κλικ στην ειδοποίηση για να συνεχίσεις τη δοκιμή."</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"Βεβαιώσου ότι η εφαρμογή λαμβάνει push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Σφάλμα: ο pusher απέρριψε το αίτημα."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Σφάλμα: %1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Σφάλμα, δεν είναι δυνατή η δοκιμή push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Σφάλμα, λήξη χρονικού ορίου αναμένοντας για push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"Το push loop back χρειάστηκε %1$d ms."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Δοκιμή push loopback"</string>
</resources>
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Llamada"</string>
<string name="notification_channel_listening_for_events">"Esperando eventos"</string>
<string name="notification_channel_noisy">"Notificaciones ruidosas"</string>
<string name="notification_channel_ringing_calls">"Llamadas entrantes"</string>
<string name="notification_channel_silent">"Notificaciones silenciosas"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d mensaje"</item>
<item quantity="other">"%1$s: %2$d mensajes"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"%d notificación"</item>
<item quantity="other">"%d notificaciones"</item>
</plurals>
<string name="notification_incoming_call">"📹 Llamada entrante"</string>
<string name="notification_inline_reply_failed">"** No se ha podido enviar - por favor, abre la sala"</string>
<string name="notification_invitation_action_join">"Unirse"</string>
<string name="notification_invitation_action_reject">"Rechazar"</string>
<plurals name="notification_invitations">
<item quantity="one">"%d invitación"</item>
<item quantity="other">"%d invitaciones"</item>
</plurals>
<string name="notification_invite_body">"Te invitó a chatear"</string>
<string name="notification_invite_body_with_sender">"%1$s te invitó a chatear"</string>
<string name="notification_mentioned_you_body">"Te mencionó: %1$s"</string>
<string name="notification_new_messages">"Mensajes nuevos"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d mensaje nuevo"</item>
<item quantity="other">"%d mensajes nuevos"</item>
</plurals>
<string name="notification_reaction_body">"Reaccionó con %1$s"</string>
<string name="notification_room_action_mark_as_read">"Marcar como leído"</string>
<string name="notification_room_action_quick_reply">"Respuesta rápida"</string>
<string name="notification_room_invite_body">"Te invitó a unirte a la sala"</string>
<string name="notification_room_invite_body_with_sender">"%1$s te invitó a unirte a la sala"</string>
<string name="notification_sender_me">"Yo"</string>
<string name="notification_sender_mention_reply">"%1$s mencionó o respondió"</string>
<string name="notification_test_push_notification_content">"¡Estás viendo la notificación! ¡Haz clic en mí!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d mensaje notificado no leído"</item>
<item quantity="other">"%d mensajes notificados no leídos"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$s y %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s en %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s en %2$s y %3$s"</string>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d sala"</item>
<item quantity="other">"%d salas"</item>
</plurals>
<string name="push_distributor_background_sync_android">"Sincronización en segundo plano"</string>
<string name="push_distributor_firebase_android">"Servicios de Google"</string>
<string name="push_no_valid_google_play_services_apk_android">"No se han encontrado Servicios de Google Play válidos. Es posible que las notificaciones no funcionen correctamente."</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Obtener el nombre del proveedor actual."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"No se ha seleccionado ningún proveedor de push."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Proveedor de push actual: %1$s."</string>
<string name="troubleshoot_notifications_test_current_push_provider_title">"Proveedor de push actual"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_description">"Asegurarse de que la aplicación tiene al menos un proveedor de push."</string>
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"No se encontró ningún proveedor de push."</string>
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
<item quantity="one">"Se encontró %1$d proveedor de push: %2$s"</item>
<item quantity="other">"Se encontraron %1$d proveedores de push: %2$s"</item>
</plurals>
<string name="troubleshoot_notifications_test_detect_push_provider_success_2">"La aplicación se compiló con compatibilidad para: %1$s"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_title">"Detectar proveedores de push"</string>
<string name="troubleshoot_notifications_test_display_notification_description">"Verificar que la aplicación pueda mostrar notificaciones."</string>
<string name="troubleshoot_notifications_test_display_notification_failure">"No se ha hecho clic en la notificación."</string>
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"No se puede mostrar la notificación."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"¡Se ha hecho clic en la notificación!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Mostrar notificación"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Haz clic en la notificación para continuar la prueba."</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"Asegurarse de que la aplicación esté recibiendo notificaciones push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Error: el servicio de push ha rechazado la solicitud."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Error: %1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Error, no se puede probar el push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Error, tiempo de espera agotado para push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"Envío y recepción de notificación push tomó %1$d ms."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Probar envío y recepción Push"</string>
</resources>
@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Kõne"</string>
<string name="notification_channel_listening_for_events">"Kontrollime, kas on uusi sündmusi"</string>
<string name="notification_channel_noisy">"Lärmakad teavitused"</string>
<string name="notification_channel_ringing_calls">"Kõnehelinad"</string>
<string name="notification_channel_silent">"Vaiksed teavitused"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d sõnum"</item>
<item quantity="other">"%1$s: %2$d sõnumit"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"%d teavitus"</item>
<item quantity="other">"%d teavitust"</item>
</plurals>
<string name="notification_error_unified_push_unregistered_android">"UnifiedPushi levitajas registreerimine ei õnnestunud ja seega sa ei saa enam teavitusi. Palun kontrolli selle rakenduse teavituste seadistusi ja tõuketeenuste levitaja olekut."</string>
<string name="notification_fallback_content">"Sulle on uusi sõnumeid."</string>
<string name="notification_incoming_call">"📹 Sissetulev kõne"</string>
<string name="notification_inline_reply_failed">"** Saatmine ei õnnestunud - palun ava jututoa täisvaade"</string>
<string name="notification_invitation_action_join">"Liitu"</string>
<string name="notification_invitation_action_reject">"Keeldu"</string>
<plurals name="notification_invitations">
<item quantity="one">"%d kutse"</item>
<item quantity="other">"%d kutset"</item>
</plurals>
<string name="notification_invite_body">"Kutse osalema vestluses"</string>
<string name="notification_invite_body_with_sender">"%1$s saatus sulle vestluskutse"</string>
<string name="notification_mentioned_you_body">"Mainis sind: %1$s"</string>
<string name="notification_new_messages">"Uued sõnumid"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d uus sõnum"</item>
<item quantity="other">"%d uut sõnumit"</item>
</plurals>
<string name="notification_reaction_body">"Reageeris nii: %1$s"</string>
<string name="notification_room_action_mark_as_read">"Märgi loetuks"</string>
<string name="notification_room_action_quick_reply">"Kiirvastus"</string>
<string name="notification_room_invite_body">"Saatis sulle kutse jututuppa"</string>
<string name="notification_room_invite_body_with_sender">"%1$s saatis sulle kutse jututoaga liitumiseks"</string>
<string name="notification_sender_me">"Mina"</string>
<string name="notification_sender_mention_reply">"%1$s mainis või vastas"</string>
<string name="notification_test_push_notification_content">"See ongi teavitus! Klõpsi mind!"</string>
<string name="notification_thread_in_room">"Jutulõng „%1$s“ jututoas"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d lugemata sõnum, millele on teavitus saadetud"</item>
<item quantity="other">"%d lugemata sõnumit, millele on teavitus saadetud"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$s ja %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s jututoas %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s jututoas %2$s ning kutse jututuppa %3$s"</string>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d jututuba"</item>
<item quantity="other">"%d jututuba"</item>
</plurals>
<string name="push_distributor_background_sync_android">"Sünkroniseerimine taustal"</string>
<string name="push_distributor_firebase_android">"Google\'i teenused"</string>
<string name="push_no_valid_google_play_services_apk_android">"Google Play Services taustateenust ei leidu. Teavitused ei pruugi toimida korrektselt."</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"Kontrollin blokeeritud kasutajaid"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"Vaata blokeeritud kasutajaid"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"Sa pole ühtegi kasutajat blokeerinud."</string>
<plurals name="troubleshoot_notifications_test_blocked_users_result_some">
<item quantity="one">"Sa oled blokeerinud %1$d kasutaja. Sa ei saa tema kohta teavitusi"</item>
<item quantity="other">"Sa oled blokeerinud %1$d kasutajat. Sa ei saa tema kohta teavitusi"</item>
</plurals>
<string name="troubleshoot_notifications_test_blocked_users_title">"Blokeeritud kasutajad"</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Vali hetkel kasutatava tõuketeenuste pakkuja nimi."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Tõuketeenuste pakkujaid pole valitud."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure_distributor_not_found">"Praegune tõuketeenuste pakkuja on %1$s ja praegune levitaja on %2$s. Aga levitajat %3$s ei leidu - kas võib olla, et rakendus on eemaldatud?"</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure_no_distributor">"Praegune tõuketeenuste pakkuja on %1$s, aga ühtegi levitajat pole seadistatud."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Hetkel kasutatav tõuketeenuste pakkuja: %1$s."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success_with_distributor">"Praegune tõuketeenuste pakkuja: %1$s (%2$s)"</string>
<string name="troubleshoot_notifications_test_current_push_provider_title">"Hetkel kasutatav tõuketeenuste pakkuja"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_description">"Palun taga selle rakenduse jaoks on seadistatud vähemalt üks tõuketeenuste pakkuja."</string>
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"Ühtegi tõuketeenuste pakkujat ei leidu."</string>
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
<item quantity="one">"Leidsime %1$d tõuketeenuse pakkuja: %2$s"</item>
<item quantity="other">"Leidsime %1$d tõuketeenuse pakkujat: %2$s"</item>
</plurals>
<string name="troubleshoot_notifications_test_detect_push_provider_success_2">"Rakendus on kompileeritud kaasneva toega teenusele: %1$s"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_title">"Tuvasta tõuketeenuste pakkujad"</string>
<string name="troubleshoot_notifications_test_display_notification_description">"Palun kontrolli, et rakendus saaks kuvada teavitusi."</string>
<string name="troubleshoot_notifications_test_display_notification_failure">"Sa pole teavitust klõpsinud."</string>
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Teavituse kuvamine ei õnnestu."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Sa oled teavitust klõpsinud!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Kuva teavitust"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Testiga jätkamiseks palun klõpsi teavitust."</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"Palun veendu, et rakendus saab tõuketeavitusi."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Viga: tõuketeenuse teostaja on päringust keeldunud."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Viga: %1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Viga: katselist tõuketeavitust pole võimalik teha."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Viga: tõuketeavituse ootamisel tekkis aegumine."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"Kogu tõuketeavituse ringi tegemiseks kulus %1$d ms."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Tee tõuketeenuse silmustest"</string>
</resources>
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Deia"</string>
<string name="notification_channel_listening_for_events">"Gertaerei adi"</string>
<string name="notification_channel_noisy">"Jakinarazpen zaratatsuak"</string>
<string name="notification_channel_silent">"Jakinarazpen isilak"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: mezu %2$d"</item>
<item quantity="other">"%1$s: %2$d mezu"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"jakinarazpen %d"</item>
<item quantity="other">"%d jakinarazpen"</item>
</plurals>
<string name="notification_fallback_content">"Mezu berriak dituzu."</string>
<string name="notification_incoming_call">"Deia jasotzen"</string>
<string name="notification_inline_reply_failed">"** Huts egin du bidalketak - ireki gela"</string>
<string name="notification_invitation_action_join">"Elkartu"</string>
<string name="notification_invitation_action_reject">"Baztertu"</string>
<plurals name="notification_invitations">
<item quantity="one">"Gonbidapen %d"</item>
<item quantity="other">"%d gonbidapen"</item>
</plurals>
<string name="notification_invite_body">"Txatetzera gonbidatu zaitu"</string>
<string name="notification_invite_body_with_sender">"%1$s(e)k txatera gonbidatu zaitu"</string>
<string name="notification_mentioned_you_body">"Aipatu zaitu: %1$s"</string>
<string name="notification_new_messages">"Mezu berriak"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"Mezu berri %d"</item>
<item quantity="other">"%d mezu berri"</item>
</plurals>
<string name="notification_reaction_body">"%1$s (r)ekin erreakzionatu du"</string>
<string name="notification_room_action_mark_as_read">"Markatu irakurritzat"</string>
<string name="notification_room_action_quick_reply">"Erantzun azkarra"</string>
<string name="notification_room_invite_body">"Gelan sartzera gonbidatu zaitu"</string>
<string name="notification_room_invite_body_with_sender">"%1$s(e)k gelan sartzera gonbidatu zaitu"</string>
<string name="notification_sender_me">"Neu"</string>
<string name="notification_sender_mention_reply">"%1$s(e)k aipatu zaitu edo erantzun dizu"</string>
<string name="notification_test_push_notification_content">"Jakinarazpena ikusten ari zara! Klikatu!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"Irakurri gabeko mezu %den jakinarazpena"</item>
<item quantity="other">"Irakurri gabeko %d mezuren jakinarazpena"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$s eta %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s %2$s gelan"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s %2$s gelan eta %3$s"</string>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"Gela %d"</item>
<item quantity="other">"%d gela"</item>
</plurals>
<string name="push_distributor_background_sync_android">"Atzeko planoko sinkronizazioa"</string>
<string name="push_distributor_firebase_android">"Google Services"</string>
<string name="push_no_valid_google_play_services_apk_android">"Ez da baliozko Google Play Servicerik aurkitu. Litekeena da jakinarazpenak behar bezala ez ibiltzea."</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Lortu uneko hornitzailearen izena."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Ez da push hornitzailerik hautatu."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Uneko push hornitzailea: %1$s."</string>
<string name="troubleshoot_notifications_test_current_push_provider_title">"Uneko push hornitzailea"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"Ez da push hornitzailerik aurkitu."</string>
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
<item quantity="one">"Push hornitzaile %1$d aurkitu da: %2$s"</item>
<item quantity="other">"%1$d push hornitzaile aurkitu dira: %2$s"</item>
</plurals>
<string name="troubleshoot_notifications_test_detect_push_provider_title">"Detektatu push hornitzaileak"</string>
<string name="troubleshoot_notifications_test_display_notification_description">"Egiaztatu aplikazioak jakinarazpena bistaratu dezakeela."</string>
<string name="troubleshoot_notifications_test_display_notification_failure">"Ez da klikik egin jakinarazpenean."</string>
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Ezin da jakinarazpena bistaratu."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Klik egin da jakinarazpenean!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Bistaratu jakinarazpena"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Klikatu jakinarazpenean probarekin jarraitzeko."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Errorea: %1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Errorea, ezin da push-a probatu."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Errorea, denbora agortu da push-aren zain."</string>
</resources>
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"تماس"</string>
<string name="notification_channel_listening_for_events">"در حال گوش دادن به رویدادها"</string>
<string name="notification_channel_noisy">"اعلان‌های پرصدا"</string>
<string name="notification_channel_ringing_calls">"زنگ خوردن تماس"</string>
<string name="notification_channel_silent">"اعلان‌های صامت"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s:%2$d پیام"</item>
<item quantity="other">"%1$s:%2$d پیام‌"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"%dاعلان"</item>
<item quantity="other">"%dاعلان‌"</item>
</plurals>
<string name="notification_fallback_content">"پیام‌های جدیدی دارید."</string>
<string name="notification_incoming_call">"📹 تماس ورودی"</string>
<string name="notification_inline_reply_failed">"**‌شکست در فرستادن - لطفاً اتاق را بگشایید"</string>
<string name="notification_invitation_action_join">"پیوستن"</string>
<string name="notification_invitation_action_reject">"رد کردن"</string>
<plurals name="notification_invitations">
<item quantity="one">"%d دعوت"</item>
<item quantity="other">"%d دعوت"</item>
</plurals>
<string name="notification_invite_body">"به گپ دعوتتان کرد"</string>
<string name="notification_invite_body_with_sender">"%1$s به گپ دعوتتان کرد"</string>
<string name="notification_mentioned_you_body">"به شما اشاره کرد: %1$s"</string>
<string name="notification_new_messages">"پیام جدید"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d پیام جدید"</item>
<item quantity="other">"%d پیام جدید"</item>
</plurals>
<string name="notification_reaction_body">"با %1$s واکنش داد"</string>
<string name="notification_room_action_mark_as_read">"علامت‌گذاری به عنوان خوانده شده"</string>
<string name="notification_room_action_quick_reply">"پاسخ سریع"</string>
<string name="notification_room_invite_body">"دعوت کرد به اتاق بپیوندید"</string>
<string name="notification_room_invite_body_with_sender">"%1$s دعوت کرد به اتاق بپیوندید"</string>
<string name="notification_sender_me">"خودم"</string>
<string name="notification_sender_mention_reply">"%1$s اشاره کرد یا پاسخ داد"</string>
<string name="notification_test_push_notification_content">"دارید آگاهی را مشاهده می‌کنید! کلیک کنید!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d پیام اعلان نشده"</item>
<item quantity="other">"%d پیام اعلان نشده"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$s و %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s در %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s در %2$s و %3$s"</string>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d اتاق"</item>
<item quantity="other">"%d اتاق"</item>
</plurals>
<string name="push_distributor_background_sync_android">"همگام سازی پس‌زمینه"</string>
<string name="push_distributor_firebase_android">"خدمات گوگل"</string>
<string name="push_no_valid_google_play_services_apk_android">"خدمت پلی گوگل معتبری پیدا نشد. ممکن است آگاهی‌ها به درستی کار نکنند."</string>
<string name="troubleshoot_notifications_test_blocked_users_title">"کاربران مسدود"</string>
<string name="troubleshoot_notifications_test_display_notification_success">"روی آگاهی کلیک شد!"</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"خطا: فرستنده درخواست را رد کرد."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"خطا: %1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"خطا. نتوانست فرستادن را بیازماید."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"خطا. مهلت زمانی انتظار برای فرستادن سر رسید."</string>
</resources>
@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Puhelu"</string>
<string name="notification_channel_listening_for_events">"Tapahtumien kuuntelu"</string>
<string name="notification_channel_noisy">"Äänekkäät ilmoitukset"</string>
<string name="notification_channel_ringing_calls">"Soivat puhelut"</string>
<string name="notification_channel_silent">"Hiljaiset ilmoitukset"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d viesti"</item>
<item quantity="other">"%1$s: %2$d viestiä"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"%d ilmoitus"</item>
<item quantity="other">"%d ilmoitusta"</item>
</plurals>
<string name="notification_error_unified_push_unregistered_android">"UnifiedPush ilmoitusten jakelijaa ei voitu rekisteröidä, joten et enää vastaanota ilmoituksia. Tarkista sovelluksen ilmoitusasetukset ja push-jakelijan tila."</string>
<string name="notification_fallback_content">"Sinulle on uusia viestejä."</string>
<string name="notification_incoming_call">"📹 Saapuva puhelu"</string>
<string name="notification_inline_reply_failed">"** Lähetys epäonnistui - avaa huone"</string>
<string name="notification_invitation_action_join">"Liity"</string>
<string name="notification_invitation_action_reject">"Hylkää"</string>
<plurals name="notification_invitations">
<item quantity="one">"%d kutsu"</item>
<item quantity="other">"%d kutsua"</item>
</plurals>
<string name="notification_invite_body">"Kutsui sinut keskustelemaan"</string>
<string name="notification_invite_body_with_sender">"%1$s kutsui sinut keskustelemaan"</string>
<string name="notification_mentioned_you_body">"Mainitsi sinut: %1$s"</string>
<string name="notification_new_messages">"Uusia viestejä"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d uusi viesti"</item>
<item quantity="other">"%d uutta viestiä"</item>
</plurals>
<string name="notification_reaction_body">"Reaktio: %1$s"</string>
<string name="notification_room_action_mark_as_read">"Merkitse luetuksi"</string>
<string name="notification_room_action_quick_reply">"Pikavastaus"</string>
<string name="notification_room_invite_body">"Kutsui sinut liittymään huoneeseen"</string>
<string name="notification_room_invite_body_with_sender">"%1$s kutsui sinut liittymään huoneeseen"</string>
<string name="notification_sender_me">"Minä"</string>
<string name="notification_sender_mention_reply">"%1$s mainitsi tai vastasi"</string>
<string name="notification_test_push_notification_content">"Katselet ilmoitusta! Klikkaa minua!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d lukematon viesti"</item>
<item quantity="other">"%d lukematonta viestiä"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$s ja %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s %2$s ja %3$s"</string>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d huoneessa"</item>
<item quantity="other">"%d huoneessa"</item>
</plurals>
<string name="push_distributor_background_sync_android">"Taustasynkronointi"</string>
<string name="push_distributor_firebase_android">"Googlen palvelut"</string>
<string name="push_no_valid_google_play_services_apk_android">"Kelvollisia Google Play -palveluita ei löytynyt. Ilmoitukset eivät ehkä toimi oikein."</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"Estettyjen käyttäjien tarkistus"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"Näytä estetyt käyttäjät"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"Yhtään käyttäjää ei ole estetty."</string>
<plurals name="troubleshoot_notifications_test_blocked_users_result_some">
<item quantity="one">"Estit %1$d käyttäjän. Et saa ilmoituksia tältä käyttäjältä."</item>
<item quantity="other">"Estit %1$d käyttäjää. Et saa ilmoituksia näiltä käyttäjiltä."</item>
</plurals>
<string name="troubleshoot_notifications_test_blocked_users_title">"Estetyt käyttäjät"</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Hakee nykyisen palveluntarjoajan nimen."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Push-palveluntarjoajia ei ole valittu."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure_distributor_not_found">"Nykyinen push-palveluntarjoaja: %1$s ja nykyinen jakelija: %2$s. Mutta jakelijaa %3$s ei löydy. Ehkä sovellus on poistettu?"</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure_no_distributor">"Nykyinen push-palveluntarjoaja: %1$s, mutta jakelijoita ei ole määritetty."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Nykyinen push-palveluntarjoaja: %1$s."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success_with_distributor">"Nykyinen push-palveluntarjoaja: %1$s (%2$s)"</string>
<string name="troubleshoot_notifications_test_current_push_provider_title">"Nykyinen push-palveluntarjoaja"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_description">"Varmistaa, että sovelluksella on vähintään yksi push-palveluntarjoaja."</string>
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"Push-palveluntarjoajia ei löytynyt."</string>
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
<item quantity="one">"Löytyi %1$d push-palveluntarjoaja: %2$s"</item>
<item quantity="other">"Löytyi %1$d push-palveluntarjoajaa: %2$s"</item>
</plurals>
<string name="troubleshoot_notifications_test_detect_push_provider_success_2">"Sovellus on rakennettu tukemaan: %1$s"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_title">"Push-palveluntarjoajien havaitseminen"</string>
<string name="troubleshoot_notifications_test_display_notification_description">"Tarkistaa, että sovellus voi näyttää ilmoituksen."</string>
<string name="troubleshoot_notifications_test_display_notification_failure">"Ilmoitusta ei ole klikattu."</string>
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Ilmoitusta ei voida näyttää."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Ilmoitusta on klikattu!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Ilmoituksen näyttäminen"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Klikkaa ilmoitusta jatkaaksesi testiä."</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"Varmistaa, että sovellus vastaanottaa push-ilmoituksen."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Virhe: pusher on hylännyt pyynnön."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Virhe: %1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Virhe, push-ilmoitusta ei voi testata."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Virhe, aikakatkaisu push-ilmoitusta odotellessa."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"Push-ilmoituksella kesti %1$d ms palata takaisin."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Testaa push-ilmoituksen paluu"</string>
</resources>
@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Appel"</string>
<string name="notification_channel_listening_for_events">"À l’écoute des événements"</string>
<string name="notification_channel_noisy">"Notifications bruyantes"</string>
<string name="notification_channel_ringing_calls">"Appels entrants"</string>
<string name="notification_channel_silent">"Notifications silencieuses"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s : %2$d message"</item>
<item quantity="other">"%1$s : %2$d messages"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"%d notification"</item>
<item quantity="other">"%d notifications"</item>
</plurals>
<string name="notification_error_unified_push_unregistered_android">"Le distributeur de notifications UnifiedPush na pas pu être enregistré, vous ne recevrez donc plus de notifications. Veuillez vérifier les paramètres de notification de lapplication et l’état du distributeur."</string>
<string name="notification_fallback_content">"Vous avez de nouveau(x) message(s)."</string>
<string name="notification_incoming_call">"📹 Appel entrant"</string>
<string name="notification_inline_reply_failed">"** Échec de lenvoi - veuillez ouvrir le salon"</string>
<string name="notification_invitation_action_join">"Rejoindre"</string>
<string name="notification_invitation_action_reject">"Rejeter"</string>
<plurals name="notification_invitations">
<item quantity="one">"%d invitation"</item>
<item quantity="other">"%d invitations"</item>
</plurals>
<string name="notification_invite_body">"Vous a invité(e) à discuter"</string>
<string name="notification_invite_body_with_sender">"%1$s vous a invité à discuter"</string>
<string name="notification_mentioned_you_body">"Mentionné(e) : %1$s"</string>
<string name="notification_new_messages">"Nouveaux messages"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d nouveau message"</item>
<item quantity="other">"%d nouveaux messages"</item>
</plurals>
<string name="notification_reaction_body">"A réagi avec %1$s"</string>
<string name="notification_room_action_mark_as_read">"Marquer comme lu"</string>
<string name="notification_room_action_quick_reply">"Réponse rapide"</string>
<string name="notification_room_invite_body">"Vous a invité(e) à rejoindre le salon"</string>
<string name="notification_room_invite_body_with_sender">"%1$s vous a invité à rejoindre le salon"</string>
<string name="notification_sender_me">"Moi"</string>
<string name="notification_sender_mention_reply">"%1$s mentionné ou en réponse"</string>
<string name="notification_test_push_notification_content">"Vous êtes en train de voir la notification ! Cliquez-moi !"</string>
<string name="notification_thread_in_room">"Discussion dans %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s : %2$s"</string>
<string name="notification_ticker_text_group">"%1$s : %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d message notifié non lu"</item>
<item quantity="other">"%d messages notifiés non lus"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$s et %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s dans %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s dans %2$s et %3$s"</string>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d salon"</item>
<item quantity="other">"%d salons"</item>
</plurals>
<string name="push_distributor_background_sync_android">"Synchronisation en arrière-plan"</string>
<string name="push_distributor_firebase_android">"Services Google"</string>
<string name="push_no_valid_google_play_services_apk_android">"Aucun service Google Play valide na été trouvé. Les notifications peuvent ne pas fonctionner correctement."</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"Vérification des utilisateurs bloqués"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"Voir les utilisateurs bloqués"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"Aucun utilisateur nest bloqué."</string>
<plurals name="troubleshoot_notifications_test_blocked_users_result_some">
<item quantity="one">"Vous avez bloqué %1$d utilisateur. Vous ne recevrez pas de notifications pour cet utilisateur."</item>
<item quantity="other">"Vous avez bloqué %1$d utilisateurs. Vous ne recevrez pas de notifications pour ces utilisateurs."</item>
</plurals>
<string name="troubleshoot_notifications_test_blocked_users_title">"Utilisateurs bloqués"</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Obtenir le nom du fournisseur de Push actuel."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Aucun fournisseur de Push nest sélectionné."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure_distributor_not_found">"Fournisseur de Push actuel: %1$s et distributeur actuel: %2$s. Mais le distributeur %3$s est introuvable. Lapplication a peut-être été désinstallée?"</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure_no_distributor">"Fournisseur de Push actuel: %1$s, mais aucun distributeur na été configuré."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Fournisseur de Push actuel : %1$s."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success_with_distributor">"Fournisseur de Push actuel: %1$s (%2$s)"</string>
<string name="troubleshoot_notifications_test_current_push_provider_title">"Fournisseur de Push actuel"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_description">"Vérifier que lapplication possède au moins un fournisseur de Push."</string>
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"Aucun fournisseur de Push na été trouvé."</string>
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
<item quantity="one">"%1$d fournisseur de Push détecté : %2$s"</item>
<item quantity="other">"%1$d fournisseurs de Push détectés : %2$s"</item>
</plurals>
<string name="troubleshoot_notifications_test_detect_push_provider_success_2">"Lapplication a été compilée avec la prise en charge de : %1$s"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_title">"Détecter les fournisseurs de Push"</string>
<string name="troubleshoot_notifications_test_display_notification_description">"Vérifier que lapplication peut afficher des notifications."</string>
<string name="troubleshoot_notifications_test_display_notification_failure">"Vous navez pas cliqué sur la notification."</string>
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Impossible dafficher la notification."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Vous avez cliqué sur la notification !"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Affichage des notifications"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Veuillez cliquer sur la notification pour continuer le test."</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"Vérifier que lapplication reçoit les Push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Erreur : le Pusher a rejeté la demande."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Erreur :%1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Erreur, impossible de tester les Push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Erreur, le délai dattente du Push est dépassé."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"La demande denvoi de Push et sa réception ont pris %1$d ms."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Tester la réception des Push"</string>
</resources>
@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Hívás"</string>
<string name="notification_channel_listening_for_events">"Események figyelése"</string>
<string name="notification_channel_noisy">"Zajos értesítések"</string>
<string name="notification_channel_ringing_calls">"Csörgő hívások"</string>
<string name="notification_channel_silent">"Csendes értesítések"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d üzenet"</item>
<item quantity="other">"%1$s: %2$d üzenet"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"%d értesítés"</item>
<item quantity="other">"%d értesítés"</item>
</plurals>
<string name="notification_error_unified_push_unregistered_android">"A UnifiedPush leküldéses értesítési terjesztő nem regisztrálható, ezért többé nem fog értesítéseket kapni. Ellenőrizze az alkalmazás értesítési beállításait és a leküldés értesítési terjesztő állapotát."</string>
<string name="notification_fallback_content">"Értesítés"</string>
<string name="notification_incoming_call">"📹 Bejövő hívás"</string>
<string name="notification_inline_reply_failed">"** Nem sikerült elküldeni nyissa meg a szobát"</string>
<string name="notification_invitation_action_join">"Csatlakozás"</string>
<string name="notification_invitation_action_reject">"Elutasítás"</string>
<plurals name="notification_invitations">
<item quantity="one">"%d meghívó"</item>
<item quantity="other">"%d meghívó"</item>
</plurals>
<string name="notification_invite_body">"Meghívta, hogy csevegjen"</string>
<string name="notification_invite_body_with_sender">"%1$s meghívta egy csevegésre"</string>
<string name="notification_mentioned_you_body">"Megemlítette Önt: %1$s"</string>
<string name="notification_new_messages">"Új üzenetek"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d új üzenet"</item>
<item quantity="other">"%d új üzenet"</item>
</plurals>
<string name="notification_reaction_body">"Ezzel reagált: %1$s"</string>
<string name="notification_room_action_mark_as_read">"Megjelölés olvasottként"</string>
<string name="notification_room_action_quick_reply">"Gyors válasz"</string>
<string name="notification_room_invite_body">"Meghívta, hogy csatlakozzon a szobához"</string>
<string name="notification_room_invite_body_with_sender">"%1$s meghívta, hogy csatlakozzon a szobához"</string>
<string name="notification_sender_me">"Én"</string>
<string name="notification_sender_mention_reply">"%1$s megemlítette vagy válaszolt"</string>
<string name="notification_test_push_notification_content">"Az értesítést nézi! Kattintson ide!"</string>
<string name="notification_thread_in_room">"Üzenetszál itt: %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d olvasatlan értesített üzenet"</item>
<item quantity="other">"%d olvasatlan értesített üzenet"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$s és %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s itt: %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s itt: %2$s és %3$s"</string>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d szoba"</item>
<item quantity="other">"%d szoba"</item>
</plurals>
<string name="push_distributor_background_sync_android">"Háttérszinkronizálás"</string>
<string name="push_distributor_firebase_android">"Google szolgáltatások"</string>
<string name="push_no_valid_google_play_services_apk_android">"A Google Play szolgáltatások nem találhatók. Előfordulhat, hogy az értesítések nem működnek megfelelően."</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"Letiltott felhasználók ellenőrzése"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"Letiltott felhasználók megtekintése"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"Nincs felhasználó letiltva."</string>
<plurals name="troubleshoot_notifications_test_blocked_users_result_some">
<item quantity="one">"Letiltotta %1$d felhasználót. Nem fog értesítéseket kapni erről a felhasználóról."</item>
<item quantity="other">"Letiltott %1$d felhasználót. Nem fog értesítéseket kapni ezekről a felhasználókról."</item>
</plurals>
<string name="troubleshoot_notifications_test_blocked_users_title">"Letiltott felhasználók"</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"A jelenlegi szolgáltató nevének lekérdezése."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Nincs kiválasztva leküldéses értesítési szolgáltató."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure_distributor_not_found">"Jelenlegi leküldéses értesítések szolgáltatója: %1$s és jelenlegi terjesztő: %2$s. De a terjesztő %3$s nem található. Lehet, hogy az alkalmazást eltávolították?"</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure_no_distributor">"Jelenlegi leküldéses értesítések szolgáltatója: %1$s, de még nem konfiguráltak forgalmazókat."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Jelenlegi leküldéses értesítési szolgáltató: %1$s."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success_with_distributor">"Jelenlegi leküldéses értesítések szolgáltatója: %1$s (%2$s)"</string>
<string name="troubleshoot_notifications_test_current_push_provider_title">"Jelenlegi leküldéses értesítési szolgáltató"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_description">"Győződjön meg arról, hogy az alkalmazás legalább egy leküldéses értesítési szolgáltatóval rendelkezik."</string>
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"Nem található leküldéses értesítési szolgáltató."</string>
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
<item quantity="one">"%1$d leküldéses értesítési szolgáltató találva: %2$s"</item>
<item quantity="other">"%1$d leküldéses értesítési szolgáltató találva: %2$s"</item>
</plurals>
<string name="troubleshoot_notifications_test_detect_push_provider_success_2">"Az alkalmazás úgy lett összeállítva, hogy támogatja a következőket: %1$s"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_title">"Leküldéses értesítési szolgáltatók észlelése"</string>
<string name="troubleshoot_notifications_test_display_notification_description">"Ellenőrizze, hogy az alkalmazás képes-e megjeleníteni az értesítést."</string>
<string name="troubleshoot_notifications_test_display_notification_failure">"Az értesítésre nem kattintottak rá."</string>
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Az értesítés nem jeleníthető meg."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Az értesítésre rákattintottak!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Értesítés megjelenítése"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"A teszt folytatásához kattintson az értesítésre."</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"Győződjön meg arról, hogy az alkalmazás megkapja-e a leküldéses értesítést."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Hiba: a leküldő elutasította a kérést."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Hiba: %1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Hiba, nem lehet tesztelni a leküldéses értesítést."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Hiba, időtúllépés a leküldéses értesítésre való várakozás során."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"A leküldéses értesítés folyamata %1$d ezredmásodpercig tartott."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Leküldéses értesítés folyamatának tesztelése"</string>
</resources>
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Panggil"</string>
<string name="notification_channel_listening_for_events">"Mendengarkan peristiwa"</string>
<string name="notification_channel_noisy">"Pemberitahuan berisik"</string>
<string name="notification_channel_ringing_calls">"Panggilan berdering"</string>
<string name="notification_channel_silent">"Pemberitahuan diam"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="other">"%1$s: %2$d pesan"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="other">"%d pemberitahuan"</item>
</plurals>
<string name="notification_fallback_content">"Anda memiliki pesan baru."</string>
<string name="notification_incoming_call">"📹 Panggilan masuk"</string>
<string name="notification_inline_reply_failed">"** Gagal mengirim — silakan buka ruangan"</string>
<string name="notification_invitation_action_join">"Gabung"</string>
<string name="notification_invitation_action_reject">"Tolak"</string>
<plurals name="notification_invitations">
<item quantity="other">"%d undangan"</item>
</plurals>
<string name="notification_invite_body">"Mengundang Anda untuk mengobrol"</string>
<string name="notification_invite_body_with_sender">"%1$s mengundang Anda untuk mengobrol"</string>
<string name="notification_mentioned_you_body">"Menyebutkan Anda: %1$s"</string>
<string name="notification_new_messages">"Pesan Baru"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="other">"%d pesan baru"</item>
</plurals>
<string name="notification_reaction_body">"Menghapus dengan %1$s"</string>
<string name="notification_room_action_mark_as_read">"Tandai sebagai dibaca"</string>
<string name="notification_room_action_quick_reply">"Balas cepat"</string>
<string name="notification_room_invite_body">"Mengundang Anda untuk bergabung ke ruangan"</string>
<string name="notification_room_invite_body_with_sender">"%1$s mengundang Anda untuk bergabung dengan ruangan"</string>
<string name="notification_sender_me">"Saya"</string>
<string name="notification_sender_mention_reply">"%1$s disebut atau dibalas"</string>
<string name="notification_test_push_notification_content">"Anda sedang melihat pemberitahuan ini! Klik saya!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="other">"%d pesan pemberitahuan yang belum dibaca"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$s dan %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s di %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s di %2$s dan %3$s"</string>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="other">"%d ruangan"</item>
</plurals>
<string name="push_distributor_background_sync_android">"Sinkronisasi latar belakang"</string>
<string name="push_distributor_firebase_android">"Layanan Google"</string>
<string name="push_no_valid_google_play_services_apk_android">"Tidak ditemukan Layanan Google Play yang valid. Pemberitahuan mungkin tidak berfungsi dengan baik."</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Dapatkan nama penyedia saat ini."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Tidak ada penyedia notifikasi dorongan yang dipilih."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Penyedia notifikasi dorongan saat ini: %1$s."</string>
<string name="troubleshoot_notifications_test_current_push_provider_title">"Penyedia notifikasi dorongan saat ini"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_description">"Pastikan aplikasi memiliki setidaknya satu penyedia notifikasi dorongan."</string>
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"Tidak ada penyedia notifikasi dorongan yang ditemukan."</string>
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
<item quantity="other">"Ditemukan %1$d penyedia notifikasi dorongan: %2$s"</item>
</plurals>
<string name="troubleshoot_notifications_test_detect_push_provider_success_2">"Aplikasi ini dibangun dengan dukungan untuk: %1$s"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_title">"Deteksi penyedia notifikasi dorongan"</string>
<string name="troubleshoot_notifications_test_display_notification_description">"Periksa apakah aplikasi dapat menampilkan notifikasi."</string>
<string name="troubleshoot_notifications_test_display_notification_failure">"Notifikasi belum diklik."</string>
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Tidak dapat menampilkan notifikasi."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Notifikasi telah diklik!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Tampilan notifikasi"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Silakan klik pada notifikasi untuk melanjutkan tes."</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"Pastikan aplikasi menerima notifikasi dorongan."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Kesalahan: pendorong telah menolak permintaan."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Kesalahan: %1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Terjadi kesalahan, tidak dapat menguji notifikasi dorongan."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Terjadi kesalahan, melebihi batas waktu menunggu notifikasi dorongan."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"Ulangan notifikasi dorongan membutuhkan %1$d ms."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Uji ulangan notifikasi dorongan lagi"</string>
</resources>

Some files were not shown because too many files have changed in this diff Show More