First Commit
This commit is contained in:
@@ -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>
|
||||
+24
@@ -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()
|
||||
}
|
||||
}
|
||||
+208
@@ -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)
|
||||
}
|
||||
}
|
||||
+116
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+35
@@ -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>
|
||||
}
|
||||
+76
@@ -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)
|
||||
}
|
||||
}
|
||||
+106
@@ -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",
|
||||
)
|
||||
+44
@@ -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)
|
||||
}
|
||||
}
|
||||
+29
@@ -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
|
||||
}
|
||||
+82
@@ -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
|
||||
}
|
||||
}
|
||||
+132
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+451
@@ -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,
|
||||
)
|
||||
+104
@@ -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()
|
||||
}
|
||||
}
|
||||
+193
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -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) }
|
||||
}
|
||||
}
|
||||
+42
@@ -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,
|
||||
)
|
||||
}
|
||||
+32
@@ -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)
|
||||
}
|
||||
*/
|
||||
+29
@@ -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"
|
||||
}
|
||||
+36
@@ -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"
|
||||
}
|
||||
}
|
||||
+17
@@ -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)
|
||||
}
|
||||
+222
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+260
@@ -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
|
||||
}
|
||||
+81
@@ -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
|
||||
}
|
||||
}
|
||||
+112
@@ -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"
|
||||
+158
@@ -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>,
|
||||
)
|
||||
+125
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -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()
|
||||
+27
@@ -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()
|
||||
}
|
||||
}
|
||||
+29
@@ -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,
|
||||
)
|
||||
+109
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+25
@@ -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()
|
||||
}
|
||||
}
|
||||
+17
@@ -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)
|
||||
}
|
||||
+194
@@ -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
|
||||
}
|
||||
+196
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -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"
|
||||
}
|
||||
+18
@@ -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,
|
||||
)
|
||||
+518
@@ -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
|
||||
+123
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+55
@@ -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()
|
||||
}
|
||||
}
|
||||
+61
@@ -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()
|
||||
}
|
||||
}
|
||||
+93
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+55
@@ -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()
|
||||
}
|
||||
}
|
||||
+30
@@ -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
|
||||
+29
@@ -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
|
||||
+29
@@ -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
|
||||
}
|
||||
+79
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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
|
||||
+34
@@ -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
|
||||
}
|
||||
}
|
||||
+28
@@ -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
|
||||
+20
@@ -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) }
|
||||
}
|
||||
+293
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+59
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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()
|
||||
}
|
||||
}
|
||||
+36
@@ -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)
|
||||
}
|
||||
}
|
||||
+35
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -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
|
||||
}
|
||||
+27
@@ -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)
|
||||
}
|
||||
}
|
||||
+14
@@ -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/"
|
||||
}
|
||||
+35
@@ -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>,
|
||||
)
|
||||
+25
@@ -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>,
|
||||
)
|
||||
+21
@@ -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
|
||||
)
|
||||
+58
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -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>
|
||||
)
|
||||
+116
@@ -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
|
||||
}
|
||||
}
|
||||
+27
@@ -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()
|
||||
}
|
||||
+43
@@ -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")
|
||||
}
|
||||
}
|
||||
+94
@@ -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()
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
}
|
||||
+70
@@ -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()
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
}
|
||||
+103
@@ -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()
|
||||
}
|
||||
+116
@@ -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()
|
||||
}
|
||||
+58
@@ -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()
|
||||
}
|
||||
+47
@@ -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)
|
||||
}
|
||||
}
|
||||
+10
@@ -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()
|
||||
+123
@@ -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>
|
||||
}
|
||||
+68
@@ -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,
|
||||
)
|
||||
}
|
||||
+129
@@ -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 n’a pas pu être enregistré, vous ne recevrez donc plus de notifications. Veuillez vérifier les paramètres de notification de l’application 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 l’envoi - 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 n’a é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 n’est 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 n’est 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. L’application 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 n’a é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 l’application possède au moins un fournisseur de Push."</string>
|
||||
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"Aucun fournisseur de Push n’a é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">"L’application 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 l’application peut afficher des notifications."</string>
|
||||
<string name="troubleshoot_notifications_test_display_notification_failure">"Vous n’avez pas cliqué sur la notification."</string>
|
||||
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Impossible d’afficher 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 l’application 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 d’attente du Push est dépassé."</string>
|
||||
<string name="troubleshoot_notifications_test_push_loop_back_success">"La demande d’envoi 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
Reference in New Issue
Block a user