Import some stuff about Push and notification from Element Android - WIP

This commit is contained in:
Benoit Marty 2023-03-14 14:57:14 +01:00 committed by Benoit Marty
parent cc58c0c8c9
commit 275fa03de3
70 changed files with 5158 additions and 2 deletions

View file

@ -214,10 +214,12 @@ dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
implementation(libs.appyx.core)
implementation(libs.androidx.splash)
implementation(libs.androidx.core)
implementation(libs.androidx.corektx)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.startup)
implementation(libs.androidx.preference)
implementation(libs.coil)
implementation(platform(libs.network.okhttp.bom))

View file

@ -17,6 +17,9 @@
package io.element.android.x.di
import android.content.Context
import android.content.SharedPreferences
import android.content.res.Resources
import androidx.preference.PreferenceManager
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
@ -25,6 +28,7 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.DefaultPreferences
import io.element.android.libraries.di.SingleIn
import io.element.android.x.BuildConfig
import io.element.android.x.R
@ -47,6 +51,11 @@ object AppModule {
return File(context.filesDir, "sessions")
}
@Provides
fun providesResources(@ApplicationContext context: Context): Resources {
return context.resources
}
@Provides
@SingleIn(AppScope::class)
fun providesAppCoroutineScope(): CoroutineScope {
@ -69,6 +78,13 @@ object AppModule {
okHttpLoggingLevel = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.BASIC,
)
@Provides
@SingleIn(AppScope::class)
@DefaultPreferences
fun providesDefaultSharedPreferences(@ApplicationContext context: Context): SharedPreferences {
return PreferenceManager.getDefaultSharedPreferences(context)
}
@Provides
@SingleIn(AppScope::class)
fun providesCoroutineDispatchers(): CoroutineDispatchers {

View file

@ -10,7 +10,7 @@ molecule = "0.8.0"
# AndroidX
material = "1.8.0"
corektx = "1.9.0"
core = "1.9.0"
datastore = "1.0.0"
constraintlayout = "2.1.4"
recyclerview = "1.3.0"
@ -60,7 +60,8 @@ google_firebase_bom = "com.google.firebase:firebase-bom:31.2.3"
# AndroidX
androidx_material = { module = "com.google.android.material:material", version.ref = "material" }
androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "corektx" }
androidx_core = { module = "androidx.core:core", version.ref = "core" }
androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" }
androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
@ -73,6 +74,7 @@ androidx_security_crypto = "androidx.security:security-crypto:1.0.0"
androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" }
androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
androidx_startup = { module = "androidx.startup:startup-runtime", version.ref = "startup" }
androidx_preference = "androidx.preference:preference:1.2.0"
androidx_compose_bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" }

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.core.cache
/**
* A FIFO circular buffer of T.
* This class is not thread safe.
*/
class CircularCache<T : Any>(cacheSize: Int, factory: (Int) -> Array<T?>) {
companion object {
inline fun <reified T : Any> create(cacheSize: Int) = CircularCache(cacheSize) { Array<T?>(cacheSize) { null } }
}
private val cache = factory(cacheSize)
private var writeIndex = 0
fun contains(value: T): Boolean = cache.contains(value)
fun put(value: T) {
if (writeIndex == cache.size) {
writeIndex = 0
}
cache[writeIndex] = value
writeIndex++
}
}

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.di
import javax.inject.Qualifier
@Qualifier annotation class DefaultPreferences

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.push.api"
}
dependencies {
implementation(libs.androidx.corektx)
implementation(libs.coroutines.core)
}

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2023 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<!-- Firebase components -->
<meta-data
android:name="firebase_analytics_collection_deactivated"
android:value="true" />
<service
android:name="VectorFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- UnifiedPush -->
<receiver
android:name="VectorUnifiedPushMessagingReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.MESSAGE" />
<action android:name="org.unifiedpush.android.connector.UNREGISTERED" />
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT" />
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED" />
<action android:name="org.unifiedpush.android.connector.REGISTRATION_REFUSED" />
</intent-filter>
</receiver>
<receiver
android:name="KeepInternalDistributor"
android:enabled="true"
android:exported="false">
<intent-filter>
<!--
This action is checked to track installed and uninstalled distributors.
We declare it to keep the background sync as an internal
unifiedpush distributor.
-->
<action android:name="org.unifiedpush.android.distributor.REGISTER" />
</intent-filter>
</receiver>
</application>
</manifest>

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.api
interface PushService {
fun setCurrentRoom(roomId: String?)
fun setCurrentThread(threadId: String?)
fun notificationStyleChanged()
}

View file

