diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 4c00894a36a..cb4dbe5766e 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -11,7 +11,7 @@ jobs: - run: | npm install --save-dev @babel/plugin-transform-flow-strip-types - name: Danger - uses: danger/danger-js@11.2.4 + uses: danger/danger-js@11.2.5 with: args: "--dangerfile ./tools/danger/dangerfile.js" env: diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 8f6fe6112ce..88ef78f9a84 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -42,7 +42,7 @@ jobs: yarn add danger-plugin-lint-report --dev - name: Danger lint if: always() - uses: danger/danger-js@11.2.4 + uses: danger/danger-js@11.2.5 with: args: "--dangerfile ./tools/danger/dangerfile-lint.js" env: diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml new file mode 100644 index 00000000000..02f93cc3e1b --- /dev/null +++ b/.github/workflows/sync-localazy.yml @@ -0,0 +1,33 @@ +name: Sync Localazy +on: + schedule: + # At 00:00 on every Monday UTC + - cron: '0 0 * * 1' + +jobs: + sync-localazy: + runs-on: ubuntu-latest + # Skip in forks + if: github.repository == 'vector-im/element-x-android' + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.9 + uses: actions/setup-python@v4 + with: + python-version: 3.9 + - name: Setup Localazy + run: | + curl -sS https://dist.localazy.com/debian/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/localazy.gpg + echo "deb [arch=amd64 signed-by=/etc/apt/trusted.gpg.d/localazy.gpg] https://maven.localazy.com/repository/apt/ stable main" | sudo tee /etc/apt/sources.list.d/localazy.list + sudo apt-get update && sudo apt-get install localazy + - name: Run Localazy script + run: ./tools/localazy/downloadStrings.sh --all + - name: Create Pull Request for Strings + uses: peter-evans/create-pull-request@v5 + with: + commit-message: Sync Strings from Localazy + title: Sync Strings + body: | + - Update Strings from Localazy + branch: sync-localazy + base: develop diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5cd8785b33f..125c69adb6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,9 @@ * [Contributing code to Matrix](#contributing-code-to-matrix) * [Android Studio settings](#android-studio-settings) * [Compilation](#compilation) -* [I want to help translating Element](#i-want-to-help-translating-element) +* [Strings](#strings) + * [I want to add new strings to the project](#i-want-to-add-new-strings-to-the-project) + * [I want to help translating Element](#i-want-to-help-translating-element) * [I want to submit a PR to fix an issue](#i-want-to-submit-a-pr-to-fix-an-issue) * [Kotlin](#kotlin) * [Changelog](#changelog) @@ -15,7 +17,6 @@ * [lint](#lint) * [Unit tests](#unit-tests) * [Tests](#tests) - * [Internationalisation](#internationalisation) * [Accessibility](#accessibility) * [Jetpack Compose](#jetpack-compose) * [Authors](#authors) @@ -40,11 +41,28 @@ Please ensure that you're using the project formatting rules (which are in the p This project should compile without any special action. Just clone it and open it with Android Studio, or compile from command line using `gradlew`. -## I want to help translating Element +Note: please make sure that the configuration is `app` and not `samples.minimal`. -For now strings are coming from Element Android project, so: -- If you want to fix an issue with an English string, please submit a PR on Element Android. -- If you want to fix an issue in other languages, or add a missing translation, or even add a new language, please use [Weblate](https://translate.element.io/projects/element-android/). +## Strings + +The strings of the project are managed externally using [https://localazy.com](https://localazy.com) and shared with ElementX iOS. + +### I want to add new strings to the project + +Only the core team can modify or add English strings to Localazy. As an external contributor, if you want to add new strings, feel free to add an Android resource file to the project (for instance a file named `temporary.xml`), with a note in the description of the PR for the reviewer to integrate the String into `Localazy`. If accepted, the reviewer will add the String(s) for you, and then you can download them on your branch (following these [instructions](./tools/localazy/README.md#download-translations)) and remove the temporary file. + +Please follow the naming rules for the key. More details in [the dedicated section in this README.md](./tools/localazy/README.md#key-naming-rules) + +### I want to help translating Element + +Please note that the Localazy project is not open yet for external contributions. + +To help translating, please go to [https://localazy.com/p/element](https://localazy.com/p/element). + +- If you want to fix an issue with an English string, please open an issue on the github project of ElementX (Android or iOS). Only the core team can modify or add English strings. +- If you want to fix an issue in other languages, or add a missing translation, or even add a new language, please go to [https://localazy.com/p/element](https://localazy.com/p/element). + +More informations can be found [in this README.md](./tools/localazy/README.md). ## I want to submit a PR to fix an issue @@ -135,10 +153,6 @@ Also, if possible, please test your change on a real device. Testing on Android You should consider adding Unit tests with your PR, and also integration tests (AndroidTest). Please refer to [this document](./docs/integration_tests.md) to install and run the integration test environment. -### Internationalisation - -For now strings are coming from Element Android project, so please read [the documentation](https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md#internationalisation) from there. - ### Accessibility Please consider accessibility as an important point. As a minimum requirement, in layout XML files please use attributes such as `android:contentDescription` and `android:importantForAccessibility`, and test with a screen reader if it's working well. You can add new string resources, dedicated to accessibility, in this case, please prefix theirs id with `a11y_`. diff --git a/anvilcodegen/build.gradle.kts b/anvilcodegen/build.gradle.kts index e8b6ab285c8..d35051d2cc1 100644 --- a/anvilcodegen/build.gradle.kts +++ b/anvilcodegen/build.gradle.kts @@ -25,7 +25,7 @@ dependencies { implementation(projects.anvilannotations) api(libs.anvil.compiler.api) implementation(libs.anvil.compiler.utils) - implementation("com.squareup:kotlinpoet:1.12.0") + implementation("com.squareup:kotlinpoet:1.13.0") implementation(libs.dagger) compileOnly("com.google.auto.service:auto-service-annotations:1.0.1") kapt("com.google.auto.service:auto-service:1.0.1") diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1e3de32f225..0b8833efbba 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 00000000000..d9aa72f7baf --- /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 31033b05009..828788ed805 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 00000000000..7924ddf996a --- /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 00000000000..9b8b6c2281f --- /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 00000000000..6c736071965 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/GoogleFcmHelper.kt @@ -0,0 +1,104 @@ +/* + * 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 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 00000000000..f1ac3469090 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt @@ -0,0 +1,173 @@ +/* + * 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 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 00000000000..12ed3f1993c --- /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 00000000000..226d0c56695 --- /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 00000000000..93f5f43ce43 --- /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 00000000000..4ab6c775e32 --- /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 00000000000..1d7a1e6247f --- /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 00000000000..b57b24d25e7 --- /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 00000000000..c5f73582413 --- /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 00000000000..055de6fc477 --- /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.find { + 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 00000000000..d2d1c96506f --- /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 00000000000..9e9b28ecb8d --- /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 00000000000..906816eb562 --- /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 00000000000..bcf48bab15b --- /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 00000000000..8769baa9478 --- /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 00000000000..aef87e7df32 --- /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 00000000000..52abb3f6a48 --- /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 00000000000..3fa613d097b --- /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 00000000000..a24f088998e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt @@ -0,0 +1,58 @@ +/* + * 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.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 00000000000..1b1fe2723e0 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt @@ -0,0 +1,65 @@ +/* + * 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.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom +import 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 00000000000..5ae39e11029 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt @@ -0,0 +1,141 @@ +/* + * 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.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 00000000000..b3f0b1e0f26 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationAction.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.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 00000000000..56054bbef87 --- /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 00000000000..7bd76f9f427 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt @@ -0,0 +1,95 @@ +/* + * 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 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 00000000000..a9c7036418f --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt @@ -0,0 +1,253 @@ +/* + * 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 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 00000000000..ae936e693bb --- /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 00000000000..c8331ed8cda --- /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 00000000000..8d3bfda4c36 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDrawerManager.kt @@ -0,0 +1,271 @@ +/* + * 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 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 00000000000..d6135f28b07 --- /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 00000000000..60fb1baa053 --- /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 00000000000..1a2d4a852f6 --- /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 00000000000..3ce941de2f7 --- /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 00000000000..277dc3b8223 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt @@ -0,0 +1,154 @@ +/* + * 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 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 00000000000..808bf4114b3 --- /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 00000000000..add8fd74eb5 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt @@ -0,0 +1,727 @@ +/* + * 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: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 00000000000..5b15dc78d2f --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt @@ -0,0 +1,45 @@ +/* + * 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.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 00000000000..2e91ca34671 --- /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 00000000000..734c34b051d --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt @@ -0,0 +1,39 @@ +/* + * 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.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 00000000000..ab38ad9fb42 --- /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 00000000000..ed0053e58cd --- /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 00000000000..42c0fe61afc --- /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 00000000000..4524d27ac26 --- /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 00000000000..b1bb7cd032c --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.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 +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 00000000000..add172e44f1 --- /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 00000000000..5cfd04474a8 --- /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 00000000000..e1fd17332e1 --- /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 00000000000..864155e5222 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushData.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.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. + * @property clientSecret A client secret, used to determine which user should receive the notification. + */ +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 00000000000..b4c9716b625 --- /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 00000000000..02bd7850e9e --- /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 00000000000..5cd46f873d4 --- /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 00000000000..7adedfcfd26 --- /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 00000000000..b7649f68008 --- /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 00000000000..ce41d2d83e9 --- /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 00000000000..7130e38d6e3 --- /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 00000000000..13d9cbad1de --- /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 00000000000..ffbd575aa4f --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt @@ -0,0 +1,149 @@ +/* + * 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.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 00000000000..08bd4a83263 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/GuardServiceStarter.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.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 00000000000..de66ed3914b --- /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 00000000000..56513ab970d --- /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 00000000000..50ca94f30db --- /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 00000000000..9788ecf1a1d --- /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 00000000000..6cd1af1de36 --- /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 00000000000..81dd389e78a --- /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 00000000000..90857d990d1 --- /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 00000000000..82c4beaf20f --- /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 00000000000..6f25599e545 --- /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 00000000000..0323713de72 --- /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 00000000000..e9b119c9690 --- /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 00000000000..1f3132a3f2f 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 00000000000..a86508b71b4 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 00000000000..eb2be251878 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 00000000000..4af4ae634b4 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 00000000000..51b4401ca05 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 00000000000..6e04238a1a9 --- /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 00000000000..ce2fee2015c --- /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 00000000000..3a11adb5d3b --- /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 00000000000..25823a57e83 --- /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 00000000000..a2d2d9c83c0 --- /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 00000000000..a9d740bf31f --- /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 00000000000..80246abb14b --- /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 00000000000..4bec096468d --- /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 00000000000..606923eb1f2 --- /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 00000000000..57f28e72db3 --- /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 00000000000..79c6dfdb025 --- /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 00000000000..9af681490a2 --- /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 00000000000..7d7812e6cb6 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationFactory.kt @@ -0,0 +1,58 @@ +/* + * 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.GroupedNotificationEvents +import io.element.android.libraries.push.impl.notifications.NotificationFactory +import io.element.android.libraries.push.impl.notifications.OneShotNotification +import io.element.android.libraries.push.impl.notifications.RoomNotification +import io.element.android.libraries.push.impl.notifications.SummaryNotification +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 00000000000..046b2edd872 --- /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 00000000000..03bf7e8491b --- /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 00000000000..df0b5ad42b1 --- /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 dcdb800a199..fc7b0553eb7 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 00000000000..9a998abf43c --- /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/rustsdk/build.gradle b/libraries/rustsdk/build.gradle deleted file mode 100644 index bfafe67f282..00000000000 --- a/libraries/rustsdk/build.gradle +++ /dev/null @@ -1,2 +0,0 @@ -configurations.maybeCreate("default") -artifacts.add("default", file('matrix-rust-sdk.aar')) \ No newline at end of file diff --git a/libraries/rustsdk/build.gradle.kts b/libraries/rustsdk/build.gradle.kts new file mode 100644 index 00000000000..56fd0948970 --- /dev/null +++ b/libraries/rustsdk/build.gradle.kts @@ -0,0 +1,2 @@ +configurations.maybeCreate("default") +artifacts.add("default", file("matrix-rust-sdk.aar")) 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 de0ec2f7271..d79d700030c 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 00000000000..7bcb4db7926 --- /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 00000000000..e61b4e2bba5 --- /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 b73ffdeb9ae..e23e34983c3 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 08dc5810186..b554bb5d8f0 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 6c32bcd1f33..9394b66e66d 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 b101fc39b49..052943388eb 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 00000000000..8fa5d9dd165 --- /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 d8fb15338cb..ea8471a36ac 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 885c04af784..0260604f6eb 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/textcomposer/src/main/res/values-es/translations.xml b/libraries/textcomposer/src/main/res/values-es/translations.xml new file mode 100644 index 00000000000..e302765a584 --- /dev/null +++ b/libraries/textcomposer/src/main/res/values-es/translations.xml @@ -0,0 +1,17 @@ + + + "Lista de puntos" + "Bloque de código" + "Mensaje…" + "Aplicar formato negrita" + "Aplicar formato cursiva" + "Aplicar formato tachado" + "Aplicar formato de subrayado" + "Pantalla completa" + "Añadir sangría" + "Código" + "Enlazar" + "Lista numérica" + "Cita" + "Quitar sangría" + \ No newline at end of file diff --git a/libraries/textcomposer/src/main/res/values-it/translations.xml b/libraries/textcomposer/src/main/res/values-it/translations.xml new file mode 100644 index 00000000000..54ca270f288 --- /dev/null +++ b/libraries/textcomposer/src/main/res/values-it/translations.xml @@ -0,0 +1,17 @@ + + + "Attiva/disattiva l\'elenco puntato" + "Attiva/disattiva il blocco di codice" + "Messaggio…" + "Applica il formato in grassetto" + "Applicare il formato corsivo" + "Applica il formato barrato" + "Applicare il formato di sottolineatura" + "Attiva/disattiva la modalità a schermo intero" + "Rientro a destra" + "Applicare il formato del codice in linea" + "Imposta collegamento" + "Attiva/disattiva elenco numerato" + "Attiva/disattiva citazione" + "Rientro a sinistra" + \ No newline at end of file diff --git a/libraries/textcomposer/src/main/res/values-ro/translations.xml b/libraries/textcomposer/src/main/res/values-ro/translations.xml new file mode 100644 index 00000000000..b053e0ecaa1 --- /dev/null +++ b/libraries/textcomposer/src/main/res/values-ro/translations.xml @@ -0,0 +1,17 @@ + + + "Comutați lista cu puncte" + "Comutați blocul de cod" + "Mesaj…" + "Aplicați formatul aldin" + "Aplicați formatul italic" + "Aplicați formatul barat" + "Aplică formatul de subliniere" + "Comutați modul ecran complet" + "Indentare" + "Aplicați formatul de cod inline" + "Setați linkul" + "Comutați lista numerotată" + "Aplicați citatul" + "Dez-identare" + \ No newline at end of file diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml index 64684b79052..22c60db4813 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -1,4 +1,5 @@ "Bestätigen" + "de" \ No newline at end of file diff --git a/libraries/ui-strings/src/main/res/values-es/translations.xml b/libraries/ui-strings/src/main/res/values-es/translations.xml new file mode 100644 index 00000000000..4b14f3a4a7d --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-es/translations.xml @@ -0,0 +1,148 @@ + + + "Ocultar contraseña" + "Enviar archivos" + "Mostrar contraseña" + "Menú de usuario" + "Atrás" + "Cancelar" + "Borrar" + "Cerrar" + "Completar verificación" + "Confirmar" + "Continuar" + "Copiar" + "Copiar enlace" + "Crear una sala" + "Desactivar" + "Hecho" + "Editar" + "Activar" + "Invitar" + "Invitar amigos a %1$s" + "Más información" + "Salir" + "Salir de la sala" + "Siguiente" + "No" + "Ahora no" + "OK" + "Respuesta rápida" + "Citar" + "Eliminar" + "Responder" + "Informar de un error" + "Reportar Contenido" + "Reintentar" + "Reintentar descifrado" + "Guardar" + "Buscar" + "Enviar" + "Compartir" + "Compartir enlace" + "Saltar" + "Comenzar" + "Iniciar chat" + "Iniciar la verificación" + "Ver Fuente" + "Sí" + "Acerca de" + "Sonido" + "Burbujas" + "Creando sala…" + "Saliste de la sala" + "Error de descifrado" + "Opciones de desarrollador" + "(editado)" + "Edición" + "Cifrado activado" + "Error" + "Archivo" + "GIF" + "Imagen" + "Enlace copiado al portapapeles" + "Cargando…" + "Mensaje" + "Diseño del mensaje" + "Mensaje eliminado" + "Moderno" + "No hay resultados" + "Sin conexión" + "Contraseña" + "Personas" + "Enlace permanente" + "Reacciones" + "Respondiendo a %1$s" + "Informar de un error" + "Informe enviado" + "Buscar a alguien" + "Seguridad" + "Selecciona tu servidor" + "Enviando…" + "Servidor no compatible" + "Dirección del servidor" + "Ajustes" + "Sticker" + "Terminado" + "Sugerencias" + "Tema" + "No se puede descifrar" + "Evento no compatible" + "Usuario" + "Verificación cancelada" + "Verificación completada" + "Vídeo" + "Esperando…" + "Confirmar" + "Error" + "Terminado" + "Atención" + "Actividades" + "Banderas" + "Comida y bebida" + "Animales y naturaleza" + "Objetos" + "Emojis y personas" + "Viajes y lugares" + "Símbolos" + "No se pudo crear el enlace permanente" + "Error al cargar mensajes" + "No se encontró ninguna aplicación compatible con esta acción." + "Algunos mensajes no se han enviado" + "Lo siento, se ha producido un error" + "Hola, puedes hablar conmigo en %1$s: %2$s" + "¿Estás seguro de que quieres salir de esta sala? Eres la única persona aquí. Si te vas, nadie podrá unirse en el futuro, ni siquiera tú." + "¿Estás seguro de que quieres abandonar esta sala? Esta sala no es pública y no podrás volver a entrar sin una invitación." + "¿Seguro que quieres salir de la habitación?" + "%1$s Android" + + "%1$d miembro" + "%1$d miembros" + + + "%1$d cambio en la sala" + "%1$d cambios en la sala" + + "Agitar con fuerza para informar de un error" + "Parece que sacudes el teléfono con frustración. ¿Quieres abrir la pantalla de informe de errores?" + "Este mensaje se notificará al administrador de su homeserver. No podrán leer ningún mensaje cifrado." + "Motivo para denunciar este contenido" + "Este es el principio de %1$s." + "Este es el principio de esta conversación." + "Nuevos" + "Bloquear usuario" + "Marque si quieres ocultar todos los mensajes actuales y futuros de este usuario" + "Bloquear" + "Los usuarios bloqueados no podrán enviarte mensajes y se ocultarán todos sus mensajes. Puede revertir esta acción en cualquier momento." + "Bloquear usuario" + "Desbloquear" + "Al desbloquear al usuario, podrás volver a ver todos sus mensajes." + "Desbloquear usuario" + "Se ha producido un error al intentar iniciar un chat" + "No podemos validar el ID de Matrix de este usuario. Es posible que no reciba la invitación." + "Agitar con fuerza" + "Umbral de detección" + "General" + "Versión: %1$s (%2$s)" + "es" + \ No newline at end of file diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index b75da2740b6..de1a80a9f54 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -1,4 +1,16 @@ + "Masquer le mot de passe" + "Envoyer des fichiers" + "Afficher le mot de passe" + "Menu utilisateur" + "Retour" + "Annuler" + "Effacer" + "Fermer" "Confirmer" + "Continuer" + "Copier" + "Copier le lien" + "fr" \ No newline at end of file diff --git a/libraries/ui-strings/src/main/res/values-it/translations.xml b/libraries/ui-strings/src/main/res/values-it/translations.xml new file mode 100644 index 00000000000..96d0648d3b3 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-it/translations.xml @@ -0,0 +1,148 @@ + + + "Nascondi password" + "Invia file" + "Mostra password" + "Menu utente" + "Indietro" + "Annulla" + "Cancella" + "Chiudi" + "Completa verifica" + "Conferma" + "Continua" + "Copia" + "Copia collegamento" + "Crea una stanza" + "Disabilita" + "Fine" + "Modifica" + "Attiva" + "Invita" + "Invita amici a %1$s" + "Ulteriori informazioni" + "Esci" + "Esci dalla stanza" + "Avanti" + "No" + "Non ora" + "OK" + "Risposta rapida" + "Citazione" + "Rimuovi" + "Rispondi" + "Segnala un problema" + "Segnala Contenuto" + "Riprova" + "Riprova la decrittazione" + "Salva" + "Ricerca" + "Invia" + "Condividi" + "Condividi collegamento" + "Salta" + "Inizia" + "Avvia conversazione" + "Avvia la verifica" + "Vedi Sorgente" + "Sì" + "Informazioni" + "Audio" + "Fumetti" + "Creazione stanza…" + "Hai lasciato la stanza" + "Errore di decrittazione" + "Opzioni sviluppatore" + "(modificato)" + "Modifica in corso" + "Crittografia abilitata" + "Errore" + "File" + "GIF" + "Immagine" + "Collegamento copiato negli appunti" + "Caricamento…" + "Messaggio" + "Layout del messaggio" + "Messaggio rimosso" + "Moderno" + "Nessun risultato" + "Non in linea" + "Password" + "Persone" + "Collegamento permanente" + "Reazioni" + "Risposta a %1$s" + "Segnala un problema" + "Segnalazione inviata" + "Cerca qualcuno" + "Sicurezza" + "Seleziona il tuo server" + "Invio in corso…" + "Server non supportato" + "URL del server" + "Impostazioni" + "Adesivo" + "Operazione riuscita" + "Suggerimenti" + "Oggetto" + "Impossibile decrittografare" + "Evento non supportato" + "Nome utente" + "Verifica annullata" + "Verifica completata" + "Video" + "In attesa…" + "Conferma" + "Errore" + "Operazione riuscita" + "Attenzione" + "Attività" + "Bandiere" + "Cibi & Bevande" + "Animali & Natura" + "Oggetti" + "Faccine & Persone" + "Viaggi & Luoghi" + "Simboli" + "Impossibile creare il collegamento permanente" + "Caricamento dei messaggi non riuscito" + "Non è stata trovata alcuna app compatibile per gestire questa azione." + "Alcuni messaggi non sono stati inviati" + "Siamo spiacenti, si è verificato un errore" + "Ehi, parlami su %1$s: %2$s" + "Sei sicuro di voler lasciare questa stanza? Sei l\'unica persona presente. Se esci, nessuno potrà unirsi in futuro, te compreso." + "Sei sicuro di voler lasciare questa stanza? Questa stanza non è pubblica e non potrai rientrare senza un invito." + "Sei sicuro di voler lasciare la stanza?" + "%1$s Android" + + "%1$d membro" + "%1$d membri" + + + "%1$d modifica alla stanza" + "%1$d modifiche alla stanza" + + "Scuoti per segnalare un problema" + "Sembra che tu stia scuotendo il telefono per la frustrazione. Vuoi aprire la schermata di segnalazione dei problemi?" + "Questo messaggio verrà segnalato all\'amministratore dell\'homeserver. Questi non sarà in grado di leggere i messaggi criptati." + "Motivo della segnalazione di questo contenuto" + "Questo è l\'inizio di %1$s." + "Questo è l\'inizio della conversazione." + "Nuovo" + "Blocca utente" + "Seleziona se vuoi nascondere tutti i messaggi attuali e futuri di questo utente" + "Blocca" + "Gli utenti bloccati non saranno in grado di inviarti nuovi messaggi e tutti quelli già esistenti saranno nascosti. Potrai annullare questa azione in qualsiasi momento." + "Blocca utente" + "Sblocca" + "Dopo aver sbloccato l\'utente, potrai vedere nuovamente tutti i suoi messaggi." + "Sblocca utente" + "Si è verificato un errore durante il tentativo di avviare una chat" + "Non possiamo convalidare l\'ID Matrix di questo utente. L\'invito potrebbe non essere ricevuto." + "Rageshake" + "Soglia di rilevamento" + "Generali" + "Versione: %1$s (%2$s)" + "it" + \ No newline at end of file diff --git a/libraries/ui-strings/src/main/res/values-ro/translations.xml b/libraries/ui-strings/src/main/res/values-ro/translations.xml index 6878d7aab4e..1872bb057f7 100644 --- a/libraries/ui-strings/src/main/res/values-ro/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml @@ -1,10 +1,150 @@ - "Confirmare" + "Ascundeți parola" + "Trimiteți fișiere" + "Afișați parola" + "Meniu utilizator" + "Înapoi" + "Anulați" + "Ștergeți" + "Închideți" + "Verificare completă" + "Confirmați" + "Continuați" + "Copiați" + "Copiați linkul" "Creați o cameră" - "Gata" + "Dezactivați" + "Efectuat" + "Editați" + "Activați" + "Invitați" + "Invitați prieteni în %1$s" + "Aflați mai multe" + "Părăsiți" + "Părăsiți camera" + "Următorul" + "Nu" + "Nu acum" "OK" - "Raportează conținutul" - "Începe discuția" - "Vezi sursa" + "Raspuns rapid" + "Citat" + "Ștergeți" + "Răspundeți" + "Raportați o eroare" + "Raportați conținutul" + "Reîncercați" + "Reîncercați decriptarea" + "Salvați" + "Căutați" + "Trimiteți" + "Partajați" + "Partajați linkul" + "Omiteți" + "Începeți" + "Începeți discuția" + "Începeți verificarea" + "Vedeți sursă" + "Da" + "Despre" + "Audio" + "Baloane" + "Se creează camera…" + "Ați parăsit camera" + "Eroare de decriptare" + "Opțiuni programator" + "(editat)" + "Editare" + "Criptare activată" + "Eroare" + "Fişier" + "GIF" + "Imagine" + "Linkul a fost copiat în clipboard" + "Se încarcă…" + "Mesaj" + "Aranjamentul mesajelor" + "Mesaj sters" + "Modern" + "Niciun rezultat" + "Deconectat" + "Parola" + "Persoane" + "Permalink" + "Reacții" + "Răspuns pentru %1$s" + "Raportați o eroare" + "Raport trimis" + "Căutați pe cineva" + "Securitate" + "Selectați serverul" + "Se trimite…" + "Serverul nu este compatibil" + "Adresa URL a serverului" + "Setări" + "Autocolant" + "Succes" + "Sugestii" + "Subiect" + "Nu s-a putut decripta" + "Eveniment neacceptat" + "Utilizator" + "Verificare anulată" + "Verificare completă" + "Video" + "Se aşteaptă…" + "Confirmare" + "Eroare" + "Succes" + "Avertisment" + "Activități" + "Steaguri" + "Mâncare & Băutură" + "Animale și Natură" + "Obiecte" + "Fețe zâmbitoare & Oameni" + "Călătorii & Locuri" + "Simboluri" + "Crearea permalink-ului a eșuat" + "Încărcarea mesajelor a eșuat" + "Nu a fost găsită nicio aplicație capabilă să gestioneze această acțiune." + "Unele mesaje nu au fost trimise" + "Ne pare rău, a apărut o eroare" + "Hei, vorbește cu mine pe %1$s: %2$s" + "Sunteți sigur că vreți să părăsiți această cameră? Sunteți singura persoană de aici. Dacă o părasiți, nimeni nu se va mai putea alătura în viitor, inclusiv dumneavoastra." + "Sunteți sigur că vrei să părăsiți această cameră? Această cameră nu este publică și nu va veti putea alătura din nou fără o invitație." + "Sunteți sigur că vreți să părăsiți camera?" + "%1$s Android" + + "%1$d membru" + + "%1$d membri" + + + "%1$d schimbare a camerii" + + "%1$d schimbări ale camerei" + + "Rageshake pentru a raporta erori" + "Se pare că scuturați telefonul de frustrare. Doriți să deschdeți ecranul de raportare a unei erori?" + "Acest mesaj va fi raportat administratorilor homeserver-ului tau. Ei nu vor putea citi niciun mesaj criptat." + "Motivul raportării acestui conținut" + "Acesta este începutul conversației %1$s." + "Acesta este începutul acestei conversații." + "Nou" + "Blocați utilizatorul" + "Confirmați că doriți să ascundeți toate mesajele curente și viitoare de la acest utilizator" + "Blocați" + "Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând." + "Blocați utilizatorul" + "Deblocați" + "La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta." + "Deblocați utilizatorul" + "A apărut o eroare la încercarea începerii conversației" + "Nu am putut valida ID-ul Matrix al acestui utilizator. Este posibil ca invitația să nu fi fost primită." + "Rageshake" + "Prag de detecție" + "General" + "Versiunea: %1$s (%2$s)" + "ro" \ No newline at end of file diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 7ae90b9b53e..37665e72074 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -4,8 +4,10 @@ "Send files" "Show password" "User menu" + "Accept" "Back" "Cancel" + "Choose photo" "Clear" "Close" "Complete verification" @@ -13,13 +15,16 @@ "Continue" "Copy" "Copy link" + "Create" "Create a room" + "Decline" "Disable" "Done" "Edit" "Enable" "Invite" "Invite friends to %1$s" + "Invites" "Learn more" "Leave" "Leave room" @@ -38,17 +43,21 @@ "Save" "Search" "Send" + "Send message" + "Share" "Share link" "Skip" "Start" "Start chat" "Start verification" + "Take photo" "View Source" "Yes" "About" "Audio" "Bubbles" "Creating room…" + "Left room" "Decryption error" "Developer options" "(edited)" @@ -128,12 +137,7 @@ "This is the beginning of %1$s." "This is the beginning of this conversation." "New" - "Block" - "Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime." - "Block user" - "Unblock" - "On unblocking the user, you will be able to see all messages by them again." - "Unblock user" + "%1$s invited you" "Block user" "Check if you want to hide all current and future messages from this user" "Block" @@ -143,8 +147,11 @@ "On unblocking the user, you will be able to see all messages by them again." "Unblock user" "An error occurred when trying to start a chat" + "We can’t validate this user’s Matrix ID. The invite might not be received." "Rageshake" "Detection threshold" "General" "Version: %1$s (%2$s)" + "en" + "en" \ No newline at end of file diff --git a/plugins/build.gradle.kts b/plugins/build.gradle.kts index 6c77f11ac2d..d4324432f3d 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 314421ebc85..9f0cf920994 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 1f9a9cb9915..6ceba913b7f 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/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt index da9356b0a2d..9911e1f9697 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt @@ -27,6 +27,7 @@ import io.element.android.features.roomlist.impl.RoomListView import io.element.android.libraries.dateformatter.impl.DateFormatters import io.element.android.libraries.dateformatter.impl.DefaultLastMessageTimestampFormatter import io.element.android.libraries.dateformatter.impl.LocalDateTimeProvider +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.coroutines.launch @@ -50,6 +51,7 @@ class RoomListScreen( roomLastMessageFormatter = DefaultRoomLastMessageFormatter(context, matrixClient), sessionVerificationService = sessionVerificationService, networkMonitor = NetworkMonitorImpl(context), + snackbarDispatcher = SnackbarDispatcher(), ) @Composable 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 00000000000..00fe638a478 --- /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 00000000000..cda023b2362 --- /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 00000000000..20e872b8031 --- /dev/null +++ b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.services.appnavstate.test + +import io.element.android.libraries.matrix.api.core.MAIN_SPACE +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 +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 00000000000..38f2e2227c9 --- /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 00000000000..d4ac1ec7394 --- /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 944b17ab523..7429f80b43e 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") } @@ -41,7 +51,6 @@ include(":appnav") include(":tests:uitests") include(":anvilannotations") include(":anvilcodegen") -include(":libraries:rustsdk") include(":samples:minimal") diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..1e92cf978d0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8f29da5d5aeb65659b065b7bd6afe276f83e020545a027780d2391308d1a4076 +size 20750 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..74acb423d66 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b7b91a2d05b975d568116615c286568f376ebead49e25ff17f5aae8b75be0e1f +size 28876 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..23c4de31941 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae3e8c4e952b97628d026dfe78781aef894d6c2e742ac6ae1f1a2c0170df159e +size 20382 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..bb7d1036e41 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44a35f28b3a59cc28937fee16eda26c26ef7b7622f929218e40f7537e096b2e8 +size 28120 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_DefaultGroup_RetryDialogDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_DefaultGroup_RetryDialogDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..62dcdf219cc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_DefaultGroup_RetryDialogDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:596dee7cf5300dc5c2c6b314bf537619466f377b9946114e675c630e2e330976 +size 12059 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_DefaultGroup_RetryDialogLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_DefaultGroup_RetryDialogLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..e1de911d46b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_DefaultGroup_RetryDialogLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f791b1c552138b0d3184b936a51982cfc3e23c0b7de2c9ef5c31559d9279245 +size 12113 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png index 1be155bb969..a4aa94b6e4a 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:67a8353194965d1139b558b848f1d1a5411370555eefc765c055ad8a126c4265 -size 6117 +oid sha256:ddbb6611ae83055106f7b67ec828542f8a896cafb49001ed0baef43633cc77c1 +size 8884 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png index 38b159ccdf4..317643c5984 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:694fda9bd548990ef322fe3db691ccb7973c5d5efb8e78d6e50030109dc96359 -size 5947 +oid sha256:5c9a3c9f68a6627654856b03d2534ae1e4e8e600989bc3719407fbf8e17a7ab1 +size 8631 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..4abfbacbbca --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d91a9a9decf08f9bd9301d5282e889fb4e12d4270e8dc7c4b8b24de0b6059126 +size 24662 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..22ad0f00594 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6548a7cc39e0861de6af8e55bc00424d8835e5aa3d99d4e7c68db682e054d677 +size 24542 diff --git a/tools/localazy/README.md b/tools/localazy/README.md index dcb45c5be7a..c1ad1d0ee0d 100644 --- a/tools/localazy/README.md +++ b/tools/localazy/README.md @@ -2,12 +2,49 @@ Localazy is used to host the source strings and their translations. + + +* [Localazy project](#localazy-project) + * [Key naming rules](#key-naming-rules) + * [Special suffixes](#special-suffixes) + * [Placeholders](#placeholders) +* [CLI Installation](#cli-installation) +* [Download translations](#download-translations) +* [Add translations to a specific module](#add-translations-to-a-specific-module) + + + ## Localazy project -To add new strings, or to translate existing strings, go the the Localazy project: [https://localazy.com/p/element](https://localazy.com/p/element). +To add new strings, or to translate existing strings, go the the Localazy project: [https://localazy.com/p/element](https://localazy.com/p/element). Please follow the key naming rules (see below). Never edit manually the files `localazy.xml` or `translations.xml`!. +### Key naming rules + +For code clarity and in order to download strings to the correct module, here are some naming rules to follow as much as possible: + +- Keys for common strings, i.e. strings that can be used at multiple places must start by `action_` if this is a verb, or `common_` if not; +- Keys for common accessibility strings must start by `a11y_`. Example: `a11y_hide_password`; +- Keys for strings used in a single screen must start with `screen_` followed by the screen name, followed by a free name. Example: `screen_onboarding_welcome_title`; +- Keys can have `_title` or `_subtitle` suffixes. Example: `screen_onboarding_welcome_title`, `screen_change_server_subtitle`; +- For dialogs, keys can have `_dialog_title`, `_dialog_content`, and `_dialog_submit` suffixes. Example: `screen_signout_confirmation_dialog_title`, `screen_signout_confirmation_dialog_content`, `screen_signout_confirmation_dialog_submit`; +- `a11y_` pattern can be used for strings that are only used for accessibility. Example: `a11y_hide_password`, `screen_roomlist_a11y_create_message`; +- Strings for error message can start by `error_`, or contain `_error_` if used in a specific screen only. Example: `error_some_messages_have_not_been_sent`, `screen_change_server_error_invalid_homeserver`. + +*Note*: those rules applies for `strings` and for `plurals`. + +#### Special suffixes + +- if a key is suffixed by `_ios`, it will not be imported in the Android project; +- if a key is suffixed by `_android`, it will not be imported in the iOS project. + +So feel free to use those suffixes when necessary for instance when the string content is referring to something related to Android only, or iOS only. + +#### Placeholders + +Placeholders should have the form `%1$s`, `%1$d`, etc.. Please use numbered placeholders. Note that Localazy will take care of converting the placeholder to Android (-> `%1$s`) and iOS specific format (-> `%1$@`). Ideally add a comment on Localazy to explain with what the placeholder(s) will be replaced at runtime. + ## CLI Installation To install the Localazy client, follow the instructions from [here](https://localazy.com/docs/cli/installation). @@ -20,7 +57,13 @@ In the root folder of the project, run: ./tools/localazy/downloadStrings.sh ``` -It will update all the `localazy.xml` and `translations.xml` resource files. In case of merge conflicts, just erase the files and download again using the script. +It will update all the `localazy.xml` resource files. In case of merge conflicts, just erase the files and download again using the script. + +To also include the translations, i.e. the `translations.xml` files, add `--all` argument: + +```shell +./tools/localazy/downloadStrings.sh --all +``` ## Add translations to a specific module diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 155a42836a3..59c6bd69116 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": [ diff --git a/tools/localazy/downloadStrings.sh b/tools/localazy/downloadStrings.sh index 163aab8e315..0f35f9ec596 100755 --- a/tools/localazy/downloadStrings.sh +++ b/tools/localazy/downloadStrings.sh @@ -18,12 +18,24 @@ set -e +if [[ $1 == "--all" ]]; then + echo "Note: I will update all the files." + allFiles=1 +else + echo "Note: I will update only the English files." + allFiles=0 +fi + echo "Generating the configuration file for localazy..." -./tools/localazy/generateLocalazyConfig.py +python3 ./tools/localazy/generateLocalazyConfig.py $allFiles -echo "Deleting all existing localazy.xml and translations.xml files..." +echo "Deleting all existing localazy.xml files..." find . -name 'localazy.xml' -delete -find . -name 'translations.xml' -delete + +if [[ $allFiles == 1 ]]; then + echo "Deleting all existing translations.xml files..." + find . -name 'translations.xml' -delete +fi echo "Importing the strings..." localazy download --config ./tools/localazy/localazy.json diff --git a/tools/localazy/generateLocalazyConfig.py b/tools/localazy/generateLocalazyConfig.py index 829541462e2..e2bc310e1e6 100755 --- a/tools/localazy/generateLocalazyConfig.py +++ b/tools/localazy/generateLocalazyConfig.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 import json +import sys # Read the config.json file with open('./tools/localazy/config.json', 'r') as f: config = json.load(f) +allFiles = sys.argv[1] == "1" # Convert a module name to a path # Ex: ":features:verifysession:impl" => "features/verifysession/impl" @@ -18,6 +20,14 @@ def convertModuleToPath(name): ".*_ios" ] +baseAction = { + "type": "android", + # Replacement done in all string values + "replacements": { + "...": "…" + } +} + # Store all regex specific to module, to eclude the corresponding keyx from the common string module allRegexToExcludeFromMainModule = [] # All actions that will be serialized in the localazy config @@ -26,8 +36,7 @@ def convertModuleToPath(name): # Iterating on the config for entry in config["modules"]: # Create action for the default language - action = { - "type": "android", + action = baseAction | { "output": convertModuleToPath(entry["name"]) + "/src/main/res/values/localazy.xml", "includeKeys": list(map(lambda i: "REGEX:" + i, entry["includeRegex"])), "excludeKeys": list(map(lambda i: "REGEX:" + i, regexToAlwaysExclude)), @@ -35,24 +44,23 @@ def convertModuleToPath(name): "equals: ${languageCode}, en" ] } - # Create action for the translations - actionTranslation = { - "type": "android", - "output": convertModuleToPath(entry["name"]) + "/src/main/res/values-${langAndroidResNoScript}/translations.xml", - "includeKeys": list(map(lambda i: "REGEX:" + i, entry["includeRegex"])), - "excludeKeys": list(map(lambda i: "REGEX:" + i, regexToAlwaysExclude)), - "conditions": [ - "!equals: ${languageCode}, en" - ] - } # print(action) - allRegexToExcludeFromMainModule.extend(entry["includeRegex"]) allActions.append(action) - allActions.append(actionTranslation) + # Create action for the translations + if allFiles: + actionTranslation = baseAction | { + "output": convertModuleToPath(entry["name"]) + "/src/main/res/values-${langAndroidResNoScript}/translations.xml", + "includeKeys": list(map(lambda i: "REGEX:" + i, entry["includeRegex"])), + "excludeKeys": list(map(lambda i: "REGEX:" + i, regexToAlwaysExclude)), + "conditions": [ + "!equals: ${languageCode}, en" + ] + } + allActions.append(actionTranslation) + allRegexToExcludeFromMainModule.extend(entry["includeRegex"]) # Append configuration for the main string module: default language -mainAction = { - "type": "android", +mainAction = baseAction | { "output": "libraries/ui-strings/src/main/res/values/localazy.xml", "excludeKeys": list(map(lambda i: "REGEX:" + i, allRegexToExcludeFromMainModule + regexToAlwaysExclude)), "conditions": [ @@ -62,16 +70,16 @@ def convertModuleToPath(name): # print(mainAction) allActions.append(mainAction) -# Append configuration for the main string module: translations -mainActionTranslation = { - "type": "android", - "output": "libraries/ui-strings/src/main/res/values-${langAndroidResNoScript}/translations.xml", - "excludeKeys": list(map(lambda i: "REGEX:" + i, allRegexToExcludeFromMainModule + regexToAlwaysExclude)), - "conditions": [ - "!equals: ${languageCode}, en" - ] -} -allActions.append(mainActionTranslation) +if allFiles: + # Append configuration for the main string module: translations + mainActionTranslation = baseAction | { + "output": "libraries/ui-strings/src/main/res/values-${langAndroidResNoScript}/translations.xml", + "excludeKeys": list(map(lambda i: "REGEX:" + i, allRegexToExcludeFromMainModule + regexToAlwaysExclude)), + "conditions": [ + "!equals: ${languageCode}, en" + ] + } + allActions.append(mainActionTranslation) # Generate the configuration for localazy result = {