diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1e3de32f22..0b8833efbb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,6 +33,7 @@ plugins { id("com.google.firebase.appdistribution") version "4.0.0" id("org.jetbrains.kotlinx.knit") version "0.4.0" id("kotlin-parcelize") + id("com.google.gms.google-services") } android { @@ -213,15 +214,20 @@ 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)) implementation("com.squareup.okhttp3:logging-interceptor") + implementation(platform(libs.google.firebase.bom)) + implementation("com.google.firebase:firebase-messaging-ktx") + implementation(libs.dagger) kapt(libs.dagger.compiler) diff --git a/app/src/debug/google-services.json b/app/src/debug/google-services.json new file mode 100644 index 0000000000..d9aa72f7ba --- /dev/null +++ b/app/src/debug/google-services.json @@ -0,0 +1,49 @@ +{ + "project_info": { + "project_number": "912726360885", + "firebase_url": "https://vector-alpha.firebaseio.com", + "project_id": "vector-alpha", + "storage_bucket": "vector-alpha.appspot.com" + }, + + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:912726360885:android:def0a4e454042e9b00427c", + "android_client_info": { + "package_name": "io.element.android.x.debug" + } + }, + "oauth_client": [ + { + "client_id": "912726360885-hvgoj23p6plt7hikhtdrakihojghaftv.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "io.element.android.x.debug", + "certificate_hash": "41bd63b3b612a15d9ba36a5245c393f2a9b992d1" + } + }, + { + "client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 31033b0500..828788ed80 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt new file mode 100644 index 0000000000..7924ddf996 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt @@ -0,0 +1,49 @@ +/* + * 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.matrix.api.MatrixClient +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.impl.config.PushConfig +import io.element.android.libraries.push.impl.log.pushLoggerTag +import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager +import timber.log.Timber +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultPushService @Inject constructor( + private val notificationDrawerManager: NotificationDrawerManager, + private val pushersManager: PushersManager, + private val fcmHelper: FcmHelper, +) : PushService { + override fun notificationStyleChanged() { + notificationDrawerManager.notificationStyleChanged() + } + + override suspend fun registerFirebasePusher(matrixClient: MatrixClient) { + val pushKey = fcmHelper.getFcmToken() ?: return Unit.also { + Timber.tag(pushLoggerTag.value).w("Unable to register pusher, Firebase token is not known.") + } + pushersManager.registerPusher(matrixClient, pushKey, PushConfig.pusher_http_url) + } + + override suspend fun testPush() { + pushersManager.testPush() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/FcmHelper.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/FcmHelper.kt new file mode 100644 index 0000000000..9b8b6c2281 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/FcmHelper.kt @@ -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) + */ +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/GoogleFcmHelper.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/GoogleFcmHelper.kt new file mode 100755 index 0000000000..0ead18c75e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/GoogleFcmHelper.kt @@ -0,0 +1,103 @@ +/* + * 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 kotlinx.coroutines.runBlocking +import timber.log.Timber +import javax.inject.Inject + +/** + * 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) { + runBlocking {// TODO + pushersManager.enqueueRegisterPusherWithFcmKey(token) + } + } + } + .addOnFailureListener { e -> + Timber.e(e, "## ensureFcmTokenIsRetrieved() : failed") + } + } catch (e: Throwable) { + Timber.e(e, "## ensureFcmTokenIsRetrieved() : failed") + } + } else { + Toast.makeText(context, R.string.push_no_valid_google_play_services_apk_android, 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" + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt new file mode 100644 index 0000000000..f8f445a6df --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt @@ -0,0 +1,173 @@ +/* + * 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.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import io.element.android.libraries.push.impl.clientsecret.PushClientSecret +import io.element.android.libraries.push.impl.config.PushConfig +import io.element.android.libraries.push.impl.log.pushLoggerTag +import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest +import io.element.android.libraries.push.impl.userpushstore.UserPushStoreFactory +import io.element.android.libraries.push.impl.userpushstore.isFirebase +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.api.toUserList +import io.element.android.services.toolbox.api.appname.AppNameProvider +import timber.log.Timber +import javax.inject.Inject + +internal const val DEFAULT_PUSHER_FILE_TAG = "mobile" + +private val loggerTag = LoggerTag("PushersManager", pushLoggerTag) + +class PushersManager @Inject constructor( + private val unifiedPushHelper: UnifiedPushHelper, + // private val localeProvider: LocaleProvider, + private val appNameProvider: AppNameProvider, + // private val getDeviceInfoUseCase: GetDeviceInfoUseCase, + private val pushGatewayNotifyRequest: PushGatewayNotifyRequest, + private val pushClientSecret: PushClientSecret, + private val sessionStore: SessionStore, + private val matrixAuthenticationService: MatrixAuthenticationService, + private val userPushStoreFactory: UserPushStoreFactory, + private val fcmHelper: FcmHelper, +) { + suspend fun testPush() { + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = unifiedPushHelper.getPushGateway() ?: return, + appId = PushConfig.pusher_app_id, + pushKey = unifiedPushHelper.getEndpointOrToken().orEmpty(), + eventId = TEST_EVENT_ID + ) + ) + } + + suspend fun enqueueRegisterPusherWithFcmKey(pushKey: String) { + // return onNewFirebaseToken(pushKey, PushConfig.pusher_http_url) + TODO() + } + + suspend fun onNewUnifiedPushEndpoint( + pushKey: String, + gateway: String + ) { + TODO() + } + + suspend fun onNewFirebaseToken(firebaseToken: String) { + fcmHelper.storeFcmToken(firebaseToken) + + // Register the pusher for all the sessions + sessionStore.getAllSessions().toUserList().forEach { userId -> + val userDataStore = userPushStoreFactory.create(userId) + if (userDataStore.isFirebase()) { + matrixAuthenticationService.restoreSession(SessionId(userId)).getOrNull()?.use { client -> + registerPusher(client, firebaseToken, PushConfig.pusher_http_url) + } + } else { + Timber.tag(loggerTag.value).d("This session is not using Firebase pusher") + } + } + } + + /** + * Register a pusher to the server if not done yet. + */ + suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) { + val userDataStore = userPushStoreFactory.create(matrixClient.sessionId.value) + if (userDataStore.getCurrentRegisteredPushKey() == pushKey) { + Timber.tag(loggerTag.value).d("Unnecessary to register again the same pusher") + } else { + // Register the pusher to the server + matrixClient.pushersService().setHttpPusher( + createHttpPusher(pushKey, gateway, matrixClient.sessionId) + ).fold( + { + userDataStore.setCurrentRegisteredPushKey(pushKey) + }, + { throwable -> + Timber.tag(loggerTag.value).e(throwable, "Unable to register the pusher") + } + ) + } + } + + private suspend fun createHttpPusher( + pushKey: String, + gateway: String, + userId: SessionId, + ): SetHttpPusherData = + SetHttpPusherData( + pushKey = pushKey, + appId = PushConfig.pusher_app_id, + profileTag = DEFAULT_PUSHER_FILE_TAG + "_" /* TODO + abs(activeSessionHolder.getActiveSession().myUserId.hashCode())*/, + lang = "en", // TODO localeProvider.current().language, + appDisplayName = appNameProvider.getAppName(), + deviceDisplayName = "MyDevice", // TODO getDeviceInfoUseCase.execute().displayName().orEmpty(), + url = gateway, + defaultPayload = createDefaultPayload(pushClientSecret.getSecretForUser(userId)) + ) + + /** + * Ex: {"cs":"sfvsdv"} + */ + private fun createDefaultPayload(secretForUser: String): String { + return "{\"cs\":\"$secretForUser\"}" + } + + 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 { + val TEST_EVENT_ID = EventId("\$THIS_IS_A_FAKE_EVENT_ID") + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushHelper.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushHelper.kt new file mode 100644 index 0000000000..12ed3f1993 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushHelper.kt @@ -0,0 +1,179 @@ +/* + * 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.services.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 + +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()) { + R.string.push_distributor_firebase_android + } else { + R.string.push_distributor_background_sync_android + } + ) + + val distributors = UnifiedPush.getDistributors(context) + val distributorsName = distributors.map { + if (it == context.packageName) { + internalDistributorName + } else { + context.getApplicationLabel(it) + } + } + + MaterialAlertDialogBuilder(context) + .setTitle(stringProvider.getString(R.string.push_choose_distributor_dialog_title_android)) + .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(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 { + return UnifiedPush.getDistributors(context) + .filterNot { it == context.packageName } + } + + fun getCurrentDistributorName(): String { + return when { + isEmbeddedDistributor() -> stringProvider.getString(R.string.push_distributor_firebase_android) + isBackgroundSync() -> stringProvider.getString(R.string.push_distributor_background_sync_android) + 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() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushStore.kt new file mode 100644 index 0000000000..5e2e33d7d3 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushStore.kt @@ -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" + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecret.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecret.kt new file mode 100644 index 0000000000..93f5f43ce4 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecret.kt @@ -0,0 +1,37 @@ +/* + * 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.clientsecret + +import io.element.android.libraries.matrix.api.core.SessionId + +interface PushClientSecret { + /** + * To call when registering a pusher. It will return the existing secret or create a new one. + */ + suspend fun getSecretForUser(userId: SessionId): String + + /** + * To call when receiving a push containing a client secret. + * Return null if not found. + */ + suspend fun getUserIdFromSecret(clientSecret: String): SessionId? + + /** + * To call when the user signs out. + */ + suspend fun resetSecretForUser(userId: SessionId) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactory.kt new file mode 100644 index 0000000000..4ab6c775e3 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactory.kt @@ -0,0 +1,21 @@ +/* + * 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.clientsecret + +interface PushClientSecretFactory { + fun create(): String +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactoryImpl.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactoryImpl.kt new file mode 100644 index 0000000000..1d7a1e6247 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactoryImpl.kt @@ -0,0 +1,29 @@ +/* + * 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.clientsecret + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import java.util.UUID +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class PushClientSecretFactoryImpl @Inject constructor() : PushClientSecretFactory { + override fun create(): String { + return UUID.randomUUID().toString() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImpl.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImpl.kt new file mode 100644 index 0000000000..b57b24d25e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImpl.kt @@ -0,0 +1,46 @@ +/* + * 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.clientsecret + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.core.SessionId +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class PushClientSecretImpl @Inject constructor( + private val pushClientSecretFactory: PushClientSecretFactory, + private val pushClientSecretStore: PushClientSecretStore, +) : PushClientSecret { + override suspend fun getSecretForUser(userId: SessionId): String { + val existingSecret = pushClientSecretStore.getSecret(userId) + if (existingSecret != null) { + return existingSecret + } + val newSecret = pushClientSecretFactory.create() + pushClientSecretStore.storeSecret(userId, newSecret) + return newSecret + } + + override suspend fun getUserIdFromSecret(clientSecret: String): SessionId? { + return pushClientSecretStore.getUserIdFromSecret(clientSecret) + } + + override suspend fun resetSecretForUser(userId: SessionId) { + pushClientSecretStore.resetSecret(userId) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStore.kt new file mode 100644 index 0000000000..c5f7358241 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStore.kt @@ -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.clientsecret + +import io.element.android.libraries.matrix.api.core.SessionId + +interface PushClientSecretStore { + suspend fun storeSecret(userId: SessionId, clientSecret: String) + suspend fun getSecret(userId: SessionId): String? + suspend fun resetSecret(userId: SessionId) + suspend fun getUserIdFromSecret(clientSecret: String): SessionId? +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStoreDataStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStoreDataStore.kt new file mode 100644 index 0000000000..98b64d4e0b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStoreDataStore.kt @@ -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.clientsecret + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +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.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.asSessionId +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +private val Context.dataStore: DataStore by preferencesDataStore(name = "push_client_secret_store") + +@ContributesBinding(AppScope::class) +class PushClientSecretStoreDataStore @Inject constructor( + @ApplicationContext private val context: Context, +) : PushClientSecretStore { + override suspend fun storeSecret(userId: SessionId, clientSecret: String) { + context.dataStore.edit { settings -> + settings[getPreferenceKeyForUser(userId)] = clientSecret + } + } + + override suspend fun getSecret(userId: SessionId): String? { + return context.dataStore.data.first()[getPreferenceKeyForUser(userId)] + } + + override suspend fun resetSecret(userId: SessionId) { + context.dataStore.edit { settings -> + settings.remove(getPreferenceKeyForUser(userId)) + } + } + + override suspend fun getUserIdFromSecret(clientSecret: String): SessionId? { + val keyValues = context.dataStore.data.first().asMap() + val matchingKey = keyValues.keys.firstOrNull { + keyValues[it] == clientSecret + } + return matchingKey?.name?.asSessionId() + } + + private fun getPreferenceKeyForUser(userId: SessionId) = stringPreferencesKey(userId.value) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/config/PushConfig.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/config/PushConfig.kt new file mode 100644 index 0000000000..d2d1c96506 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/config/PushConfig.kt @@ -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 +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/EnsureFcmTokenIsRetrievedUseCase.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/EnsureFcmTokenIsRetrievedUseCase.kt new file mode 100644 index 0000000000..9e9b28ecb8 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/EnsureFcmTokenIsRetrievedUseCase.kt @@ -0,0 +1,47 @@ +/* + * 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.firebase + +import io.element.android.libraries.push.impl.FcmHelper +import io.element.android.libraries.push.impl.PushersManager +import io.element.android.libraries.push.impl.UnifiedPushHelper +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 + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/FirebasePushParser.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/FirebasePushParser.kt new file mode 100644 index 0000000000..906816eb56 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/FirebasePushParser.kt @@ -0,0 +1,33 @@ +/* + * 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.firebase + +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.push.impl.push.PushData +import javax.inject.Inject + +class FirebasePushParser @Inject constructor() { + fun parse(message: Map): PushData { + val pushDataFirebase = PushDataFirebase( + eventId = message["event_id"], + roomId = message["room_id"], + unread = message["unread"]?.let { tryOrNull { Integer.parseInt(it) } }, + clientSecret = message["cs"], + ) + return pushDataFirebase.toPushData() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/PushDataFirebase.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/PushDataFirebase.kt new file mode 100644 index 0000000000..bcf48bab15 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/PushDataFirebase.kt @@ -0,0 +1,49 @@ +/* + * 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.firebase + +import io.element.android.libraries.matrix.api.core.MatrixPatterns +import io.element.android.libraries.matrix.api.core.asEventId +import io.element.android.libraries.matrix.api.core.asRoomId +import io.element.android.libraries.push.impl.push.PushData + +/** + * In this case, the format is: + *
+ * {
+ *     "event_id":"$anEventId",
+ *     "room_id":"!aRoomId",
+ *     "unread":"1",
+ *     "prio":"high",
+ *     "cs":""
+ * }
+ * 
+ * . + */ +data class PushDataFirebase( + val eventId: String?, + val roomId: String?, + var unread: Int?, + val clientSecret: String? +) + +fun PushDataFirebase.toPushData() = PushData( + eventId = eventId?.takeIf { MatrixPatterns.isEventId(it) }?.asEventId(), + roomId = roomId?.takeIf { MatrixPatterns.isRoomId(it) }?.asRoomId(), + unread = unread, + clientSecret = clientSecret, +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingService.kt new file mode 100644 index 0000000000..8769baa947 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingService.kt @@ -0,0 +1,61 @@ +/* + * 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.firebase + +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.impl.PushersManager +import io.element.android.libraries.push.impl.log.pushLoggerTag +import io.element.android.libraries.push.impl.push.PushHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("Firebase", pushLoggerTag) + +class VectorFirebaseMessagingService : FirebaseMessagingService() { + @Inject lateinit var pushersManager: PushersManager + @Inject lateinit var pushParser: FirebasePushParser + @Inject lateinit var pushHandler: PushHandler + + private val coroutineScope = CoroutineScope(SupervisorJob()) + + override fun onCreate() { + super.onCreate() + applicationContext.bindings().inject(this) + } + + override fun onNewToken(token: String) { + Timber.tag(loggerTag.value).d("New Firebase token") + coroutineScope.launch { + pushersManager.onNewFirebaseToken(token) + } + } + + override fun onMessageReceived(message: RemoteMessage) { + Timber.tag(loggerTag.value).d("New Firebase message") + coroutineScope.launch { + pushParser.parse(message.data).let { + pushHandler.handle(it) + } + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingServiceBindings.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingServiceBindings.kt new file mode 100644 index 0000000000..aef87e7df3 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingServiceBindings.kt @@ -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.firebase + +import com.squareup.anvil.annotations.ContributesTo +import io.element.android.libraries.di.AppScope + +@ContributesTo(AppScope::class) +interface VectorFirebaseMessagingServiceBindings { + fun inject(service: VectorFirebaseMessagingService) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt new file mode 100644 index 0000000000..52abb3f6a4 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt @@ -0,0 +1,35 @@ +/* + * 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.intent + +import android.content.Intent +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId + +interface IntentProvider { + /** + * Provide an intent to start the application. + */ + fun getMainIntent(): Intent + + fun getIntent( + sessionId: SessionId, + roomId: RoomId?, + threadId: ThreadId?, + ): Intent +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/log/LoggerTag.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/log/LoggerTag.kt new file mode 100644 index 0000000000..3fa613d097 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/log/LoggerTag.kt @@ -0,0 +1,22 @@ +/* + * 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.log + +import io.element.android.libraries.core.log.logger.LoggerTag + +internal val pushLoggerTag = LoggerTag("Push") +internal val notificationLoggerTag = LoggerTag("Notification", pushLoggerTag) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt new file mode 100644 index 0000000000..6e92c2ec60 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt @@ -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 + } + */ +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt new file mode 100644 index 0000000000..7c083555e4 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt @@ -0,0 +1,61 @@ +/* + * 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.* +import io.element.android.services.appnavstate.api.AppNavigationState +import timber.log.Timber +import javax.inject.Inject + +private typealias ProcessedEvents = List> + +class NotifiableEventProcessor @Inject constructor( + private val outdatedDetector: OutdatedEventDetector, +) { + + fun process( + queuedEvents: List, + appNavigationState: AppNavigationState?, + renderedEvents: ProcessedEvents, + ): ProcessedEvents { + val processedEvents = queuedEvents.map { + val type = when (it) { + is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP + is NotifiableMessageEvent -> when { + it.shouldIgnoreMessageEventInRoom(appNavigationState) -> { + 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 + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt new file mode 100644 index 0000000000..6463773e7b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt @@ -0,0 +1,140 @@ +/* + * 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.log.logger.LoggerTag +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.notification.NotificationData +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.push.impl.log.pushLoggerTag +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("NotifiableEventResolver", pushLoggerTag) + +/** + * 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 matrixAuthenticationService: MatrixAuthenticationService, + private val buildMeta: BuildMeta, +) { + + suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { + // Restore session + val session = matrixAuthenticationService.restoreSession(sessionId).getOrNull() ?: return null + // TODO EAx, no need for a session? + val notificationData = session.let {// TODO Use make the app crashes + it.notificationService().getNotification( + userId = sessionId, + roomId = roomId, + eventId = eventId, + ) + }.fold( + { + it + }, + { + Timber.tag(loggerTag.value).e(it, "Unable to resolve event.") + null + } + ).orDefault(roomId, eventId) + + return notificationData.asNotifiableEvent(sessionId, roomId, eventId) + } +} + +private fun NotificationData.asNotifiableEvent(userId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent { + return NotifiableMessageEvent( + sessionId = userId, + roomId = roomId, + eventId = eventId, + editedEventId = null, + canBeReplaced = true, + noisy = false, + timestamp = System.currentTimeMillis(), + senderName = null, + senderId = null, + body = "$eventId in $roomId", + imageUriString = null, + threadId = null, + roomName = null, + roomIsDirect = false, + roomAvatarPath = null, + senderAvatarPath = null, + soundName = null, + outGoingMessage = false, + outGoingMessageFailed = false, + isRedacted = false, + isUpdated = false + ) +} + +/** + * TODO This is a temporary method for EAx + */ +private fun NotificationData?.orDefault(roomId: RoomId, eventId: EventId): NotificationData { + return this ?: NotificationData( + item = MatrixTimelineItem.Event( + event = EventTimelineItem( + uniqueIdentifier = eventId.value, + eventId = eventId, + isEditable = false, + isLocal = false, + isOwn = false, + isRemote = false, + localSendState = null, + reactions = emptyList(), + sender = UserId(""), + senderProfile = ProfileTimelineDetails.Unavailable, + timestamp = System.currentTimeMillis(), + content = MessageContent( + body = eventId.value, + inReplyTo = null, + isEdited = false, + type = TextMessageType( + body = eventId.value, + formatted = null + ) + ) + ), + ), + title = roomId.value, + subtitle = eventId.value, + isNoisy = false, + avatarUrl = null, + ) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationAction.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationAction.kt new file mode 100644 index 0000000000..31ec28d023 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationAction.kt @@ -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.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) +} + */ diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt new file mode 100644 index 0000000000..56054bbef8 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt @@ -0,0 +1,40 @@ +/* + * 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" +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt new file mode 100644 index 0000000000..d0ab023789 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt @@ -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 + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt new file mode 100644 index 0000000000..078de3a8a8 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt @@ -0,0 +1,253 @@ +/* + * 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.architecture.bindings +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.asRoomId +import io.element.android.libraries.matrix.api.core.asSessionId +import io.element.android.libraries.matrix.api.core.asThreadId +import io.element.android.libraries.push.impl.log.notificationLoggerTag +import io.element.android.services.analytics.api.AnalyticsTracker +import io.element.android.services.toolbox.api.systemclock.SystemClock +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("NotificationBroadcastReceiver", notificationLoggerTag) + +/** + * 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().inject(this) + Timber.tag(loggerTag.value).v("NotificationBroadcastReceiver received : $intent") + val sessionId = intent.extras?.getString(KEY_SESSION_ID)?.asSessionId() ?: return + when (intent.action) { + actionIds.smartReply -> + handleSmartReply(intent, context) + actionIds.dismissRoom -> + intent.getStringExtra(KEY_ROOM_ID)?.asRoomId()?.let { roomId -> + notificationDrawerManager.clearMessagesForRoom(sessionId, roomId) + } + actionIds.dismissSummary -> + notificationDrawerManager.clearAllEvents(sessionId) + actionIds.markRoomRead -> + intent.getStringExtra(KEY_ROOM_ID)?.asRoomId()?.let { roomId -> + notificationDrawerManager.clearMessagesForRoom(sessionId, roomId) + handleMarkAsRead(sessionId, roomId) + } + actionIds.join -> { + intent.getStringExtra(KEY_ROOM_ID)?.asRoomId()?.let { roomId -> + notificationDrawerManager.clearMemberShipNotificationForRoom(sessionId, roomId) + handleJoinRoom(sessionId, roomId) + } + } + actionIds.reject -> { + intent.getStringExtra(KEY_ROOM_ID)?.asRoomId()?.let { roomId -> + notificationDrawerManager.clearMemberShipNotificationForRoom(sessionId, roomId) + handleRejectRoom(sessionId, roomId) + } + } + } + } + + private fun handleJoinRoom(sessionId: SessionId, roomId: RoomId) { + /* + 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(sessionId: SessionId, roomId: RoomId) { + /* + activeSessionHolder.getSafeActiveSession()?.let { session -> + session.coroutineScope.launch { + tryOrNull { session.roomService().leaveRoom(roomId) } + } + } + + */ + } + + private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId) { + /* + 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 sessionId = intent.getStringExtra(KEY_SESSION_ID)?.asSessionId() + val roomId = intent.getStringExtra(KEY_ROOM_ID)?.asRoomId() + val threadId = intent.getStringExtra(KEY_THREAD_ID)?.asThreadId() + + if (message.isNullOrBlank() || roomId == null) { + // 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(R.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 { + 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(R.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_SESSION_ID = "sessionID" + const val KEY_ROOM_ID = "roomID" + const val KEY_THREAD_ID = "threadID" + const val KEY_TEXT_REPLY = "key_text_reply" + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverBindings.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverBindings.kt new file mode 100644 index 0000000000..ae936e693b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverBindings.kt @@ -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) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt new file mode 100644 index 0000000000..c8331ed8cd --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt @@ -0,0 +1,54 @@ +/* + * 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.Manifest +import android.app.Notification +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationManagerCompat +import io.element.android.libraries.di.ApplicationContext +import timber.log.Timber +import javax.inject.Inject + +class NotificationDisplayer @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val notificationManager = NotificationManagerCompat.from(context) + + fun showNotificationMessage(tag: String?, id: Int, notification: Notification) { + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + Timber.w("Not allowed to notify.") + return + } + 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") + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDrawerManager.kt new file mode 100644 index 0000000000..4688a4a24c --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDrawerManager.kt @@ -0,0 +1,270 @@ +/* + * 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.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.push.api.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 io.element.android.services.appnavstate.api.AppNavigationState +import io.element.android.services.appnavstate.api.AppNavigationStateService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +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 pushDataStore: PushDataStore, + private val notifiableEventProcessor: NotifiableEventProcessor, + private val notificationRenderer: NotificationRenderer, + private val notificationEventPersistence: NotificationEventPersistence, + private val filteredEventDetector: FilteredEventDetector, + private val appNavigationStateService: AppNavigationStateService, + private val coroutineScope: CoroutineScope, + private val buildMeta: BuildMeta, +) { + + private val handlerThread: HandlerThread = HandlerThread("NotificationDrawerManager", Thread.MIN_PRIORITY) + private var backgroundHandler: Handler + + /** + * 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 currentAppNavigationState: AppNavigationState? = null + private val firstThrottler = FirstThrottler(200) + + private var useCompleteNotificationFormat = pushDataStore.useCompleteNotificationFormat() + + init { + handlerThread.start() + backgroundHandler = Handler(handlerThread.looper) + // Observe application state + coroutineScope.launch { + appNavigationStateService.appNavigationStateFlow + .collect { onAppNavigationStateChange(it) } + } + } + + private fun onAppNavigationStateChange(appNavigationState: AppNavigationState) { + currentAppNavigationState = appNavigationState + when (appNavigationState) { + AppNavigationState.Root -> {} + is AppNavigationState.Session -> {} + is AppNavigationState.Space -> {} + is AppNavigationState.Room -> { + // Cleanup notification for current room + clearMessagesForRoom(appNavigationState.parentSpace.parentSession.sessionId, appNavigationState.roomId) + } + is AppNavigationState.Thread -> { + onEnteringThread( + appNavigationState.parentRoom.parentSpace.parentSession.sessionId, + appNavigationState.parentRoom.roomId, + appNavigationState.threadId + ) + } + } + } + + 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) + } + + private 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) + } + + /** + * 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 onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { + updateEvents { + it.onNotifiableEventReceived(notifiableEvent) + } + } + + /** + * Clear all known events and refresh the notification drawer. + */ + fun clearAllEvents(sessionId: SessionId) { + updateEvents { + it.clearMessagesForSession(sessionId) + } + } + + /** + * Should be called when the application is currently opened and showing timeline for the given roomId. + * Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room. + * Can also be called when a notification for this room is dismissed by the user. + */ + fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) { + updateEvents { + it.clearMessagesForRoom(sessionId, roomId) + } + } + + /** + * Clear invitation notification for the provided room. + */ + fun clearMemberShipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { + updateEvents { + it.clearMemberShipNotificationForRoom(sessionId, 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. + */ + private fun onEnteringThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) { + updateEvents { + it.clearMessagesForThread(sessionId, roomId, threadId) + } + } + + // TODO EAx Must be per account + fun notificationStyleChanged() { + updateEvents { + val newSettings = pushDataStore.useCompleteNotificationFormat() + if (newSettings != useCompleteNotificationFormat) { + // Settings has changed, remove all current notifications + notificationRenderer.cancelAllNotifications() + useCompleteNotificationFormat = newSettings + } + } + } + + private 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(), currentAppNavigationState, 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) + renderEvents(eventsToRender) + persistEvents() + } + } + + private fun persistEvents() { + notificationState.queuedEvents { queuedEvents -> + notificationEventPersistence.persistEvents(queuedEvents) + } + } + + private fun renderEvents(eventsToRender: List>) { + // Group by sessionId + val eventsForSessions = eventsToRender.groupBy { + it.event.sessionId + } + + eventsForSessions.forEach { (sessionId, notifiableEvents) -> + // TODO EAx val user = session.getUserOrDefault(session.myUserId) + // myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash + val myUserDisplayName = "Todo display name" // user.toMatrixItem().getBestName() + // TODO EAx avatar URL + val myUserAvatarUrl = null // session.contentUrlResolver().resolveThumbnail( + // contentUrl = user.avatarUrl, + // width = avatarSize, + // height = avatarSize, + // method = ContentUrlResolver.ThumbnailMethod.SCALE + //) + notificationRenderer.render(sessionId, myUserDisplayName, myUserAvatarUrl, useCompleteNotificationFormat, notifiableEvents) + } + } + + fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean { + return resolvedEvent.shouldIgnoreMessageEventInRoom(currentAppNavigationState) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt new file mode 100644 index 0000000000..d6135f28b0 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt @@ -0,0 +1,91 @@ +/* + * 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.androidutils.file.EncryptedFileFactory +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.push.impl.log.notificationLoggerTag +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import timber.log.Timber +import java.io.File +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import javax.inject.Inject + +private const val ROOMS_NOTIFICATIONS_FILE_NAME_LEGACY = "im.vector.notifications.cache" +private const val FILE_NAME = "notifications.bin" + +private val loggerTag = LoggerTag("NotificationEventPersistence", notificationLoggerTag) + +class NotificationEventPersistence @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val file by lazy { + deleteLegacyFileIfAny() + context.getDatabasePath(FILE_NAME) + } + + private val encryptedFile by lazy { + EncryptedFileFactory(context).create(file) + } + + fun loadEvents(factory: (List) -> NotificationEventQueue): NotificationEventQueue { + val rawEvents: ArrayList? = file + .takeIf { it.exists() } + ?.let { + try { + encryptedFile.openFileInput().use { fis -> + ObjectInputStream(fis).use { ois -> + @Suppress("UNCHECKED_CAST") + ois.readObject() as? ArrayList + } + }.also { + Timber.tag(loggerTag.value).d("Deserializing ${it?.size} NotifiableEvent(s)") + } + } catch (e: Throwable) { + Timber.tag(loggerTag.value).e(e, "## Failed to load cached notification info") + null + } + } + return factory(rawEvents.orEmpty()) + } + + fun persistEvents(queuedEvents: NotificationEventQueue) { + Timber.tag(loggerTag.value).d("Serializing ${queuedEvents.rawEvents().size} NotifiableEvent(s)") + // Always delete file before writing, or encryptedFile.openFileOutput() will throw + file.delete() + if (queuedEvents.isEmpty()) return + try { + encryptedFile.openFileOutput().use { fos -> + ObjectOutputStream(fos).use { oos -> + oos.writeObject(queuedEvents.rawEvents()) + } + } + } catch (e: Throwable) { + Timber.tag(loggerTag.value).e(e, "## Failed to save cached notification info") + } + } + + private fun deleteLegacyFileIfAny() { + tryOrNull { + File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME_LEGACY).delete() + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt new file mode 100644 index 0000000000..60fb1baa05 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt @@ -0,0 +1,162 @@ +/* + * 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.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.push.impl.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 constructor( + private val queue: MutableList, + /** + * 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 +) { + + fun markRedacted(eventIds: List) { + 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) + } + } + } + } + + // TODO EAx call this + fun syncRoomEvents(roomsLeft: Collection, roomsJoined: Collection) { + 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) { + 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.sessionId == notifiableEvent.sessionId && 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(sessionId: SessionId, roomId: RoomId) { + Timber.d("clearMemberShipOfRoom $sessionId, $roomId") + queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId && it.roomId == roomId } + } + + fun clearMessagesForSession(sessionId: SessionId) { + Timber.d("clearMessagesForSession $sessionId") + queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId } + } + + fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) { + Timber.d("clearMessageEventOfRoom $sessionId, $roomId") + queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId } + } + + fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) { + Timber.d("clearMessageEventOfThread $sessionId, $roomId, $threadId") + queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId && it.threadId == threadId } + } + + fun rawEvents(): List = queue +} + +private fun MutableList.replace(eventId: EventId, block: (NotifiableEvent) -> NotifiableEvent) { + val indexToReplace = indexOfFirst { it.eventId == eventId } + if (indexToReplace == -1) { + return + } + set(indexToReplace, block(get(indexToReplace))) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt new file mode 100644 index 0000000000..1a2d4a852f --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt @@ -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 android.app.Notification +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.impl.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> + +class NotificationFactory @Inject constructor( + private val notificationUtils: NotificationUtils, + private val roomGroupMessageCreator: RoomGroupMessageCreator, + private val summaryGroupMessageCreator: SummaryGroupMessageCreator +) { + + fun Map.toNotifications( + sessionId: SessionId, + myUserDisplayName: String, + myUserAvatarUrl: String? + ): List { + return map { (roomId, events) -> + when { + events.hasNoEventsToDisplay() -> RoomNotification.Removed(roomId) + else -> { + val messageEvents = events.onlyKeptEvents().filterNot { it.isRedacted } + roomGroupMessageCreator.createRoomMessage( + sessionId = sessionId, + events = messageEvents, + roomId = roomId, + userDisplayName = myUserDisplayName, + userAvatarUrl = myUserAvatarUrl + ) + } + } + } + } + + private fun ProcessedMessageEvents.hasNoEventsToDisplay() = isEmpty() || all { + it.type == ProcessedEvent.Type.REMOVE || it.event.canNotBeDisplayed() + } + + private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted + + @JvmName("toNotificationsInviteNotifiableEvent") + fun List>.toNotifications(): List { + return map { (processed, event) -> + when (processed) { + ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.roomId.value) + ProcessedEvent.Type.KEEP -> OneShotNotification.Append( + notificationUtils.buildRoomInvitationNotification(event), + OneShotNotification.Append.Meta( + key = event.roomId.value, + summaryLine = event.description, + isNoisy = event.noisy, + timestamp = event.timestamp + ) + ) + } + } + } + + @JvmName("toNotificationsSimpleNotifiableEvent") + fun List>.toNotifications(): List { + return map { (processed, event) -> + when (processed) { + ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId.value) + ProcessedEvent.Type.KEEP -> OneShotNotification.Append( + notificationUtils.buildSimpleEventNotification(event), + OneShotNotification.Append.Meta( + key = event.eventId.value, + summaryLine = event.description, + isNoisy = event.noisy, + timestamp = event.timestamp + ) + ) + } + } + } + + fun createSummaryNotification( + sessionId: SessionId, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + useCompleteNotificationFormat: Boolean + ): SummaryNotification { + val roomMeta = roomNotifications.filterIsInstance().map { it.meta } + val invitationMeta = invitationNotifications.filterIsInstance().map { it.meta } + val simpleMeta = simpleNotifications.filterIsInstance().map { it.meta } + return when { + roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed + else -> SummaryNotification.Update( + summaryGroupMessageCreator.createSummaryNotification( + sessionId = sessionId, + roomNotifications = roomMeta, + invitationNotifications = invitationMeta, + simpleNotifications = simpleMeta, + useCompleteNotificationFormat = useCompleteNotificationFormat + ) + ) + } + } +} + +sealed interface RoomNotification { + data class Removed(val roomId: RoomId) : RoomNotification + data class Message(val notification: Notification, val meta: Meta) : RoomNotification { + data class Meta( + val roomId: RoomId, + val summaryLine: CharSequence, + val messageCount: Int, + val latestTimestamp: Long, + 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 +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProvider.kt new file mode 100644 index 0000000000..3ce941de2f --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProvider.kt @@ -0,0 +1,51 @@ +/* + * 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 io.element.android.libraries.matrix.api.core.SessionId +import javax.inject.Inject +import kotlin.math.abs + +class NotificationIdProvider @Inject constructor() { + fun getSummaryNotificationId(sessionId: SessionId): Int { + return getOffset(sessionId) + SUMMARY_NOTIFICATION_ID + } + + fun getRoomMessagesNotificationId(sessionId: SessionId): Int { + return getOffset(sessionId) + ROOM_MESSAGES_NOTIFICATION_ID + } + + fun getRoomEventNotificationId(sessionId: SessionId): Int { + return getOffset(sessionId) + ROOM_EVENT_NOTIFICATION_ID + } + + fun getRoomInvitationNotificationId(sessionId: SessionId): Int { + return getOffset(sessionId) + ROOM_INVITATION_NOTIFICATION_ID + } + + private fun getOffset(sessionId: SessionId): Int { + // Compute a int from a string with a low risk of collision. + return abs(sessionId.value.hashCode() % 100_000) * 10 + } + + companion object { + private const val SUMMARY_NOTIFICATION_ID = 0 + private const val ROOM_MESSAGES_NOTIFICATION_ID = 1 + private const val ROOM_EVENT_NOTIFICATION_ID = 2 + private const val ROOM_INVITATION_NOTIFICATION_ID = 3 + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt new file mode 100644 index 0000000000..521f4f3c8c --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt @@ -0,0 +1,153 @@ +/* + * 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.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +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 notificationIdProvider: NotificationIdProvider, + private val notificationDisplayer: NotificationDisplayer, + private val notificationFactory: NotificationFactory, +) { + + @WorkerThread + fun render( + sessionId: SessionId, + myUserDisplayName: String, + myUserAvatarUrl: String?, + useCompleteNotificationFormat: Boolean, + eventsToProcess: List> + ) { + val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType() + with(notificationFactory) { + val roomNotifications = roomEvents.toNotifications(sessionId, myUserDisplayName, myUserAvatarUrl) + val invitationNotifications = invitationEvents.toNotifications() + val simpleNotifications = simpleEvents.toNotifications() + val summaryNotification = createSummaryNotification( + sessionId = sessionId, + 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, notificationIdProvider.getSummaryNotificationId(sessionId)) + } + + roomNotifications.forEach { wrapper -> + when (wrapper) { + is RoomNotification.Removed -> { + Timber.d("Removing room messages notification ${wrapper.roomId}") + notificationDisplayer.cancelNotificationMessage(wrapper.roomId.value, notificationIdProvider.getRoomMessagesNotificationId(sessionId)) + } + is RoomNotification.Message -> if (useCompleteNotificationFormat) { + Timber.d("Updating room messages notification ${wrapper.meta.roomId}") + notificationDisplayer.showNotificationMessage( + wrapper.meta.roomId.value, + notificationIdProvider.getRoomMessagesNotificationId(sessionId), + wrapper.notification + ) + } + } + } + + invitationNotifications.forEach { wrapper -> + when (wrapper) { + is OneShotNotification.Removed -> { + Timber.d("Removing invitation notification ${wrapper.key}") + notificationDisplayer.cancelNotificationMessage(wrapper.key, notificationIdProvider.getRoomInvitationNotificationId(sessionId)) + } + is OneShotNotification.Append -> if (useCompleteNotificationFormat) { + Timber.d("Updating invitation notification ${wrapper.meta.key}") + notificationDisplayer.showNotificationMessage( + wrapper.meta.key, + notificationIdProvider.getRoomInvitationNotificationId(sessionId), + wrapper.notification + ) + } + } + } + + simpleNotifications.forEach { wrapper -> + when (wrapper) { + is OneShotNotification.Removed -> { + Timber.d("Removing simple notification ${wrapper.key}") + notificationDisplayer.cancelNotificationMessage(wrapper.key, notificationIdProvider.getRoomEventNotificationId(sessionId)) + } + is OneShotNotification.Append -> if (useCompleteNotificationFormat) { + Timber.d("Updating simple notification ${wrapper.meta.key}") + notificationDisplayer.showNotificationMessage( + wrapper.meta.key, + notificationIdProvider.getRoomEventNotificationId(sessionId), + 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, + notificationIdProvider.getSummaryNotificationId(sessionId), + summaryNotification.notification + ) + } + } + } + + fun cancelAllNotifications() { + notificationDisplayer.cancelAllNotifications() + } +} + +private fun List>.groupByType(): GroupedNotificationEvents { + val roomIdToEventMap: MutableMap>> = LinkedHashMap() + val simpleEvents: MutableList> = ArrayList() + val invitationEvents: MutableList> = 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 ProcessedEvent.castedToEventType(): ProcessedEvent = this as ProcessedEvent + +data class GroupedNotificationEvents( + val roomEvents: Map>>, + val simpleEvents: List>, + val invitationEvents: List> +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt new file mode 100644 index 0000000000..808bf4114b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt @@ -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>, +) { + + fun updateQueuedEvents( + drawerManager: NotificationDrawerManager, + action: NotificationDrawerManager.(NotificationEventQueue, List>) -> T + ): T { + return synchronized(queuedEvents) { + action(drawerManager, queuedEvents, renderedEvents) + } + } + + fun clearAndAddRenderedEvents(eventsToRender: List>) { + renderedEvents.clear() + renderedEvents.addAll(eventsToRender) + } + + fun hasAlreadyRendered(eventsToRender: List>) = renderedEvents == eventsToRender + + fun queuedEvents(block: (NotificationEventQueue) -> Unit) { + synchronized(queuedEvents) { + block(queuedEvents) + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt new file mode 100755 index 0000000000..eeb02ebb8b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt @@ -0,0 +1,727 @@ +/* + * 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.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.DrawableRes +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.core.content.res.ResourcesCompat +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.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.intent.IntentProvider +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock +import timber.log.Timber +import javax.inject.Inject + +// 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 intentProvider: IntentProvider, + 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) + + init { + createNotificationChannels() + } + + /* ========================================================================================== + * Channel names + * ========================================================================================== */ + + /** + * Create notification channels. + */ + private 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(R.string.notification_channel_noisy).ifEmpty { "Noisy notifications" }, + NotificationManager.IMPORTANCE_DEFAULT + ) + .apply { + description = stringProvider.getString(R.string.notification_channel_noisy) + enableVibration(true) + enableLights(true) + lightColor = accentColor + }) + + /** + * Low notification importance: shows everywhere, but is not intrusive. + */ + notificationManager.createNotificationChannel(NotificationChannel( + SILENT_NOTIFICATION_CHANNEL_ID, + stringProvider.getString(R.string.notification_channel_silent).ifEmpty { "Silent notifications" }, + NotificationManager.IMPORTANCE_LOW + ) + .apply { + description = stringProvider.getString(R.string.notification_channel_silent) + setSound(null, null) + enableLights(true) + lightColor = accentColor + }) + + notificationManager.createNotificationChannel(NotificationChannel( + LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID, + stringProvider.getString(R.string.notification_channel_listening_for_events).ifEmpty { "Listening for events" }, + NotificationManager.IMPORTANCE_MIN + ) + .apply { + description = stringProvider.getString(R.string.notification_channel_listening_for_events) + setSound(null, null) + setShowBadge(false) + }) + + notificationManager.createNotificationChannel(NotificationChannel( + CALL_NOTIFICATION_CHANNEL_ID, + stringProvider.getString(R.string.notification_channel_call).ifEmpty { "Call" }, + NotificationManager.IMPORTANCE_HIGH + ) + .apply { + description = stringProvider.getString(R.string.notification_channel_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: ThreadId?, + 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.sessionId, 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.value) + // Title for API < 16 devices. + .setContentTitle(roomInfo.roomDisplayName) + // Content for API < 16 devices. + .setContentText(stringProvider.getString(R.string.notification_new_messages)) + // Number of new notifications for API <24 (M and below) devices. + .setSubText( + stringProvider.getQuantityString( + R.plurals.notification_new_messages_for_room, + 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 + .setGroup(roomInfo.sessionId.value) + // 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("markRead?${roomInfo.sessionId}&$${roomInfo.roomId}") + markRoomReadIntent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, roomInfo.sessionId) + markRoomReadIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId) + val markRoomReadPendingIntent = PendingIntent.getBroadcast( + context, + clock.epochMillis().toInt(), + markRoomReadIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + NotificationCompat.Action.Builder( + R.drawable.ic_material_done_all_white, + stringProvider.getString(R.string.notification_room_action_mark_as_read), markRoomReadPendingIntent + ) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) + .setShowsUserInterface(false) + .build() + .let { addAction(it) } + + // Quick reply + if (!roomInfo.hasSmartReplyError) { + buildQuickReplyIntent(roomInfo.sessionId, roomInfo.roomId, threadId, senderDisplayNameForReplyCompat)?.let { replyPendingIntent -> + val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY) + .setLabel(stringProvider.getString(R.string.notification_room_action_quick_reply)) + .build() + NotificationCompat.Action.Builder( + R.drawable.vector_notification_quick_reply, + stringProvider.getString(R.string.notification_room_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.action = actionIds.dismissRoom + intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, roomInfo.sessionId) + intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId) + val pendingIntent = PendingIntent.getBroadcast( + context.applicationContext, + clock.epochMillis().toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + setDeleteIntent(pendingIntent) + } + .setTicker(tickerText) + .build() + } + + fun buildRoomInvitationNotification( + inviteNotifiableEvent: InviteNotifiableEvent + ): 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(inviteNotifiableEvent.sessionId.value) + .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("rejectInvite?${inviteNotifiableEvent.sessionId}&$roomId") + rejectIntent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, inviteNotifiableEvent.sessionId) + rejectIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId) + val rejectIntentPendingIntent = PendingIntent.getBroadcast( + context, + clock.epochMillis().toInt(), + rejectIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + addAction( + R.drawable.vector_notification_reject_invitation, + stringProvider.getString(R.string.notification_invitation_action_reject), + rejectIntentPendingIntent + ) + + // offer to type a quick accept button + val joinIntent = Intent(context, NotificationBroadcastReceiver::class.java) + joinIntent.action = actionIds.join + joinIntent.data = createIgnoredUri("acceptInvite?${inviteNotifiableEvent.sessionId}&$roomId") + joinIntent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, inviteNotifiableEvent.sessionId) + joinIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId) + val joinIntentPendingIntent = PendingIntent.getBroadcast( + context, + clock.epochMillis().toInt(), + joinIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + addAction( + R.drawable.vector_notification_accept_invitation, + stringProvider.getString(R.string.notification_invitation_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, + ): 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(simpleNotifiableEvent.sessionId.value) + .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(sessionId: SessionId, roomId: RoomId): PendingIntent? { + val roomIntent = intentProvider.getIntent(sessionId = sessionId, roomId = roomId, threadId = null) + roomIntent.action = actionIds.tapToView + // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that + roomIntent.data = createIgnoredUri("openRoom?$sessionId&$roomId") + + return PendingIntent.getActivity( + context, + clock.epochMillis().toInt(), + roomIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + private fun buildOpenThreadIntent(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): PendingIntent? { + val sessionId = roomInfo.sessionId + val roomId = roomInfo.roomId + val threadIntentTap = intentProvider.getIntent(sessionId = sessionId, roomId = roomId, threadId = threadId) + 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?$sessionId&$roomId&$threadId") + + return PendingIntent.getActivity( + context, + clock.epochMillis().toInt(), + threadIntentTap, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + private fun buildOpenHomePendingIntentForSummary(sessionId: SessionId): PendingIntent { + val intent = intentProvider.getIntent(sessionId = sessionId, roomId = null, threadId = null) + intent.data = createIgnoredUri("tapSummary?$sessionId") + return PendingIntent.getActivity( + context, + clock.epochMillis().toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.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( + sessionId: SessionId, + roomId: RoomId, + threadId: ThreadId?, + 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("quickReply?$sessionId&$roomId") + intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId) + 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 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE + } else { + 0 + } + ) + } 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( + sessionId: SessionId, + 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(sessionId.value) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setSmallIcon(smallIcon) + // set content text to support devices running API level < 24 + .setContentText(compatSummary) + .setGroup(sessionId.value) + // 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(sessionId)) + .setDeleteIntent(getDismissSummaryPendingIntent(sessionId)) + .build() + } + + private fun getDismissSummaryPendingIntent(sessionId: SessionId): PendingIntent { + val intent = Intent(context, NotificationBroadcastReceiver::class.java) + intent.action = actionIds.dismissSummary + intent.data = createIgnoredUri("deleteSummary?$sessionId") + intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId) + return PendingIntent.getBroadcast( + context.applicationContext, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + /** + * 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() { + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + Timber.w("Not allowed to notify.") + return + } + val testActionIntent = Intent(context, TestNotificationReceiver::class.java) + testActionIntent.action = actionIds.diagnostic + val testPendingIntent = PendingIntent.getBroadcast( + context, + 0, + testActionIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + notificationManager.notify( + "DIAGNOSTIC", + 888, + NotificationCompat.Builder(context, NOISY_NOTIFICATION_CHANNEL_ID) + .setContentTitle(buildMeta.applicationName) + .setContentText(stringProvider.getString(R.string.notification_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()!!.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 + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt new file mode 100644 index 0000000000..1d82fc31e4 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt @@ -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 + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt new file mode 100644 index 0000000000..2e91ca3467 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt @@ -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( + val type: Type, + val event: T +) { + enum class Type { + KEEP, + REMOVE + } +} + +fun List>.onlyKeptEvents() = mapNotNull { processedEvent -> + processedEvent.event.takeIf { processedEvent.type == ProcessedEvent.Type.KEEP } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt new file mode 100644 index 0000000000..b2a5fcbedb --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt @@ -0,0 +1,39 @@ +/* + * 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 + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId + +/** + * Data class to hold information about a group of notifications for a room. + */ +data class RoomEventGroupInfo( + val sessionId: SessionId, + val roomId: RoomId, + val roomDisplayName: String, + val 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 +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt new file mode 100644 index 0000000000..ab38ad9fb4 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt @@ -0,0 +1,179 @@ +/* + * 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.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.services.toolbox.api.strings.StringProvider +import me.gujun.android.span.Span +import me.gujun.android.span.span +import timber.log.Timber +import javax.inject.Inject + +class RoomGroupMessageCreator @Inject constructor( + private val bitmapLoader: NotificationBitmapLoader, + private val stringProvider: StringProvider, + private val notificationUtils: NotificationUtils +) { + + fun createRoomMessage( + sessionId: SessionId, + events: List, + roomId: RoomId, + 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.sessionId.value) + .build() + ).also { + it.conversationTitle = roomName.takeIf { roomIsGroup } + it.isGroupConversation = roomIsGroup + it.addMessagesFromEvents(events) + } + + val tickerText = if (roomIsGroup) { + stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderName, events.last().description) + } else { + stringProvider.getString(R.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( + sessionId = sessionId, + roomId = roomId, + roomDisplayName = 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) { + 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(R.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, roomName: String, roomIsDirect: Boolean): CharSequence { + return try { + when (events.size) { + 1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDirect) + else -> { + stringProvider.getQuantityString( + R.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): Bitmap? { + // Use the last event (most recent?) + return events.lastOrNull() + ?.roomAvatarPath + ?.let { bitmapLoader.getRoomBitmap(it) } + } +} + +private fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt new file mode 100644 index 0000000000..ed0053e58c --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt @@ -0,0 +1,176 @@ +/* + * 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.matrix.api.core.SessionId +import io.element.android.libraries.push.impl.R +import io.element.android.services.toolbox.api.strings.StringProvider +import javax.inject.Inject + +/** + * ======== 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( + sessionId: SessionId, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + 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(R.plurals.notification_compat_summary_title, nbEvents, nbEvents) + summaryInboxStyle.setBigContentTitle(sumTitle) + // TODO get latest event? + .setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents)) + return if (useCompleteNotificationFormat) { + notificationUtils.buildSummaryListNotification( + sessionId, + summaryInboxStyle, + sumTitle, + noisy = summaryIsNoisy, + lastMessageTimestamp = lastMessageTimestamp + ) + } else { + processSimpleGroupSummary( + sessionId, + summaryIsNoisy, + messageCount, + simpleNotifications.size, + invitationNotifications.size, + roomNotifications.size, + lastMessageTimestamp + ) + } + } + + private fun processSimpleGroupSummary( + sessionId: SessionId, + 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( + R.plurals.notification_invitations, + invitationEventsCount, + invitationEventsCount + ) + if (messageNotificationCount > 0) { + // Invitation and message + val messageStr = stringProvider.getQuantityString( + R.plurals.notification_new_messages_for_room, + messageNotificationCount, messageNotificationCount + ) + if (roomCount > 1) { + // In several rooms + val roomStr = stringProvider.getQuantityString( + R.plurals.notification_unread_notified_messages_in_room_rooms, + roomCount, + roomCount + ) + stringProvider.getString( + R.string.notification_unread_notified_messages_in_room_and_invitation, + messageStr, + roomStr, + invitationsStr + ) + } else { + // In one room + stringProvider.getString( + R.string.notification_unread_notified_messages_and_invitation, + messageStr, + invitationsStr + ) + } + } else { + // Only invitation + invitationsStr + } + } else { + // No invitation, only messages + val messageStr = stringProvider.getQuantityString( + R.plurals.notification_new_messages_for_room, + messageNotificationCount, + messageNotificationCount + ) + if (roomCount > 1) { + // In several rooms + val roomStr = stringProvider.getQuantityString( + R.plurals.notification_unread_notified_messages_in_room_rooms, + roomCount, + roomCount + ) + stringProvider.getString( + R.string.notification_unread_notified_messages_in_room, + messageStr, + roomStr + ) + } else { + // In one room + messageStr + } + } + return notificationUtils.buildSummaryListNotification( + sessionId = sessionId, + style = null, + compatSummary = privacyTitle, + noisy = summaryIsNoisy, + lastMessageTimestamp = lastMessageTimestamp + ) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt new file mode 100644 index 0000000000..42c0fe61af --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt @@ -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) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/InviteNotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/InviteNotifiableEvent.kt new file mode 100644 index 0000000000..4524d27ac2 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/InviteNotifiableEvent.kt @@ -0,0 +1,37 @@ +/* + * 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 io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId + +data class InviteNotifiableEvent( + override val sessionId: SessionId, + override val roomId: RoomId, + override val eventId: EventId, + override val editedEventId: EventId?, + override val canBeReplaced: Boolean, + val roomName: String?, + val noisy: Boolean, + val title: String, + val description: String, + val type: String?, + val timestamp: Long, + val soundName: String?, + override val isRedacted: Boolean = false, + override val isUpdated: Boolean = false +) : NotifiableEvent diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt new file mode 100644 index 0000000000..40d84496a5 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt @@ -0,0 +1,36 @@ +/* + * 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 io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import java.io.Serializable + +/** + * Parent interface for all events which can be displayed as a Notification. + */ +sealed interface NotifiableEvent : Serializable { + val sessionId: SessionId + val roomId: RoomId + val eventId: EventId + val editedEventId: EventId? + + // Used to know if event should be replaced with the one coming from eventstream + val canBeReplaced: Boolean + val isRedacted: Boolean + val isUpdated: Boolean +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt new file mode 100644 index 0000000000..add172e44f --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt @@ -0,0 +1,71 @@ +/* + * 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 +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.services.appnavstate.api.AppNavigationState +import io.element.android.services.appnavstate.api.currentRoomId +import io.element.android.services.appnavstate.api.currentSessionId +import io.element.android.services.appnavstate.api.currentThreadId + +data class NotifiableMessageEvent( + override val sessionId: SessionId, + override val roomId: RoomId, + override val eventId: EventId, + override val editedEventId: EventId?, + override val canBeReplaced: Boolean, + val 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 threadId: ThreadId?, + val roomName: String?, + val roomIsDirect: Boolean = false, + val roomAvatarPath: String? = null, + val senderAvatarPath: String? = null, + val soundName: String? = null, + // This is used for >N notification, as the result of a smart reply + val outGoingMessage: Boolean = false, + val outGoingMessageFailed: Boolean = false, + override val isRedacted: Boolean = false, + override val isUpdated: Boolean = false +) : 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( + appNavigationState: AppNavigationState? +): Boolean { + val currentSessionId = appNavigationState?.currentSessionId() ?: return false + return when (val currentRoomId = appNavigationState.currentRoomId()) { + null -> false + else -> sessionId == currentSessionId && roomId == currentRoomId && threadId == appNavigationState.currentThreadId() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt new file mode 100644 index 0000000000..5cfd04474a --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt @@ -0,0 +1,36 @@ +/* + * 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 io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId + +data class SimpleNotifiableEvent( + override val sessionId: SessionId, + override val roomId: RoomId, + override val eventId: EventId, + override val editedEventId: EventId?, + val noisy: Boolean, + val title: String, + 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 diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/permission/NotificationPermissionManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/permission/NotificationPermissionManager.kt new file mode 100644 index 0000000000..e1fd17332e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/permission/NotificationPermissionManager.kt @@ -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.permission + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import io.element.android.libraries.di.ApplicationContext +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import javax.inject.Inject + +// TODO EAx move +class NotificationPermissionManager @Inject constructor( + private val sdkIntProvider: BuildVersionSdkIntProvider, + @ApplicationContext private val context: Context, +) { + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + fun isPermissionGranted(): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } + + /* + fun eventuallyRequestPermission( + activity: Activity, + requestPermissionLauncher: ActivityResultLauncher>, + showRationale: Boolean = true, + ignorePreference: Boolean = false, + ) { + if (!sdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) return + // if (!vectorPreferences.areNotificationEnabledForDevice() && !ignorePreference) return + checkPermissions( + listOf(Manifest.permission.POST_NOTIFICATIONS), + activity, + activityResultLauncher = requestPermissionLauncher, + if (showRationale) R.string.permissions_rationale_msg_notification else 0 + ) + } + */ + + fun eventuallyRevokePermission( + activity: Activity, + ) { + if (!sdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) return + activity.revokeSelfPermissionOnKill(Manifest.permission.POST_NOTIFICATIONS) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushData.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushData.kt new file mode 100644 index 0000000000..31d3eb7ea0 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushData.kt @@ -0,0 +1,34 @@ +/* + * 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.push + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId + +/** + * 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: EventId?, + val roomId: RoomId?, + val unread: Int?, + val clientSecret: String?, +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushHandler.kt new file mode 100644 index 0000000000..b4c9716b62 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushHandler.kt @@ -0,0 +1,142 @@ +/* + * 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.push + +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +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.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.push.api.store.PushDataStore +import io.element.android.libraries.push.impl.PushersManager +import io.element.android.libraries.push.impl.clientsecret.PushClientSecret +import io.element.android.libraries.push.impl.log.pushLoggerTag +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 timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("PushHandler", pushLoggerTag) + +class PushHandler @Inject constructor( + private val notificationDrawerManager: NotificationDrawerManager, + private val notifiableEventResolver: NotifiableEventResolver, + private val pushDataStore: PushDataStore, + private val defaultPushDataStore: DefaultPushDataStore, + private val pushClientSecret: PushClientSecret, + private val actionIds: NotificationActionIds, + @ApplicationContext private val context: Context, + private val buildMeta: BuildMeta, + private val matrixAuthenticationService: MatrixAuthenticationService, +) { + + 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. + */ + suspend fun handle(pushData: PushData) { + Timber.tag(loggerTag.value).d("## handling pushData") + + if (buildMeta.lowPrivacyLoggingEnabled) { + Timber.tag(loggerTag.value).d("## pushData: $pushData") + } + + defaultPushDataStore.incrementPushCounter() + + // Diagnostic Push + if (pushData.eventId == PushersManager.TEST_EVENT_ID) { + val intent = Intent(actionIds.push) + LocalBroadcastManager.getInstance(context).sendBroadcast(intent) + return + } + + // TODO EAx Should be per user + if (!pushDataStore.areNotificationEnabledForDevice()) { + Timber.tag(loggerTag.value).i("Notification are disabled for this device") + return + } + + mUIHandler.post { + 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()") + } + + pushData.roomId ?: return + pushData.eventId ?: return + + val clientSecret = pushData.clientSecret + val userId = if (clientSecret == null) { + // Should not happen. In this case, restore default session + null + } else { + // Get userId from client secret + pushClientSecret.getUserIdFromSecret(clientSecret) + } ?: run { + matrixAuthenticationService.getLatestSessionId() + } + + if (userId == null) { + Timber.w("Unable to get a session") + return + } + + val notificationData = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId) + + if (notificationData == null) { + Timber.w("Unable to get a notification data") + return + } + + notificationDrawerManager.onNotifiableEventReceived(notificationData) + } catch (e: Exception) { + Timber.tag(loggerTag.value).e(e, "## handleInternal() failed") + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt new file mode 100644 index 0000000000..02bd7850e9 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt @@ -0,0 +1,30 @@ +/* + * 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.pushgateway + + +import retrofit2.http.Body +import retrofit2.http.POST + +internal interface PushGatewayAPI { + /** + * Ask the Push Gateway to send a push to the current device. + * + * Ref: https://matrix.org/docs/spec/push_gateway/r0.1.1#post-matrix-push-v1-notify + */ + @POST(PushGatewayConfig.URI_PUSH_GATEWAY_PREFIX_PATH + "notify") + suspend fun notify(@Body body: PushGatewayNotifyBody): PushGatewayNotifyResponse +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayConfig.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayConfig.kt new file mode 100644 index 0000000000..5cd46f873d --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayConfig.kt @@ -0,0 +1,22 @@ +/* + * 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.pushgateway + +object PushGatewayConfig { + // Push Gateway + const val URI_PUSH_GATEWAY_PREFIX_PATH = "_matrix/push/v1/" +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt new file mode 100644 index 0000000000..7adedfcfd2 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt @@ -0,0 +1,34 @@ +/* + * 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.pushgateway + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class PushGatewayDevice( + /** + * Required. The app_id given when the pusher was created. + */ + @SerialName("app_id") + val appId: String, + /** + * Required. The pushkey given when the pusher was created. + */ + @SerialName("pushkey") + val pushKey: String +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt new file mode 100644 index 0000000000..b7649f6800 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt @@ -0,0 +1,32 @@ +/* + * 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.pushgateway + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class PushGatewayNotification( + @SerialName("event_id") + val eventId: String, + + /** + * Required. This is an array of devices that the notification should be sent to. + */ + @SerialName("devices") + val devices: List +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt new file mode 100644 index 0000000000..ce41d2d83e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt @@ -0,0 +1,29 @@ +/* + * 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.pushgateway + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class PushGatewayNotifyBody( + /** + * Required. Information about the push notification + */ + @SerialName("notification") + val notification: PushGatewayNotification +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt new file mode 100644 index 0000000000..7130e38d6e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt @@ -0,0 +1,57 @@ +/* + * 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.pushgateway + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.network.RetrofitFactory +import io.element.android.libraries.push.api.gateway.PushGatewayFailure +import javax.inject.Inject + +class PushGatewayNotifyRequest @Inject constructor( + private val retrofitFactory: RetrofitFactory, +) { + data class Params( + val url: String, + val appId: String, + val pushKey: String, + val eventId: EventId + ) + + suspend fun execute(params: Params) { + val sygnalApi = retrofitFactory.create( + params.url.substringBefore(PushGatewayConfig.URI_PUSH_GATEWAY_PREFIX_PATH) + ) + .create(PushGatewayAPI::class.java) + + val response = sygnalApi.notify( + PushGatewayNotifyBody( + PushGatewayNotification( + eventId = params.eventId.value, + devices = listOf( + PushGatewayDevice( + params.appId, + params.pushKey + ) + ) + ) + ) + ) + + if (response.rejectedPushKeys.contains(params.pushKey)) { + throw PushGatewayFailure.PusherRejected + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt new file mode 100644 index 0000000000..13d9cbad1d --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt @@ -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.pushgateway + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class PushGatewayNotifyResponse( + @SerialName("rejected") + val rejectedPushKeys: List +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt new file mode 100644 index 0000000000..8474682ab6 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt @@ -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 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 = 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" + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/GuardServiceStarter.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/GuardServiceStarter.kt new file mode 100644 index 0000000000..4c93b8a929 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/GuardServiceStarter.kt @@ -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.unifiedpush + +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 { + +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/KeepInternalDistributor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/KeepInternalDistributor.kt new file mode 100644 index 0000000000..de66ed3914 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/KeepInternalDistributor.kt @@ -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.unifiedpush + +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) {} +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/PushDataUnifiedPush.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/PushDataUnifiedPush.kt new file mode 100644 index 0000000000..56513ab970 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/PushDataUnifiedPush.kt @@ -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.unifiedpush + +import io.element.android.libraries.matrix.api.core.MatrixPatterns +import io.element.android.libraries.matrix.api.core.asEventId +import io.element.android.libraries.matrix.api.core.asRoomId +import io.element.android.libraries.push.impl.push.PushData +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * In this case, the format is: + *
+ * {
+ *     "notification":{
+ *         "event_id":"$anEventId",
+ *         "room_id":"!aRoomId",
+ *         "counts":{
+ *             "unread":1
+ *         },
+ *         "prio":"high"
+ *     }
+ * }
+ * 
+ * . + */ +@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) }?.asEventId(), + roomId = notification?.roomId?.takeIf { MatrixPatterns.isRoomId(it) }?.asRoomId(), + unread = notification?.counts?.unread, + clientSecret = null // TODO EAx check how client secret will be sent through UnifiedPush +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/RegisterUnifiedPushUseCase.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/RegisterUnifiedPushUseCase.kt new file mode 100644 index 0000000000..50ca94f30d --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/RegisterUnifiedPushUseCase.kt @@ -0,0 +1,68 @@ +/* + * 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.unifiedpush + +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) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnifiedPushParser.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnifiedPushParser.kt new file mode 100644 index 0000000000..9788ecf1a1 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnifiedPushParser.kt @@ -0,0 +1,29 @@ +/* + * 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.unifiedpush + +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.push.impl.push.PushData +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import javax.inject.Inject + +class UnifiedPushParser @Inject constructor() { + fun parse(message: ByteArray): PushData? { + return tryOrNull { Json.decodeFromString(String(message)) }?.toPushData() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnregisterUnifiedPushUseCase.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnregisterUnifiedPushUseCase.kt new file mode 100644 index 0000000000..6cd1af1de3 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnregisterUnifiedPushUseCase.kt @@ -0,0 +1,52 @@ +/* + * 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.unifiedpush + +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 io.element.android.libraries.push.impl.PushersManager +import io.element.android.libraries.push.impl.UnifiedPushHelper +import io.element.android.libraries.push.impl.UnifiedPushStore +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) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiver.kt new file mode 100644 index 0000000000..81dd389e78 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiver.kt @@ -0,0 +1,123 @@ +/* + * 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.unifiedpush + +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.PushersManager +import io.element.android.libraries.push.impl.UnifiedPushHelper +import io.element.android.libraries.push.impl.UnifiedPushStore +import io.element.android.libraries.push.impl.log.pushLoggerTag +import io.element.android.libraries.push.impl.push.PushHandler +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("Unified", pushLoggerTag) + +class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { + @Inject lateinit var pushersManager: PushersManager + @Inject lateinit var pushParser: UnifiedPushParser + + //@Inject lateinit var activeSessionHolder: ActiveSessionHolder + @Inject lateinit var pushDataStore: PushDataStore + @Inject lateinit var pushHandler: PushHandler + @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().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") + coroutineScope.launch { + pushParser.parse(message)?.let { + pushHandler.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 { + coroutineScope.launch { + pushersManager.onNewUnifiedPushEndpoint(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") + } + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt new file mode 100644 index 0000000000..90857d990d --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt @@ -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.unifiedpush + +import com.squareup.anvil.annotations.ContributesTo +import io.element.android.libraries.di.AppScope + +@ContributesTo(AppScope::class) +interface VectorUnifiedPushMessagingReceiverBindings { + fun inject(receiver: VectorUnifiedPushMessagingReceiver) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStore.kt new file mode 100644 index 0000000000..a66b283519 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStore.kt @@ -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.userpushstore + +const val NOTIFICATION_METHOD_FIREBASE = "NOTIFICATION_METHOD_FIREBASE" +const val NOTIFICATION_METHOD_UNIFIEDPUSH = "NOTIFICATION_METHOD_UNIFIEDPUSH" + +/** + * Store data related to push about a user. + */ +interface UserPushStore { + /** + * NOTIFICATION_METHOD_FIREBASE or NOTIFICATION_METHOD_UNIFIEDPUSH + */ + suspend fun getNotificationMethod(): String + + suspend fun setNotificationMethod(value: String) + + suspend fun getCurrentRegisteredPushKey(): String? + + suspend fun setCurrentRegisteredPushKey(value: String) + + suspend fun reset() +} + +suspend fun UserPushStore.isFirebase(): Boolean = getNotificationMethod() == NOTIFICATION_METHOD_FIREBASE diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreDataStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreDataStore.kt new file mode 100644 index 0000000000..6f25599e54 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreDataStore.kt @@ -0,0 +1,63 @@ +/* + * 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.userpushstore + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.first + +/** + * Store data related to push about a user. + */ +class UserPushStoreDataStore( + private val context: Context, + userId: String, +) : UserPushStore { + private val Context.dataStore: DataStore by preferencesDataStore(name = "push_store_$userId") + private val notificationMethod = stringPreferencesKey("notificationMethod") + private val currentPushKey = stringPreferencesKey("currentPushKey") + + override suspend fun getNotificationMethod(): String { + return context.dataStore.data.first()[notificationMethod] ?: NOTIFICATION_METHOD_FIREBASE + } + + override suspend fun setNotificationMethod(value: String) { + context.dataStore.edit { + it[notificationMethod] = value + } + } + + override suspend fun getCurrentRegisteredPushKey(): String? { + return context.dataStore.data.first()[currentPushKey] + } + + override suspend fun setCurrentRegisteredPushKey(value: String) { + context.dataStore.edit { + it[currentPushKey] = value + } + } + + override suspend fun reset() { + context.dataStore.edit { + it.clear() + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreFactory.kt new file mode 100644 index 0000000000..0323713de7 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreFactory.kt @@ -0,0 +1,59 @@ +/* + * 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.userpushstore + +import android.content.Context +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.sessionstorage.api.observer.SessionListener +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import javax.inject.Inject + +@SingleIn(AppScope::class) +class UserPushStoreFactory @Inject constructor( + @ApplicationContext private val context: Context, + private val sessionObserver: SessionObserver, +) : SessionListener { + init { + observeSessions() + } + + // We can have only one class accessing a single data store, so keep a cache of them. + private val cache = mutableMapOf() + fun create(userId: String): UserPushStore { + return cache.getOrPut(userId) { + UserPushStoreDataStore( + context = context, + userId = userId + ) + } + } + + private fun observeSessions() { + sessionObserver.addListener(this) + } + + override suspend fun onSessionCreated(userId: String) { + // Nothing to do + } + + override suspend fun onSessionDeleted(userId: String) { + // Delete the store + create(userId).reset() + } +} diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/element_logo_green.xml b/libraries/push/impl/src/main/res/drawable-xxhdpi/element_logo_green.xml new file mode 100644 index 0000000000..e9b119c969 --- /dev/null +++ b/libraries/push/impl/src/main/res/drawable-xxhdpi/element_logo_green.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_material_done_all_white.png b/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_material_done_all_white.png new file mode 100755 index 0000000000..1f3132a3f2 Binary files /dev/null and b/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_material_done_all_white.png differ diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_notification.png b/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_notification.png new file mode 100644 index 0000000000..a86508b71b Binary files /dev/null and b/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_notification.png differ diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_accept_invitation.png b/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_accept_invitation.png new file mode 100755 index 0000000000..eb2be25187 Binary files /dev/null and b/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_accept_invitation.png differ diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_quick_reply.png b/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_quick_reply.png new file mode 100755 index 0000000000..4af4ae634b Binary files /dev/null and b/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_quick_reply.png differ diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_reject_invitation.png b/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_reject_invitation.png new file mode 100755 index 0000000000..51b4401ca0 Binary files /dev/null and b/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_reject_invitation.png differ diff --git a/libraries/push/impl/src/main/res/values/colors.xml b/libraries/push/impl/src/main/res/values/colors.xml new file mode 100644 index 0000000000..6e04238a1a --- /dev/null +++ b/libraries/push/impl/src/main/res/values/colors.xml @@ -0,0 +1,22 @@ + + + + + + #368BD6 + + diff --git a/libraries/push/impl/src/main/res/values/dimens.xml b/libraries/push/impl/src/main/res/values/dimens.xml new file mode 100644 index 0000000000..ce2fee2015 --- /dev/null +++ b/libraries/push/impl/src/main/res/values/dimens.xml @@ -0,0 +1,21 @@ + + + + + 50dp + + diff --git a/libraries/push/impl/src/main/res/values/localazy.xml b/libraries/push/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..3a11adb5d3 --- /dev/null +++ b/libraries/push/impl/src/main/res/values/localazy.xml @@ -0,0 +1,48 @@ + + + "Call" + "Listening for events" + "Noisy notifications" + "Silent notifications" + "** Failed to send - please open room" + "Join" + "Reject" + "New Messages" + "Mark as read" + "Quick reply" + "Me" + "You are viewing the notification! Click me!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + "%1$s and %2$s" + "%1$s in %2$s" + "%1$s in %2$s and %3$s" + + "%1$s: %2$d message" + "%1$s: %2$d messages" + + + "%d notification" + "%d notifications" + + + "%d invitation" + "%d invitations" + + + "%d new message" + "%d new messages" + + + "%d unread notified message" + "%d unread notified messages" + + + "%d room" + "%d rooms" + + "Choose how to receive notifications" + "Background synchronization" + "Google Services" + "No valid Google Play Services found. Notifications may not work properly." + \ No newline at end of file diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/FakePushClientSecretFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/FakePushClientSecretFactory.kt new file mode 100644 index 0000000000..25823a57e8 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/FakePushClientSecretFactory.kt @@ -0,0 +1,29 @@ +/* + * 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.clientsecret + +private const val A_SECRET_PREFIX = "A_SECRET_" + +class FakePushClientSecretFactory : PushClientSecretFactory { + private var index = 0 + + override fun create() = getSecretForUser(index++) + + fun getSecretForUser(i: Int): String { + return A_SECRET_PREFIX + i + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/InMemoryPushClientSecretStore.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/InMemoryPushClientSecretStore.kt new file mode 100644 index 0000000000..a2d2d9c83c --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/InMemoryPushClientSecretStore.kt @@ -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.clientsecret + +import io.element.android.libraries.matrix.api.core.SessionId + +class InMemoryPushClientSecretStore : PushClientSecretStore { + private val secrets = mutableMapOf() + + fun getSecrets(): Map = secrets + + override suspend fun storeSecret(userId: SessionId, clientSecret: String) { + secrets[userId] = clientSecret + } + + override suspend fun getSecret(userId: SessionId): String? { + return secrets[userId] + } + + override suspend fun resetSecret(userId: SessionId) { + secrets.remove(userId) + } + + override suspend fun getUserIdFromSecret(clientSecret: String): SessionId? { + return secrets.keys.firstOrNull { secrets[it] == clientSecret } + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImplTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImplTest.kt new file mode 100644 index 0000000000..a9d740bf31 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImplTest.kt @@ -0,0 +1,76 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.push.impl.clientsecret + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +private val A_USER_ID_0 = SessionId("A_USER_ID_0") +private val A_USER_ID_1 = SessionId("A_USER_ID_1") + +private const val A_UNKNOWN_SECRET = "A_UNKNOWN_SECRET" + +internal class PushClientSecretImplTest { + + @Test + fun test() = runTest { + val factory = FakePushClientSecretFactory() + val store = InMemoryPushClientSecretStore() + val sut = PushClientSecretImpl(factory, store) + + val secret0 = factory.getSecretForUser(0) + val secret1 = factory.getSecretForUser(1) + val secret2 = factory.getSecretForUser(2) + + assertThat(store.getSecrets()).isEmpty() + assertThat(sut.getUserIdFromSecret(secret0)).isNull() + // Create a secret + assertThat(sut.getSecretForUser(A_USER_ID_0)).isEqualTo(secret0) + assertThat(store.getSecrets()).hasSize(1) + // Same secret returned + assertThat(sut.getSecretForUser(A_USER_ID_0)).isEqualTo(secret0) + assertThat(store.getSecrets()).hasSize(1) + // Another secret returned for another user + assertThat(sut.getSecretForUser(A_USER_ID_1)).isEqualTo(secret1) + assertThat(store.getSecrets()).hasSize(2) + + // Get users from secrets + assertThat(sut.getUserIdFromSecret(secret0)).isEqualTo(A_USER_ID_0) + assertThat(sut.getUserIdFromSecret(secret1)).isEqualTo(A_USER_ID_1) + // Unknown secret + assertThat(sut.getUserIdFromSecret(A_UNKNOWN_SECRET)).isNull() + + // User signs out + sut.resetSecretForUser(A_USER_ID_0) + assertThat(store.getSecrets()).hasSize(1) + // Create a new secret after reset + assertThat(sut.getSecretForUser(A_USER_ID_0)).isEqualTo(secret2) + + // Check the store content + assertThat(store.getSecrets()).isEqualTo( + mapOf( + A_USER_ID_0 to secret2, + A_USER_ID_1 to secret1, + ) + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt new file mode 100644 index 0000000000..80246abb14 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt @@ -0,0 +1,194 @@ +/* + * 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 com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SPACE_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID +import io.element.android.libraries.push.impl.notifications.fake.FakeOutdatedEventDetector +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent +import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.services.appnavstate.test.anAppNavigationState +import org.junit.Test + +private val NOT_VIEWING_A_ROOM = anAppNavigationState() +private val VIEWING_A_ROOM = anAppNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID) +private val VIEWING_A_THREAD = anAppNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID, A_THREAD_ID) + +class NotifiableEventProcessorTest { + + private val outdatedDetector = FakeOutdatedEventDetector() + private val eventProcessor = NotifiableEventProcessor(outdatedDetector.instance) + + @Test + fun `given simple events when processing then keep simple events`() { + val events = listOf( + aSimpleNotifiableEvent(eventId = AN_EVENT_ID), + aSimpleNotifiableEvent(eventId = AN_EVENT_ID_2) + ) + + val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.KEEP to events[0], + ProcessedEvent.Type.KEEP to events[1] + ) + ) + } + + @Test + fun `given redacted simple event when processing then remove redaction event`() { + val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID, type = "m.room.redaction")) + + val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.REMOVE to events[0] + ) + ) + } + + @Test + fun `given invites are not auto accepted when processing then keep invitation events`() { + val events = listOf( + anInviteNotifiableEvent(roomId = A_ROOM_ID), + anInviteNotifiableEvent(roomId = A_ROOM_ID_2) + ) + + val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.KEEP to events[0], + ProcessedEvent.Type.KEEP to events[1] + ) + ) + } + + @Test + fun `given out of date message event when processing then removes message event`() { + val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)) + outdatedDetector.givenEventIsOutOfDate(events[0]) + + val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.REMOVE to events[0], + ) + ) + } + + @Test + fun `given in date message event when processing then keep message event`() { + val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)) + outdatedDetector.givenEventIsInDate(events[0]) + + val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.KEEP to events[0], + ) + ) + } + + @Test + fun `given viewing the same room main timeline when processing main timeline message event then removes message`() { + val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = null)) + + val result = eventProcessor.process(events, VIEWING_A_ROOM, renderedEvents = emptyList()) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.REMOVE to events[0], + ) + ) + } + + @Test + fun `given viewing the same thread timeline when processing thread message event then removes message`() { + val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID)) + + val result = eventProcessor.process(events, VIEWING_A_THREAD, renderedEvents = emptyList()) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.REMOVE to events[0], + ) + ) + } + + @Test + fun `given viewing main timeline of the same room when processing thread timeline message event then keep message`() { + val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID)) + outdatedDetector.givenEventIsInDate(events[0]) + + val result = eventProcessor.process(events, VIEWING_A_ROOM, renderedEvents = emptyList()) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.KEEP to events[0], + ) + ) + } + + @Test + fun `given viewing thread timeline of the same room when processing main timeline message event then keep message`() { + val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)) + outdatedDetector.givenEventIsInDate(events[0]) + + val result = eventProcessor.process(events, VIEWING_A_THREAD, renderedEvents = emptyList()) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.KEEP to events[0], + ) + ) + } + + @Test + fun `given events are different to rendered events when processing then removes difference`() { + val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID)) + val renderedEvents = listOf>( + ProcessedEvent(ProcessedEvent.Type.KEEP, events[0]), + ProcessedEvent(ProcessedEvent.Type.KEEP, anInviteNotifiableEvent(eventId = AN_EVENT_ID_2)) + ) + + val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = renderedEvents) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.REMOVE to renderedEvents[1].event, + ProcessedEvent.Type.KEEP to renderedEvents[0].event + ) + ) + } + + private fun listOfProcessedEvents(vararg event: Pair) = event.map { + ProcessedEvent(it.first, it.second) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt new file mode 100644 index 0000000000..4bec096468 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt @@ -0,0 +1,231 @@ +/* + * 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 com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.cache.CircularCache +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent +import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import org.junit.Test + +class NotificationEventQueueTest { + + private val seenIdsCache = CircularCache.create(5) + + @Test + fun `given events when redacting some then marks matching event ids as redacted`() { + val queue = givenQueue( + listOf( + aSimpleNotifiableEvent(eventId = EventId("redacted-id-1")), + aNotifiableMessageEvent(eventId = EventId("redacted-id-2")), + anInviteNotifiableEvent(eventId = EventId("redacted-id-3")), + aSimpleNotifiableEvent(eventId = EventId("kept-id")), + ) + ) + + queue.markRedacted(listOf(EventId("redacted-id-1"), EventId("redacted-id-2"), EventId("redacted-id-3"))) + + assertThat(queue.rawEvents()).isEqualTo( + listOf( + aSimpleNotifiableEvent(eventId = EventId("redacted-id-1"), isRedacted = true), + aNotifiableMessageEvent(eventId = EventId("redacted-id-2"), isRedacted = true), + anInviteNotifiableEvent(eventId = EventId("redacted-id-3"), isRedacted = true), + aSimpleNotifiableEvent(eventId = EventId("kept-id"), isRedacted = false), + ) + ) + } + + @Test + fun `given invite event when leaving invited room and syncing then removes event`() { + val queue = givenQueue(listOf(anInviteNotifiableEvent(roomId = A_ROOM_ID))) + val roomsLeft = listOf(A_ROOM_ID) + + queue.syncRoomEvents(roomsLeft = roomsLeft, roomsJoined = emptyList()) + + assertThat(queue.rawEvents()).isEmpty() + } + + @Test + fun `given invite event when joining invited room and syncing then removes event`() { + val queue = givenQueue(listOf(anInviteNotifiableEvent(roomId = A_ROOM_ID))) + val joinedRooms = listOf(A_ROOM_ID) + + queue.syncRoomEvents(roomsLeft = emptyList(), roomsJoined = joinedRooms) + + assertThat(queue.rawEvents()).isEmpty() + } + + @Test + fun `given message event when leaving message room and syncing then removes event`() { + val queue = givenQueue(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID))) + val roomsLeft = listOf(A_ROOM_ID) + + queue.syncRoomEvents(roomsLeft = roomsLeft, roomsJoined = emptyList()) + + assertThat(queue.rawEvents()).isEmpty() + } + + @Test + fun `given events when syncing without rooms left or joined ids then does not change the events`() { + val queue = givenQueue( + listOf( + aNotifiableMessageEvent(roomId = A_ROOM_ID), + anInviteNotifiableEvent(roomId = A_ROOM_ID) + ) + ) + + queue.syncRoomEvents(roomsLeft = emptyList(), roomsJoined = emptyList()) + + assertThat(queue.rawEvents()).isEqualTo( + listOf( + aNotifiableMessageEvent(roomId = A_ROOM_ID), + anInviteNotifiableEvent(roomId = A_ROOM_ID) + ) + ) + } + + @Test + fun `given events then is not empty`() { + val queue = givenQueue(listOf(aSimpleNotifiableEvent())) + + assertThat(queue.isEmpty()).isFalse() + } + + @Test + fun `given no events then is empty`() { + val queue = givenQueue(emptyList()) + + assertThat(queue.isEmpty()).isTrue() + } + + @Test + fun `given events when clearing and adding then removes previous events and adds only new events`() { + val queue = givenQueue(listOf(aSimpleNotifiableEvent())) + + queue.clearAndAdd(listOf(anInviteNotifiableEvent())) + + assertThat(queue.rawEvents()).isEqualTo(listOf(anInviteNotifiableEvent())) + } + + @Test + fun `when clearing then is empty`() { + val queue = givenQueue(listOf(aSimpleNotifiableEvent())) + + queue.clear() + + assertThat(queue.rawEvents()).isEmpty() + } + + @Test + fun `given no events when adding then adds event`() { + val queue = givenQueue(listOf()) + + queue.add(aSimpleNotifiableEvent()) + + assertThat(queue.rawEvents()).isEqualTo(listOf(aSimpleNotifiableEvent())) + } + + @Test + fun `given no events when adding already seen event then ignores event`() { + val queue = givenQueue(listOf()) + val notifiableEvent = aSimpleNotifiableEvent() + seenIdsCache.put(notifiableEvent.eventId) + + queue.add(notifiableEvent) + + assertThat(queue.rawEvents()).isEmpty() + } + + @Test + fun `given replaceable event when adding event with same id then updates existing event`() { + val replaceableEvent = aSimpleNotifiableEvent(canBeReplaced = true) + val updatedEvent = replaceableEvent.copy(title = "updated title", isUpdated = true) + val queue = givenQueue(listOf(replaceableEvent)) + + queue.add(updatedEvent) + + assertThat(queue.rawEvents()).isEqualTo(listOf(updatedEvent)) + } + + @Test + fun `given non replaceable event when adding event with same id then ignores event`() { + val nonReplaceableEvent = aSimpleNotifiableEvent(canBeReplaced = false) + val updatedEvent = nonReplaceableEvent.copy(title = "updated title") + val queue = givenQueue(listOf(nonReplaceableEvent)) + + queue.add(updatedEvent) + + assertThat(queue.rawEvents()).isEqualTo(listOf(nonReplaceableEvent)) + } + + @Test + fun `given event when adding new event with edited event id matching the existing event id then updates existing event`() { + val editedEvent = aSimpleNotifiableEvent(eventId = EventId("id-to-edit")) + val updatedEvent = editedEvent.copy(eventId = EventId("1"), editedEventId = EventId("id-to-edit"), title = "updated title", isUpdated = true) + val queue = givenQueue(listOf(editedEvent)) + + queue.add(updatedEvent) + + assertThat(queue.rawEvents()).isEqualTo(listOf(updatedEvent)) + } + + @Test + fun `given event when adding new event with edited event id matching the existing event edited id then updates existing event`() { + val editedEvent = aSimpleNotifiableEvent(eventId = EventId("0"), editedEventId = EventId("id-to-edit")) + val updatedEvent = editedEvent.copy(eventId = EventId("1"), editedEventId = EventId("id-to-edit"), title = "updated title", isUpdated = true) + val queue = givenQueue(listOf(editedEvent)) + + queue.add(updatedEvent) + + assertThat(queue.rawEvents()).isEqualTo(listOf(updatedEvent)) + } + + @Test + fun `when clearing membership notification then removes invite events with matching room id`() { + val queue = givenQueue( + listOf( + anInviteNotifiableEvent(roomId = A_ROOM_ID), + aNotifiableMessageEvent(roomId = A_ROOM_ID) + ) + ) + + queue.clearMemberShipNotificationForRoom(A_SESSION_ID, A_ROOM_ID) + + assertThat(queue.rawEvents()).isEqualTo(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID))) + } + + @Test + fun `when clearing messages for room then removes message events with matching room id`() { + val queue = givenQueue( + listOf( + anInviteNotifiableEvent(roomId = A_ROOM_ID), + aNotifiableMessageEvent(roomId = A_ROOM_ID) + ) + ) + + queue.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID) + + assertThat(queue.rawEvents()).isEqualTo(listOf(anInviteNotifiableEvent(roomId = A_ROOM_ID))) + } + + private fun givenQueue(events: List) = NotificationEventQueue(events.toMutableList(), seenEventIds = seenIdsCache) +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt new file mode 100644 index 0000000000..606923eb1f --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt @@ -0,0 +1,194 @@ +/* + * 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 com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationUtils +import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent +import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent +import org.junit.Test + +private val MY_AVATAR_URL: String? = null +private val AN_INVITATION_EVENT = anInviteNotifiableEvent(roomId = A_ROOM_ID) +private val A_SIMPLE_EVENT = aSimpleNotifiableEvent(eventId = AN_EVENT_ID) +private val A_MESSAGE_EVENT = aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID) + +class NotificationFactoryTest { + + private val notificationUtils = FakeNotificationUtils() + private val roomGroupMessageCreator = FakeRoomGroupMessageCreator() + private val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator() + + private val notificationFactory = NotificationFactory( + notificationUtils.instance, + roomGroupMessageCreator.instance, + summaryGroupMessageCreator.instance + ) + + @Test + fun `given a room invitation when mapping to notification then is Append`() = testWith(notificationFactory) { + val expectedNotification = notificationUtils.givenBuildRoomInvitationNotificationFor(AN_INVITATION_EVENT) + val roomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, AN_INVITATION_EVENT)) + + val result = roomInvitation.toNotifications() + + assertThat(result).isEqualTo( + listOf( + OneShotNotification.Append( + notification = expectedNotification, + meta = OneShotNotification.Append.Meta( + key = A_ROOM_ID.value, + summaryLine = AN_INVITATION_EVENT.description, + isNoisy = AN_INVITATION_EVENT.noisy, + timestamp = AN_INVITATION_EVENT.timestamp + ) + ) + ) + ) + } + + @Test + fun `given a missing event in room invitation when mapping to notification then is Removed`() = testWith(notificationFactory) { + val missingEventRoomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, AN_INVITATION_EVENT)) + + val result = missingEventRoomInvitation.toNotifications() + + assertThat(result).isEqualTo( + listOf( + OneShotNotification.Removed( + key = A_ROOM_ID.value + ) + ) + ) + } + + @Test + fun `given a simple event when mapping to notification then is Append`() = testWith(notificationFactory) { + val expectedNotification = notificationUtils.givenBuildSimpleInvitationNotificationFor(A_SIMPLE_EVENT) + val roomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_SIMPLE_EVENT)) + + val result = roomInvitation.toNotifications() + + assertThat(result).isEqualTo( + listOf( + OneShotNotification.Append( + notification = expectedNotification, + meta = OneShotNotification.Append.Meta( + key = AN_EVENT_ID.value, + summaryLine = A_SIMPLE_EVENT.description, + isNoisy = A_SIMPLE_EVENT.noisy, + timestamp = AN_INVITATION_EVENT.timestamp + ) + ) + ) + ) + } + + @Test + fun `given a missing simple event when mapping to notification then is Removed`() = testWith(notificationFactory) { + val missingEventRoomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, A_SIMPLE_EVENT)) + + val result = missingEventRoomInvitation.toNotifications() + + assertThat(result).isEqualTo( + listOf( + OneShotNotification.Removed( + key = AN_EVENT_ID.value + ) + ) + ) + } + + @Test + fun `given room with message when mapping to notification then delegates to room group message creator`() = testWith(notificationFactory) { + val events = listOf(A_MESSAGE_EVENT) + val expectedNotification = roomGroupMessageCreator.givenCreatesRoomMessageFor( + A_SESSION_ID, events, A_ROOM_ID, A_SESSION_ID.value, MY_AVATAR_URL + ) + val roomWithMessage = mapOf(A_ROOM_ID to listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT))) + + val result = roomWithMessage.toNotifications(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL) + + assertThat(result).isEqualTo(listOf(expectedNotification)) + } + + @Test + fun `given a room with no events to display when mapping to notification then is Empty`() = testWith(notificationFactory) { + val events = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, A_MESSAGE_EVENT)) + val emptyRoom = mapOf(A_ROOM_ID to events) + + val result = emptyRoom.toNotifications(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL) + + assertThat(result).isEqualTo( + listOf( + RoomNotification.Removed( + roomId = A_ROOM_ID + ) + ) + ) + } + + @Test + fun `given a room with only redacted events when mapping to notification then is Empty`() = testWith(notificationFactory) { + val redactedRoom = mapOf(A_ROOM_ID to listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(isRedacted = true)))) + + val result = redactedRoom.toNotifications(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL) + + assertThat(result).isEqualTo( + listOf( + RoomNotification.Removed( + roomId = A_ROOM_ID + ) + ) + ) + } + + @Test + fun `given a room with redacted and non redacted message events when mapping to notification then redacted events are removed`() = testWith( + notificationFactory + ) { + val roomWithRedactedMessage = mapOf( + A_ROOM_ID to listOf( + ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(isRedacted = true)), + ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(eventId = EventId("not-redacted"))) + ) + ) + val withRedactedRemoved = listOf(A_MESSAGE_EVENT.copy(eventId = EventId("not-redacted"))) + val expectedNotification = roomGroupMessageCreator.givenCreatesRoomMessageFor( + A_SESSION_ID, + withRedactedRemoved, + A_ROOM_ID, + A_SESSION_ID.value, + MY_AVATAR_URL + ) + + val result = roomWithRedactedMessage.toNotifications(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL) + + assertThat(result).isEqualTo(listOf(expectedNotification)) + } +} + +fun testWith(receiver: T, block: T.() -> Unit) { + receiver.block() +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProviderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProviderTest.kt new file mode 100644 index 0000000000..57f28e72db --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProviderTest.kt @@ -0,0 +1,36 @@ +/* + * 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.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import org.junit.Test + +class NotificationIdProviderTest { + @Test + fun `test notification id provider`() { + val sut = NotificationIdProvider() + val offsetForASessionId = 305410 + assertThat(sut.getSummaryNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 0) + assertThat(sut.getRoomMessagesNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 1) + assertThat(sut.getRoomEventNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 2) + assertThat(sut.getRoomInvitationNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 3) + // Check that value will be different for another sessionId + assertThat(sut.getSummaryNotificationId(A_SESSION_ID)).isNotEqualTo(sut.getSummaryNotificationId(A_SESSION_ID_2)) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt new file mode 100644 index 0000000000..79c6dfdb02 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt @@ -0,0 +1,227 @@ +/* + * 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.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationFactory +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.mockk.mockk +import org.junit.Test + +private const val MY_USER_DISPLAY_NAME = "display-name" +private const val MY_USER_AVATAR_URL = "avatar-url" +private const val USE_COMPLETE_NOTIFICATION_FORMAT = true + +private val AN_EVENT_LIST = listOf>() +private val A_PROCESSED_EVENTS = GroupedNotificationEvents(emptyMap(), emptyList(), emptyList()) +private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(mockk()) +private val A_REMOVE_SUMMARY_NOTIFICATION = SummaryNotification.Removed +private val A_NOTIFICATION = mockk() +private val MESSAGE_META = RoomNotification.Message.Meta( + summaryLine = "ignored", messageCount = 1, latestTimestamp = -1, roomId = A_ROOM_ID, shouldBing = false +) +private val ONE_SHOT_META = OneShotNotification.Append.Meta(key = "ignored", summaryLine = "ignored", isNoisy = false, timestamp = -1) + +class NotificationRendererTest { + + private val notificationDisplayer = FakeNotificationDisplayer() + private val notificationFactory = FakeNotificationFactory() + private val notificationIdProvider = NotificationIdProvider() + + private val notificationRenderer = NotificationRenderer( + notificationIdProvider = notificationIdProvider, + notificationDisplayer = notificationDisplayer.instance, + notificationFactory = notificationFactory.instance, + ) + + @Test + fun `given no notifications when rendering then cancels summary notification`() { + givenNoNotifications() + + renderEventsAsNotifications() + + notificationDisplayer.verifySummaryCancelled() + notificationDisplayer.verifyNoOtherInteractions() + } + + @Test + fun `given last room message group notification is removed when rendering then remove the summary and then remove message notification`() { + givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)) + cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID)) + } + } + + @Test + fun `given a room message group notification is removed when rendering then remove the message notification and update summary`() { + givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID))) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID)) + showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given a room message group notification is added when rendering then show the message notification and update summary`() { + givenNotifications( + roomNotifications = listOf( + RoomNotification.Message( + A_NOTIFICATION, + MESSAGE_META + ) + ) + ) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + showNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), A_NOTIFICATION) + showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given last simple notification is removed when rendering then remove the summary and then remove simple notification`() { + givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID.value)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)) + cancelNotificationMessage(tag = AN_EVENT_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID)) + } + } + + @Test + fun `given a simple notification is removed when rendering then remove the simple notification and update summary`() { + givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID.value))) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = AN_EVENT_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID)) + showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given a simple notification is added when rendering then show the simple notification and update summary`() { + givenNotifications( + simpleNotifications = listOf( + OneShotNotification.Append( + A_NOTIFICATION, + ONE_SHOT_META.copy(key = AN_EVENT_ID.value) + ) + ) + ) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + showNotificationMessage(tag = AN_EVENT_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID), A_NOTIFICATION) + showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given last invitation notification is removed when rendering then remove the summary and then remove invitation notification`() { + givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID.value)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)) + cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID)) + } + } + + @Test + fun `given an invitation notification is removed when rendering then remove the invitation notification and update summary`() { + givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID.value))) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID)) + showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given an invitation notification is added when rendering then show the invitation notification and update summary`() { + givenNotifications( + simpleNotifications = listOf( + OneShotNotification.Append( + A_NOTIFICATION, + ONE_SHOT_META.copy(key = A_ROOM_ID.value) + ) + ) + ) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + showNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID), A_NOTIFICATION) + showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) + } + } + + private fun renderEventsAsNotifications() { + notificationRenderer.render( + sessionId = A_SESSION_ID, + myUserDisplayName = MY_USER_DISPLAY_NAME, + myUserAvatarUrl = MY_USER_AVATAR_URL, + useCompleteNotificationFormat = USE_COMPLETE_NOTIFICATION_FORMAT, + eventsToProcess = AN_EVENT_LIST + ) + } + + private fun givenNoNotifications() { + givenNotifications(emptyList(), emptyList(), emptyList(), USE_COMPLETE_NOTIFICATION_FORMAT, A_REMOVE_SUMMARY_NOTIFICATION) + } + + private fun givenNotifications( + roomNotifications: List = emptyList(), + invitationNotifications: List = emptyList(), + simpleNotifications: List = emptyList(), + useCompleteNotificationFormat: Boolean = USE_COMPLETE_NOTIFICATION_FORMAT, + summaryNotification: SummaryNotification = A_SUMMARY_NOTIFICATION + ) { + notificationFactory.givenNotificationsFor( + groupedEvents = A_PROCESSED_EVENTS, + sessionId = A_SESSION_ID, + myUserDisplayName = MY_USER_DISPLAY_NAME, + myUserAvatarUrl = MY_USER_AVATAR_URL, + useCompleteNotificationFormat = useCompleteNotificationFormat, + roomNotifications = roomNotifications, + invitationNotifications = invitationNotifications, + simpleNotifications = simpleNotifications, + summaryNotification = summaryNotification + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt new file mode 100644 index 0000000000..9af681490a --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt @@ -0,0 +1,42 @@ +/* + * 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.fake + +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.impl.notifications.NotificationDisplayer +import io.element.android.libraries.push.impl.notifications.NotificationIdProvider +import io.mockk.confirmVerified +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder + +class FakeNotificationDisplayer { + val instance = mockk(relaxed = true) + + fun verifySummaryCancelled() { + verify { instance.cancelNotificationMessage(tag = null, NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID)) } + } + + fun verifyNoOtherInteractions() { + confirmVerified(instance) + } + + fun verifyInOrder(verifyBlock: NotificationDisplayer.() -> Unit) { + verifyOrder { verifyBlock(instance) } + verifyNoOtherInteractions() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationFactory.kt new file mode 100644 index 0000000000..d9ba17c28e --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationFactory.kt @@ -0,0 +1,54 @@ +/* + * 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.fake + +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.impl.notifications.* +import io.mockk.every +import io.mockk.mockk + +class FakeNotificationFactory { + val instance = mockk() + + fun givenNotificationsFor( + groupedEvents: GroupedNotificationEvents, + sessionId: SessionId, + myUserDisplayName: String, + myUserAvatarUrl: String?, + useCompleteNotificationFormat: Boolean, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + summaryNotification: SummaryNotification + ) { + with(instance) { + every { groupedEvents.roomEvents.toNotifications(sessionId, myUserDisplayName, myUserAvatarUrl) } returns roomNotifications + every { groupedEvents.invitationEvents.toNotifications() } returns invitationNotifications + every { groupedEvents.simpleEvents.toNotifications() } returns simpleNotifications + + every { + createSummaryNotification( + sessionId, + roomNotifications, + invitationNotifications, + simpleNotifications, + useCompleteNotificationFormat + ) + } returns summaryNotification + } + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationUtils.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationUtils.kt new file mode 100644 index 0000000000..046b2edd87 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationUtils.kt @@ -0,0 +1,40 @@ +/* + * 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.fake + +import android.app.Notification +import io.element.android.libraries.push.impl.notifications.NotificationUtils +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.mockk.every +import io.mockk.mockk + +class FakeNotificationUtils { + val instance = mockk() + + fun givenBuildRoomInvitationNotificationFor(event: InviteNotifiableEvent): Notification { + val mockNotification = mockk() + every { instance.buildRoomInvitationNotification(event) } returns mockNotification + return mockNotification + } + + fun givenBuildSimpleInvitationNotificationFor(event: SimpleNotifiableEvent): Notification { + val mockNotification = mockk() + every { instance.buildSimpleEventNotification(event) } returns mockNotification + return mockNotification + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeOutdatedEventDetector.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeOutdatedEventDetector.kt new file mode 100644 index 0000000000..03bf7e8491 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeOutdatedEventDetector.kt @@ -0,0 +1,34 @@ +/* + * 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.fake + +import io.element.android.libraries.push.impl.notifications.OutdatedEventDetector +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.mockk.every +import io.mockk.mockk + +class FakeOutdatedEventDetector { + val instance = mockk() + + fun givenEventIsOutOfDate(notifiableEvent: NotifiableEvent) { + every { instance.isMessageOutdated(notifiableEvent) } returns true + } + + fun givenEventIsInDate(notifiableEvent: NotifiableEvent) { + every { instance.isMessageOutdated(notifiableEvent) } returns false + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt new file mode 100644 index 0000000000..df0b5ad42b --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt @@ -0,0 +1,42 @@ +/* + * 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.fake + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.impl.notifications.RoomGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.RoomNotification +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.mockk.every +import io.mockk.mockk + +class FakeRoomGroupMessageCreator { + + val instance = mockk() + + fun givenCreatesRoomMessageFor( + sessionId: SessionId, + events: List, + roomId: RoomId, + userDisplayName: String, + userAvatarUrl: String? + ): RoomNotification.Message { + val mockMessage = mockk() + every { instance.createRoomMessage(sessionId, events, roomId, userDisplayName, userAvatarUrl) } returns mockMessage + return mockMessage + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/intent/PendingIntentCompat.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt similarity index 63% rename from libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/intent/PendingIntentCompat.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt index dcdb800a19..fc7b0553eb 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/intent/PendingIntentCompat.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt @@ -14,17 +14,12 @@ * limitations under the License. */ -package io.element.android.libraries.androidutils.intent +package io.element.android.libraries.push.impl.notifications.fake -import android.app.PendingIntent -import android.os.Build +import io.element.android.libraries.push.impl.notifications.SummaryGroupMessageCreator +import io.mockk.mockk -object PendingIntentCompat { - const val FLAG_IMMUTABLE = PendingIntent.FLAG_IMMUTABLE +class FakeSummaryGroupMessageCreator { - val FLAG_MUTABLE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_MUTABLE - } else { - 0 - } + val instance = mockk() } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt new file mode 100644 index 0000000000..9a998abf43 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt @@ -0,0 +1,96 @@ +/* + * 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.fixtures + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +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 + +fun aSimpleNotifiableEvent( + sessionId: SessionId = A_SESSION_ID, + roomId: RoomId = A_ROOM_ID, + eventId: EventId = AN_EVENT_ID, + type: String? = null, + isRedacted: Boolean = false, + canBeReplaced: Boolean = false, + editedEventId: EventId? = null +) = SimpleNotifiableEvent( + sessionId = sessionId, + roomId = roomId, + eventId = eventId, + editedEventId = editedEventId, + noisy = false, + title = "title", + description = "description", + type = type, + timestamp = 0, + soundName = null, + canBeReplaced = canBeReplaced, + isRedacted = isRedacted +) + +fun anInviteNotifiableEvent( + sessionId: SessionId = A_SESSION_ID, + roomId: RoomId = A_ROOM_ID, + eventId: EventId = AN_EVENT_ID, + isRedacted: Boolean = false +) = InviteNotifiableEvent( + sessionId = sessionId, + eventId = eventId, + roomId = roomId, + roomName = "a room name", + editedEventId = null, + noisy = false, + title = "title", + description = "description", + type = null, + timestamp = 0, + soundName = null, + canBeReplaced = false, + isRedacted = isRedacted +) + +fun aNotifiableMessageEvent( + sessionId: SessionId = A_SESSION_ID, + roomId: RoomId = A_ROOM_ID, + eventId: EventId = AN_EVENT_ID, + threadId: ThreadId? = null, + isRedacted: Boolean = false +) = NotifiableMessageEvent( + sessionId = sessionId, + eventId = eventId, + editedEventId = null, + noisy = false, + timestamp = 0, + senderName = "sender-name", + senderId = "sending-id", + body = "message-body", + roomId = roomId, + threadId = threadId, + roomName = "room-name", + roomIsDirect = false, + canBeReplaced = false, + isRedacted = isRedacted, + imageUriString = null +) diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt index de0ec2f727..d79d700030 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt @@ -17,11 +17,22 @@ package io.element.android.libraries.sessionstorage.api import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map interface SessionStore { fun isLoggedIn(): Flow + fun sessionsFlow(): Flow> suspend fun storeData(sessionData: SessionData) suspend fun getSession(sessionId: String): SessionData? + suspend fun getAllSessions(): List suspend fun getLatestSession(): SessionData? suspend fun removeSession(sessionId: String) } + +fun List.toUserList(): List { + return map { it.userId } +} + +fun Flow>.toUserListFlow(): Flow> { + return map { it.toUserList() } +} diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionListener.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionListener.kt new file mode 100644 index 0000000000..7bcb4db792 --- /dev/null +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionListener.kt @@ -0,0 +1,22 @@ +/* + * 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.sessionstorage.api.observer + +interface SessionListener { + suspend fun onSessionCreated(userId: String) + suspend fun onSessionDeleted(userId: String) +} diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionObserver.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionObserver.kt new file mode 100644 index 0000000000..e61b4e2bba --- /dev/null +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionObserver.kt @@ -0,0 +1,22 @@ +/* + * 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.sessionstorage.api.observer + +interface SessionObserver { + fun addListener(listener: SessionListener) + fun removeListener(listener: SessionListener) +} diff --git a/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt b/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt index b73ffdeb9a..e23e34983c 100644 --- a/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt +++ b/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt @@ -30,6 +30,10 @@ class InMemorySessionStore : SessionStore { return sessionDataFlow.map { it != null } } + override fun sessionsFlow(): Flow> { + return sessionDataFlow.map { listOfNotNull(it) } + } + override suspend fun storeData(sessionData: SessionData) { sessionDataFlow.value = sessionData } @@ -38,6 +42,10 @@ class InMemorySessionStore : SessionStore { return sessionDataFlow.value.takeIf { it?.userId == sessionId } } + override suspend fun getAllSessions(): List { + return listOfNotNull(sessionDataFlow.value) + } + override suspend fun getLatestSession(): SessionData? { return sessionDataFlow.value } diff --git a/libraries/session-storage/impl/build.gradle.kts b/libraries/session-storage/impl/build.gradle.kts index 08dc581018..b554bb5d8f 100644 --- a/libraries/session-storage/impl/build.gradle.kts +++ b/libraries/session-storage/impl/build.gradle.kts @@ -30,6 +30,7 @@ anvil { dependencies { implementation(libs.dagger) + implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.encryptedDb) api(projects.libraries.sessionStorage.api) diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index 6c32bcd1f3..9394b66e66 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.sessionstorage.impl import com.squareup.anvil.annotations.ContributesBinding import com.squareup.sqldelight.runtime.coroutines.asFlow +import com.squareup.sqldelight.runtime.coroutines.mapToList import com.squareup.sqldelight.runtime.coroutines.mapToOneOrNull import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.SingleIn @@ -25,6 +26,7 @@ import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import timber.log.Timber import javax.inject.Inject @SingleIn(AppScope::class) @@ -34,7 +36,10 @@ class DatabaseSessionStore @Inject constructor( ) : SessionStore { override fun isLoggedIn(): Flow { - return database.sessionDataQueries.selectFirst().asFlow().mapToOneOrNull().map { it != null } + return database.sessionDataQueries.selectFirst() + .asFlow() + .mapToOneOrNull() + .map { it != null } } override suspend fun storeData(sessionData: SessionData) { @@ -53,6 +58,20 @@ class DatabaseSessionStore @Inject constructor( ?.toApiModel() } + override suspend fun getAllSessions(): List { + return database.sessionDataQueries.selectAll() + .executeAsList() + .map { it.toApiModel() } + } + + override fun sessionsFlow(): Flow> { + Timber.w("Observing session list!") + return database.sessionDataQueries.selectAll() + .asFlow() + .mapToList() + .map { it.map { sessionData -> sessionData.toApiModel() } } + } + override suspend fun removeSession(sessionId: String) { database.sessionDataQueries.removeSession(sessionId) } diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt index b101fc39b4..052943388e 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt @@ -35,7 +35,7 @@ object SessionStorageModule { fun provideMatrixDatabase(@ApplicationContext context: Context): SessionDatabase { val name = "session_database" val secretFile = context.getDatabasePath("$name.key") - val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile, name) + val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile) val driver = SqlCipherDriverFactory(passphraseProvider) .create(SessionDatabase.Schema, "$name.db", context) return SessionDatabase(driver) diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserver.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserver.kt new file mode 100644 index 0000000000..8fa5d9dd16 --- /dev/null +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserver.kt @@ -0,0 +1,91 @@ +/* + * 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.sessionstorage.impl.observer + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.api.observer.SessionListener +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import io.element.android.libraries.sessionstorage.api.toUserListFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.concurrent.CopyOnWriteArraySet +import javax.inject.Inject + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultSessionObserver @Inject constructor( + private val sessionStore: SessionStore, + private val coroutineScope: CoroutineScope, + private val dispatchers: CoroutineDispatchers, +) : SessionObserver { + // Keep only the userId + private var currentUsers: Set? = null + + init { + observeDatabase() + } + + private val listeners = CopyOnWriteArraySet() + override fun addListener(listener: SessionListener) { + listeners.add(listener) + } + + override fun removeListener(listener: SessionListener) { + listeners.remove(listener) + } + + private fun observeDatabase() { + coroutineScope.launch { + withContext(dispatchers.io) { + sessionStore.sessionsFlow() + .toUserListFlow() + .map { it.toSet() } + .onEach { newUserSet -> + val currentUserSet = currentUsers + if (currentUserSet != null) { + // Compute diff + // Removed user + val removedUsers = currentUserSet - newUserSet + removedUsers.forEach { removedUser -> + listeners.onEach { listener -> + listener.onSessionDeleted(removedUser) + } + } + // Added user + val addedUsers = newUserSet - currentUserSet + addedUsers.forEach { addedUser -> + listeners.onEach { listener -> + listener.onSessionCreated(addedUser) + } + } + } + + currentUsers = newUserSet + } + .collect() + } + } + } +} diff --git a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq index d8fb15338c..ea8471a36a 100644 --- a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq +++ b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq @@ -10,6 +10,9 @@ CREATE TABLE SessionData ( selectFirst: SELECT * FROM SessionData LIMIT 1; +selectAll: +SELECT * FROM SessionData; + selectByUserId: SELECT * FROM SessionData WHERE userId = ?; diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt index 885c04af78..0260604f6e 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt @@ -57,6 +57,7 @@ class DatabaseSessionStoreTests { databaseSessionStore.storeData(aSessionData.toApiModel()) assertThat(database.sessionDataQueries.selectFirst().executeAsOneOrNull()).isEqualTo(aSessionData) + assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(1) } @Test @@ -88,6 +89,7 @@ class DatabaseSessionStoreTests { val foundSession = databaseSessionStore.getSession(aSessionData.userId)?.toDbModel() assertThat(foundSession).isEqualTo(aSessionData) + assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(2) } @Test @@ -107,5 +109,4 @@ class DatabaseSessionStoreTests { assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOneOrNull()).isNull() } - } diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 28cef98c60..c37935d35b 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -38,6 +38,7 @@ "Save" "Search" "Send" + "Send message" "Share" "Share link" "Skip" diff --git a/plugins/build.gradle.kts b/plugins/build.gradle.kts index 6c77f11ac2..d4324432f3 100644 --- a/plugins/build.gradle.kts +++ b/plugins/build.gradle.kts @@ -27,6 +27,8 @@ repositories { dependencies { implementation(libs.android.gradle.plugin) implementation(libs.kotlin.gradle.plugin) - implementation(libs.firebase.gradle.plugin) + implementation(platform(libs.google.firebase.bom)) + // FIXME: using the bom ^, it should not be necessary to provide the version v... + implementation("com.google.firebase:firebase-appdistribution-gradle:4.0.0") implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location)) } diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index 314421ebc8..9f0cf92099 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -73,6 +73,8 @@ fun DependencyHandlerScope.allLibrariesImpl() { implementation(project(":libraries:matrixui")) implementation(project(":libraries:network")) implementation(project(":libraries:core")) + implementation(project(":libraries:permissions:impl")) + implementation(project(":libraries:push:impl")) implementation(project(":libraries:architecture")) implementation(project(":libraries:dateformatter:impl")) implementation(project(":libraries:di")) diff --git a/samples/minimal/build.gradle.kts b/samples/minimal/build.gradle.kts index 9ad0df0c0c..8ad1c125d7 100644 --- a/samples/minimal/build.gradle.kts +++ b/samples/minimal/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.impl) + implementation(projects.libraries.permissions.noop) implementation(projects.libraries.sessionStorage.implMemory) implementation(projects.libraries.designsystem) implementation(projects.libraries.architecture) diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateExtension.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateExtension.kt new file mode 100644 index 0000000000..00fe638a47 --- /dev/null +++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateExtension.kt @@ -0,0 +1,62 @@ +/* + * 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.services.appnavstate.api + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.SpaceId +import io.element.android.libraries.matrix.api.core.ThreadId + +fun AppNavigationState.currentSessionId(): SessionId? { + return when (this) { + AppNavigationState.Root -> null + is AppNavigationState.Session -> sessionId + is AppNavigationState.Space -> parentSession.sessionId + is AppNavigationState.Room -> parentSpace.parentSession.sessionId + is AppNavigationState.Thread -> parentRoom.parentSpace.parentSession.sessionId + } +} + +fun AppNavigationState.currentSpaceId(): SpaceId? { + return when (this) { + AppNavigationState.Root -> null + is AppNavigationState.Session -> null + is AppNavigationState.Space -> spaceId + is AppNavigationState.Room -> parentSpace.spaceId + is AppNavigationState.Thread -> parentRoom.parentSpace.spaceId + } +} + +fun AppNavigationState.currentRoomId(): RoomId? { + return when (this) { + AppNavigationState.Root -> null + is AppNavigationState.Session -> null + is AppNavigationState.Space -> null + is AppNavigationState.Room -> roomId + is AppNavigationState.Thread -> parentRoom.roomId + } +} + +fun AppNavigationState.currentThreadId(): ThreadId? { + return when (this) { + AppNavigationState.Root -> null + is AppNavigationState.Session -> null + is AppNavigationState.Space -> null + is AppNavigationState.Room -> null + is AppNavigationState.Thread -> threadId + } +} diff --git a/services/appnavstate/test/build.gradle.kts b/services/appnavstate/test/build.gradle.kts new file mode 100644 index 0000000000..cda023b236 --- /dev/null +++ b/services/appnavstate/test/build.gradle.kts @@ -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. + */ + +// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.services.appnavstate.test" +} + +dependencies { + api(projects.libraries.matrix.api) + api(projects.services.appnavstate.api) +} diff --git a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt new file mode 100644 index 0000000000..6afab893c3 --- /dev/null +++ b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt @@ -0,0 +1,44 @@ +/* + * 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.services.appnavstate.test + +import io.element.android.libraries.matrix.api.core.* +import io.element.android.services.appnavstate.api.AppNavigationState + +fun anAppNavigationState( + sessionId: SessionId? = null, + spaceId: SpaceId? = MAIN_SPACE, + roomId: RoomId? = null, + threadId: ThreadId? = null, +): AppNavigationState { + if (sessionId == null) { + return AppNavigationState.Root + } + val session = AppNavigationState.Session(sessionId) + if (spaceId == null) { + return session + } + val space = AppNavigationState.Space(spaceId, session) + if (roomId == null) { + return space + } + val room = AppNavigationState.Room(roomId, space) + if (threadId == null) { + return room + } + return AppNavigationState.Thread(threadId, room) +} diff --git a/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/sdk/BuildVersionSdkIntProvider.kt b/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/sdk/BuildVersionSdkIntProvider.kt new file mode 100644 index 0000000000..38f2e2227c --- /dev/null +++ b/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/sdk/BuildVersionSdkIntProvider.kt @@ -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.services.toolbox.api.sdk + +import androidx.annotation.ChecksSdkIntAtLeast + +interface BuildVersionSdkIntProvider { + /** + * Return the current version of the Android SDK. + */ + fun get(): Int + + /** + * Checks the if the current OS version is equal or greater than [version]. + * @return A `non-null` result if true, `null` otherwise. + */ + @ChecksSdkIntAtLeast(parameter = 0, lambda = 1) + fun whenAtLeast(version: Int, result: () -> T): T? { + return if (get() >= version) { + result() + } else null + } + + @ChecksSdkIntAtLeast(parameter = 0) + fun isAtLeast(version: Int) = get() >= version +} diff --git a/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/sdk/DefaultBuildVersionSdkIntProvider.kt b/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/sdk/DefaultBuildVersionSdkIntProvider.kt new file mode 100644 index 0000000000..d4ac1ec739 --- /dev/null +++ b/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/sdk/DefaultBuildVersionSdkIntProvider.kt @@ -0,0 +1,29 @@ +/* + * 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.services.toolbox.impl.sdk + +import android.os.Build +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultBuildVersionSdkIntProvider @Inject constructor() : + BuildVersionSdkIntProvider { + override fun get() = Build.VERSION.SDK_INT +} diff --git a/settings.gradle.kts b/settings.gradle.kts index e44a2cb8fa..7429f80b43 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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") } diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 155a42836a..59c6bd6911 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -43,6 +43,13 @@ "rich_text_editor_.*" ] }, + { + "name": ":libraries:push:impl", + "includeRegex": [ + "push_.*", + "notification_.*" + ] + }, { "name": ":features:login:impl", "includeRegex": [