@ -0,0 +1,48 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.api.model
/**
* Different strategies for Background sync, only applicable to F-Droid version of the app.
*/
enum class BackgroundSyncMode {
/**
* In this mode background syncs are scheduled via Workers, meaning that the system will have control on the periodicity
* of syncs when battery is low or when the phone is idle (sync will occur in allowed maintenance windows). After completion
* the sync work will schedule another one.
*/
FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY,
/**
* This mode requires the app to be exempted from battery optimization. Alarms will be launched and will wake up the app
* in order to perform the background sync as a foreground service. After completion the service will schedule another alarm
*/
FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME,
/**
* The app won't sync in background.
*/
FDROID_BACKGROUND_SYNC_MODE_DISABLED;
companion object {
const val DEFAULT_SYNC_DELAY_SECONDS = 60
const val DEFAULT_SYNC_TIMEOUT_SECONDS = 6
fun fromString(value: String?): BackgroundSyncMode = values().firstOrNull { it.name == value }
?: FDROID_BACKGROUND_SYNC_MODE_DISABLED
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.api.store
import io.element.android.libraries.push.api.model.BackgroundSyncMode
import kotlinx.coroutines.flow.Flow
interface PushDataStore {
val pushCounterFlow: Flow<Int>
fun areNotificationEnabledForDevice(): Boolean
fun setNotificationEnabledForDevice(enabled: Boolean)
fun backgroundSyncTimeOut(): Int
fun setBackgroundSyncTimeout(timeInSecond: Int)
fun backgroundSyncDelay(): Int
fun setBackgroundSyncDelay(timeInSecond: Int)
fun isBackgroundSyncEnabled(): Boolean
fun setFdroidSyncBackgroundMode(mode: BackgroundSyncMode)
fun getFdroidSyncBackgroundMode(): BackgroundSyncMode
/**
* Return true if Pin code is disabled, or if user set the settings to see full notification content.
*/
fun useCompleteNotificationFormat(): Boolean
}

View file

@ -0,0 +1,64 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
alias(libs.plugins.anvil)
kotlin("plugin.serialization") version "1.8.10"
}
android {
namespace = "io.element.android.libraries.push.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(libs.dagger)
implementation(libs.androidx.corektx)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.lifecycle.process)
implementation(libs.serialization.json)
implementation(projects.libraries.architecture)
implementation(projects.libraries.analytics.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.push.api)
implementation(projects.services.toolbox.api)
api("me.gujun.android:span:1.7") {
exclude(group = "com.android.support", module = "support-annotations")
}
implementation(platform(libs.google.firebase.bom))
implementation("com.google.firebase:firebase-messaging-ktx")
// UnifiedPush
api("com.github.UnifiedPush:android-connector:2.1.1")
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.coroutines.test)
}

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2023 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<!-- Firebase components -->
<meta-data
android:name="firebase_analytics_collection_deactivated"
android:value="true" />
<service
android:name="VectorFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- UnifiedPush -->
<receiver
android:name="VectorUnifiedPushMessagingReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.MESSAGE" />
<action android:name="org.unifiedpush.android.connector.UNREGISTERED" />
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT" />
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED" />
<action android:name="org.unifiedpush.android.connector.REGISTRATION_REFUSED" />
</intent-filter>
</receiver>
<receiver
android:name="KeepInternalDistributor"
android:enabled="true"
android:exported="false">
<intent-filter>
<!--
This action is checked to track installed and uninstalled distributors.
We declare it to keep the background sync as an internal
unifiedpush distributor.
-->
<action android:name="org.unifiedpush.android.distributor.REGISTER" />
</intent-filter>
</receiver>
</application>
</manifest>

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
// TODO Move away
/**
* This interface defines 2 flags so you can handle auto accept invites.
* At the moment we only have [CompileTimeAutoAcceptInvites] implementation.
*/
interface AutoAcceptInvites {
/**
* Enable auto-accept invites. It means, as soon as you got an invite from the sync, it will try to join it.
*/
val isEnabled: Boolean
/**
* Hide invites from the UI (from notifications, notification count and room list). By default invites are hidden when [isEnabled] is true
*/
val hideInvites: Boolean
get() = isEnabled
}
fun AutoAcceptInvites.showInvites() = !hideInvites
/**
* Simple compile time implementation of AutoAcceptInvites flags.
*/
@ContributesBinding(AppScope::class)
class CompileTimeAutoAcceptInvites @Inject constructor() : AutoAcceptInvites {
override val isEnabled = false
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultPushService @Inject constructor(
private val notificationDrawerManager: NotificationDrawerManager,
) : PushService {
override fun setCurrentRoom(roomId: String?) {
notificationDrawerManager.setCurrentRoom(roomId)
}
override fun setCurrentThread(threadId: String?) {
notificationDrawerManager.setCurrentThread(threadId)
}
override fun notificationStyleChanged() {
notificationDrawerManager.notificationStyleChanged()
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl
import javax.inject.Inject
class EnsureFcmTokenIsRetrievedUseCase @Inject constructor(
private val unifiedPushHelper: UnifiedPushHelper,
private val fcmHelper: FcmHelper,
// private val activeSessionHolder: ActiveSessionHolder,
) {
fun execute(pushersManager: PushersManager, registerPusher: Boolean) {
if (unifiedPushHelper.isEmbeddedDistributor()) {
fcmHelper.ensureFcmTokenIsRetrieved(pushersManager, shouldAddHttpPusher(registerPusher))
}
}
private fun shouldAddHttpPusher(registerPusher: Boolean) = if (registerPusher) {
/*
TODO EAx
val currentSession = activeSessionHolder.getActiveSession()
val currentPushers = currentSession.pushersService().getPushers()
currentPushers.none { it.deviceId == currentSession.sessionParams.deviceId }
*/
true
} else {
false
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl
interface FcmHelper {
fun isFirebaseAvailable(): Boolean
/**
* Retrieves the FCM registration token.
*
* @return the FCM token or null if not received from FCM.
*/
fun getFcmToken(): String?
/**
* Store FCM token to the SharedPrefs.
*
* @param token the token to store.
*/
fun storeFcmToken(token: String?)
/**
* onNewToken may not be called on application upgrade, so ensure my shared pref is set.
*
* @param pushersManager the instance to register the pusher on.
* @param registerPusher whether the pusher should be registered.
*/
fun ensureFcmTokenIsRetrieved(pushersManager: PushersManager, registerPusher: Boolean)
/*
fun onEnterForeground(activeSessionHolder: ActiveSessionHolder)
fun onEnterBackground(activeSessionHolder: ActiveSessionHolder)
*/
}

View file

@ -0,0 +1,101 @@
/*
* Copyright 2018 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl
import android.content.Context
import android.content.SharedPreferences
import android.widget.Toast
import androidx.core.content.edit
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.firebase.messaging.FirebaseMessaging
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.DefaultPreferences
import timber.log.Timber
import javax.inject.Inject
import io.element.android.libraries.ui.strings.R as StringR
/**
* This class store the FCM token in SharedPrefs and ensure this token is retrieved.
* It has an alter ego in the fdroid variant.
*/
@ContributesBinding(AppScope::class)
class GoogleFcmHelper @Inject constructor(
@ApplicationContext private val context: Context,
@DefaultPreferences private val sharedPrefs: SharedPreferences,
) : FcmHelper {
override fun isFirebaseAvailable(): Boolean = true
override fun getFcmToken(): String? {
return sharedPrefs.getString(PREFS_KEY_FCM_TOKEN, null)
}
override fun storeFcmToken(token: String?) {
sharedPrefs.edit {
putString(PREFS_KEY_FCM_TOKEN, token)
}
}
override fun ensureFcmTokenIsRetrieved(pushersManager: PushersManager, registerPusher: Boolean) {
// 'app should always check the device for a compatible Google Play services APK before accessing Google Play services features'
if (checkPlayServices(context)) {
try {
FirebaseMessaging.getInstance().token
.addOnSuccessListener { token ->
storeFcmToken(token)
if (registerPusher) {
pushersManager.enqueueRegisterPusherWithFcmKey(token)
}
}
.addOnFailureListener { e ->
Timber.e(e, "## ensureFcmTokenIsRetrieved() : failed")
}
} catch (e: Throwable) {
Timber.e(e, "## ensureFcmTokenIsRetrieved() : failed")
}
} else {
Toast.makeText(context, StringR.string.no_valid_google_play_services_apk, Toast.LENGTH_SHORT).show()
Timber.e("No valid Google Play Services found. Cannot use FCM.")
}
}
/**
* Check the device to make sure it has the Google Play Services APK. If
* it doesn't, display a dialog that allows users to download the APK from
* the Google Play Store or enable it in the device's system settings.
*/
private fun checkPlayServices(context: Context): Boolean {
val apiAvailability = GoogleApiAvailability.getInstance()
val resultCode = apiAvailability.isGooglePlayServicesAvailable(context)
return resultCode == ConnectionResult.SUCCESS
}
/*
override fun onEnterForeground(activeSessionHolder: ActiveSessionHolder) {
// No op
}
override fun onEnterBackground(activeSessionHolder: ActiveSessionHolder) {
// No op
}
*/
companion object {
private const val PREFS_KEY_FCM_TOKEN = "FCM_TOKEN"
}
}

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
interface GuardServiceStarter {
fun start() {}
fun stop() {}
}
@ContributesBinding(AppScope::class)
class NoopGuardServiceStarter @Inject constructor() : GuardServiceStarter {
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/**
* UnifiedPush lib tracks an action to check installed and uninstalled distributors.
* We declare it to keep the background sync as an internal unifiedpush distributor.
* This class is used to declare this action.
*/
class KeepInternalDistributor : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {}
}

View file

@ -0,0 +1,124 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl
import io.element.android.libraries.push.impl.config.PushConfig
import io.element.android.libraries.toolbox.api.appname.AppNameProvider
import java.util.UUID
import javax.inject.Inject
internal const val DEFAULT_PUSHER_FILE_TAG = "mobile"
// TODO EAx Communicate with the SDK
class PushersManager @Inject constructor(
private val unifiedPushHelper: UnifiedPushHelper,
// private val activeSessionHolder: ActiveSessionHolder,
// private val localeProvider: LocaleProvider,
private val appNameProvider: AppNameProvider,
// private val getDeviceInfoUseCase: GetDeviceInfoUseCase,
) {
suspend fun testPush() {
/*
val currentSession = activeSessionHolder.getActiveSession()
currentSession.pushersService().testPush(
unifiedPushHelper.getPushGateway() ?: return,
PushConfig.pusher_app_id,
unifiedPushHelper.getEndpointOrToken().orEmpty(),
TEST_EVENT_ID
)
*/
}
fun enqueueRegisterPusherWithFcmKey(pushKey: String): UUID {
return enqueueRegisterPusher(pushKey, PushConfig.pusher_http_url)
}
fun enqueueRegisterPusher(
pushKey: String,
gateway: String
): UUID {
/*
val currentSession = activeSessionHolder.getActiveSession()
val pusher = createHttpPusher(pushKey, gateway)
return currentSession.pushersService().enqueueAddHttpPusher(pusher)
*/
// TODO EAx
TODO()
}
private fun createHttpPusher(
pushKey: String,
gateway: String
): Any = TODO()
/*
HttpPusher(
pushkey = pushKey,
appId = PushConfig.pusher_app_id,
profileTag = DEFAULT_PUSHER_FILE_TAG + "_" + abs(activeSessionHolder.getActiveSession().myUserId.hashCode()),
lang = localeProvider.current().language,
appDisplayName = appNameProvider.getAppName(),
deviceDisplayName = getDeviceInfoUseCase.execute().displayName().orEmpty(),
url = gateway,
enabled = true,
deviceId = activeSessionHolder.getActiveSession().sessionParams.deviceId ?: "MOBILE",
append = false,
withEventIdOnly = true,
)
*/
suspend fun registerEmailForPush(email: String) {
TODO()
/*
val currentSession = activeSessionHolder.getActiveSession()
val appName = appNameProvider.getAppName()
currentSession.pushersService().addEmailPusher(
email = email,
lang = localeProvider.current().language,
emailBranding = appName,
appDisplayName = appName,
deviceDisplayName = currentSession.sessionParams.deviceId ?: "MOBILE"
)
*/
}
fun getPusherForCurrentSession() {}/*: Pusher? {
val session = activeSessionHolder.getSafeActiveSession() ?: return null
val deviceId = session.sessionParams.deviceId
return session.pushersService().getPushers().firstOrNull { it.deviceId == deviceId }
}
*/
suspend fun unregisterEmailPusher(email: String) {
// val currentSession = activeSessionHolder.getSafeActiveSession() ?: return
// currentSession.pushersService().removeEmailPusher(email)
}
suspend fun unregisterPusher(pushKey: String) {
// val currentSession = activeSessionHolder.getSafeActiveSession() ?: return
// currentSession.pushersService().removeHttpPusher(pushKey, PushConfig.pusher_app_id)
}
companion object {
const val TEST_EVENT_ID = "\$THIS_IS_A_FAKE_EVENT_ID"
}
}

View file

@ -0,0 +1,68 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl
import android.content.Context
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.push.impl.config.PushConfig
import org.unifiedpush.android.connector.UnifiedPush
import javax.inject.Inject
class RegisterUnifiedPushUseCase @Inject constructor(
@ApplicationContext private val context: Context,
) {
sealed interface RegisterUnifiedPushResult {
object Success : RegisterUnifiedPushResult
object NeedToAskUserForDistributor : RegisterUnifiedPushResult
}
fun execute(distributor: String = ""): RegisterUnifiedPushResult {
if (distributor.isNotEmpty()) {
saveAndRegisterApp(distributor)
return RegisterUnifiedPushResult.Success
}
if (!PushConfig.allowExternalUnifiedPushDistributors) {
saveAndRegisterApp(context.packageName)
return RegisterUnifiedPushResult.Success
}
if (UnifiedPush.getDistributor(context).isNotEmpty()) {
registerApp()
return RegisterUnifiedPushResult.Success
}
val distributors = UnifiedPush.getDistributors(context)
return if (distributors.size == 1) {
saveAndRegisterApp(distributors.first())
RegisterUnifiedPushResult.Success
} else {
RegisterUnifiedPushResult.NeedToAskUserForDistributor
}
}
private fun saveAndRegisterApp(distributor: String) {
UnifiedPush.saveDistributor(context, distributor)
registerApp()
}
private fun registerApp() {
UnifiedPush.registerApp(context)
}
}

View file

@ -0,0 +1,180 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl
import android.content.Context
import io.element.android.libraries.androidutils.system.getApplicationLabel
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.push.impl.config.PushConfig
import io.element.android.libraries.toolbox.api.strings.StringProvider
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.unifiedpush.android.connector.UnifiedPush
import timber.log.Timber
import java.net.URL
import javax.inject.Inject
import io.element.android.libraries.ui.strings.R as StringR
class UnifiedPushHelper @Inject constructor(
@ApplicationContext private val context: Context,
private val unifiedPushStore: UnifiedPushStore,
// private val matrix: Matrix,
private val fcmHelper: FcmHelper,
private val stringProvider: StringProvider,
) {
/* TODO EAx
@MainThread
fun showSelectDistributorDialog(
context: Context,
onDistributorSelected: (String) -> Unit,
) {
val internalDistributorName = stringProvider.getString(
if (fcmHelper.isFirebaseAvailable()) {
StringR.string.unifiedpush_distributor_fcm_fallback
} else {
StringR.string.unifiedpush_distributor_background_sync
}
)
val distributors = UnifiedPush.getDistributors(context)
val distributorsName = distributors.map {
if (it == context.packageName) {
internalDistributorName
} else {
context.getApplicationLabel(it)
}
}
MaterialAlertDialogBuilder(context)
.setTitle(stringProvider.getString(StringR.string.unifiedpush_getdistributors_dialog_title))
.setItems(distributorsName.toTypedArray()) { _, which ->
val distributor = distributors[which]
onDistributorSelected(distributor)
}
.setOnCancelListener {
// we do not want to change the distributor on behalf of the user
if (UnifiedPush.getDistributor(context).isEmpty()) {
// By default, use internal solution (fcm/background sync)
onDistributorSelected(context.packageName)
}
}
.setCancelable(true)
.show()
}
*/
@Serializable
internal data class DiscoveryResponse(
@SerialName("unifiedpush") val unifiedpush: DiscoveryUnifiedPush = DiscoveryUnifiedPush()
)
@Serializable
internal data class DiscoveryUnifiedPush(
@SerialName("gateway") val gateway: String = ""
)
suspend fun storeCustomOrDefaultGateway(
endpoint: String,
onDoneRunnable: Runnable? = null
) {
// if we use the embedded distributor,
// register app_id type upfcm on sygnal
// the pushkey if FCM key
if (UnifiedPush.getDistributor(context) == context.packageName) {
unifiedPushStore.storePushGateway(PushConfig.pusher_http_url)
onDoneRunnable?.run()
return
}
/* TODO EAx UnifiedPush
// else, unifiedpush, and pushkey is an endpoint
val gateway = PushConfig.default_push_gateway_http_url
val parsed = URL(endpoint)
val custom = "${parsed.protocol}://${parsed.host}/_matrix/push/v1/notify"
Timber.i("Testing $custom")
try {
val response = matrix.rawService().getUrl(custom, CacheStrategy.NoCache)
tryOrNull { Json.decodeFromString<DiscoveryResponse>(response) }
?.let { discoveryResponse ->
if (discoveryResponse.unifiedpush.gateway == "matrix") {
Timber.d("Using custom gateway")
unifiedPushStore.storePushGateway(custom)
onDoneRunnable?.run()
return
}
}
} catch (e: Throwable) {
Timber.d(e, "Cannot try custom gateway")
}
unifiedPushStore.storePushGateway(gateway)
onDoneRunnable?.run()
*/
}
fun getExternalDistributors(): List<String> {
return UnifiedPush.getDistributors(context)
.filterNot { it == context.packageName }
}
fun getCurrentDistributorName(): String {
return when {
isEmbeddedDistributor() -> stringProvider.getString(StringR.string.unifiedpush_distributor_fcm_fallback)
isBackgroundSync() -> stringProvider.getString(StringR.string.unifiedpush_distributor_background_sync)
else -> context.getApplicationLabel(UnifiedPush.getDistributor(context))
}
}
fun isEmbeddedDistributor(): Boolean {
return isInternalDistributor() && fcmHelper.isFirebaseAvailable()
}
fun isBackgroundSync(): Boolean {
return isInternalDistributor() && !fcmHelper.isFirebaseAvailable()
}
private fun isInternalDistributor(): Boolean {
return UnifiedPush.getDistributor(context).isEmpty() ||
UnifiedPush.getDistributor(context) == context.packageName
}
fun getPrivacyFriendlyUpEndpoint(): String? {
val endpoint = getEndpointOrToken()
if (endpoint.isNullOrEmpty()) return null
if (isEmbeddedDistributor()) {
return endpoint
}
return try {
val parsed = URL(endpoint)
"${parsed.protocol}://${parsed.host}/***"
} catch (e: Exception) {
Timber.e(e, "Error parsing unifiedpush endpoint")
null
}
}
fun getEndpointOrToken(): String? {
return if (isEmbeddedDistributor()) fcmHelper.getFcmToken()
else unifiedPushStore.getEndpoint()
}
fun getPushGateway(): String? {
return if (isEmbeddedDistributor()) PushConfig.pusher_http_url
else unifiedPushStore.getPushGateway()
}
}

View file

@ -0,0 +1,77 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.DefaultPreferences
import javax.inject.Inject
/**
* TODO EAx Store in BDD (for multisession)
*/
class UnifiedPushStore @Inject constructor(
@ApplicationContext val context: Context,
@DefaultPreferences private val defaultPrefs: SharedPreferences,
) {
/**
* Retrieves the UnifiedPush Endpoint.
*
* @return the UnifiedPush Endpoint or null if not received
*/
fun getEndpoint(): String? {
return defaultPrefs.getString(PREFS_ENDPOINT_OR_TOKEN, null)
}
/**
* Store UnifiedPush Endpoint to the SharedPrefs.
*
* @param endpoint the endpoint to store
*/
fun storeUpEndpoint(endpoint: String?) {
defaultPrefs.edit {
putString(PREFS_ENDPOINT_OR_TOKEN, endpoint)
}
}
/**
* Retrieves the Push Gateway.
*
* @return the Push Gateway or null if not defined
*/
fun getPushGateway(): String? {
return defaultPrefs.getString(PREFS_PUSH_GATEWAY, null)
}
/**
* Store Push Gateway to the SharedPrefs.
*
* @param gateway the push gateway to store
*/
fun storePushGateway(gateway: String?) {
defaultPrefs.edit {
putString(PREFS_PUSH_GATEWAY, gateway)
}
}
companion object {
private const val PREFS_ENDPOINT_OR_TOKEN = "UP_ENDPOINT_OR_TOKEN"
private const val PREFS_PUSH_GATEWAY = "PUSH_GATEWAY"
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl
import android.content.Context
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.push.api.model.BackgroundSyncMode
import io.element.android.libraries.push.api.store.PushDataStore
import org.unifiedpush.android.connector.UnifiedPush
import timber.log.Timber
import javax.inject.Inject
class UnregisterUnifiedPushUseCase @Inject constructor(
@ApplicationContext private val context: Context,
private val pushDataStore: PushDataStore,
private val unifiedPushStore: UnifiedPushStore,
private val unifiedPushHelper: UnifiedPushHelper,
) {
suspend fun execute(pushersManager: PushersManager?) {
val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME
pushDataStore.setFdroidSyncBackgroundMode(mode)
try {
unifiedPushHelper.getEndpointOrToken()?.let {
Timber.d("Removing $it")
pushersManager?.unregisterPusher(it)
}
} catch (e: Exception) {
Timber.d(e, "Probably unregistering a non existing pusher")
}
unifiedPushStore.storeUpEndpoint(null)
unifiedPushStore.storePushGateway(null)
UnifiedPush.unregisterApp(context)
}
}

View file

@ -0,0 +1,64 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.push.api.store.PushDataStore
import io.element.android.libraries.push.impl.config.PushConfig
import io.element.android.libraries.push.impl.di.FirebaseMessagingServiceBindings
import io.element.android.libraries.push.impl.parser.PushParser
import timber.log.Timber
import javax.inject.Inject
private val loggerTag = LoggerTag("Push", LoggerTag.SYNC)
class VectorFirebaseMessagingService : FirebaseMessagingService() {
@Inject lateinit var fcmHelper: FcmHelper
@Inject lateinit var pushDataStore: PushDataStore
// @Inject lateinit var activeSessionHolder: ActiveSessionHolder
@Inject lateinit var pushersManager: PushersManager
@Inject lateinit var pushParser: PushParser
@Inject lateinit var vectorPushHandler: VectorPushHandler
@Inject lateinit var unifiedPushHelper: UnifiedPushHelper
override fun onCreate() {
super.onCreate()
applicationContext.bindings<FirebaseMessagingServiceBindings>().inject(this)
}
override fun onNewToken(token: String) {
Timber.tag(loggerTag.value).d("New Firebase token")
fcmHelper.storeFcmToken(token)
if (
pushDataStore.areNotificationEnabledForDevice() &&
// TODO EAx activeSessionHolder.hasActiveSession() &&
unifiedPushHelper.isEmbeddedDistributor()
) {
pushersManager.enqueueRegisterPusher(token, PushConfig.pusher_http_url)
}
}
override fun onMessageReceived(message: RemoteMessage) {
Timber.tag(loggerTag.value).d("New Firebase message")
pushParser.parsePushDataFcm(message.data).let {
vectorPushHandler.handle(it)
}
}
}

View file

@ -0,0 +1,188 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import io.element.android.libraries.androidutils.network.WifiDetector
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.push.api.store.PushDataStore
import io.element.android.libraries.push.impl.model.PushData
import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager
import io.element.android.libraries.push.impl.store.DefaultPushDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import javax.inject.Inject
private val loggerTag = LoggerTag("Push", LoggerTag.SYNC)
class VectorPushHandler @Inject constructor(
private val notificationDrawerManager: NotificationDrawerManager,
private val notifiableEventResolver: NotifiableEventResolver,
// private val activeSessionHolder: ActiveSessionHolder,
private val pushDataStore: PushDataStore,
private val defaultPushDataStore: DefaultPushDataStore,
private val actionIds: NotificationActionIds,
@ApplicationContext private val context: Context,
private val buildMeta: BuildMeta
) {
private val coroutineScope = CoroutineScope(SupervisorJob())
private val wifiDetector: WifiDetector = WifiDetector(context)
// UI handler
private val mUIHandler by lazy {
Handler(Looper.getMainLooper())
}
/**
* Called when message is received.
*
* @param pushData the data received in the push.
*/
fun handle(pushData: PushData) {
Timber.tag(loggerTag.value).d("## handling pushData")
if (buildMeta.lowPrivacyLoggingEnabled) {
Timber.tag(loggerTag.value).d("## pushData: $pushData")
}
runBlocking {
defaultPushDataStore.incrementPushCounter()
}
// Diagnostic Push
if (pushData.eventId == PushersManager.TEST_EVENT_ID) {
val intent = Intent(actionIds.push)
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
return
}
if (!pushDataStore.areNotificationEnabledForDevice()) {
Timber.tag(loggerTag.value).i("Notification are disabled for this device")
return
}
mUIHandler.post {
if (ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
// we are in foreground, let the sync do the things?
Timber.tag(loggerTag.value).d("PUSH received in a foreground state, ignore")
} else {
coroutineScope.launch(Dispatchers.IO) { handleInternal(pushData) }
}
}
}
/**
* Internal receive method.
*
* @param pushData Object containing message data.
*/
private suspend fun handleInternal(pushData: PushData) {
try {
if (buildMeta.lowPrivacyLoggingEnabled) {
Timber.tag(loggerTag.value).d("## handleInternal() : $pushData")
} else {
Timber.tag(loggerTag.value).d("## handleInternal()")
}
/* TODO EAx
val session = activeSessionHolder.getOrInitializeSession()
if (session == null) {
Timber.tag(loggerTag.value).w("## Can't sync from push, no current session")
} else {
if (isEventAlreadyKnown(pushData)) {
Timber.tag(loggerTag.value).d("Ignoring push, event already known")
} else {
// Try to get the Event content faster
Timber.tag(loggerTag.value).d("Requesting event in fast lane")
getEventFastLane(session, pushData)
Timber.tag(loggerTag.value).d("Requesting background sync")
session.syncService().requireBackgroundSync()
}
}
*/
} catch (e: Exception) {
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
}
}
/* TODO EAx
private suspend fun getEventFastLane(session: Session, pushData: PushData) {
pushData.roomId ?: return
pushData.eventId ?: return
if (wifiDetector.isConnectedToWifi().not()) {
Timber.tag(loggerTag.value).d("No WiFi network, do not get Event")
return
}
Timber.tag(loggerTag.value).d("Fast lane: start request")
val event = tryOrNull { session.eventService().getEvent(pushData.roomId, pushData.eventId) } ?: return
val resolvedEvent = notifiableEventResolver.resolveInMemoryEvent(session, event, canBeReplaced = true)
if (resolvedEvent is NotifiableMessageEvent) {
// If the room is currently displayed, we will not show a notification, so no need to get the Event faster
if (notificationDrawerManager.shouldIgnoreMessageEventInRoom(resolvedEvent)) {
return
}
}
resolvedEvent
?.also { Timber.tag(loggerTag.value).d("Fast lane: notify drawer") }
?.let {
notificationDrawerManager.updateEvents { it.onNotifiableEventReceived(resolvedEvent) }
}
}
*/
// check if the event was not yet received
// a previous catchup might have already retrieved the notified event
private fun isEventAlreadyKnown(pushData: PushData): Boolean {
/* TODO EAx
if (pushData.eventId != null && pushData.roomId != null) {
try {
val session = activeSessionHolder.getSafeActiveSession() ?: return false
val room = session.getRoom(pushData.roomId) ?: return false
return room.getTimelineEvent(pushData.eventId) != null
} catch (e: Exception) {
Timber.tag(loggerTag.value).e(e, "## isEventAlreadyKnown() : failed to check if the event was already defined")
}
}
*/
return false
}
}

View file

@ -0,0 +1,117 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl
import android.content.Context
import android.content.Intent
import android.widget.Toast
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.push.api.model.BackgroundSyncMode
import io.element.android.libraries.push.api.store.PushDataStore
import io.element.android.libraries.push.impl.di.VectorUnifiedPushMessagingReceiverBindings
import io.element.android.libraries.push.impl.parser.PushParser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.unifiedpush.android.connector.MessagingReceiver
import timber.log.Timber
import javax.inject.Inject
private val loggerTag = LoggerTag("Push", LoggerTag.SYNC)
class VectorUnifiedPushMessagingReceiver : MessagingReceiver() {
@Inject lateinit var pushersManager: PushersManager
@Inject lateinit var pushParser: PushParser
//@Inject lateinit var activeSessionHolder: ActiveSessionHolder
@Inject lateinit var pushDataStore: PushDataStore
@Inject lateinit var vectorPushHandler: VectorPushHandler
@Inject lateinit var guardServiceStarter: GuardServiceStarter
@Inject lateinit var unifiedPushStore: UnifiedPushStore
@Inject lateinit var unifiedPushHelper: UnifiedPushHelper
private val coroutineScope = CoroutineScope(SupervisorJob())
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
// Inject
context.applicationContext.bindings<VectorUnifiedPushMessagingReceiverBindings>().inject(this)
}
/**
* Called when message is received.
*
* @param context the Android context
* @param message the message
* @param instance connection, for multi-account
*/
override fun onMessage(context: Context, message: ByteArray, instance: String) {
Timber.tag(loggerTag.value).d("New message")
pushParser.parsePushDataUnifiedPush(message)?.let {
vectorPushHandler.handle(it)
} ?: run {
Timber.tag(loggerTag.value).w("Invalid received data Json format")
}
}
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
Timber.tag(loggerTag.value).i("onNewEndpoint: adding $endpoint")
if (pushDataStore.areNotificationEnabledForDevice() /* TODO EAx && activeSessionHolder.hasActiveSession() */) {
// If the endpoint has changed
// or the gateway has changed
if (unifiedPushHelper.getEndpointOrToken() != endpoint) {
unifiedPushStore.storeUpEndpoint(endpoint)
coroutineScope.launch {
unifiedPushHelper.storeCustomOrDefaultGateway(endpoint) {
unifiedPushHelper.getPushGateway()?.let {
pushersManager.enqueueRegisterPusher(endpoint, it)
}
}
}
} else {
Timber.tag(loggerTag.value).i("onNewEndpoint: skipped")
}
}
val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_DISABLED
pushDataStore.setFdroidSyncBackgroundMode(mode)
guardServiceStarter.stop()
}
override fun onRegistrationFailed(context: Context, instance: String) {
Toast.makeText(context, "Push service registration failed", Toast.LENGTH_SHORT).show()
val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME
pushDataStore.setFdroidSyncBackgroundMode(mode)
guardServiceStarter.start()
}
override fun onUnregistered(context: Context, instance: String) {
Timber.tag(loggerTag.value).d("Unifiedpush: Unregistered")
val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME
pushDataStore.setFdroidSyncBackgroundMode(mode)
guardServiceStarter.start()
runBlocking {
try {
pushersManager.unregisterPusher(unifiedPushHelper.getEndpointOrToken().orEmpty())
} catch (e: Exception) {
Timber.tag(loggerTag.value).d("Probably unregistering a non existing pusher")
}
}
}
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.config
object PushConfig {
/**
* It is the push gateway for FCM embedded distributor.
* Note: pusher_http_url should have path '/_matrix/push/v1/notify' -->
*/
const val pusher_http_url: String = "https://matrix.org/_matrix/push/v1/notify"
/**
* It is the push gateway for UnifiedPush.
* Note: default_push_gateway_http_url should have path '/_matrix/push/v1/notify'
*/
const val default_push_gateway_http_url: String = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify"
/**
* Note: pusher_app_id cannot exceed 64 chars.
*/
const val pusher_app_id: String = "im.vector.app.android"
/**
* Set to true to allow external push distributor such as Ntfy.
*/
const val allowExternalUnifiedPushDistributors: Boolean = false
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.push.impl.VectorFirebaseMessagingService
@ContributesTo(AppScope::class)
interface FirebaseMessagingServiceBindings {
fun inject(service: VectorFirebaseMessagingService)
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.push.impl.VectorUnifiedPushMessagingReceiver
@ContributesTo(AppScope::class)
interface VectorUnifiedPushMessagingReceiverBindings {
fun inject(receiver: VectorUnifiedPushMessagingReceiver)
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.model
/**
* Represent parsed data that the app has received from a Push content.
*
* @property eventId The Event ID. If not null, it will not be empty, and will have a valid format.
* @property roomId The Room ID. If not null, it will not be empty, and will have a valid format.
* @property unread Number of unread message.
*/
data class PushData(
val eventId: String?,
val roomId: String?,
val unread: Int?,
)

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.model
import io.element.android.libraries.matrix.api.core.MatrixPatterns
/**
* In this case, the format is:
* <pre>
* {
* "event_id":"$anEventId",
* "room_id":"!aRoomId",
* "unread":"1",
* "prio":"high"
* }
* </pre>
* .
*/
data class PushDataFcm(
val eventId: String?,
val roomId: String?,
var unread: Int?,
)
fun PushDataFcm.toPushData() = PushData(
eventId = eventId?.takeIf { MatrixPatterns.isEventId(it) },
roomId = roomId?.takeIf { MatrixPatterns.isRoomId(it) },
unread = unread
)

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.model
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* In this case, the format is:
* <pre>
* {
* "notification":{
* "event_id":"$anEventId",
* "room_id":"!aRoomId",
* "counts":{
* "unread":1
* },
* "prio":"high"
* }
* }
* </pre>
* .
*/
@Serializable
data class PushDataUnifiedPush(
val notification: PushDataUnifiedPushNotification?
)
@Serializable
data class PushDataUnifiedPushNotification(
@SerialName("event_id") val eventId: String?,
@SerialName("room_id") val roomId: String?,
@SerialName("counts") var counts: PushDataUnifiedPushCounts?,
)
@Serializable
data class PushDataUnifiedPushCounts(
@SerialName("unread") val unread: Int?
)
fun PushDataUnifiedPush.toPushData() = PushData(
eventId = notification?.eventId?.takeIf { MatrixPatterns.isEventId(it) },
roomId = notification?.roomId?.takeIf { MatrixPatterns.isRoomId(it) },
unread = notification?.counts?.unread
)

View file

@ -0,0 +1,57 @@
/*
* Copyright 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import javax.inject.Inject
class FilteredEventDetector @Inject constructor(
//private val activeSessionDataSource: ActiveSessionDataSource
) {
/**
* Returns true if the given event should be ignored.
* Used to skip notifications if a non expected message is received.
*/
fun shouldBeIgnored(notifiableEvent: NotifiableEvent): Boolean {
/* TODO EAx
val session = activeSessionDataSource.currentValue?.orNull() ?: return false
if (notifiableEvent is NotifiableMessageEvent) {
val room = session.getRoom(notifiableEvent.roomId) ?: return false
val timelineEvent = room.getTimelineEvent(notifiableEvent.eventId) ?: return false
return timelineEvent.shouldBeIgnored()
}
*/
return false
}
/**
* Whether the timeline event should be ignored.
*/
/*
private fun TimelineEvent.shouldBeIgnored(): Boolean {
if (root.isVoiceMessage()) {
val audioEvent = root.asMessageAudioEvent()
// if the event is a voice message related to a voice broadcast, only show the event on the first chunk.
return audioEvent.isVoiceBroadcast() && audioEvent?.sequence != 1
}
return false
}
*/
}

View file

@ -0,0 +1,62 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.push.impl.AutoAcceptInvites
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.SimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom
import timber.log.Timber
import javax.inject.Inject
private typealias ProcessedEvents = List<ProcessedEvent<NotifiableEvent>>
class NotifiableEventProcessor @Inject constructor(
private val outdatedDetector: OutdatedEventDetector,
private val autoAcceptInvites: AutoAcceptInvites
) {
fun process(queuedEvents: List<NotifiableEvent>, currentRoomId: String?, currentThreadId: String?, renderedEvents: ProcessedEvents): ProcessedEvents {
val processedEvents = queuedEvents.map {
val type = when (it) {
is InviteNotifiableEvent -> if (autoAcceptInvites.hideInvites) ProcessedEvent.Type.REMOVE else ProcessedEvent.Type.KEEP
is NotifiableMessageEvent -> when {
it.shouldIgnoreMessageEventInRoom(currentRoomId, currentThreadId) -> {
ProcessedEvent.Type.REMOVE
.also { Timber.d("notification message removed due to currently viewing the same room or thread") }
}
outdatedDetector.isMessageOutdated(it) -> ProcessedEvent.Type.REMOVE
.also { Timber.d("notification message removed due to being read") }
else -> ProcessedEvent.Type.KEEP
}
is SimpleNotifiableEvent -> when (it.type) {
/*EventType.REDACTION*/ "m.room.redaction" -> ProcessedEvent.Type.REMOVE
else -> ProcessedEvent.Type.KEEP
}
}
ProcessedEvent(type, it)
}
val removedEventsDiff = renderedEvents.filter { renderedEvent ->
queuedEvents.none { it.eventId == renderedEvent.event.eventId }
}.map { ProcessedEvent(ProcessedEvent.Type.REMOVE, it.event) }
return removedEventsDiff + processedEvents
}
}

View file

@ -0,0 +1,264 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.toolbox.api.strings.StringProvider
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.toolbox.api.systemclock.SystemClock
import javax.inject.Inject
/**
* 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.
*/
class NotifiableEventResolver @Inject constructor(
private val stringProvider: StringProvider,
// private val noticeEventFormatter: NoticeEventFormatter,
// private val displayableEventFormatter: DisplayableEventFormatter,
private val clock: SystemClock,
private val buildMeta: BuildMeta,
) {
suspend fun resolveEvent(/*event: Event, session: Session, isNoisy: Boolean*/): NotifiableEvent? {
return TODO()
/*
val roomID = event.roomId ?: return null
val eventId = event.eventId ?: return null
if (event.getClearType() == EventType.STATE_ROOM_MEMBER) {
return resolveStateRoomEvent(event, session, canBeReplaced = false, isNoisy = isNoisy)
}
val timelineEvent = session.getRoom(roomID)?.getTimelineEvent(eventId) ?: return null
return when {
event.supportsNotification() || event.type == EventType.ENCRYPTED -> {
resolveMessageEvent(timelineEvent, session, canBeReplaced = false, isNoisy = isNoisy)
}
else -> {
// If the event can be displayed, display it as is
Timber.w("NotifiableEventResolver Received an unsupported event matching a bing rule")
// TODO Better event text display
val bodyPreview = event.type ?: EventType.MISSING_TYPE
SimpleNotifiableEvent(
session.myUserId,
eventId = event.eventId!!,
editedEventId = timelineEvent.getEditedEventId(),
noisy = false, // will be updated
timestamp = event.originServerTs ?: clock.epochMillis(),
description = bodyPreview,
title = stringProvider.getString(StringR.string.notification_unknown_new_event),
soundName = null,
type = event.type,
canBeReplaced = false
)
}
}
*/
}
suspend fun resolveInMemoryEvent(/*session: Session, event: Event, canBeReplaced: Boolean*/): NotifiableEvent? {
TODO()
/*
if (!event.supportsNotification()) return null
// Ignore message edition
if (event.isEdition()) return null
val actions = session.pushRuleService().getActions(event)
val notificationAction = actions.toNotificationAction()
return if (notificationAction.shouldNotify) {
val user = session.getUserOrDefault(event.senderId!!)
val timelineEvent = TimelineEvent(
root = event,
localId = -1,
eventId = event.eventId!!,
displayIndex = 0,
senderInfo = SenderInfo(
userId = user.userId,
displayName = user.toMatrixItem().getBestName(),
isUniqueDisplayName = true,
avatarUrl = user.avatarUrl
)
)
resolveMessageEvent(timelineEvent, session, canBeReplaced = canBeReplaced, isNoisy = !notificationAction.soundName.isNullOrBlank())
} else {
Timber.d("Matched push rule is set to not notify")
null
}
*/
}
private suspend fun resolveMessageEvent(/*event: TimelineEvent, session: Session, canBeReplaced: Boolean, isNoisy: Boolean*/): NotifiableMessageEvent? {
TODO()
/*
// The event only contains an eventId, and roomId (type is m.room.*) , we need to get the displayable content (names, avatar, text, etc...)
val room = session.getRoom(event.root.roomId!! /*roomID cannot be null*/)
return if (room == null) {
Timber.e("## Unable to resolve room for eventId [$event]")
// Ok room is not known in store, but we can still display something
val body = displayableEventFormatter.format(event, isDm = false, appendAuthor = false)
val roomName = stringProvider.getString(StringR.string.notification_unknown_room_name)
val senderDisplayName = event.senderInfo.disambiguatedDisplayName
NotifiableMessageEvent(
eventId = event.root.eventId!!,
editedEventId = event.getEditedEventId(),
canBeReplaced = canBeReplaced,
timestamp = event.root.originServerTs ?: 0,
noisy = isNoisy,
senderName = senderDisplayName,
senderId = event.root.senderId,
body = body.toString(),
imageUriString = event.fetchImageIfPresent(session)?.toString(),
roomId = event.root.roomId!!,
threadId = event.root.getRootThreadEventId(),
roomName = roomName,
matrixID = session.myUserId
)
} else {
event.attemptToDecryptIfNeeded(session)
// only convert encrypted messages to NotifiableMessageEvents
when {
event.root.supportsNotification() -> {
val body = displayableEventFormatter.format(event, isDm = room.roomSummary()?.isDirect.orFalse(), appendAuthor = false).toString()
val roomName = room.roomSummary()?.displayName ?: ""
val senderDisplayName = event.senderInfo.disambiguatedDisplayName
NotifiableMessageEvent(
eventId = event.root.eventId!!,
editedEventId = event.getEditedEventId(),
canBeReplaced = canBeReplaced,
timestamp = event.root.originServerTs ?: 0,
noisy = isNoisy,
senderName = senderDisplayName,
senderId = event.root.senderId,
body = body,
imageUriString = event.fetchImageIfPresent(session)?.toString(),
roomId = event.root.roomId!!,
threadId = event.root.getRootThreadEventId(),
roomName = roomName,
roomIsDirect = room.roomSummary()?.isDirect ?: false,
roomAvatarPath = session.contentUrlResolver()
.resolveThumbnail(
room.roomSummary()?.avatarUrl,
250,
250,
ContentUrlResolver.ThumbnailMethod.SCALE
),
senderAvatarPath = session.contentUrlResolver()
.resolveThumbnail(
event.senderInfo.avatarUrl,
250,
250,
ContentUrlResolver.ThumbnailMethod.SCALE
),
matrixID = session.myUserId,
soundName = null
)
}
else -> null
}
}
*/
}
/*
private suspend fun TimelineEvent.attemptToDecryptIfNeeded(session: Session) {
if (root.isEncrypted() && root.mxDecryptionResult == null) {
// TODO use a global event decryptor? attache to session and that listen to new sessionId?
// for now decrypt sync
try {
val result = session.cryptoService().decryptEvent(root, root.roomId + UUID.randomUUID().toString())
root.mxDecryptionResult = OlmDecryptionResult(
payload = result.clearEvent,
senderKey = result.senderCurve25519Key,
keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) },
forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain,
isSafe = result.isSafe
)
} catch (ignore: MXCryptoError) {
}
}
}
*/
/*
private suspend fun TimelineEvent.fetchImageIfPresent(session: Session): Uri? {
return when {
root.isEncrypted() && root.mxDecryptionResult == null -> null
root.isImageMessage() -> downloadAndExportImage(session)
else -> null
}
}
*/
/*
private suspend fun TimelineEvent.downloadAndExportImage(session: Session): Uri? {
return kotlin.runCatching {
getVectorLastMessageContent()?.takeAs<MessageWithAttachmentContent>()?.let { imageMessage ->
val fileService = session.fileService()
fileService.downloadFile(imageMessage)
fileService.getTemporarySharableURI(imageMessage)
}
}.onFailure {
Timber.e(it, "Failed to download and export image for notification")
}.getOrNull()
}
*/
/*
private fun resolveStateRoomEvent(event: Event, session: Session, canBeReplaced: Boolean, isNoisy: Boolean): NotifiableEvent? {
val content = event.content?.toModel<RoomMemberContent>() ?: return null
val roomId = event.roomId ?: return null
val dName = event.senderId?.let { session.roomService().getRoomMember(it, roomId)?.displayName }
if (Membership.INVITE == content.membership) {
val roomSummary = session.getRoomSummary(roomId)
val body = noticeEventFormatter.format(event, dName, isDm = roomSummary?.isDirect.orFalse())
?: stringProvider.getString(StringR.string.notification_new_invitation)
return InviteNotifiableEvent(
session.myUserId,
eventId = event.eventId!!,
editedEventId = null,
canBeReplaced = canBeReplaced,
roomId = roomId,
roomName = roomSummary?.displayName,
timestamp = event.originServerTs ?: 0,
noisy = isNoisy,
title = stringProvider.getString(StringR.string.notification_new_invitation),
description = body.toString(),
soundName = null, // will be set later
type = event.getClearType()
)
} else {
Timber.e("## unsupported notifiable event for eventId [${event.eventId}]")
if (buildMeta.lowPrivacyLoggingEnabled) {
Timber.e("## unsupported notifiable event for event [$event]")
}
// TODO generic handling?
}
return null
}
*/
}

View file

@ -0,0 +1,39 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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)
}
*/

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.core.meta.BuildMeta
import javax.inject.Inject
/**
* Util class for creating notifications.
* Note: Cannot inject ColorProvider in the constructor, because it requires an Activity
*/
data class NotificationActionIds @Inject constructor(
private val buildMeta: BuildMeta,
) {
val join = "${buildMeta.applicationId}.NotificationActions.JOIN_ACTION"
val reject = "${buildMeta.applicationId}.NotificationActions.REJECT_ACTION"
val quickLaunch = "${buildMeta.applicationId}.NotificationActions.QUICK_LAUNCH_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 tapToView = "${buildMeta.applicationId}.NotificationActions.TAP_TO_VIEW_ACTION"
val diagnostic = "${buildMeta.applicationId}.NotificationActions.DIAGNOSTIC"
val push = "${buildMeta.applicationId}.PUSH"
}

View file

@ -0,0 +1,95 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import androidx.annotation.WorkerThread
import androidx.core.graphics.drawable.IconCompat
import io.element.android.libraries.di.ApplicationContext
import timber.log.Timber
import javax.inject.Inject
class NotificationBitmapLoader @Inject constructor(
@ApplicationContext private val context: Context
) {
/**
* Get icon of a room.
*/
@WorkerThread
fun getRoomBitmap(path: String?): Bitmap? {
if (path == null) {
return null
}
return loadRoomBitmap(path)
}
@WorkerThread
private fun loadRoomBitmap(path: String): Bitmap? {
return try {
null
/* TODO Notification
Glide.with(context)
.asBitmap()
.load(path)
.format(DecodeFormat.PREFER_ARGB_8888)
.signature(ObjectKey("room-icon-notification"))
.submit()
.get()
*/
} catch (e: Exception) {
Timber.e(e, "decodeFile failed")
null
}
}
/**
* Get icon of a user.
* Before Android P, this does nothing because the icon won't be used
*/
@WorkerThread
fun getUserIcon(path: String?): IconCompat? {
if (path == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
return null
}
return loadUserIcon(path)
}
@WorkerThread
private fun loadUserIcon(path: String): IconCompat? {
return try {
null
/* TODO Notification
val bitmap = Glide.with(context)
.asBitmap()
.load(path)
.transform(CircleCrop())
.format(DecodeFormat.PREFER_ARGB_8888)
.signature(ObjectKey("user-icon-notification"))
.submit()
.get()
IconCompat.createWithBitmap(bitmap)
*/
} catch (e: Exception) {
Timber.e(e, "decodeFile failed")
null
}
}
}

View file

@ -0,0 +1,247 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.RemoteInput
import io.element.android.libraries.analytics.api.AnalyticsTracker
import io.element.android.libraries.analytics.api.plan.JoinedRoom
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import io.element.android.libraries.ui.strings.R as StringR
/**
* Receives actions broadcast by notification (on click, on dismiss, inline replies, etc.).
*/
class NotificationBroadcastReceiver : BroadcastReceiver() {
@Inject lateinit var notificationDrawerManager: NotificationDrawerManager
//@Inject lateinit var activeSessionHolder: ActiveSessionHolder
@Inject lateinit var analyticsTracker: AnalyticsTracker
@Inject lateinit var clock: SystemClock
@Inject lateinit var actionIds: NotificationActionIds
override fun onReceive(context: Context?, intent: Intent?) {
if (intent == null || context == null) return
context.bindings<NotificationBroadcastReceiverBindings>().inject(this)
Timber.v("NotificationBroadcastReceiver received : $intent")
when (intent.action) {
actionIds.smartReply ->
handleSmartReply(intent, context)
actionIds.dismissRoom ->
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.updateEvents { it.clearMessagesForRoom(roomId) }
}
actionIds.dismissSummary ->
notificationDrawerManager.clearAllEvents()
actionIds.markRoomRead ->
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.updateEvents { it.clearMessagesForRoom(roomId) }
handleMarkAsRead(roomId)
}
actionIds.join -> {
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomId) }
handleJoinRoom(roomId)
}
}
actionIds.reject -> {
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomId) }
handleRejectRoom(roomId)
}
}
}
}
private fun handleJoinRoom(roomId: String) {
/*
activeSessionHolder.getSafeActiveSession()?.let { session ->
val room = session.getRoom(roomId)
if (room != null) {
session.coroutineScope.launch {
tryOrNull {
session.roomService().joinRoom(room.roomId)
analyticsTracker.capture(room.roomSummary().toAnalyticsJoinedRoom(JoinedRoom.Trigger.Notification))
}
}
}
}
*/
}
private fun handleRejectRoom(roomId: String) {
/*
activeSessionHolder.getSafeActiveSession()?.let { session ->
session.coroutineScope.launch {
tryOrNull { session.roomService().leaveRoom(roomId) }
}
}
*/
}
private fun handleMarkAsRead(roomId: String) {
/*
activeSessionHolder.getActiveSession().let { session ->
val room = session.getRoom(roomId)
if (room != null) {
session.coroutineScope.launch {
tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT, mainTimeLineOnly = false) }
}
}
}
*/
}
private fun handleSmartReply(intent: Intent, context: Context) {
val message = getReplyMessage(intent)
val roomId = intent.getStringExtra(KEY_ROOM_ID)
val threadId = intent.getStringExtra(KEY_THREAD_ID)
if (message.isNullOrBlank() || roomId.isNullOrBlank()) {
// ignore this event
// Can this happen? should we update notification?
return
}
/*
activeSessionHolder.getActiveSession().let { session ->
session.getRoom(roomId)?.let { room ->
sendMatrixEvent(message, threadId, session, room, context)
}
}
*/
}
/*
private fun sendMatrixEvent(message: String, threadId: String?, session: Session, room: Room, context: Context?) {
if (threadId != null) {
room.relationService().replyInThread(
rootThreadEventId = threadId,
replyInThreadText = message,
)
} else {
room.sendService().sendTextMessage(message)
}
// Create a new event to be displayed in the notification drawer, right now
val notifiableMessageEvent = NotifiableMessageEvent(
// Generate a Fake event id
eventId = UUID.randomUUID().toString(),
editedEventId = null,
noisy = false,
timestamp = clock.epochMillis(),
senderName = session.roomService().getRoomMember(session.myUserId, room.roomId)?.displayName
?: context?.getString(StringR.string.notification_sender_me),
senderId = session.myUserId,
body = message,
imageUriString = null,
roomId = room.roomId,
threadId = threadId,
roomName = room.roomSummary()?.displayName ?: room.roomId,
roomIsDirect = room.roomSummary()?.isDirect == true,
outGoingMessage = true,
canBeReplaced = false
)
notificationDrawerManager.updateEvents { it.onNotifiableEventReceived(notifiableMessageEvent) }
/*
// TODO Error cannot be managed the same way than in Riot
val event = Event(mxMessage, session.credentials.userId, roomId)
room.storeOutgoingEvent(event)
room.sendEvent(event, object : MatrixCallback<Void?> {
override fun onSuccess(info: Void?) {
Timber.v("Send message : onSuccess ")
}
override fun onNetworkError(e: Exception) {
Timber.e(e, "Send message : onNetworkError")
onSmartReplyFailed(e.localizedMessage)
}
override fun onMatrixError(e: MatrixError) {
Timber.v("Send message : onMatrixError " + e.message)
if (e is MXCryptoError) {
Toast.makeText(context, e.detailedErrorDescription, Toast.LENGTH_SHORT).show()
onSmartReplyFailed(e.detailedErrorDescription)
} else {
Toast.makeText(context, e.localizedMessage, Toast.LENGTH_SHORT).show()
onSmartReplyFailed(e.localizedMessage)
}
}
override fun onUnexpectedError(e: Exception) {
Timber.e(e, "Send message : onUnexpectedError " + e.message)
onSmartReplyFailed(e.message)
}
fun onSmartReplyFailed(reason: String?) {
val notifiableMessageEvent = NotifiableMessageEvent(
event.eventId,
false,
clock.epochMillis(),
session.myUser?.displayname
?: context?.getString(StringR.string.notification_sender_me),
session.myUserId,
message,
roomId,
room.getRoomDisplayName(context),
room.isDirect)
notifiableMessageEvent.outGoingMessage = true
notifiableMessageEvent.outGoingMessageFailed = true
VectorApp.getInstance().notificationDrawerManager.onNotifiableEventReceived(notifiableMessageEvent)
VectorApp.getInstance().notificationDrawerManager.refreshNotificationDrawer(null)
}
})
*/
}
*/
private fun getReplyMessage(intent: Intent?): String? {
if (intent != null) {
val remoteInput = RemoteInput.getResultsFromIntent(intent)
if (remoteInput != null) {
return remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString()
}
}
return null
}
companion object {
const val KEY_ROOM_ID = "roomID"
const val KEY_THREAD_ID = "threadID"
const val KEY_TEXT_REPLY = "key_text_reply"
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.libraries.di.AppScope
@ContributesTo(AppScope::class)
interface NotificationBroadcastReceiverBindings {
fun inject(receiver: NotificationBroadcastReceiver)
}

View file

@ -0,0 +1,48 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import io.element.android.libraries.di.ApplicationContext
import timber.log.Timber
import javax.inject.Inject
class NotificationDisplayer @Inject constructor(
@ApplicationContext context: Context,
) {
private val notificationManager = NotificationManagerCompat.from(context)
fun showNotificationMessage(tag: String?, id: Int, notification: Notification) {
notificationManager.notify(tag, id, notification)
}
fun cancelNotificationMessage(tag: String?, id: Int) {
notificationManager.cancel(tag, id)
}
fun cancelAllNotifications() {
// Keep this try catch (reported by GA)
try {
notificationManager.cancelAll()
} catch (e: Exception) {
Timber.e(e, "## cancelAllNotifications() failed")
}
}
}

View file

@ -0,0 +1,241 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import android.content.Context
import android.os.Handler
import android.os.HandlerThread
import androidx.annotation.WorkerThread
import io.element.android.libraries.androidutils.throttler.FirstThrottler
import io.element.android.libraries.core.cache.CircularCache
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.push.api.store.PushDataStore
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.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom
import timber.log.Timber
import javax.inject.Inject
/**
* The NotificationDrawerManager receives notification events as they arrived (from event stream or fcm) 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)
class NotificationDrawerManager @Inject constructor(
@ApplicationContext context: Context,
private val notificationDisplayer: NotificationDisplayer,
private val pushDataStore: PushDataStore,
// private val activeSessionDataSource: ActiveSessionDataSource,
private val notifiableEventProcessor: NotifiableEventProcessor,
private val notificationRenderer: NotificationRenderer,
private val notificationEventPersistence: NotificationEventPersistence,
private val filteredEventDetector: FilteredEventDetector,
private val buildMeta: BuildMeta,
) {
private val handlerThread: HandlerThread = HandlerThread("NotificationDrawerManager", Thread.MIN_PRIORITY)
private var backgroundHandler: Handler
// TODO Multi-session: this will have to be improved
/*
private val currentSession: Session?
get() = activeSessionDataSource.currentValue?.orNull()
*/
/**
* Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events.
*/
private val notificationState by lazy { createInitialNotificationState() }
private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size)
private var currentRoomId: String? = null
private var currentThreadId: String? = null
private val firstThrottler = FirstThrottler(200)
private var useCompleteNotificationFormat = pushDataStore.useCompleteNotificationFormat()
init {
handlerThread.start()
backgroundHandler = Handler(handlerThread.looper)
}
private fun createInitialNotificationState(): NotificationState {
val queuedEvents = notificationEventPersistence.loadEvents(factory = { rawEvents ->
NotificationEventQueue(rawEvents.toMutableList(), seenEventIds = CircularCache.create(cacheSize = 25))
})
val renderedEvents = queuedEvents.rawEvents().map { ProcessedEvent(ProcessedEvent.Type.KEEP, it) }.toMutableList()
return NotificationState(queuedEvents, renderedEvents)
}
/**
Should be called as soon as a new event is ready to be displayed.
The notification corresponding to this event will not be displayed until
#refreshNotificationDrawer() is called.
Events might be grouped and there might not be one notification per event!
*/
fun NotificationEventQueue.onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
if (!pushDataStore.areNotificationEnabledForDevice()) {
Timber.i("Notification are disabled for this device")
return
}
// If we support multi session, event list should be per userId
// Currently only manage single session
if (buildMeta.lowPrivacyLoggingEnabled) {
Timber.d("onNotifiableEventReceived(): $notifiableEvent")
} else {
Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}")
}
if (filteredEventDetector.shouldBeIgnored(notifiableEvent)) {
Timber.d("onNotifiableEventReceived(): ignore the event")
return
}
add(notifiableEvent)
}
/**
* Clear all known events and refresh the notification drawer.
*/
fun clearAllEvents() {
updateEvents { it.clear() }
}
/**
* 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.
*/
fun setCurrentRoom(roomId: String?) {
updateEvents {
val hasChanged = roomId != currentRoomId
currentRoomId = roomId
if (hasChanged && roomId != null) {
it.clearMessagesForRoom(roomId)
}
}
}
/**
* 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.
*/
fun setCurrentThread(threadId: String?) {
updateEvents {
val hasChanged = threadId != currentThreadId
currentThreadId = threadId
currentRoomId?.let { roomId ->
if (hasChanged && threadId != null) {
it.clearMessagesForThread(roomId, threadId)
}
}
}
}
fun notificationStyleChanged() {
updateEvents {
val newSettings = pushDataStore.useCompleteNotificationFormat()
if (newSettings != useCompleteNotificationFormat) {
// Settings has changed, remove all current notifications
notificationDisplayer.cancelAllNotifications()
useCompleteNotificationFormat = newSettings
}
}
}
fun updateEvents(action: NotificationDrawerManager.(NotificationEventQueue) -> Unit) {
notificationState.updateQueuedEvents(this) { queuedEvents, _ ->
action(queuedEvents)
}
refreshNotificationDrawer()
}
private fun refreshNotificationDrawer() {
// Implement last throttler
val canHandle = firstThrottler.canHandle()
Timber.v("refreshNotificationDrawer(), delay: ${canHandle.waitMillis()} ms")
backgroundHandler.removeCallbacksAndMessages(null)
backgroundHandler.postDelayed(
{
try {
refreshNotificationDrawerBg()
} catch (throwable: Throwable) {
// It can happen if for instance session has been destroyed. It's a bit ugly to try catch like this, but it's safer
Timber.w(throwable, "refreshNotificationDrawerBg failure")
}
},
canHandle.waitMillis()
)
}
@WorkerThread
private fun refreshNotificationDrawerBg() {
Timber.v("refreshNotificationDrawerBg()")
val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents ->
notifiableEventProcessor.process(queuedEvents.rawEvents(), currentRoomId, currentThreadId, renderedEvents).also {
queuedEvents.clearAndAdd(it.onlyKeptEvents())
}
}
if (notificationState.hasAlreadyRendered(eventsToRender)) {
Timber.d("Skipping notification update due to event list not changing")
} else {
notificationState.clearAndAddRenderedEvents(eventsToRender)
// TODO EAx
//val session = currentSession ?: return
//renderEvents(session, eventsToRender)
persistEvents()
}
}
private fun persistEvents() {
notificationState.queuedEvents { queuedEvents ->
notificationEventPersistence.persistEvents(queuedEvents)
}
}
private fun renderEvents(/*session: Session, eventsToRender: List<ProcessedEvent<NotifiableEvent>>*/) {
/* TODO EAx
val user = session.getUserOrDefault(session.myUserId)
// myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash
val myUserDisplayName = user.toMatrixItem().getBestName()
val myUserAvatarUrl = session.contentUrlResolver().resolveThumbnail(
contentUrl = user.avatarUrl,
width = avatarSize,
height = avatarSize,
method = ContentUrlResolver.ThumbnailMethod.SCALE
)
notificationRenderer.render(session.myUserId, myUserDisplayName, myUserAvatarUrl, useCompleteNotificationFormat, eventsToRender)
*/
}
fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean {
return resolvedEvent.shouldIgnoreMessageEventInRoom(currentRoomId, currentThreadId)
}
companion object {
const val SUMMARY_NOTIFICATION_ID = 0
const val ROOM_MESSAGES_NOTIFICATION_ID = 1
const val ROOM_EVENT_NOTIFICATION_ID = 2
const val ROOM_INVITATION_NOTIFICATION_ID = 3
}
}

View file

@ -0,0 +1,76 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import android.content.Context
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import javax.inject.Inject
// TODO Multi-account
private const val ROOMS_NOTIFICATIONS_FILE_NAME = "im.vector.notifications.cache"
private const val KEY_ALIAS_SECRET_STORAGE = "notificationMgr"
class NotificationEventPersistence @Inject constructor(
@ApplicationContext private val context: Context,
// private val matrix: Matrix,
) {
fun loadEvents(factory: (List<NotifiableEvent>) -> NotificationEventQueue): NotificationEventQueue {
try {
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (file.exists()) {
file.inputStream().use {
val events: ArrayList<NotifiableEvent>? = null // TODO EAx matrix.secureStorageService().loadSecureSecret(it, KEY_ALIAS_SECRET_STORAGE)
if (events != null) {
return factory(events)
}
}
}
} catch (e: Throwable) {
Timber.e(e, "## Failed to load cached notification info")
}
return factory(emptyList())
}
fun persistEvents(queuedEvents: NotificationEventQueue) {
if (queuedEvents.isEmpty()) {
deleteCachedRoomNotifications(context)
return
}
try {
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (!file.exists()) file.createNewFile()
FileOutputStream(file).use {
// TODO EAx
// matrix.secureStorageService().securelyStoreObject(queuedEvents.rawEvents(), KEY_ALIAS_SECRET_STORAGE, it)
}
} catch (e: Throwable) {
Timber.e(e, "## Failed to save cached notification info")
}
}
private fun deleteCachedRoomNotifications(context: Context) {
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (file.exists()) {
file.delete()
}
}
}

View file

@ -0,0 +1,152 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.core.cache.CircularCache
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.SimpleNotifiableEvent
import timber.log.Timber
data class NotificationEventQueue(
private val queue: MutableList<NotifiableEvent>,
/**
* An in memory FIFO cache of the seen events.
* Acts as a notification debouncer to stop already dismissed push notifications from
* displaying again when the /sync response is delayed.
*/
private val seenEventIds: CircularCache<String>
) {
fun markRedacted(eventIds: List<String>) {
eventIds.forEach { redactedId ->
queue.replace(redactedId) {
when (it) {
is InviteNotifiableEvent -> it.copy(isRedacted = true)
is NotifiableMessageEvent -> it.copy(isRedacted = true)
is SimpleNotifiableEvent -> it.copy(isRedacted = true)
}
}
}
}
fun syncRoomEvents(roomsLeft: Collection<String>, roomsJoined: Collection<String>) {
if (roomsLeft.isNotEmpty() || roomsJoined.isNotEmpty()) {
queue.removeAll {
when (it) {
is NotifiableMessageEvent -> roomsLeft.contains(it.roomId)
is InviteNotifiableEvent -> roomsLeft.contains(it.roomId) || roomsJoined.contains(it.roomId)
else -> false
}
}
}
}
fun isEmpty() = queue.isEmpty()
fun clearAndAdd(events: List<NotifiableEvent>) {
queue.clear()
queue.addAll(events)
}
fun clear() {
queue.clear()
}
fun add(notifiableEvent: NotifiableEvent) {
val existing = findExistingById(notifiableEvent)
val edited = findEdited(notifiableEvent)
when {
existing != null -> {
if (existing.canBeReplaced) {
// Use the event coming from the event stream as it may contains more info than
// the fcm one (like type/content/clear text) (e.g when an encrypted message from
// FCM should be update with clear text after a sync)
// In this case the message has already been notified, and might have done some noise
// So we want the notification to be updated even if it has already been displayed
// Use setOnlyAlertOnce to ensure update notification does not interfere with sound
// from first notify invocation as outlined in:
// https://developer.android.com/training/notify-user/build-notification#Updating
replace(replace = existing, with = notifiableEvent)
} else {
// keep the existing one, do not replace
}
}
edited != null -> {
// Replace the existing notification with the new content
replace(replace = edited, with = notifiableEvent)
}
seenEventIds.contains(notifiableEvent.eventId) -> {
// we've already seen the event, lets skip
Timber.d("onNotifiableEventReceived(): skipping event, already seen")
}
else -> {
seenEventIds.put(notifiableEvent.eventId)
queue.add(notifiableEvent)
}
}
}
private fun findExistingById(notifiableEvent: NotifiableEvent): NotifiableEvent? {
return queue.firstOrNull { it.eventId == notifiableEvent.eventId }
}
private fun findEdited(notifiableEvent: NotifiableEvent): NotifiableEvent? {
return notifiableEvent.editedEventId?.let { editedId ->
queue.firstOrNull {
it.eventId == editedId || it.editedEventId == editedId
}
}
}
private fun replace(replace: NotifiableEvent, with: NotifiableEvent) {
queue.remove(replace)
queue.add(
when (with) {
is InviteNotifiableEvent -> with.copy(isUpdated = true)
is NotifiableMessageEvent -> with.copy(isUpdated = true)
is SimpleNotifiableEvent -> with.copy(isUpdated = true)
}
)
}
fun clearMemberShipNotificationForRoom(roomId: String) {
Timber.d("clearMemberShipOfRoom $roomId")
queue.removeAll { it is InviteNotifiableEvent && it.roomId == roomId }
}
fun clearMessagesForRoom(roomId: String) {
Timber.d("clearMessageEventOfRoom $roomId")
queue.removeAll { it is NotifiableMessageEvent && it.roomId == roomId }
}
fun clearMessagesForThread(roomId: String, threadId: String) {
Timber.d("clearMessageEventOfThread $roomId, $threadId")
queue.removeAll { it is NotifiableMessageEvent && it.roomId == roomId && it.threadId == threadId }
}
fun rawEvents(): List<NotifiableEvent> = queue
}
private fun MutableList<NotifiableEvent>.replace(eventId: String, block: (NotifiableEvent) -> NotifiableEvent) {
val indexToReplace = indexOfFirst { it.eventId == eventId }
if (indexToReplace == -1) {
return
}
set(indexToReplace, block(get(indexToReplace)))
}

View file

@ -0,0 +1,138 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import android.app.Notification
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 javax.inject.Inject
private typealias ProcessedMessageEvents = List<ProcessedEvent<NotifiableMessageEvent>>
class NotificationFactory @Inject constructor(
private val notificationUtils: NotificationUtils,
private val roomGroupMessageCreator: RoomGroupMessageCreator,
private val summaryGroupMessageCreator: SummaryGroupMessageCreator
) {
fun Map<String, ProcessedMessageEvents>.toNotifications(myUserDisplayName: String, myUserAvatarUrl: String?): List<RoomNotification> {
return map { (roomId, events) ->
when {
events.hasNoEventsToDisplay() -> RoomNotification.Removed(roomId)
else -> {
val messageEvents = events.onlyKeptEvents().filterNot { it.isRedacted }
roomGroupMessageCreator.createRoomMessage(messageEvents, roomId, myUserDisplayName, myUserAvatarUrl)
}
}
}
}
private fun ProcessedMessageEvents.hasNoEventsToDisplay() = isEmpty() || all {
it.type == ProcessedEvent.Type.REMOVE || it.event.canNotBeDisplayed()
}
private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted
@JvmName("toNotificationsInviteNotifiableEvent")
fun List<ProcessedEvent<InviteNotifiableEvent>>.toNotifications(myUserId: String): List<OneShotNotification> {
return map { (processed, event) ->
when (processed) {
ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.roomId)
ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
notificationUtils.buildRoomInvitationNotification(event, myUserId),
OneShotNotification.Append.Meta(
key = event.roomId,
summaryLine = event.description,
isNoisy = event.noisy,
timestamp = event.timestamp
)
)
}
}
}
@JvmName("toNotificationsSimpleNotifiableEvent")
fun List<ProcessedEvent<SimpleNotifiableEvent>>.toNotifications(myUserId: String): List<OneShotNotification> {
return map { (processed, event) ->
when (processed) {
ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId)
ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
notificationUtils.buildSimpleEventNotification(event, myUserId),
OneShotNotification.Append.Meta(
key = event.eventId,
summaryLine = event.description,
isNoisy = event.noisy,
timestamp = event.timestamp
)
)
}
}
}
fun createSummaryNotification(
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean
): SummaryNotification {
val roomMeta = roomNotifications.filterIsInstance<RoomNotification.Message>().map { it.meta }
val invitationMeta = invitationNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
val simpleMeta = simpleNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
return when {
roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed
else -> SummaryNotification.Update(
summaryGroupMessageCreator.createSummaryNotification(
roomNotifications = roomMeta,
invitationNotifications = invitationMeta,
simpleNotifications = simpleMeta,
useCompleteNotificationFormat = useCompleteNotificationFormat
)
)
}
}
}
sealed interface RoomNotification {
data class Removed(val roomId: String) : RoomNotification
data class Message(val notification: Notification, val meta: Meta) : RoomNotification {
data class Meta(
val summaryLine: CharSequence,
val messageCount: Int,
val latestTimestamp: Long,
val roomId: String,
val shouldBing: Boolean
)
}
}
sealed interface OneShotNotification {
data class Removed(val key: String) : OneShotNotification
data class Append(val notification: Notification, val meta: Meta) : OneShotNotification {
data class Meta(
val key: String,
val summaryLine: CharSequence,
val isNoisy: Boolean,
val timestamp: Long,
)
}
}
sealed interface SummaryNotification {
object Removed : SummaryNotification
data class Update(val notification: Notification) : SummaryNotification
}

View file

@ -0,0 +1,133 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import androidx.annotation.WorkerThread
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager.Companion.ROOM_EVENT_NOTIFICATION_ID
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager.Companion.ROOM_INVITATION_NOTIFICATION_ID
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager.Companion.ROOM_MESSAGES_NOTIFICATION_ID
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager.Companion.SUMMARY_NOTIFICATION_ID
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.SimpleNotifiableEvent
import timber.log.Timber
import javax.inject.Inject
class NotificationRenderer @Inject constructor(
private val notificationDisplayer: NotificationDisplayer,
private val notificationFactory: NotificationFactory,
) {
@WorkerThread
fun render(
myUserId: String,
myUserDisplayName: String,
myUserAvatarUrl: String?,
useCompleteNotificationFormat: Boolean,
eventsToProcess: List<ProcessedEvent<NotifiableEvent>>
) {
val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType()
with(notificationFactory) {
val roomNotifications = roomEvents.toNotifications(myUserDisplayName, myUserAvatarUrl)
val invitationNotifications = invitationEvents.toNotifications(myUserId)
val simpleNotifications = simpleEvents.toNotifications(myUserId)
val summaryNotification = createSummaryNotification(
roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications,
useCompleteNotificationFormat = useCompleteNotificationFormat
)
// Remove summary first to avoid briefly displaying it after dismissing the last notification
if (summaryNotification == SummaryNotification.Removed) {
Timber.d("Removing summary notification")
notificationDisplayer.cancelNotificationMessage(null, SUMMARY_NOTIFICATION_ID)
}
roomNotifications.forEach { wrapper ->
when (wrapper) {
is RoomNotification.Removed -> {
Timber.d("Removing room messages notification ${wrapper.roomId}")
notificationDisplayer.cancelNotificationMessage(wrapper.roomId, ROOM_MESSAGES_NOTIFICATION_ID)
}
is RoomNotification.Message -> if (useCompleteNotificationFormat) {
Timber.d("Updating room messages notification ${wrapper.meta.roomId}")
notificationDisplayer.showNotificationMessage(wrapper.meta.roomId, ROOM_MESSAGES_NOTIFICATION_ID, wrapper.notification)
}
}
}
invitationNotifications.forEach { wrapper ->
when (wrapper) {
is OneShotNotification.Removed -> {
Timber.d("Removing invitation notification ${wrapper.key}")
notificationDisplayer.cancelNotificationMessage(wrapper.key, ROOM_INVITATION_NOTIFICATION_ID)
}
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
Timber.d("Updating invitation notification ${wrapper.meta.key}")
notificationDisplayer.showNotificationMessage(wrapper.meta.key, ROOM_INVITATION_NOTIFICATION_ID, wrapper.notification)
}
}
}
simpleNotifications.forEach { wrapper ->
when (wrapper) {
is OneShotNotification.Removed -> {
Timber.d("Removing simple notification ${wrapper.key}")
notificationDisplayer.cancelNotificationMessage(wrapper.key, ROOM_EVENT_NOTIFICATION_ID)
}
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
Timber.d("Updating simple notification ${wrapper.meta.key}")
notificationDisplayer.showNotificationMessage(wrapper.meta.key, ROOM_EVENT_NOTIFICATION_ID, wrapper.notification)
}
}
}
// Update summary last to avoid briefly displaying it before other notifications
if (summaryNotification is SummaryNotification.Update) {
Timber.d("Updating summary notification")
notificationDisplayer.showNotificationMessage(null, SUMMARY_NOTIFICATION_ID, summaryNotification.notification)
}
}
}
}
private fun List<ProcessedEvent<NotifiableEvent>>.groupByType(): GroupedNotificationEvents {
val roomIdToEventMap: MutableMap<String, MutableList<ProcessedEvent<NotifiableMessageEvent>>> = LinkedHashMap()
val simpleEvents: MutableList<ProcessedEvent<SimpleNotifiableEvent>> = ArrayList()
val invitationEvents: MutableList<ProcessedEvent<InviteNotifiableEvent>> = ArrayList()
forEach {
when (val event = it.event) {
is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType())
is NotifiableMessageEvent -> {
val roomEvents = roomIdToEventMap.getOrPut(event.roomId) { ArrayList() }
roomEvents.add(it.castedToEventType())
}
is SimpleNotifiableEvent -> simpleEvents.add(it.castedToEventType())
}
}
return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents)
}
@Suppress("UNCHECKED_CAST")
private fun <T : NotifiableEvent> ProcessedEvent<NotifiableEvent>.castedToEventType(): ProcessedEvent<T> = this as ProcessedEvent<T>
data class GroupedNotificationEvents(
val roomEvents: Map<String, List<ProcessedEvent<NotifiableMessageEvent>>>,
val simpleEvents: List<ProcessedEvent<SimpleNotifiableEvent>>,
val invitationEvents: List<ProcessedEvent<InviteNotifiableEvent>>
)

View file

@ -0,0 +1,62 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
class NotificationState(
/**
* The notifiable events queued for rendering or currently rendered.
*
* This is our source of truth for notifications, any changes to this list will be rendered as notifications.
* When events are removed the previously rendered notifications will be cancelled.
* When adding or updating, the notifications will be notified.
*
* Events are unique by their properties, we should be careful not to insert multiple events with the same event-id.
*/
private val queuedEvents: NotificationEventQueue,
/**
* The last known rendered notifiable events.
* We keep track of them in order to know which events have been removed from the eventList
* allowing us to cancel any notifications previous displayed by now removed events
*/
private val renderedEvents: MutableList<ProcessedEvent<NotifiableEvent>>,
) {
fun <T> updateQueuedEvents(
drawerManager: NotificationDrawerManager,
action: NotificationDrawerManager.(NotificationEventQueue, List<ProcessedEvent<NotifiableEvent>>) -> T
): T {
return synchronized(queuedEvents) {
action(drawerManager, queuedEvents, renderedEvents)
}
}
fun clearAndAddRenderedEvents(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) {
renderedEvents.clear()
renderedEvents.addAll(eventsToRender)
}
fun hasAlreadyRendered(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) = renderedEvents == eventsToRender
fun queuedEvents(block: (NotificationEventQueue) -> Unit) {
synchronized(queuedEvents) {
block(queuedEvents)
}
}
}

View file

@ -0,0 +1,744 @@
/*
* Copyright 2018 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("UNUSED_PARAMETER")
package io.element.android.libraries.push.impl.notifications
import android.annotation.SuppressLint
import android.app.Activity
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import androidx.core.content.getSystemService
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
import androidx.annotation.DrawableRes
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import io.element.android.libraries.androidutils.intent.PendingIntentCompat
import io.element.android.libraries.androidutils.system.startNotificationChannelSettingsIntent
import io.element.android.libraries.androidutils.uri.createIgnoredUri
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.toolbox.api.strings.StringProvider
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.libraries.toolbox.api.systemclock.SystemClock
import timber.log.Timber
import javax.inject.Inject
import io.element.android.libraries.ui.strings.R as StringR
// TODO EAx Split into factories
@SingleIn(AppScope::class)
class NotificationUtils @Inject constructor(
@ApplicationContext private val context: Context,
// private val vectorPreferences: VectorPreferences,
private val stringProvider: StringProvider,
private val clock: SystemClock,
private val actionIds: NotificationActionIds,
private val buildMeta: BuildMeta,
) {
companion object {
/* ==========================================================================================
* IDs for notifications
* ========================================================================================== */
/**
* Identifier of the foreground notification used to keep the application alive
* when it runs in background.
* This notification, which is not removable by the end user, displays what
* the application is doing while in background.
*/
const val NOTIFICATION_ID_FOREGROUND_SERVICE = 61
/* ==========================================================================================
* IDs for channels
* ========================================================================================== */
// on devices >= android O, we need to define a channel for each notifications
private const val LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID = "LISTEN_FOR_EVENTS_NOTIFICATION_CHANNEL_ID"
private const val NOISY_NOTIFICATION_CHANNEL_ID = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID"
const val SILENT_NOTIFICATION_CHANNEL_ID = "DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID_V2"
private const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_NOTIFICATION_CHANNEL_ID_V2"
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
fun supportNotificationChannels() = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
fun openSystemSettingsForSilentCategory(activity: Activity) {
startNotificationChannelSettingsIntent(activity, SILENT_NOTIFICATION_CHANNEL_ID)
}
fun openSystemSettingsForNoisyCategory(activity: Activity) {
startNotificationChannelSettingsIntent(activity, NOISY_NOTIFICATION_CHANNEL_ID)
}
fun openSystemSettingsForCallCategory(activity: Activity) {
startNotificationChannelSettingsIntent(activity, CALL_NOTIFICATION_CHANNEL_ID)
}
}
private val notificationManager = NotificationManagerCompat.from(context)
/* ==========================================================================================
* Channel names
* ========================================================================================== */
/**
* Create notification channels.
*/
fun createNotificationChannels() {
if (!supportNotificationChannels()) {
return
}
val accentColor = ContextCompat.getColor(context, R.color.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", "CALL_NOTIFICATION_CHANNEL_ID")) {
notificationManager.getNotificationChannel(channelId)?.let {
notificationManager.deleteNotificationChannel(channelId)
}
}
/**
* Default notification importance: shows everywhere, makes noise, but does not visually
* intrude.
*/
notificationManager.createNotificationChannel(NotificationChannel(
NOISY_NOTIFICATION_CHANNEL_ID,
stringProvider.getString(StringR.string.notification_noisy_notifications).ifEmpty { "Noisy notifications" },
NotificationManager.IMPORTANCE_DEFAULT
)
.apply {
description = stringProvider.getString(StringR.string.notification_noisy_notifications)
enableVibration(true)
enableLights(true)
lightColor = accentColor
})
/**
* Low notification importance: shows everywhere, but is not intrusive.
*/
notificationManager.createNotificationChannel(NotificationChannel(
SILENT_NOTIFICATION_CHANNEL_ID,
stringProvider.getString(StringR.string.notification_silent_notifications).ifEmpty { "Silent notifications" },
NotificationManager.IMPORTANCE_LOW
)
.apply {
description = stringProvider.getString(StringR.string.notification_silent_notifications)
setSound(null, null)
enableLights(true)
lightColor = accentColor
})
notificationManager.createNotificationChannel(NotificationChannel(
LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID,
stringProvider.getString(StringR.string.notification_listening_for_events).ifEmpty { "Listening for events" },
NotificationManager.IMPORTANCE_MIN
)
.apply {
description = stringProvider.getString(StringR.string.notification_listening_for_events)
setSound(null, null)
setShowBadge(false)
})
notificationManager.createNotificationChannel(NotificationChannel(
CALL_NOTIFICATION_CHANNEL_ID,
stringProvider.getString(StringR.string.call).ifEmpty { "Call" },
NotificationManager.IMPORTANCE_HIGH
)
.apply {
description = stringProvider.getString(StringR.string.call)
setSound(null, null)
enableLights(true)
lightColor = accentColor
})
}
fun getChannel(channelId: String): NotificationChannel? {
return notificationManager.getNotificationChannel(channelId)
}
fun getChannelForIncomingCall(fromBg: Boolean): NotificationChannel? {
val notificationChannel = if (fromBg) CALL_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
return getChannel(notificationChannel)
}
/**
* Build a notification for a Room.
*/
fun buildMessagesListNotification(
messageStyle: NotificationCompat.MessagingStyle,
roomInfo: RoomEventGroupInfo,
threadId: String?,
largeIcon: Bitmap?,
lastMessageTimestamp: Long,
senderDisplayNameForReplyCompat: String?,
tickerText: String
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
// Build the pending intent for when the notification is clicked
val openIntent = when {
threadId != null &&
true /** TODO EAx vectorPreferences.areThreadMessagesEnabled() */
-> buildOpenThreadIntent(roomInfo, threadId)
else -> buildOpenRoomIntent(roomInfo.roomId)
}
val smallIcon = R.drawable.ic_notification
val channelID = if (roomInfo.shouldBing) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
return NotificationCompat.Builder(context, channelID)
.setOnlyAlertOnce(roomInfo.isUpdated)
.setWhen(lastMessageTimestamp)
// MESSAGING_STYLE sets title and content for API 16 and above devices.
.setStyle(messageStyle)
// 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)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
// ID of the corresponding shortcut, for conversation features under API 30+
.setShortcutId(roomInfo.roomId)
// Title for API < 16 devices.
.setContentTitle(roomInfo.roomDisplayName)
// Content for API < 16 devices.
.setContentText(stringProvider.getString(StringR.string.notification_new_messages))
// Number of new notifications for API <24 (M and below) devices.
.setSubText(
stringProvider.getQuantityString(
StringR.plurals.room_new_messages_notification,
messageStyle.messages.size,
messageStyle.messages.size
)
)
// Auto-bundling is enabled for 4 or more notifications on API 24+ (N+)
// devices and all Wear devices. But we want a custom grouping, so we specify the groupID
// TODO Group should be current user display name
.setGroup(buildMeta.applicationName)
// In order to avoid notification making sound twice (due to the summary notification)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
// Set primary color (important for Wear 2.0 Notifications).
.setColor(accentColor)
// 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.
.apply {
if (roomInfo.shouldBing) {
// Compat
priority = NotificationCompat.PRIORITY_DEFAULT
/*
vectorPreferences.getNotificationRingTone()?.let {
setSound(it)
}
*/
setLights(accentColor, 500, 500)
} else {
priority = NotificationCompat.PRIORITY_LOW
}
// Add actions and notification intents
// Mark room as read
val markRoomReadIntent = Intent(context, NotificationBroadcastReceiver::class.java)
markRoomReadIntent.action = actionIds.markRoomRead
markRoomReadIntent.data = createIgnoredUri(roomInfo.roomId)
markRoomReadIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId)
val markRoomReadPendingIntent = PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
markRoomReadIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
NotificationCompat.Action.Builder(
R.drawable.ic_material_done_all_white,
stringProvider.getString(StringR.string.action_mark_room_read), markRoomReadPendingIntent
)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
.setShowsUserInterface(false)
.build()
.let { addAction(it) }
// Quick reply
if (!roomInfo.hasSmartReplyError) {
buildQuickReplyIntent(roomInfo.roomId, threadId, senderDisplayNameForReplyCompat)?.let { replyPendingIntent ->
val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY)
.setLabel(stringProvider.getString(StringR.string.action_quick_reply))
.build()
NotificationCompat.Action.Builder(
R.drawable.vector_notification_quick_reply,
stringProvider.getString(StringR.string.action_quick_reply), replyPendingIntent
)
.addRemoteInput(remoteInput)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(false)
.build()
.let { addAction(it) }
}
}
if (openIntent != null) {
setContentIntent(openIntent)
}
if (largeIcon != null) {
setLargeIcon(largeIcon)
}
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId)
intent.action = actionIds.dismissRoom
val pendingIntent = PendingIntent.getBroadcast(
context.applicationContext,
clock.epochMillis().toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
setDeleteIntent(pendingIntent)
}
.setTicker(tickerText)
.build()
}
fun buildRoomInvitationNotification(
inviteNotifiableEvent: InviteNotifiableEvent,
matrixId: String
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
// Build the pending intent for when the notification is clicked
val smallIcon = R.drawable.ic_notification
val channelID = if (inviteNotifiableEvent.noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
return NotificationCompat.Builder(context, channelID)
.setOnlyAlertOnce(true)
.setContentTitle(inviteNotifiableEvent.roomName ?: buildMeta.applicationName)
.setContentText(inviteNotifiableEvent.description)
.setGroup(buildMeta.applicationName)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
.setColor(accentColor)
.apply {
val roomId = inviteNotifiableEvent.roomId
// offer to type a quick reject button
val rejectIntent = Intent(context, NotificationBroadcastReceiver::class.java)
rejectIntent.action = actionIds.reject
rejectIntent.data = createIgnoredUri("$roomId&$matrixId")
rejectIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
val rejectIntentPendingIntent = PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
rejectIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
addAction(
R.drawable.vector_notification_reject_invitation,
stringProvider.getString(StringR.string.action_reject),
rejectIntentPendingIntent
)
// offer to type a quick accept button
val joinIntent = Intent(context, NotificationBroadcastReceiver::class.java)
joinIntent.action = actionIds.join
joinIntent.data = createIgnoredUri("$roomId&$matrixId")
joinIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
val joinIntentPendingIntent = PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
joinIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
addAction(
R.drawable.vector_notification_accept_invitation,
stringProvider.getString(StringR.string.action_join),
joinIntentPendingIntent
)
/*
val contentIntent = HomeActivity.newIntent(
context,
firstStartMainActivity = true,
inviteNotificationRoomId = inviteNotifiableEvent.roomId
)
contentIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
contentIntent.data = createIgnoredUri(inviteNotifiableEvent.eventId)
setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE))
*/
if (inviteNotifiableEvent.noisy) {
// Compat
priority = NotificationCompat.PRIORITY_DEFAULT
/*
vectorPreferences.getNotificationRingTone()?.let {
setSound(it)
}
*/
setLights(accentColor, 500, 500)
} else {
priority = NotificationCompat.PRIORITY_LOW
}
setAutoCancel(true)
}
.build()
}
fun buildSimpleEventNotification(
simpleNotifiableEvent: SimpleNotifiableEvent,
matrixId: String
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
// Build the pending intent for when the notification is clicked
val smallIcon = R.drawable.ic_notification
val channelID = if (simpleNotifiableEvent.noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
return NotificationCompat.Builder(context, channelID)
.setOnlyAlertOnce(true)
.setContentTitle(buildMeta.applicationName)
.setContentText(simpleNotifiableEvent.description)
.setGroup(buildMeta.applicationName)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
.setColor(accentColor)
.setAutoCancel(true)
.apply {
/* TODO EAx
val contentIntent = HomeActivity.newIntent(context, firstStartMainActivity = true)
contentIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
contentIntent.data = createIgnoredUri(simpleNotifiableEvent.eventId)
setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE))
*/
if (simpleNotifiableEvent.noisy) {
// Compat
priority = NotificationCompat.PRIORITY_DEFAULT
/*
vectorPreferences.getNotificationRingTone()?.let {
setSound(it)
}
*/
setLights(accentColor, 500, 500)
} else {
priority = NotificationCompat.PRIORITY_LOW
}
setAutoCancel(true)
}
.build()
}
private fun buildOpenRoomIntent(roomId: String): PendingIntent? {
return null
/*
val roomIntentTap = RoomDetailActivity.newIntent(context, TimelineArgs(roomId = roomId, switchToParentSpace = true), true)
roomIntentTap.action = actionIds.tapToView
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
roomIntentTap.data = createIgnoredUri("openRoom?$roomId")
// Recreate the back stack
return TaskStackBuilder.create(context)
.addNextIntentWithParentStack(HomeActivity.newIntent(context, firstStartMainActivity = false))
.addNextIntent(roomIntentTap)
.getPendingIntent(
clock.epochMillis().toInt(),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
*/
}
private fun buildOpenThreadIntent(roomInfo: RoomEventGroupInfo, threadId: String?): PendingIntent? {
return null
/*
val threadTimelineArgs = ThreadTimelineArgs(
startsThread = false,
roomId = roomInfo.roomId,
rootThreadEventId = threadId,
showKeyboard = false,
displayName = roomInfo.roomDisplayName,
avatarUrl = null,
roomEncryptionTrustLevel = null,
)
val threadIntentTap = ThreadsActivity.newIntent(
context = context,
threadTimelineArgs = threadTimelineArgs,
threadListArgs = null,
firstStartMainActivity = true,
)
threadIntentTap.action = actionIds.tapToView
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
threadIntentTap.data = createIgnoredUri("openThread?$threadId")
val roomIntent = RoomDetailActivity.newIntent(
context = context,
timelineArgs = TimelineArgs(
roomId = roomInfo.roomId,
switchToParentSpace = true
),
firstStartMainActivity = false
)
// Recreate the back stack
return TaskStackBuilder.create(context)
.addNextIntentWithParentStack(HomeActivity.newIntent(context, firstStartMainActivity = false))
.addNextIntentWithParentStack(roomIntent)
.addNextIntent(threadIntentTap)
.getPendingIntent(
clock.epochMillis().toInt(),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
*/
}
private fun buildOpenHomePendingIntentForSummary(): PendingIntent {
TODO()
/*
val intent = HomeActivity.newIntent(context, firstStartMainActivity = false, clearNotification = true)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
intent.data = createIgnoredUri("tapSummary")
val mainIntent = MainActivity.getIntentWithNextIntent(context, intent)
return PendingIntent.getActivity(
context,
Random.nextInt(1000),
mainIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
*/
}
/*
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(roomId: String, threadId: String?, senderName: String?): PendingIntent? {
val intent: Intent
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.smartReply
intent.data = createIgnoredUri(roomId)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
threadId?.let {
intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it)
}
return PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
intent,
// PendingIntents attached to actions with remote inputs must be mutable
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_MUTABLE
)
} else {
/*
TODO
if (!LockScreenActivity.isDisplayingALockScreenActivity()) {
// start your activity for Android M and below
val quickReplyIntent = Intent(context, LockScreenActivity::class.java)
quickReplyIntent.putExtra(LockScreenActivity.EXTRA_ROOM_ID, roomId)
quickReplyIntent.putExtra(LockScreenActivity.EXTRA_SENDER_NAME, senderName ?: "")
// the action must be unique else the parameters are ignored
quickReplyIntent.action = QUICK_LAUNCH_ACTION
quickReplyIntent.data = createIgnoredUri($roomId")
return PendingIntent.getActivity(context, 0, quickReplyIntent, PendingIntentCompat.FLAG_IMMUTABLE)
}
*/
}
return null
}
// // Number of new notifications for API <24 (M and below) devices.
/**
* Build the summary notification.
*/
fun buildSummaryListNotification(
style: NotificationCompat.InboxStyle?,
compatSummary: String,
noisy: Boolean,
lastMessageTimestamp: Long
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
val smallIcon = R.drawable.ic_notification
return NotificationCompat.Builder(context, if (noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID)
.setOnlyAlertOnce(true)
// used in compat < N, after summary is built based on child notifications
.setWhen(lastMessageTimestamp)
.setStyle(style)
.setContentTitle(buildMeta.applicationName)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setSmallIcon(smallIcon)
// set content text to support devices running API level < 24
.setContentText(compatSummary)
.setGroup(buildMeta.applicationName)
// set this notification as the summary for the group
.setGroupSummary(true)
.setColor(accentColor)
.apply {
if (noisy) {
// Compat
priority = NotificationCompat.PRIORITY_DEFAULT
/*
vectorPreferences.getNotificationRingTone()?.let {
setSound(it)
}
*/
setLights(accentColor, 500, 500)
} else {
// compat
priority = NotificationCompat.PRIORITY_LOW
}
}
.setContentIntent(buildOpenHomePendingIntentForSummary())
.setDeleteIntent(getDismissSummaryPendingIntent())
.build()
}
private fun getDismissSummaryPendingIntent(): PendingIntent {
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.dismissSummary
intent.data = createIgnoredUri("deleteSummary")
return PendingIntent.getBroadcast(
context.applicationContext,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
}
fun showNotificationMessage(tag: String?, id: Int, notification: Notification) {
notificationManager.notify(tag, id, notification)
}
fun cancelNotificationMessage(tag: String?, id: Int) {
notificationManager.cancel(tag, id)
}
/**
* Cancel the foreground notification service.
*/
fun cancelNotificationForegroundService() {
notificationManager.cancel(NOTIFICATION_ID_FOREGROUND_SERVICE)
}
/**
* Cancel all the notification.
*/
fun cancelAllNotifications() {
// Keep this try catch (reported by GA)
try {
notificationManager.cancelAll()
} catch (e: Exception) {
Timber.e(e, "## cancelAllNotifications() failed")
}
}
@SuppressLint("LaunchActivityFromNotification")
fun displayDiagnosticNotification() {
val testActionIntent = Intent(context, TestNotificationReceiver::class.java)
testActionIntent.action = actionIds.diagnostic
val testPendingIntent = PendingIntent.getBroadcast(
context,
0,
testActionIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
notificationManager.notify(
"DIAGNOSTIC",
888,
NotificationCompat.Builder(context, NOISY_NOTIFICATION_CHANNEL_ID)
.setContentTitle(buildMeta.applicationName)
.setContentText(stringProvider.getString(StringR.string.settings_troubleshoot_test_push_notification_content))
.setSmallIcon(R.drawable.ic_notification)
.setLargeIcon(getBitmap(context, R.drawable.element_logo_green))
.setColor(ContextCompat.getColor(context, R.color.notification_accent_color))
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setAutoCancel(true)
.setContentIntent(testPendingIntent)
.build()
)
}
private fun getBitmap(context: Context, @DrawableRes drawableRes: Int): Bitmap? {
val drawable = ResourcesCompat.getDrawable(context.resources, drawableRes, null) ?: return null
val canvas = Canvas()
val bitmap = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888)
canvas.setBitmap(bitmap)
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
drawable.draw(canvas)
return bitmap
}
/**
* Return true it the user has enabled the do not disturb mode.
*/
fun isDoNotDisturbModeOn(): Boolean {
// We cannot use NotificationManagerCompat here.
val setting = context.getSystemService<NotificationManager>()!!.currentInterruptionFilter
return setting == NotificationManager.INTERRUPTION_FILTER_NONE ||
setting == NotificationManager.INTERRUPTION_FILTER_ALARMS
}
/*
private fun getActionText(@StringRes stringRes: Int, @AttrRes colorRes: Int): Spannable {
return SpannableString(context.getText(stringRes)).apply {
val foregroundColorSpan = ForegroundColorSpan(ThemeUtils.getColor(context, colorRes))
setSpan(foregroundColorSpan, 0, length, 0)
}
}
*/
private fun ensureTitleNotEmpty(title: String?): CharSequence {
if (title.isNullOrBlank()) {
return buildMeta.applicationName
}
return title
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import javax.inject.Inject
class OutdatedEventDetector @Inject constructor(
/// private val activeSessionDataSource: ActiveSessionDataSource
) {
/**
* Returns true if the given event is outdated.
* Used to clean up notifications if a displayed message has been read on an
* other device.
*/
fun isMessageOutdated(notifiableEvent: NotifiableEvent): Boolean {
/* TODO EAx
val session = activeSessionDataSource.currentValue?.orNull() ?: return false
if (notifiableEvent is NotifiableMessageEvent) {
val eventID = notifiableEvent.eventId
val roomID = notifiableEvent.roomId
val room = session.getRoom(roomID) ?: return false
return room.readService().isEventRead(eventID)
}
*/
return false
}
}

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
data class ProcessedEvent<T>(
val type: Type,
val event: T
) {
enum class Type {
KEEP,
REMOVE
}
}
fun <T> List<ProcessedEvent<T>>.onlyKeptEvents() = mapNotNull { processedEvent ->
processedEvent.event.takeIf { processedEvent.type == ProcessedEvent.Type.KEEP }
}

View file

@ -0,0 +1,35 @@
/*
* Copyright 2018 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
/**
* Data class to hold information about a group of notifications for a room.
*/
data class RoomEventGroupInfo(
val roomId: String,
val roomDisplayName: String = "",
val isDirect: Boolean = false
) {
// An event in the list has not yet been display
var hasNewEvent: Boolean = false
// true if at least one on the not yet displayed event is noisy
var shouldBing: Boolean = false
var customSound: String? = null
var hasSmartReplyError: Boolean = false
var isUpdated: Boolean = false
}

View file

@ -0,0 +1,166 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import android.graphics.Bitmap
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import io.element.android.libraries.toolbox.api.strings.StringProvider
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import me.gujun.android.span.Span
import me.gujun.android.span.span
import timber.log.Timber
import javax.inject.Inject
import io.element.android.libraries.ui.strings.R as StringR
class RoomGroupMessageCreator @Inject constructor(
private val bitmapLoader: NotificationBitmapLoader,
private val stringProvider: StringProvider,
private val notificationUtils: NotificationUtils
) {
fun createRoomMessage(events: List<NotifiableMessageEvent>, roomId: String, userDisplayName: String, userAvatarUrl: String?): RoomNotification.Message {
val lastKnownRoomEvent = events.last()
val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderName ?: ""
val roomIsGroup = !lastKnownRoomEvent.roomIsDirect
val style = NotificationCompat.MessagingStyle(
Person.Builder()
.setName(userDisplayName)
.setIcon(bitmapLoader.getUserIcon(userAvatarUrl))
.setKey(lastKnownRoomEvent.matrixID)
.build()
).also {
it.conversationTitle = roomName.takeIf { roomIsGroup }
it.isGroupConversation = roomIsGroup
it.addMessagesFromEvents(events)
}
val tickerText = if (roomIsGroup) {
stringProvider.getString(StringR.string.notification_ticker_text_group, roomName, events.last().senderName, events.last().description)
} else {
stringProvider.getString(StringR.string.notification_ticker_text_dm, events.last().senderName, events.last().description)
}
val largeBitmap = getRoomBitmap(events)
val lastMessageTimestamp = events.last().timestamp
val smartReplyErrors = events.filter { it.isSmartReplyError() }
val messageCount = (events.size - smartReplyErrors.size)
val meta = RoomNotification.Message.Meta(
summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, roomIsDirect = !roomIsGroup),
messageCount = messageCount,
latestTimestamp = lastMessageTimestamp,
roomId = roomId,
shouldBing = events.any { it.noisy }
)
return RoomNotification.Message(
notificationUtils.buildMessagesListNotification(
style,
RoomEventGroupInfo(roomId, roomName, isDirect = !roomIsGroup).also {
it.hasSmartReplyError = smartReplyErrors.isNotEmpty()
it.shouldBing = meta.shouldBing
it.customSound = events.last().soundName
it.isUpdated = events.last().isUpdated
},
threadId = lastKnownRoomEvent.threadId,
largeIcon = largeBitmap,
lastMessageTimestamp,
userDisplayName,
tickerText
),
meta
)
}
private fun NotificationCompat.MessagingStyle.addMessagesFromEvents(events: List<NotifiableMessageEvent>) {
events.forEach { event ->
val senderPerson = if (event.outGoingMessage) {
null
} else {
Person.Builder()
.setName(event.senderName)
.setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath))
.setKey(event.senderId)
.build()
}
when {
event.isSmartReplyError() -> addMessage(
stringProvider.getString(StringR.string.notification_inline_reply_failed),
event.timestamp,
senderPerson
)
else -> {
val message = NotificationCompat.MessagingStyle.Message(event.body, event.timestamp, senderPerson).also { message ->
event.imageUri?.let {
message.setData("image/", it)
}
}
addMessage(message)
}
}
}
}
private fun createRoomMessagesGroupSummaryLine(events: List<NotifiableMessageEvent>, roomName: String, roomIsDirect: Boolean): CharSequence {
return try {
when (events.size) {
1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDirect)
else -> {
stringProvider.getQuantityString(
StringR.plurals.notification_compat_summary_line_for_room,
events.size,
roomName,
events.size
)
}
}
} catch (e: Throwable) {
// String not found or bad format
Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER failed to resolve string")
roomName
}
}
private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): Span {
return if (roomIsDirect) {
span {
span {
textStyle = "bold"
+String.format("%s: ", event.senderName)
}
+(event.description)
}
} else {
span {
span {
textStyle = "bold"
+String.format("%s: %s ", roomName, event.senderName)
}
+(event.description)
}
}
}
private fun getRoomBitmap(events: List<NotifiableMessageEvent>): Bitmap? {
// Use the last event (most recent?)
return events.lastOrNull()
?.roomAvatarPath
?.let { bitmapLoader.getRoomBitmap(it) }
}
}
private fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed

View file

@ -0,0 +1,157 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import androidx.core.app.NotificationCompat
import io.element.android.libraries.toolbox.api.strings.StringProvider
import javax.inject.Inject
import io.element.android.libraries.ui.strings.R as StringR
/**
* ======== 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.
* To support older versions, which cannot show a nested group of notifications,
* you must create an extra notification that acts as the summary.
* This appears as the only notification and the system hides all the others.
* So this summary should include a snippet from all the other notifications,
* which the user can tap to open your app.
* 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
*/
class SummaryGroupMessageCreator @Inject constructor(
private val stringProvider: StringProvider,
private val notificationUtils: NotificationUtils
) {
fun createSummaryNotification(
roomNotifications: List<RoomNotification.Message.Meta>,
invitationNotifications: List<OneShotNotification.Append.Meta>,
simpleNotifications: List<OneShotNotification.Append.Meta>,
useCompleteNotificationFormat: Boolean
): Notification {
val summaryInboxStyle = NotificationCompat.InboxStyle().also { style ->
roomNotifications.forEach { style.addLine(it.summaryLine) }
invitationNotifications.forEach { style.addLine(it.summaryLine) }
simpleNotifications.forEach { style.addLine(it.summaryLine) }
}
val summaryIsNoisy = roomNotifications.any { it.shouldBing } ||
invitationNotifications.any { it.isNoisy } ||
simpleNotifications.any { it.isNoisy }
val messageCount = roomNotifications.fold(initial = 0) { acc, current -> acc + current.messageCount }
val lastMessageTimestamp = roomNotifications.lastOrNull()?.latestTimestamp
?: invitationNotifications.lastOrNull()?.timestamp
?: simpleNotifications.last().timestamp
// FIXME roomIdToEventMap.size is not correct, this is the number of rooms
val nbEvents = roomNotifications.size + simpleNotifications.size
val sumTitle = stringProvider.getQuantityString(StringR.plurals.notification_compat_summary_title, nbEvents, nbEvents)
summaryInboxStyle.setBigContentTitle(sumTitle)
// TODO get latest event?
.setSummaryText(stringProvider.getQuantityString(StringR.plurals.notification_unread_notified_messages, nbEvents, nbEvents))
return if (useCompleteNotificationFormat) {
notificationUtils.buildSummaryListNotification(
summaryInboxStyle,
sumTitle,
noisy = summaryIsNoisy,
lastMessageTimestamp = lastMessageTimestamp
)
} else {
processSimpleGroupSummary(
summaryIsNoisy,
messageCount,
simpleNotifications.size,
invitationNotifications.size,
roomNotifications.size,
lastMessageTimestamp
)
}
}
private fun processSimpleGroupSummary(
summaryIsNoisy: Boolean,
messageEventsCount: Int,
simpleEventsCount: Int,
invitationEventsCount: Int,
roomCount: Int,
lastMessageTimestamp: Long
): Notification {
// Add the simple events as message (?)
val messageNotificationCount = messageEventsCount + simpleEventsCount
val privacyTitle = if (invitationEventsCount > 0) {
val invitationsStr = stringProvider.getQuantityString(StringR.plurals.notification_invitations, invitationEventsCount, invitationEventsCount)
if (messageNotificationCount > 0) {
// Invitation and message
val messageStr = stringProvider.getQuantityString(
StringR.plurals.room_new_messages_notification,
messageNotificationCount, messageNotificationCount
)
if (roomCount > 1) {
// In several rooms
val roomStr = stringProvider.getQuantityString(
StringR.plurals.notification_unread_notified_messages_in_room_rooms,
roomCount, roomCount
)
stringProvider.getString(
StringR.string.notification_unread_notified_messages_in_room_and_invitation,
messageStr,
roomStr,
invitationsStr
)
} else {
// In one room
stringProvider.getString(
StringR.string.notification_unread_notified_messages_and_invitation,
messageStr,
invitationsStr
)
}
} else {
// Only invitation
invitationsStr
}
} else {
// No invitation, only messages
val messageStr = stringProvider.getQuantityString(
StringR.plurals.room_new_messages_notification,
messageNotificationCount, messageNotificationCount
)
if (roomCount > 1) {
// In several rooms
val roomStr = stringProvider.getQuantityString(StringR.plurals.notification_unread_notified_messages_in_room_rooms, roomCount, roomCount)
stringProvider.getString(StringR.string.notification_unread_notified_messages_in_room, messageStr, roomStr)
} else {
// In one room
messageStr
}
}
return notificationUtils.buildSummaryListNotification(
style = null,
compatSummary = privacyTitle,
noisy = summaryIsNoisy,
lastMessageTimestamp = lastMessageTimestamp
)
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.localbroadcastmanager.content.LocalBroadcastManager
class TestNotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// Internal broadcast to any one interested
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
}
}

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications.model
data class InviteNotifiableEvent(
val matrixID: String?,
override val eventId: String,
override val editedEventId: String?,
override val canBeReplaced: Boolean,
val roomId: String,
val roomName: String?,
val noisy: Boolean,
val title: String,
val description: String,
val type: String?,
val timestamp: Long,
val soundName: String?,
override val isRedacted: Boolean = false,
override val isUpdated: Boolean = false
) : NotifiableEvent

View file

@ -0,0 +1,31 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications.model
import java.io.Serializable
/**
* Parent interface for all events which can be displayed as a Notification.
*/
sealed interface NotifiableEvent : Serializable {
val eventId: String
val editedEventId: String?
// Used to know if event should be replaced with the one coming from eventstream
val canBeReplaced: Boolean
val isRedacted: Boolean
val isUpdated: Boolean
}

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications.model
import android.net.Uri
data class NotifiableMessageEvent(
override val eventId: String,
override val editedEventId: String?,
override val canBeReplaced: Boolean,
val noisy: Boolean,
val timestamp: Long,
val senderName: String?,
val senderId: String?,
val body: String?,
// We cannot use Uri? type here, as that could trigger a
// NotSerializableException when persisting this to storage
val imageUriString: String?,
val roomId: String,
val threadId: String?,
val roomName: String?,
val roomIsDirect: Boolean = false,
val roomAvatarPath: String? = null,
val senderAvatarPath: String? = null,
val matrixID: 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
) : NotifiableEvent {
val type: String = /* EventType.MESSAGE */ "m.room.message"
val description: String = body ?: ""
val title: String = senderName ?: ""
val imageUri: Uri?
get() = imageUriString?.let { Uri.parse(it) }
}
fun NotifiableMessageEvent.shouldIgnoreMessageEventInRoom(currentRoomId: String?, currentThreadId: String?): Boolean {
return when (currentRoomId) {
null -> false
else -> roomId == currentRoomId && threadId == currentThreadId
}
}

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications.model
data class SimpleNotifiableEvent(
val matrixID: String?,
override val eventId: String,
override val editedEventId: String?,
val noisy: Boolean,
val title: String,
val description: String,
val type: String?,
val timestamp: Long,
val soundName: String?,
override var canBeReplaced: Boolean,
override val isRedacted: Boolean = false,
override val isUpdated: Boolean = false
) : NotifiableEvent

View file

@ -0,0 +1,56 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.parser
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.push.impl.model.PushData
import io.element.android.libraries.push.impl.model.PushDataFcm
import io.element.android.libraries.push.impl.model.PushDataUnifiedPush
import io.element.android.libraries.push.impl.model.toPushData
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import javax.inject.Inject
/**
* Parse the received data from Push. Json format are different depending on the source.
*
* Notifications received by FCM are formatted by the matrix gateway [1]. The data send to FCM is the content
* of the "notification" attribute of the json sent to the gateway [2][3].
* On the other side, with UnifiedPush, the content of the message received is the content posted to the push
* gateway endpoint [3].
*
* *Note*: If we want to get the same content with FCM and unifiedpush, we can do a new sygnal pusher [4].
*
* [1] https://github.com/matrix-org/sygnal/blob/main/sygnal/gcmpushkin.py
* [2] https://github.com/matrix-org/sygnal/blob/main/sygnal/gcmpushkin.py#L366
* [3] https://spec.matrix.org/latest/push-gateway-api/
* [4] https://github.com/p1gp1g/sygnal/blob/unifiedpush/sygnal/upfcmpushkin.py (Not tested for a while)
*/
class PushParser @Inject constructor() {
fun parsePushDataUnifiedPush(message: ByteArray): PushData? {
return tryOrNull { Json.decodeFromString<PushDataUnifiedPush>(String(message)) }?.toPushData()
}
fun parsePushDataFcm(message: Map<String, String?>): PushData {
val pushDataFcm = PushDataFcm(
eventId = message["event_id"],
roomId = message["room_id"],
unread = message["unread"]?.let { tryOrNull { Integer.parseInt(it) } },
)
return pushDataFcm.toPushData()
}
}

View file

@ -0,0 +1,133 @@
package io.element.android.libraries.push.impl.store
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.DefaultPreferences
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.push.api.model.BackgroundSyncMode
import io.element.android.libraries.push.api.store.PushDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "push_store")
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultPushDataStore @Inject constructor(
@ApplicationContext private val context: Context,
@DefaultPreferences private val defaultPrefs: SharedPreferences,
) : PushDataStore {
private val pushCounter = intPreferencesKey("push_counter")
override val pushCounterFlow: Flow<Int> = context.dataStore.data.map { preferences ->
preferences[pushCounter] ?: 0
}
suspend fun incrementPushCounter() {
context.dataStore.edit { settings ->
val currentCounterValue = settings[pushCounter] ?: 0
settings[pushCounter] = currentCounterValue + 1
}
}
override fun areNotificationEnabledForDevice(): Boolean {
return defaultPrefs.getBoolean(SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY, true)
}
override fun setNotificationEnabledForDevice(enabled: Boolean) {
defaultPrefs.edit {
putBoolean(SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY, enabled)
}
}
override fun backgroundSyncTimeOut(): Int {
return tryOrNull {
// The xml pref is saved as a string so use getString and parse
defaultPrefs.getString(SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY, null)?.toInt()
} ?: BackgroundSyncMode.DEFAULT_SYNC_TIMEOUT_SECONDS
}
override fun setBackgroundSyncTimeout(timeInSecond: Int) {
defaultPrefs
.edit()
.putString(SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY, timeInSecond.toString())
.apply()
}
override fun backgroundSyncDelay(): Int {
return tryOrNull {
// The xml pref is saved as a string so use getString and parse
defaultPrefs.getString(SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY, null)?.toInt()
} ?: BackgroundSyncMode.DEFAULT_SYNC_DELAY_SECONDS
}
override fun setBackgroundSyncDelay(timeInSecond: Int) {
defaultPrefs
.edit()
.putString(SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY, timeInSecond.toString())
.apply()
}
override fun isBackgroundSyncEnabled(): Boolean {
return getFdroidSyncBackgroundMode() != BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_DISABLED
}
override fun setFdroidSyncBackgroundMode(mode: BackgroundSyncMode) {
defaultPrefs
.edit()
.putString(SETTINGS_FDROID_BACKGROUND_SYNC_MODE, mode.name)
.apply()
}
override fun getFdroidSyncBackgroundMode(): BackgroundSyncMode {
return try {
val strPref = defaultPrefs
.getString(SETTINGS_FDROID_BACKGROUND_SYNC_MODE, BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY.name)
BackgroundSyncMode.values().firstOrNull { it.name == strPref } ?: BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY
} catch (e: Throwable) {
BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY
}
}
/**
* Return true if Pin code is disabled, or if user set the settings to see full notification content.
*/
override fun useCompleteNotificationFormat(): Boolean {
return true
/*
return !useFlagPinCode() ||
defaultPrefs.getBoolean(SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG, true)
*/
}
companion object {
// notifications
const val SETTINGS_ENABLE_ALL_NOTIF_PREFERENCE_KEY = "SETTINGS_ENABLE_ALL_NOTIF_PREFERENCE_KEY"
const val SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY = "SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY"
// background sync
const val SETTINGS_START_ON_BOOT_PREFERENCE_KEY = "SETTINGS_START_ON_BOOT_PREFERENCE_KEY"
const val SETTINGS_ENABLE_BACKGROUND_SYNC_PREFERENCE_KEY = "SETTINGS_ENABLE_BACKGROUND_SYNC_PREFERENCE_KEY"
const val SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY = "SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY"
const val SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY = "SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY"
const val SETTINGS_FDROID_BACKGROUND_SYNC_MODE = "SETTINGS_FDROID_BACKGROUND_SYNC_MODE"
const val SETTINGS_BACKGROUND_SYNC_PREFERENCE_KEY = "SETTINGS_BACKGROUND_SYNC_PREFERENCE_KEY"
const val SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG = "SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG"
// notification method
const val SETTINGS_NOTIFICATION_METHOD_KEY = "SETTINGS_NOTIFICATION_METHOD_KEY"
}
}

View file

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="64dp"
android:height="64dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M23.04,3.84C23.04,1.7192 24.7593,0 26.88,0C41.0185,0 52.48,11.4615 52.48,25.6C52.48,27.7208 50.7608,29.44 48.64,29.44C46.5193,29.44 44.8,27.7208 44.8,25.6C44.8,15.7031 36.777,7.68 26.88,7.68C24.7593,7.68 23.04,5.9608 23.04,3.84Z"
android:fillColor="#0DBD8B"
android:fillType="evenOdd"/>
<path
android:pathData="M40.96,60.16C40.96,62.2808 39.2407,64 37.12,64C22.9815,64 11.52,52.5385 11.52,38.4C11.52,36.2792 13.2392,34.56 15.36,34.56C17.4807,34.56 19.2,36.2792 19.2,38.4C19.2,48.2969 27.223,56.32 37.12,56.32C39.2407,56.32 40.96,58.0392 40.96,60.16Z"
android:fillColor="#0DBD8B"
android:fillType="evenOdd"/>
<path
android:pathData="M3.84,40.96C1.7192,40.96 -0,39.2407 -0,37.12C-0,22.9815 11.4615,11.52 25.6,11.52C27.7208,11.52 29.44,13.2392 29.44,15.36C29.44,17.4807 27.7208,19.2 25.6,19.2C15.7031,19.2 7.68,27.223 7.68,37.12C7.68,39.2407 5.9608,40.96 3.84,40.96Z"
android:fillColor="#0DBD8B"
android:fillType="evenOdd"/>
<path
android:pathData="M60.16,23.04C62.2808,23.04 64,24.7593 64,26.88C64,41.0185 52.5385,52.48 38.4,52.48C36.2792,52.48 34.56,50.7608 34.56,48.64C34.56,46.5193 36.2792,44.8 38.4,44.8C48.2969,44.8 56.32,36.777 56.32,26.88C56.32,24.7593 58.0392,23.04 60.16,23.04Z"
android:fillColor="#0DBD8B"
android:fillType="evenOdd"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 B

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2023 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<resources>
<!-- TODO EAx -->
<color name="notification_accent_color">#368BD6</color>
</resources>

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2023 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<resources>
<dimen name="profile_avatar_size">50dp</dimen>
</resources>

View file

@ -1,3 +1,5 @@
import java.net.URI
/*
* Copyright (c) 2022 New Vector Ltd
*
@ -27,6 +29,14 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven {
url = URI("https://www.jitpack.io")
content {
includeModule("com.github.UnifiedPush", "android-connector")
}
}
//noinspection JcenterRepositoryObsolete
jcenter()
flatDir {
dirs("libraries/matrix/libs")
}