From 28ed753090cd5f55081706aa56de8ac921cf4b38 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 28 Mar 2023 11:36:23 +0200 Subject: [PATCH 001/107] Add string key naming rules. --- tools/localazy/README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tools/localazy/README.md b/tools/localazy/README.md index dcb45c5be7..af040d17f7 100644 --- a/tools/localazy/README.md +++ b/tools/localazy/README.md @@ -4,10 +4,22 @@ Localazy is used to host the source strings and their translations. ## 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 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`; +- `a11y_` pattern can be used for strings 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`. + ## CLI Installation To install the Localazy client, follow the instructions from [here](https://localazy.com/docs/cli/installation). From 965a8676a60d080bc2f077e2345dc5514dc671a7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 28 Mar 2023 14:03:52 +0200 Subject: [PATCH 002/107] Update documentation about strings. --- CONTRIBUTING.md | 32 ++++++++++++++++++++++---------- tools/localazy/README.md | 10 ++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5cd8785b33..0c462036b9 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,26 @@ 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 +## Strings -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/). +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 +151,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/tools/localazy/README.md b/tools/localazy/README.md index af040d17f7..e855b33992 100644 --- a/tools/localazy/README.md +++ b/tools/localazy/README.md @@ -2,6 +2,16 @@ Localazy is used to host the source strings and their translations. + + +* [Localazy project](#localazy-project) + * [Key naming rules](#key-naming-rules) +* [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). Please follow the key naming rules (see below). From adf9b1bed93d354bef18ae99dcd92ceb048f2358 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 28 Mar 2023 14:04:17 +0200 Subject: [PATCH 003/107] Add a note about the configuration. --- CONTRIBUTING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c462036b9..82602e7e63 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,8 @@ 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`. +Note: please make sure that the configuration is `app` and not `samples.minimal`. + ## Strings The strings of the project are managed externally using [https://localazy.com](https://localazy.com) and shared with ElementX iOS. From 7688cfb0310d71d72164ea539ab36e90fa931a14 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 28 Mar 2023 14:05:50 +0200 Subject: [PATCH 004/107] Small clarification --- tools/localazy/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/localazy/README.md b/tools/localazy/README.md index e855b33992..6117cf17c0 100644 --- a/tools/localazy/README.md +++ b/tools/localazy/README.md @@ -25,7 +25,7 @@ For code clarity and in order to download strings to the correct module, here ar - 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 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`; -- `a11y_` pattern can be used for strings used for accessibility. Example: `a11y_hide_password`, `screen_roomlist_a11y_create_message`; +- `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`. From ae5a094fe42dbab5c36b38326edd3960b8477705 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 28 Mar 2023 14:07:22 +0200 Subject: [PATCH 005/107] Add rule for string keys starting with `a11y`. --- tools/localazy/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/localazy/README.md b/tools/localazy/README.md index 6117cf17c0..10faf09ead 100644 --- a/tools/localazy/README.md +++ b/tools/localazy/README.md @@ -23,6 +23,7 @@ Never edit manually the files `localazy.xml` or `translations.xml`!. 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`; - `a11y_` pattern can be used for strings that are only used for accessibility. Example: `a11y_hide_password`, `screen_roomlist_a11y_create_message`; From bff138ae22d5e60f079e7a05fb48f96d23367137 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 28 Mar 2023 14:09:46 +0200 Subject: [PATCH 006/107] typo --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 82602e7e63..125c69adb6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,7 +59,7 @@ 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 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). From cbc7982cf5eb86e1a4ee0aa6a9811362783fe156 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 28 Mar 2023 15:16:03 +0200 Subject: [PATCH 007/107] Add rules for platform suffixes. --- tools/localazy/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tools/localazy/README.md b/tools/localazy/README.md index 10faf09ead..ed47efd2c3 100644 --- a/tools/localazy/README.md +++ b/tools/localazy/README.md @@ -6,6 +6,7 @@ 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) * [CLI Installation](#cli-installation) * [Download translations](#download-translations) * [Add translations to a specific module](#add-translations-to-a-specific-module) @@ -31,6 +32,13 @@ For code clarity and in order to download strings to the correct module, here ar *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. + ## CLI Installation To install the Localazy client, follow the instructions from [here](https://localazy.com/docs/cli/installation). From 943d9600c3bf4588429d62b7e83e1f97de7da2c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 4 Apr 2023 08:24:33 +0200 Subject: [PATCH 008/107] Disable nightly workflow for forks --- .github/workflows/nightly.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index afefa27baa..2f7c10a3d0 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -13,6 +13,7 @@ jobs: nightly: name: Build and publish nightly APK to Firebase runs-on: ubuntu-latest + if: ${{ github.repository == 'vector-im/element-x-android' }} steps: - uses: actions/checkout@v3 - name: Use JDK 17 From 6450fc572482202981de2085163a16d5c31eec26 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Fri, 24 Mar 2023 16:01:14 +0100 Subject: [PATCH 009/107] Create or retrieve DM --- .../android/appnav/LoggedInFlowNode.kt | 12 ++++- features/createroom/api/build.gradle.kts | 1 + .../createroom/api/CreateRoomEntryPoint.kt | 19 +++++++- .../createroom/impl/CreateRoomFlowNode.kt | 7 +++ .../impl/DefaultCreateRoomEntryPoint.kt | 19 +++++++- .../impl/root/CreateRoomRootEvents.kt | 4 +- .../impl/root/CreateRoomRootNode.kt | 20 ++++---- .../impl/root/CreateRoomRootPresenter.kt | 46 +++++++++++++++++-- .../impl/root/CreateRoomRootState.kt | 4 ++ .../impl/root/CreateRoomRootStateProvider.kt | 5 +- .../impl/root/CreateRoomRootView.kt | 41 ++++++++++++++++- .../impl/root/CreateRoomRootPresenterTests.kt | 9 ++-- .../selectusers/api/SelectUsersEvents.kt | 1 + .../impl/DefaultSelectUsersPresenter.kt | 1 + .../libraries/matrix/api/MatrixClient.kt | 3 ++ .../libraries/matrix/impl/RustMatrixClient.kt | 27 +++++++++++ .../libraries/matrix/test/FakeMatrixClient.kt | 29 ++++++++++-- 17 files changed, 220 insertions(+), 28 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 4b2d653d79..fa2643b4ff 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -32,6 +32,7 @@ import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.operation.push +import com.bumble.appyx.navmodel.backstack.operation.replace import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode @@ -178,7 +179,16 @@ class LoggedInFlowNode @AssistedInject constructor( .build() } NavTarget.CreateRoom -> { - createRoomEntryPoint.createNode(this, buildContext) + val callback = object : CreateRoomEntryPoint.Callback { + override fun onOpenRoom(roomId: RoomId) { + backstack.replace(NavTarget.Room(roomId)) + } + } + + createRoomEntryPoint + .nodeBuilder(this, buildContext) + .callback(callback) + .build() } NavTarget.VerifySession -> { verifySessionEntryPoint.createNode(this, buildContext) diff --git a/features/createroom/api/build.gradle.kts b/features/createroom/api/build.gradle.kts index b3fceedc27..cafcf7c4b5 100644 --- a/features/createroom/api/build.gradle.kts +++ b/features/createroom/api/build.gradle.kts @@ -24,4 +24,5 @@ android { dependencies { implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) } diff --git a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt index 049c101806..73f5110daa 100644 --- a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt +++ b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt @@ -16,6 +16,21 @@ package io.element.android.features.createroom.api -import io.element.android.libraries.architecture.SimpleFeatureEntryPoint +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.matrix.api.core.RoomId -interface CreateRoomEntryPoint : SimpleFeatureEntryPoint +interface CreateRoomEntryPoint : FeatureEntryPoint { + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + interface NodeBuilder { + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + interface Callback : Plugin { + fun onOpenRoom(roomId: RoomId) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt index 13c3ff52be..22851904c4 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt @@ -23,17 +23,20 @@ import com.bumble.appyx.core.composable.Children import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.operation.push import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.createroom.impl.addpeople.AddPeopleNode import io.element.android.features.createroom.impl.root.CreateRoomRootNode import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.parcelize.Parcelize @ContributesNode(SessionScope::class) @@ -64,6 +67,10 @@ class CreateRoomFlowNode @AssistedInject constructor( override fun onCreateNewRoom() { backstack.push(NavTarget.NewRoom) } + + override fun onOpenRoom(roomId: RoomId) { + plugins().forEach { it.onOpenRoom(roomId) } + } } createNode(buildContext, plugins = listOf(callback)) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt index 214ed3a9ec..34e514be3e 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt @@ -18,6 +18,7 @@ package io.element.android.features.createroom.impl import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.libraries.architecture.createNode @@ -26,7 +27,21 @@ import javax.inject.Inject @ContributesBinding(AppScope::class) class DefaultCreateRoomEntryPoint @Inject constructor() : CreateRoomEntryPoint { - override fun createNode(parentNode: Node, buildContext: BuildContext): Node { - return parentNode.createNode(buildContext) + + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): CreateRoomEntryPoint.NodeBuilder { + + val plugins = ArrayList() + + return object : CreateRoomEntryPoint.NodeBuilder { + + override fun callback(callback: CreateRoomEntryPoint.Callback): CreateRoomEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins) + } + } } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt index 5d2f0f684c..d90708ed2b 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt @@ -19,6 +19,8 @@ package io.element.android.features.createroom.impl.root import io.element.android.libraries.matrix.ui.model.MatrixUser sealed interface CreateRoomRootEvents { - data class StartDM(val matrixUser: MatrixUser) : CreateRoomRootEvents object InvitePeople : CreateRoomRootEvents + data class SelectUser(val matrixUser: MatrixUser) : CreateRoomRootEvents + data class CreateDM(val matrixUser: MatrixUser) : CreateRoomRootEvents + object CancelCreateDM : CreateRoomRootEvents } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt index dadc16efc4..3a714a0eb3 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt @@ -16,7 +16,6 @@ package io.element.android.features.createroom.impl.root -import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.bumble.appyx.core.modality.BuildContext @@ -27,7 +26,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.libraries.di.SessionScope -import kotlinx.parcelize.Parcelize +import io.element.android.libraries.matrix.api.core.RoomId @ContributesNode(SessionScope::class) class CreateRoomRootNode @AssistedInject constructor( @@ -38,15 +37,17 @@ class CreateRoomRootNode @AssistedInject constructor( interface Callback : Plugin { fun onCreateNewRoom() + fun onOpenRoom(roomId: RoomId) } - private fun onCreateNewRoom() { - plugins().forEach { it.onCreateNewRoom() } - } + private val callback = object : Callback { + override fun onCreateNewRoom() { + plugins().forEach { it.onCreateNewRoom() } + } - sealed interface NavTarget : Parcelable { - @Parcelize - object Root : NavTarget + override fun onOpenRoom(roomId: RoomId) { + plugins().forEach { it.onOpenRoom(roomId) } + } } @Composable @@ -56,7 +57,8 @@ class CreateRoomRootNode @AssistedInject constructor( state = state, modifier = modifier, onClosePressed = this::navigateUp, - onNewRoomClicked = this::onCreateNewRoom, + onNewRoomClicked = callback::onCreateNewRoom, + onOpenDM = callback::onOpenRoom, ) } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt index 2f3f3ded4a..68901037f0 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt @@ -17,16 +17,30 @@ package io.element.android.features.createroom.impl.root import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import io.element.android.features.selectusers.api.SelectUsersEvents import io.element.android.features.selectusers.api.SelectUsersPresenter import io.element.android.features.selectusers.api.SelectUsersPresenterArgs import io.element.android.features.selectusers.api.SelectionMode +import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.execute +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.ui.model.MatrixUser -import timber.log.Timber +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import javax.inject.Inject class CreateRoomRootPresenter @Inject constructor( private val presenterFactory: SelectUsersPresenter.Factory, + private val matrixClient: MatrixClient, ) : Presenter { private val presenter by lazy { @@ -37,20 +51,44 @@ class CreateRoomRootPresenter @Inject constructor( override fun present(): CreateRoomRootState { val selectUsersState = presenter.present() + val localCoroutineScope = rememberCoroutineScope() + + var showCreateDmConfirmationDialog by rememberSaveable { mutableStateOf(false) } + val startDmAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } + fun handleEvents(event: CreateRoomRootEvents) { when (event) { - is CreateRoomRootEvents.StartDM -> handleStartDM(event.matrixUser) + is CreateRoomRootEvents.SelectUser -> { + val existingDM = matrixClient.findDM(event.matrixUser.id) + if (existingDM == null) { + showCreateDmConfirmationDialog = true + } else { + startDmAction.value = Async.Success(existingDM.roomId) + } + } + is CreateRoomRootEvents.CreateDM -> { + showCreateDmConfirmationDialog = false + localCoroutineScope.createDM(event.matrixUser, startDmAction) + } + CreateRoomRootEvents.CancelCreateDM -> { + showCreateDmConfirmationDialog = false + selectUsersState.eventSink(SelectUsersEvents.ClearSelection) + } CreateRoomRootEvents.InvitePeople -> Unit // Todo Handle invite people action } } return CreateRoomRootState( selectUsersState = selectUsersState, + showCreateDmConfirmationDialog = showCreateDmConfirmationDialog, + startDmAction = startDmAction.value, eventSink = ::handleEvents, ) } - private fun handleStartDM(matrixUser: MatrixUser) { - Timber.d("handleStartDM: $matrixUser") // Todo handle start DM action + private fun CoroutineScope.createDM(user: MatrixUser, startDmAction: MutableState>) = launch { + suspend { + matrixClient.createDM(user.id).getOrThrow() + }.execute(startDmAction) } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt index a57d6aaaf6..89b702a011 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt @@ -17,8 +17,12 @@ package io.element.android.features.createroom.impl.root import io.element.android.features.selectusers.api.SelectUsersState +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.RoomId data class CreateRoomRootState( val selectUsersState: SelectUsersState, + val showCreateDmConfirmationDialog: Boolean, + val startDmAction: Async, val eventSink: (CreateRoomRootEvents) -> Unit, ) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt index 678f02476c..750b9cfe32 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt @@ -18,6 +18,7 @@ package io.element.android.features.createroom.impl.root import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.selectusers.api.aSelectUsersState +import io.element.android.libraries.architecture.Async open class CreateRoomRootStateProvider : PreviewParameterProvider { override val values: Sequence @@ -28,5 +29,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider Unit = {}, onNewRoomClicked: () -> Unit = {}, + onOpenDM: (RoomId) -> Unit = {}, ) { + if (state.startDmAction is Async.Success) { + LaunchedEffect(state.startDmAction) { + onOpenDM(state.startDmAction.state) + } + } + Scaffold( modifier = modifier.fillMaxWidth(), topBar = { @@ -72,7 +85,7 @@ fun CreateRoomRootView( SelectUsersView( modifier = Modifier.fillMaxWidth(), state = state.selectUsersState, - onUserSelected = { state.eventSink.invoke(CreateRoomRootEvents.StartDM(it)) }, + onUserSelected = { state.eventSink(CreateRoomRootEvents.SelectUser(it)) }, ) if (!state.selectUsersState.isSearchActive) { @@ -83,6 +96,12 @@ fun CreateRoomRootView( } } } + + CreateDmConfirmationDialog(state) + + if (state.startDmAction is Async.Loading) { + ProgressDialog(text = "Creating room...") + } } @OptIn(ExperimentalMaterial3Api::class) @@ -153,6 +172,26 @@ fun CreateRoomActionButton( } } +@Composable +fun CreateDmConfirmationDialog( + state: CreateRoomRootState, + modifier: Modifier = Modifier, +) { + if (state.showCreateDmConfirmationDialog) { + val selectedUser = state.selectUsersState.selectedUsers.firstOrNull() + if (selectedUser != null) { + ConfirmationDialog( + modifier = modifier, + title = "Start chat", + content = "You're about starting a chat with ${selectedUser.getBestName()}, do you want to continue?", + submitText = stringResource(io.element.android.libraries.ui.strings.R.string._continue), + onSubmitClicked = { state.eventSink(CreateRoomRootEvents.CreateDM(selectedUser)) }, + onDismiss = { state.eventSink(CreateRoomRootEvents.CancelCreateDM) }, + ) + } + } +} + @Preview @Composable fun CreateRoomRootViewLightPreview(@PreviewParameter(CreateRoomRootStateProvider::class) state: CreateRoomRootState) = diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt index 5dc9ec00e9..4aa1068ce4 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt @@ -25,6 +25,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.selectusers.api.SelectUsersPresenterArgs import io.element.android.features.selectusers.impl.DefaultSelectUsersPresenter import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.ui.model.MatrixUser import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -34,13 +35,15 @@ import org.junit.Test class CreateRoomRootPresenterTests { private lateinit var presenter: CreateRoomRootPresenter + private lateinit var fakeMatrixClient: FakeMatrixClient @Before fun setup() { val selectUsersPresenter = object : DefaultSelectUsersPresenter.DefaultSelectUsersFactory { override fun create(args: SelectUsersPresenterArgs) = DefaultSelectUsersPresenter(args) } - presenter = CreateRoomRootPresenter(selectUsersPresenter) + fakeMatrixClient = FakeMatrixClient() + presenter = CreateRoomRootPresenter(selectUsersPresenter, fakeMatrixClient) } @Test @@ -64,13 +67,13 @@ class CreateRoomRootPresenterTests { } @Test - fun `present - trigger start DM action`() = runTest { + fun `present - trigger select user action`() = runTest { moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { val initialState = awaitItem() val matrixUser = MatrixUser(UserId("@name:matrix.org")) - initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) + initialState.eventSink(CreateRoomRootEvents.SelectUser(matrixUser)) } } } diff --git a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersEvents.kt b/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersEvents.kt index e0ee6ddf68..22c72f6dfb 100644 --- a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersEvents.kt +++ b/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersEvents.kt @@ -22,5 +22,6 @@ sealed interface SelectUsersEvents { data class UpdateSearchQuery(val query: String) : SelectUsersEvents data class AddToSelection(val matrixUser: MatrixUser) : SelectUsersEvents data class RemoveFromSelection(val matrixUser: MatrixUser) : SelectUsersEvents + object ClearSelection : SelectUsersEvents data class OnSearchActiveChanged(val active: Boolean) : SelectUsersEvents } diff --git a/features/selectusers/impl/src/main/kotlin/io/element/android/features/selectusers/impl/DefaultSelectUsersPresenter.kt b/features/selectusers/impl/src/main/kotlin/io/element/android/features/selectusers/impl/DefaultSelectUsersPresenter.kt index e1135cd1a2..55c2cd694d 100644 --- a/features/selectusers/impl/src/main/kotlin/io/element/android/features/selectusers/impl/DefaultSelectUsersPresenter.kt +++ b/features/selectusers/impl/src/main/kotlin/io/element/android/features/selectusers/impl/DefaultSelectUsersPresenter.kt @@ -79,6 +79,7 @@ class DefaultSelectUsersPresenter @AssistedInject constructor( localCoroutineScope.scrollToFirstSelectedUser(selectedUsersListState) } is SelectUsersEvents.RemoveFromSelection -> selectedUsers.value = selectedUsers.value.minus(event.matrixUser).toImmutableList() + SelectUsersEvents.ClearSelection -> selectedUsers.value = persistentListOf() } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 9ce5502843..15ba838bcb 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.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.UserId import io.element.android.libraries.matrix.api.media.MediaResolver import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource @@ -28,6 +29,8 @@ interface MatrixClient : Closeable { val sessionId: SessionId val roomSummaryDataSource: RoomSummaryDataSource fun getRoom(roomId: RoomId): MatrixRoom? + suspend fun createDM(userId: UserId): Result + fun findDM(userId: UserId): MatrixRoom? fun startSync() fun stopSync() fun mediaResolver(): MediaResolver diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 378fec56c7..ce9286d2be 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -37,7 +37,10 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientDelegate +import org.matrix.rustcomponents.sdk.CreateRoomParameters import org.matrix.rustcomponents.sdk.RequiredState +import org.matrix.rustcomponents.sdk.RoomPreset +import org.matrix.rustcomponents.sdk.RoomVisibility import org.matrix.rustcomponents.sdk.SlidingSyncListBuilder import org.matrix.rustcomponents.sdk.SlidingSyncMode import org.matrix.rustcomponents.sdk.SlidingSyncRequestListFilters @@ -154,6 +157,30 @@ class RustMatrixClient constructor( ) } + override fun findDM(userId: UserId): MatrixRoom? { + val roomId = client.getDmRoom(userId.value)?.use { RoomId(it.id()) } + return roomId?.let { getRoom(it) } + } + + override suspend fun createDM(userId: UserId): Result = + withContext(dispatchers.io) { + runCatching { + val roomId = client.createRoom( + CreateRoomParameters( + name = "", + topic = null, + isEncrypted = true, + isDirect = true, + visibility = RoomVisibility.PRIVATE, + preset = RoomPreset.TRUSTED_PRIVATE_CHAT, + invite = listOf(userId.value), + avatar = null, + ) + ) + RoomId(roomId) + } + } + override fun mediaResolver(): MediaResolver = mediaResolver override fun sessionVerificationService(): SessionVerificationService = verificationService diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 9272e794b4..ec5068e93d 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.test import io.element.android.libraries.matrix.api.MatrixClient 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.media.MediaResolver import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource @@ -37,12 +38,22 @@ class FakeMatrixClient( private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService() ) : MatrixClient { + private var createDmResult: Result = Result.success(A_ROOM_ID) + private var findDmResult: MatrixRoom? = FakeMatrixRoom() private var logoutFailure: Throwable? = null override fun getRoom(roomId: RoomId): MatrixRoom? { return FakeMatrixRoom(roomId) } + override suspend fun createDM(userId: UserId): Result { + return createDmResult + } + + override fun findDM(userId: UserId): MatrixRoom? { + return findDmResult + } + override fun startSync() = Unit override fun stopSync() = Unit @@ -51,10 +62,6 @@ class FakeMatrixClient( return FakeMediaResolver() } - fun givenLogoutError(failure: Throwable) { - logoutFailure = failure - } - override suspend fun logout() { delay(100) logoutFailure?.let { throw it } @@ -81,4 +88,18 @@ class FakeMatrixClient( override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService override fun onSlidingSyncUpdate() {} + + // Mocks + + fun givenLogoutError(failure: Throwable) { + logoutFailure = failure + } + + fun givenCreateDmResult(result: Result) { + createDmResult = result + } + + fun givenFindDmResult(result: MatrixRoom?) { + findDmResult = result + } } From 708d7b2fb44d543c594a875042d46df3013f73b0 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 29 Mar 2023 07:41:04 +0200 Subject: [PATCH 010/107] convert rustsdk gradle file to kts --- libraries/rustsdk/build.gradle | 2 -- libraries/rustsdk/build.gradle.kts | 2 ++ settings.gradle.kts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) delete mode 100644 libraries/rustsdk/build.gradle create mode 100644 libraries/rustsdk/build.gradle.kts diff --git a/libraries/rustsdk/build.gradle b/libraries/rustsdk/build.gradle deleted file mode 100644 index bfafe67f28..0000000000 --- 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 0000000000..56fd094897 --- /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/settings.gradle.kts b/settings.gradle.kts index 944b17ab52..e44a2cb8fa 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -41,7 +41,6 @@ include(":appnav") include(":tests:uitests") include(":anvilannotations") include(":anvilcodegen") -include(":libraries:rustsdk") include(":samples:minimal") From c6c9d1c5929812f2c282e647ad07bbc1099e8b46 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 29 Mar 2023 07:41:44 +0200 Subject: [PATCH 011/107] fix rebase issue --- .../android/features/createroom/impl/root/CreateRoomRootView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt index 8c0e9382d0..6259840a21 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt @@ -184,7 +184,7 @@ fun CreateDmConfirmationDialog( modifier = modifier, title = "Start chat", content = "You're about starting a chat with ${selectedUser.getBestName()}, do you want to continue?", - submitText = stringResource(io.element.android.libraries.ui.strings.R.string._continue), + submitText = stringResource(io.element.android.libraries.ui.strings.R.string.action_continue), onSubmitClicked = { state.eventSink(CreateRoomRootEvents.CreateDM(selectedUser)) }, onDismiss = { state.eventSink(CreateRoomRootEvents.CancelCreateDM) }, ) From 4bee8f19cd498d73a3d7a0d5c9c7516fbaaf802b Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 29 Mar 2023 08:47:06 +0200 Subject: [PATCH 012/107] Add preview --- .../impl/root/CreateRoomRootStateProvider.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt index 750b9cfe32..be720de5b2 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt @@ -19,11 +19,24 @@ package io.element.android.features.createroom.impl.root import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.selectusers.api.aSelectUsersState import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import kotlinx.collections.immutable.persistentListOf open class CreateRoomRootStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aCreateRoomRootState(), + aCreateRoomRootState().copy( + showCreateDmConfirmationDialog = true, + selectUsersState = aMatrixUser().let { + aSelectUsersState().copy( + searchQuery = it.id.value, + searchResults = persistentListOf(it), + selectedUsers = persistentListOf(it), + isSearchActive = true, + ) + } + ), ) } From 6124f669e7874fcdebf59b167c44f8dc209b66b5 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 29 Mar 2023 09:34:13 +0200 Subject: [PATCH 013/107] Remove confirmation dialog --- .../impl/root/CreateRoomRootEvents.kt | 4 +-- .../impl/root/CreateRoomRootPresenter.kt | 19 ++------------ .../impl/root/CreateRoomRootState.kt | 1 - .../impl/root/CreateRoomRootStateProvider.kt | 5 ++-- .../impl/root/CreateRoomRootView.kt | 26 +------------------ .../impl/root/CreateRoomRootPresenterTests.kt | 2 +- .../selectusers/api/SelectUsersEvents.kt | 1 - .../impl/DefaultSelectUsersPresenter.kt | 1 - 8 files changed, 7 insertions(+), 52 deletions(-) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt index d90708ed2b..d3cd1e0287 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt @@ -20,7 +20,5 @@ import io.element.android.libraries.matrix.ui.model.MatrixUser sealed interface CreateRoomRootEvents { object InvitePeople : CreateRoomRootEvents - data class SelectUser(val matrixUser: MatrixUser) : CreateRoomRootEvents - data class CreateDM(val matrixUser: MatrixUser) : CreateRoomRootEvents - object CancelCreateDM : CreateRoomRootEvents + data class StartDM(val matrixUser: MatrixUser) : CreateRoomRootEvents } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt index 68901037f0..2ec32c7bac 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt @@ -18,13 +18,9 @@ package io.element.android.features.createroom.impl.root import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import io.element.android.features.selectusers.api.SelectUsersEvents import io.element.android.features.selectusers.api.SelectUsersPresenter import io.element.android.features.selectusers.api.SelectUsersPresenterArgs import io.element.android.features.selectusers.api.SelectionMode @@ -52,35 +48,24 @@ class CreateRoomRootPresenter @Inject constructor( val selectUsersState = presenter.present() val localCoroutineScope = rememberCoroutineScope() - - var showCreateDmConfirmationDialog by rememberSaveable { mutableStateOf(false) } val startDmAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } fun handleEvents(event: CreateRoomRootEvents) { when (event) { - is CreateRoomRootEvents.SelectUser -> { + is CreateRoomRootEvents.StartDM -> { val existingDM = matrixClient.findDM(event.matrixUser.id) if (existingDM == null) { - showCreateDmConfirmationDialog = true + localCoroutineScope.createDM(event.matrixUser, startDmAction) } else { startDmAction.value = Async.Success(existingDM.roomId) } } - is CreateRoomRootEvents.CreateDM -> { - showCreateDmConfirmationDialog = false - localCoroutineScope.createDM(event.matrixUser, startDmAction) - } - CreateRoomRootEvents.CancelCreateDM -> { - showCreateDmConfirmationDialog = false - selectUsersState.eventSink(SelectUsersEvents.ClearSelection) - } CreateRoomRootEvents.InvitePeople -> Unit // Todo Handle invite people action } } return CreateRoomRootState( selectUsersState = selectUsersState, - showCreateDmConfirmationDialog = showCreateDmConfirmationDialog, startDmAction = startDmAction.value, eventSink = ::handleEvents, ) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt index 89b702a011..9b8b81aef1 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt @@ -22,7 +22,6 @@ import io.element.android.libraries.matrix.api.core.RoomId data class CreateRoomRootState( val selectUsersState: SelectUsersState, - val showCreateDmConfirmationDialog: Boolean, val startDmAction: Async, val eventSink: (CreateRoomRootEvents) -> Unit, ) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt index be720de5b2..e0e3152318 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt @@ -27,7 +27,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider selectedUsers.value = selectedUsers.value.minus(event.matrixUser).toImmutableList() - SelectUsersEvents.ClearSelection -> selectedUsers.value = persistentListOf() } } From 578a9c81836e9604176867e871688db1a16abf62 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 29 Mar 2023 10:01:04 +0200 Subject: [PATCH 014/107] Add presenter tests --- .../impl/root/CreateRoomRootPresenterTests.kt | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt index 1cca23f74a..c3cacb25c7 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt @@ -24,8 +24,11 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.selectusers.api.SelectUsersPresenterArgs import io.element.android.features.selectusers.impl.DefaultSelectUsersPresenter +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.ui.model.MatrixUser import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -67,13 +70,39 @@ class CreateRoomRootPresenterTests { } @Test - fun `present - trigger select user action`() = runTest { + fun `present - trigger create DM action`() = runTest { moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { val initialState = awaitItem() val matrixUser = MatrixUser(UserId("@name:matrix.org")) + val createDmResult = Result.success(RoomId("!createDmResult")) + + fakeMatrixClient.givenFindDmResult(null) + fakeMatrixClient.givenCreateDmResult(createDmResult) + initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) + val stateAfterStartDM = awaitItem() + assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java) + assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(createDmResult.getOrNull()) + } + } + + @Test + fun `present - trigger retrieve DM action`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val matrixUser = MatrixUser(UserId("@name:matrix.org")) + val fakeDmResult = FakeMatrixRoom(RoomId("!fakeDmResult")) + + fakeMatrixClient.givenFindDmResult(fakeDmResult) + + initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) + val stateAfterStartDM = awaitItem() + assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java) + assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(fakeDmResult.roomId) } } } From 67f9252173fc266783d6d758c00e524bf0066197 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 29 Mar 2023 10:13:36 +0200 Subject: [PATCH 015/107] Use string resource --- .../android/features/createroom/impl/root/CreateRoomRootView.kt | 2 +- libraries/ui-strings/src/main/res/values/localazy.xml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt index b75bddc8e0..4266878a33 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt @@ -96,7 +96,7 @@ fun CreateRoomRootView( } if (state.startDmAction is Async.Loading) { - ProgressDialog(text = "Creating room...") + ProgressDialog(text = stringResource(id = StringR.string.common_creating_room)) } } diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index a28d8bb7bc..084a442442 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -48,6 +48,7 @@ "About" "Audio" "Bubbles" + "Creating room…" "Decryption error" "Developer options" "(edited)" From e325c58cb0b7101081a8e020d8e5bb5b32dd9cb9 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 29 Mar 2023 10:51:39 +0200 Subject: [PATCH 016/107] Changelog --- changelog.d/96.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/96.feature diff --git a/changelog.d/96.feature b/changelog.d/96.feature new file mode 100644 index 0000000000..7a1c8d21b8 --- /dev/null +++ b/changelog.d/96.feature @@ -0,0 +1 @@ +[Create and join rooms] Show or create direct message room From f65375b5c4cee70fddd259628cf87bd58a954b49 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 29 Mar 2023 13:48:54 +0200 Subject: [PATCH 017/107] Handle errors on create DM --- .../impl/root/CreateRoomRootEvents.kt | 2 + .../impl/root/CreateRoomRootPresenter.kt | 22 ++++-- .../impl/root/CreateRoomRootStateProvider.kt | 13 +++- .../impl/root/CreateRoomRootView.kt | 17 ++++- .../impl/root/CreateRoomRootPresenterTests.kt | 72 +++++++++++++++++-- .../components/dialogs/ErrorDialog.kt | 11 ++- .../libraries/matrix/test/FakeMatrixClient.kt | 9 ++- 7 files changed, 130 insertions(+), 16 deletions(-) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt index d3cd1e0287..de9810f34a 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt @@ -21,4 +21,6 @@ import io.element.android.libraries.matrix.ui.model.MatrixUser sealed interface CreateRoomRootEvents { object InvitePeople : CreateRoomRootEvents data class StartDM(val matrixUser: MatrixUser) : CreateRoomRootEvents + object RetryStartDM : CreateRoomRootEvents + object CancelStartDM : CreateRoomRootEvents } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt index 2ec32c7bac..6bc8d3c45e 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt @@ -50,16 +50,24 @@ class CreateRoomRootPresenter @Inject constructor( val localCoroutineScope = rememberCoroutineScope() val startDmAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } + fun startDm(matrixUser: MatrixUser) { + startDmAction.value = Async.Uninitialized + val existingDM = matrixClient.findDM(matrixUser.id) + if (existingDM == null) { + localCoroutineScope.createDM(matrixUser, startDmAction) + } else { + startDmAction.value = Async.Success(existingDM.roomId) + } + } + fun handleEvents(event: CreateRoomRootEvents) { when (event) { - is CreateRoomRootEvents.StartDM -> { - val existingDM = matrixClient.findDM(event.matrixUser.id) - if (existingDM == null) { - localCoroutineScope.createDM(event.matrixUser, startDmAction) - } else { - startDmAction.value = Async.Success(existingDM.roomId) - } + is CreateRoomRootEvents.StartDM -> startDm(event.matrixUser) + CreateRoomRootEvents.RetryStartDM -> { + startDmAction.value = Async.Uninitialized + selectUsersState.selectedUsers.firstOrNull()?.let { startDm(it) } } + CreateRoomRootEvents.CancelStartDM -> startDmAction.value = Async.Uninitialized CreateRoomRootEvents.InvitePeople -> Unit // Todo Handle invite people action } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt index e0e3152318..e8f739c738 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt @@ -36,7 +36,18 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider { + ProgressDialog(text = stringResource(id = StringR.string.common_creating_room)) + } + is Async.Failure -> { + ErrorDialog( + content = stringResource(id = StringR.string.screen_start_chat_error_starting_chat), + dismissText = stringResource(id = StringR.string.action_cancel), + submitText = stringResource(id = StringR.string.action_retry), + onDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) }, + onSubmit = { state.eventSink(CreateRoomRootEvents.RetryStartDM) }, + ) + } + else -> Unit } } diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt index c3cacb25c7..6853fed3d8 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt @@ -18,18 +18,23 @@ package io.element.android.features.createroom.impl.root +import androidx.compose.runtime.Composable import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.selectusers.api.SelectUsersPresenter import io.element.android.features.selectusers.api.SelectUsersPresenterArgs -import io.element.android.features.selectusers.impl.DefaultSelectUsersPresenter +import io.element.android.features.selectusers.api.SelectUsersState +import io.element.android.features.selectusers.api.aSelectUsersState import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.ui.model.MatrixUser +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Before @@ -38,15 +43,17 @@ import org.junit.Test class CreateRoomRootPresenterTests { private lateinit var presenter: CreateRoomRootPresenter + private lateinit var fakeSelectUsersPresenter: FakeSelectUserPresenter private lateinit var fakeMatrixClient: FakeMatrixClient @Before fun setup() { - val selectUsersPresenter = object : DefaultSelectUsersPresenter.DefaultSelectUsersFactory { - override fun create(args: SelectUsersPresenterArgs) = DefaultSelectUsersPresenter(args) + val factory = object : SelectUsersPresenter.Factory { + override fun create(args: SelectUsersPresenterArgs) = fakeSelectUsersPresenter } + fakeSelectUsersPresenter = FakeSelectUserPresenter() fakeMatrixClient = FakeMatrixClient() - presenter = CreateRoomRootPresenter(selectUsersPresenter, fakeMatrixClient) + presenter = CreateRoomRootPresenter(factory, fakeMatrixClient) } @Test @@ -82,6 +89,7 @@ class CreateRoomRootPresenterTests { fakeMatrixClient.givenCreateDmResult(createDmResult) initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) + assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java) val stateAfterStartDM = awaitItem() assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java) assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(createDmResult.getOrNull()) @@ -105,4 +113,60 @@ class CreateRoomRootPresenterTests { assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(fakeDmResult.roomId) } } + + @Test + fun `present - trigger retry create DM action`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val matrixUser = MatrixUser(UserId("@name:matrix.org")) + val createDmResult = Result.success(RoomId("!createDmResult")) + fakeSelectUsersPresenter.givenState(aSelectUsersState().copy(selectedUsers = persistentListOf(matrixUser))) + + fakeMatrixClient.givenFindDmResult(null) + fakeMatrixClient.givenCreateDmError(A_THROWABLE) + fakeMatrixClient.givenCreateDmResult(createDmResult) + + // Failure + initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) + assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java) + val stateAfterStartDM = awaitItem() + assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Failure::class.java) + + // Cancel + stateAfterStartDM.eventSink(CreateRoomRootEvents.CancelStartDM) + val stateAfterCancel = awaitItem() + assertThat(stateAfterCancel.startDmAction).isInstanceOf(Async.Uninitialized::class.java) + + // Failure + stateAfterCancel.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) + assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java) + val stateAfterSecondAttempt = awaitItem() + assertThat(stateAfterSecondAttempt.startDmAction).isInstanceOf(Async.Failure::class.java) + + // Retry with success + fakeMatrixClient.givenCreateDmError(null) + stateAfterSecondAttempt.eventSink(CreateRoomRootEvents.RetryStartDM) + assertThat(awaitItem().startDmAction).isInstanceOf(Async.Uninitialized::class.java) + assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java) + val stateAfterRetryStartDM = awaitItem() + assertThat(stateAfterRetryStartDM.startDmAction).isInstanceOf(Async.Success::class.java) + assertThat(stateAfterRetryStartDM.startDmAction.dataOrNull()).isEqualTo(createDmResult.getOrNull()) + } + } + + private class FakeSelectUserPresenter : SelectUsersPresenter { + + private var state = aSelectUsersState() + + fun givenState(state: SelectUsersState) { + this.state = state + } + + @Composable + override fun present(): SelectUsersState { + return state + } + } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt index ce1730ed0c..da1aeb836e 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt @@ -37,7 +37,9 @@ fun ErrorDialog( modifier: Modifier = Modifier, title: String = ErrorDialogDefaults.title, submitText: String = ErrorDialogDefaults.submitText, + dismissText: String? = null, onDismiss: () -> Unit = {}, + onSubmit: () -> Unit = onDismiss, shape: Shape = AlertDialogDefaults.shape, containerColor: Color = AlertDialogDefaults.containerColor, iconContentColor: Color = AlertDialogDefaults.iconContentColor, @@ -55,10 +57,17 @@ fun ErrorDialog( Text(content) }, confirmButton = { - TextButton(onClick = onDismiss) { + TextButton(onClick = onSubmit) { Text(submitText) } }, + dismissButton = dismissText?.let { + { + TextButton(onClick = onDismiss) { + Text(it) + } + } + }, shape = shape, containerColor = containerColor, iconContentColor = iconContentColor, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index ec5068e93d..5889d35e0c 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -39,6 +39,7 @@ class FakeMatrixClient( ) : MatrixClient { private var createDmResult: Result = Result.success(A_ROOM_ID) + private var createDmFailure: Throwable? = null private var findDmResult: MatrixRoom? = FakeMatrixRoom() private var logoutFailure: Throwable? = null @@ -47,6 +48,8 @@ class FakeMatrixClient( } override suspend fun createDM(userId: UserId): Result { + delay(100) + createDmFailure?.let { throw it } return createDmResult } @@ -91,7 +94,7 @@ class FakeMatrixClient( // Mocks - fun givenLogoutError(failure: Throwable) { + fun givenLogoutError(failure: Throwable?) { logoutFailure = failure } @@ -99,6 +102,10 @@ class FakeMatrixClient( createDmResult = result } + fun givenCreateDmError(failure: Throwable?) { + createDmFailure = failure + } + fun givenFindDmResult(result: MatrixRoom?) { findDmResult = result } From 14ac9ff13804a173d5c0a5d4451841e837bc1c5a Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 29 Mar 2023 16:18:15 +0200 Subject: [PATCH 018/107] Add new screenshots --- ..._CreateRoomRootViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png | 3 +++ ..._CreateRoomRootViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png | 3 +++ ...CreateRoomRootViewLightPreview_0_null_1,NEXUS_5,1.0,en].png | 3 +++ ...CreateRoomRootViewLightPreview_0_null_2,NEXUS_5,1.0,en].png | 3 +++ 4 files changed, 12 insertions(+) create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 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 0000000000..1e92cf978d --- /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 0000000000..74acb423d6 --- /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 0000000000..23c4de3194 --- /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 0000000000..bb7d1036e4 --- /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 From 678a10acef1ae02c02edce4019d648588c4b0dbe Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Thu, 30 Mar 2023 08:49:48 +0200 Subject: [PATCH 019/107] Pass null name when creating DM --- .../element/android/libraries/matrix/impl/RustMatrixClient.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index ce9286d2be..3dc630c42d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -167,7 +167,7 @@ class RustMatrixClient constructor( runCatching { val roomId = client.createRoom( CreateRoomParameters( - name = "", + name = null, topic = null, isEncrypted = true, isDirect = true, From 832186c3a57b386e4b50ac8bd46c22b534c374a6 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Mon, 3 Apr 2023 16:27:34 +0200 Subject: [PATCH 020/107] Move FakeSelectUserPresenter to dedicated module --- features/createroom/impl/build.gradle.kts | 1 + .../impl/addpeople/AddPeoplePresenterTests.kt | 8 ++--- .../impl/root/CreateRoomRootPresenterTests.kt | 17 +-------- features/selectusers/test/build.gradle.kts | 28 +++++++++++++++ .../test/FakeSelectUserPresenter.kt | 36 +++++++++++++++++++ .../test/FakeSelectUserPresenterFactory.kt | 25 +++++++++++++ 6 files changed, 93 insertions(+), 22 deletions(-) create mode 100644 features/selectusers/test/build.gradle.kts create mode 100644 features/selectusers/test/src/main/kotlin/io/element/android/features/selectusers/test/FakeSelectUserPresenter.kt create mode 100644 features/selectusers/test/src/main/kotlin/io/element/android/features/selectusers/test/FakeSelectUserPresenterFactory.kt diff --git a/features/createroom/impl/build.gradle.kts b/features/createroom/impl/build.gradle.kts index 366cc1e0bd..b68ad614e2 100644 --- a/features/createroom/impl/build.gradle.kts +++ b/features/createroom/impl/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) testImplementation(projects.features.selectusers.impl) + testImplementation(projects.features.selectusers.test) androidTestImplementation(libs.test.junitext) diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt index d9f40c1dbf..caf1544a8f 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt @@ -22,8 +22,7 @@ import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.features.selectusers.api.SelectUsersPresenterArgs -import io.element.android.features.selectusers.impl.DefaultSelectUsersPresenter +import io.element.android.features.selectusers.test.FakeSelectUserPresenterFactory import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Before @@ -35,10 +34,7 @@ class AddPeoplePresenterTests { @Before fun setup() { - val selectUsersFactory = object : DefaultSelectUsersPresenter.DefaultSelectUsersFactory { - override fun create(args: SelectUsersPresenterArgs) = DefaultSelectUsersPresenter(args) - } - presenter = AddPeoplePresenter(selectUsersFactory) + presenter = AddPeoplePresenter(FakeSelectUserPresenterFactory()) } @Test diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt index 6853fed3d8..28f4668033 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt @@ -18,15 +18,14 @@ package io.element.android.features.createroom.impl.root -import androidx.compose.runtime.Composable import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.selectusers.api.SelectUsersPresenter import io.element.android.features.selectusers.api.SelectUsersPresenterArgs -import io.element.android.features.selectusers.api.SelectUsersState import io.element.android.features.selectusers.api.aSelectUsersState +import io.element.android.features.selectusers.test.FakeSelectUserPresenter import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId @@ -155,18 +154,4 @@ class CreateRoomRootPresenterTests { assertThat(stateAfterRetryStartDM.startDmAction.dataOrNull()).isEqualTo(createDmResult.getOrNull()) } } - - private class FakeSelectUserPresenter : SelectUsersPresenter { - - private var state = aSelectUsersState() - - fun givenState(state: SelectUsersState) { - this.state = state - } - - @Composable - override fun present(): SelectUsersState { - return state - } - } } diff --git a/features/selectusers/test/build.gradle.kts b/features/selectusers/test/build.gradle.kts new file mode 100644 index 0000000000..fb4e505709 --- /dev/null +++ b/features/selectusers/test/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.selectusers.test" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.features.selectusers.api) +} diff --git a/features/selectusers/test/src/main/kotlin/io/element/android/features/selectusers/test/FakeSelectUserPresenter.kt b/features/selectusers/test/src/main/kotlin/io/element/android/features/selectusers/test/FakeSelectUserPresenter.kt new file mode 100644 index 0000000000..e92c99df92 --- /dev/null +++ b/features/selectusers/test/src/main/kotlin/io/element/android/features/selectusers/test/FakeSelectUserPresenter.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.features.selectusers.test + +import androidx.compose.runtime.Composable +import io.element.android.features.selectusers.api.SelectUsersPresenter +import io.element.android.features.selectusers.api.SelectUsersState +import io.element.android.features.selectusers.api.aSelectUsersState + +class FakeSelectUserPresenter : SelectUsersPresenter { + + private var state = aSelectUsersState() + + fun givenState(state: SelectUsersState) { + this.state = state + } + + @Composable + override fun present(): SelectUsersState { + return state + } +} diff --git a/features/selectusers/test/src/main/kotlin/io/element/android/features/selectusers/test/FakeSelectUserPresenterFactory.kt b/features/selectusers/test/src/main/kotlin/io/element/android/features/selectusers/test/FakeSelectUserPresenterFactory.kt new file mode 100644 index 0000000000..7bf74370c9 --- /dev/null +++ b/features/selectusers/test/src/main/kotlin/io/element/android/features/selectusers/test/FakeSelectUserPresenterFactory.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.features.selectusers.test + +import io.element.android.features.selectusers.api.SelectUsersPresenter +import io.element.android.features.selectusers.api.SelectUsersPresenterArgs + +class FakeSelectUserPresenterFactory : SelectUsersPresenter.Factory { + + override fun create(args: SelectUsersPresenterArgs): SelectUsersPresenter = FakeSelectUserPresenter() +} From 9ba4d77bc620f2afa4fc67c71177213b44749856 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Mon, 3 Apr 2023 16:41:09 +0200 Subject: [PATCH 021/107] Unplug DM creation --- .../createroom/impl/root/CreateRoomRootView.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt index 63f0216ec9..fb211febc2 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt @@ -16,6 +16,7 @@ package io.element.android.features.createroom.impl.root +import android.widget.Toast import androidx.annotation.DrawableRes import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -32,6 +33,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview @@ -81,10 +83,16 @@ fun CreateRoomRootView( modifier = Modifier.padding(paddingValues), verticalArrangement = Arrangement.spacedBy(8.dp), ) { + val context = LocalContext.current SelectUsersView( modifier = Modifier.fillMaxWidth(), state = state.selectUsersState, - onUserSelected = { state.eventSink(CreateRoomRootEvents.StartDM(it)) }, + onUserSelected = { + // Fixme disabled DM creation since it can break the account data which is not correctly synced + // uncomment to enable it again or move behind a feature flag + Toast.makeText(context, "Create DM feature is disabled.", Toast.LENGTH_SHORT).show() +// state.eventSink(CreateRoomRootEvents.StartDM(it)) + }, ) if (!state.selectUsersState.isSearchActive) { From 42f30c2a7caa4510c26540a15f6f4732593f9125 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Tue, 4 Apr 2023 16:21:10 +0200 Subject: [PATCH 022/107] Remove hardcoded string --- .../createroom/impl/root/CreateRoomRootStateProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt index e8f739c738..85d9cb41c5 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt @@ -38,7 +38,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider Date: Tue, 4 Apr 2023 16:44:01 +0200 Subject: [PATCH 023/107] Revert "Update Gradle and AGP to v8.0 (#283)" (#295) This reverts commit 4e085efcf4390d55a7b7d4ec6e82475bc11a2efc. --- .github/workflows/build.yml | 5 ----- .github/workflows/maestro.yml | 5 ----- .github/workflows/nightly.yml | 5 ----- .github/workflows/nightly_manual.yml | 5 ----- .github/workflows/quality.yml | 12 +----------- .github/workflows/tests.yml | 5 ----- app/build.gradle.kts | 7 ++----- gradle.properties | 2 +- gradle/libs.versions.toml | 4 +++- gradle/wrapper/gradle-wrapper.properties | 4 ++-- libraries/core/build.gradle.kts | 4 ++-- libraries/coroutines/build.gradle.kts | 4 ++-- libraries/designsystem/build.gradle.kts | 4 ---- libraries/matrix/api/build.gradle.kts | 4 ---- libraries/statemachine/build.gradle.kts | 4 ++-- libraries/ui-strings/build.gradle.kts | 7 +++++++ plugins/src/main/kotlin/Versions.kt | 2 +- plugins/src/main/kotlin/extension/CommonExtension.kt | 4 ++-- .../main/kotlin/extension/DependencyHandleScope.kt | 9 +++------ 19 files changed, 28 insertions(+), 68 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3db9e4c5ef..71cfd8bca0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,11 +28,6 @@ jobs: # Ensure we are building the branch and not the branch after being merged on develop # https://github.com/actions/checkout/issues/881 ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} - - name: Use JDK 17 - uses: actions/setup-java@v3 - with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '17' - name: Configure gradle uses: gradle/gradle-build-action@v2 with: diff --git a/.github/workflows/maestro.yml b/.github/workflows/maestro.yml index b359f44b75..fcd109225d 100644 --- a/.github/workflows/maestro.yml +++ b/.github/workflows/maestro.yml @@ -24,11 +24,6 @@ jobs: cancel-in-progress: true steps: - uses: actions/checkout@v3 - - uses: actions/setup-java@v3 - name: Use JDK 17 - with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '17' - name: Assemble debug APK run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES - uses: mobile-dev-inc/action-maestro-cloud@v1.3.1 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 2f7c10a3d0..e5c8447446 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -16,11 +16,6 @@ jobs: if: ${{ github.repository == 'vector-im/element-x-android' }} steps: - uses: actions/checkout@v3 - - name: Use JDK 17 - uses: actions/setup-java@v3 - with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '17' - name: Install towncrier run: | python3 -m pip install towncrier diff --git a/.github/workflows/nightly_manual.yml b/.github/workflows/nightly_manual.yml index 6e8ef7c684..707d424817 100644 --- a/.github/workflows/nightly_manual.yml +++ b/.github/workflows/nightly_manual.yml @@ -13,11 +13,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Use JDK 17 - uses: actions/setup-java@v3 - with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '17' - name: Install towncrier run: | python3 -m pip install towncrier diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 06d79e857d..8f6fe6112c 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -8,7 +8,7 @@ on: # Enrich gradle.properties for CI/CD env: - GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxMetaspaceSize=512m -Dkotlin.daemon.jvm.options="-Xmx2g" -Dkotlin.incremental=false + GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxPermSize=512m -Dkotlin.daemon.jvm.options="-Xmx2g" -Dkotlin.incremental=false CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon jobs: @@ -21,11 +21,6 @@ jobs: cancel-in-progress: true steps: - uses: actions/checkout@v3 - - name: Use JDK 17 - uses: actions/setup-java@v3 - with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '17' - name: Configure gradle uses: gradle/gradle-build-action@v2 with: @@ -65,11 +60,6 @@ jobs: cancel-in-progress: true steps: - uses: actions/checkout@v3 - - name: Use JDK 17 - uses: actions/setup-java@v3 - with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '17' - name: Configure gradle uses: gradle/gradle-build-action@v2 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ada7ba1ab3..a4fce4b8e6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,11 +25,6 @@ jobs: uses: actions/checkout@v3 with: lfs: 'true' - - name: ☕️ Use JDK 17 - uses: actions/setup-java@v3 - with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '17' - name: Configure gradle uses: gradle/gradle-build-action@v2 with: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cc79516e7f..1e3de32f22 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,6 +25,7 @@ import extension.allServicesImpl @Suppress("DSL_SCOPE_VIOLATION") plugins { id("io.element.android-compose-application") + alias(libs.plugins.stem) alias(libs.plugins.kotlin.android) alias(libs.plugins.anvil) alias(libs.plugins.ksp) @@ -139,7 +140,7 @@ android { } } kotlinOptions { - jvmTarget = "17" + jvmTarget = "1.8" } // Waiting for https://github.com/google/ksp/issues/37 @@ -150,10 +151,6 @@ android { } } } - - buildFeatures { - buildConfig = true - } } androidComponents { diff --git a/gradle.properties b/gradle.properties index 15ee7e1fcb..df832c13ba 100644 --- a/gradle.properties +++ b/gradle.properties @@ -46,7 +46,7 @@ signing.element.nightly.keyPassword=Secret # Customise the Lint version to use a more recent version than the one bundled with AGP # https://googlesamples.github.io/android-custom-lint-rules/usage/newer-lint.md.html -android.experimental.lint.version=8.0.0-rc01 +android.experimental.lint.version=8.0.0-alpha10 # Enable test fixture for all modules by default android.experimental.enableTestFixtures=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cfec8a2f2d..aa7ed975b7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ [versions] # Project -android_gradle_plugin = "8.0.0-rc01" +android_gradle_plugin = "7.4.2" firebase_gradle_plugin = "3.2.0" kotlin = "1.8.10" ksp = "1.8.10-1.0.9" @@ -156,6 +156,8 @@ detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } dependencygraph = { id = "com.savvasdalkitsis.module-dependency-graph", version.ref = "dependencygraph" } dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "dependencycheck" } +stem = { id = "com.likethesalad.stem", version.ref = "stem" } +stemlibrary = { id = "com.likethesalad.stem-library", version.ref = "stem" } paparazzi = "app.cash.paparazzi:1.2.0" sonarqube = "org.sonarqube:4.0.0.2929" kover = "org.jetbrains.kotlinx.kover:0.6.1" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 4b00b4d231..147d0a111f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -16,8 +16,8 @@ #Fri Oct 07 15:02:00 CEST 2022 distributionBase=GRADLE_USER_HOME -distributionSha256Sum=47a5bfed9ef814f90f8debcbbb315e8e7c654109acd224595ea39fca95c5d4da -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-all.zip +distributionSha256Sum=518a863631feb7452b8f1b3dc2aaee5f388355cc3421bbd0275fbeadd77e84b2 +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/libraries/core/build.gradle.kts b/libraries/core/build.gradle.kts index b3695b861a..dad8f844cc 100644 --- a/libraries/core/build.gradle.kts +++ b/libraries/core/build.gradle.kts @@ -23,8 +23,8 @@ plugins { } java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 } dependencies { diff --git a/libraries/coroutines/build.gradle.kts b/libraries/coroutines/build.gradle.kts index 10cfd0ee80..f9f12e0b09 100644 --- a/libraries/coroutines/build.gradle.kts +++ b/libraries/coroutines/build.gradle.kts @@ -21,8 +21,8 @@ plugins { } java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 } dependencies { diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts index aea3c89305..45430e5d82 100644 --- a/libraries/designsystem/build.gradle.kts +++ b/libraries/designsystem/build.gradle.kts @@ -24,10 +24,6 @@ plugins { android { namespace = "io.element.android.libraries.designsystem" - buildFeatures { - buildConfig = true - } - dependencies { // Should not be there, but this is a POC implementation(libs.coil.compose) diff --git a/libraries/matrix/api/build.gradle.kts b/libraries/matrix/api/build.gradle.kts index aee8c7b840..3a2fd2472a 100644 --- a/libraries/matrix/api/build.gradle.kts +++ b/libraries/matrix/api/build.gradle.kts @@ -25,10 +25,6 @@ plugins { android { namespace = "io.element.android.libraries.matrix.api" - - buildFeatures { - buildConfig = true - } } anvil { diff --git a/libraries/statemachine/build.gradle.kts b/libraries/statemachine/build.gradle.kts index 5757c9fe4f..31fe22b5f8 100644 --- a/libraries/statemachine/build.gradle.kts +++ b/libraries/statemachine/build.gradle.kts @@ -23,8 +23,8 @@ plugins { } java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 } dependencies { diff --git a/libraries/ui-strings/build.gradle.kts b/libraries/ui-strings/build.gradle.kts index df27a9e5e8..33dc7d6ba6 100644 --- a/libraries/ui-strings/build.gradle.kts +++ b/libraries/ui-strings/build.gradle.kts @@ -18,8 +18,15 @@ @Suppress("DSL_SCOPE_VIOLATION") plugins { id("io.element.android-library") + alias(libs.plugins.stemlibrary) } android { namespace = "io.element.android.libraries.ui.strings" } + +// forcing the stem string template generator to be cacheable, without this the templates +// are regenerated causing the app module to recompile its sources +tasks.withType(com.likethesalad.android.templates.common.tasks.BaseTask::class.java) { + outputs.cacheIf { true } +} diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index 36a3a33e70..6236bfd307 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -24,6 +24,6 @@ object Versions { const val compileSdk = 33 const val targetSdk = 33 const val minSdk = 23 - val javaCompileVersion = JavaVersion.VERSION_17 + val javaCompileVersion = JavaVersion.VERSION_11 val javaLanguageVersion: JavaLanguageVersion = JavaLanguageVersion.of(11) } diff --git a/plugins/src/main/kotlin/extension/CommonExtension.kt b/plugins/src/main/kotlin/extension/CommonExtension.kt index 52df186a66..16e9b13c1c 100644 --- a/plugins/src/main/kotlin/extension/CommonExtension.kt +++ b/plugins/src/main/kotlin/extension/CommonExtension.kt @@ -32,8 +32,8 @@ fun CommonExtension<*, *, *, *>.androidConfig(project: Project) { compileOptions { isCoreLibraryDesugaringEnabled = true - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 } testOptions { diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index 62ecbd4f96..314421ebc8 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -16,17 +16,14 @@ package extension +import gradle.kotlin.dsl.accessors._71f190358cebd46a469f2989484fd643.androidTestImplementation +import gradle.kotlin.dsl.accessors._71f190358cebd46a469f2989484fd643.debugImplementation +import gradle.kotlin.dsl.accessors._71f190358cebd46a469f2989484fd643.implementation import org.gradle.accessors.dm.LibrariesForLibs import org.gradle.kotlin.dsl.DependencyHandlerScope import org.gradle.kotlin.dsl.project import java.io.File -private fun DependencyHandlerScope.implementation(dependency: Any) = dependencies.add("implementation", dependency) - -private fun DependencyHandlerScope.androidTestImplementation(dependency: Any) = dependencies.add("androidTestImplementation", dependency) - -private fun DependencyHandlerScope.debugImplementation(dependency: Any) = dependencies.add("debugImplementation", dependency) - /** * Dependencies used by all the modules */ From f0b95d30beb4571333e196268f066c53a5fe4c58 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 4 Apr 2023 16:50:50 +0200 Subject: [PATCH 024/107] Disable Diawi when running from fork (#292) --- .github/workflows/build.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 71cfd8bca0..1607e8efca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,12 +42,14 @@ jobs: app/build/outputs/apk/debug/*.apk - uses: rnkdsh/action-upload-diawi@v1.3.2 id: diawi - if: ${{ github.event_name == 'pull_request' }} - with: + env: token: ${{ secrets.DIAWI_TOKEN }} + if: ${{ github.event_name == 'pull_request' && env.token != '' }} + with: + token: ${{ env.token }} file: app/build/outputs/apk/debug/app-arm64-v8a-debug.apk - name: Add or update PR comment with QR Code to download APK. - if: ${{ github.event_name == 'pull_request' }} + if: ${{ github.event_name == 'pull_request' && steps.diawi.conclusion == 'success' }} uses: NejcZdovc/comment-pr@v2 with: message: | From d7a6779343a3cd513584b63b217e3affe82f3955 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 4 Apr 2023 18:07:57 +0200 Subject: [PATCH 025/107] [Room member list] Display room member list (#276) * Implement room member list * Move timeline initialization back to `TimelinePresenter`. * Fix crash when the `innerRoom` inside a `RustMatrixRoom` is destroyed but `syncUpdateFlow` is still running. * Address review comments --- .../io/element/android/appnav/RoomFlowNode.kt | 3 + .../android/appnav/RoomFlowPresenter.kt | 46 ++++++ .../android/appnav/RoomFlowPresenterTest.kt | 62 ++++++++ changelog.d/251.feature | 1 + features/createroom/impl/build.gradle.kts | 5 +- .../impl/AllMatrixUsersDataSource.kt | 33 ++++ .../impl/addpeople/AddPeoplePresenter.kt | 23 ++- .../impl/addpeople/AddPeopleState.kt | 4 +- .../impl/addpeople/AddPeopleStateProvider.kt | 12 +- .../impl/addpeople/AddPeopleView.kt | 10 +- .../createroom/impl/di/CreateRoomModule.kt | 35 +++++ .../impl/root/CreateRoomRootPresenter.kt | 20 ++- .../impl/root/CreateRoomRootState.kt | 4 +- .../impl/root/CreateRoomRootStateProvider.kt | 4 +- .../impl/root/CreateRoomRootView.kt | 10 +- .../impl/addpeople/AddPeoplePresenterTests.kt | 14 +- .../impl/root/CreateRoomRootPresenterTests.kt | 14 +- features/roomdetails/impl/build.gradle.kts | 3 + .../roomdetails/impl/RoomDetailsFlowNode.kt | 18 ++- .../roomdetails/impl/RoomDetailsNode.kt | 8 + .../roomdetails/impl/RoomDetailsPresenter.kt | 21 ++- .../roomdetails/impl/RoomDetailsState.kt | 4 +- .../impl/RoomDetailsStateProvider.kt | 4 +- .../roomdetails/impl/RoomDetailsView.kt | 22 ++- .../roomdetails/impl/di/RoomMemberModule.kt | 35 +++++ .../impl/members/RoomMatrixUserDataSource.kt | 58 +++++++ .../impl/members/RoomMemberListNode.kt | 52 +++++++ .../impl/members/RoomMemberListPresenter.kt | 64 ++++++++ .../impl/members/RoomMemberListState.kt | 28 ++++ .../members/RoomMemberListStateProvider.kt | 42 +++++ .../impl/members/RoomMemberListView.kt | 145 ++++++++++++++++++ .../impl/src/main/res/values/localazy.xml | 4 + .../roomdetails/RoomDetailsPresenterTests.kt | 38 ++++- .../members/RoomMemberListPresenterTests.kt | 62 ++++++++ .../api/build.gradle.kts | 2 +- .../userlist/api/MatrixUserDataSource.kt | 25 +++ .../features/userlist/api/UserListEvents.kt} | 12 +- .../userlist/api/UserListPresenter.kt} | 6 +- .../userlist/api/UserListPresenterArgs.kt} | 4 +- .../features/userlist/api/UserListState.kt} | 6 +- .../userlist/api/UserListStateProvider.kt} | 22 +-- .../features/userlist/api/UserListView.kt} | 24 +-- .../impl/build.gradle.kts | 5 +- .../impl/DefaultUserListPresenter.kt} | 42 ++--- .../impl/DefaultUserListPresenterTests.kt} | 55 ++++--- features/userlist/test/build.gradle.kts | 32 ++++ .../userlist/test/FakeMatrixUserDataSource.kt | 39 +++++ .../android/libraries/architecture/Async.kt | 4 + .../components/preferences/PreferenceText.kt | 16 +- .../libraries/matrix/api/room/MatrixRoom.kt | 4 +- .../matrix/impl/room/RustMatrixRoom.kt | 30 +++- .../impl/timeline/RustMatrixTimeline.kt | 8 - .../matrix/test/room/FakeMatrixRoom.kt | 29 +++- .../src/main/res/values-ro/translations.xml | 10 ++ .../src/main/res/values/localazy.xml | 11 +- ...stDarkPreview_0_null_0,NEXUS_5,1.0,en].png | 3 + ...stDarkPreview_0_null_1,NEXUS_5,1.0,en].png | 3 + ...tLightPreview_0_null_0,NEXUS_5,1.0,en].png | 3 + ...tLightPreview_0_null_1,NEXUS_5,1.0,en].png | 3 + ...lsDarkPreview_0_null_4,NEXUS_5,1.0,en].png | 3 + ...sLightPreview_0_null_4,NEXUS_5,1.0,en].png | 3 + tools/localazy/config.json | 4 +- 62 files changed, 1159 insertions(+), 157 deletions(-) create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/RoomFlowPresenter.kt create mode 100644 appnav/src/test/kotlin/io/element/android/appnav/RoomFlowPresenterTest.kt create mode 100644 features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/AllMatrixUsersDataSource.kt create mode 100644 features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomModule.kt create mode 100644 features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt create mode 100644 features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMatrixUserDataSource.kt create mode 100644 features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt create mode 100644 features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt create mode 100644 features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt create mode 100644 features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt create mode 100644 features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt create mode 100644 features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt rename features/{selectusers => userlist}/api/build.gradle.kts (93%) create mode 100644 features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/MatrixUserDataSource.kt rename features/{selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersEvents.kt => userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListEvents.kt} (68%) rename features/{selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersPresenter.kt => userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListPresenter.kt} (76%) rename features/{selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersPresenterArgs.kt => userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListPresenterArgs.kt} (88%) rename features/{selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersState.kt => userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListState.kt} (89%) rename features/{selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersStateProvider.kt => userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListStateProvider.kt} (82%) rename features/{selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersView.kt => userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListView.kt} (92%) rename features/{selectusers => userlist}/impl/build.gradle.kts (92%) rename features/{selectusers/impl/src/main/kotlin/io/element/android/features/selectusers/impl/DefaultSelectUsersPresenter.kt => userlist/impl/src/main/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenter.kt} (73%) rename features/{selectusers/impl/src/test/kotlin/io/element/android/features/selectusers/impl/DefaultSelectUsersPresenterTests.kt => userlist/impl/src/test/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenterTests.kt} (68%) create mode 100644 features/userlist/test/build.gradle.kts create mode 100644 features/userlist/test/src/main/kotlin/io/element/android/features/userlist/test/FakeMatrixUserDataSource.kt create mode 100644 libraries/ui-strings/src/main/res/values-ro/translations.xml create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_0,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_1,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_0,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_1,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview_0_null_4,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview_0_null_4,NEXUS_5,1.0,en].png diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt index 6ef47e4a61..14b90b0064 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt @@ -69,6 +69,8 @@ class RoomFlowNode @AssistedInject constructor( private val inputs: Inputs = inputs() + private val roomFlowPresenter = RoomFlowPresenter(inputs.room) + init { lifecycle.subscribe( onCreate = { @@ -110,6 +112,7 @@ class RoomFlowNode @AssistedInject constructor( @Composable override fun View(modifier: Modifier) { + roomFlowPresenter.present() Children( navModel = backstack, modifier = modifier, diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowPresenter.kt new file mode 100644 index 0000000000..0a2b066da4 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowPresenter.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.appnav + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.room.MatrixRoom +import timber.log.Timber + +class RoomFlowPresenter( + private val room: MatrixRoom, +) : Presenter { + + @Composable + override fun present(): RoomFlowState { + // Preload room members so we can quickly detect if the room is a DM room + LaunchedEffect(Unit) { + room.fetchMembers() + .onFailure { + Timber.e(it, "Fail to fetch members for room ${room.roomId}") + }.onSuccess { + Timber.v("Success fetching members for room ${room.roomId}") + } + } + + return RoomFlowState + } +} + +// At first the return type was Unit, but detekt complained about it +object RoomFlowState diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowPresenterTest.kt new file mode 100644 index 0000000000..1347b0b24c --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowPresenterTest.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.appnav + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.lang.IllegalStateException + +class RoomFlowPresenterTest { + + @Test + fun `present - fetches room members`() = runTest { + val fakeTimeline = FakeMatrixTimeline() + val room = FakeMatrixRoom(matrixTimeline = fakeTimeline) + val presenter = RoomFlowPresenter(room) + + Truth.assertThat(room.areMembersFetched).isFalse() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + Truth.assertThat(room.areMembersFetched).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - recovers from error while fetching room members`() = runTest { + val fakeTimeline = FakeMatrixTimeline() + val room = FakeMatrixRoom(matrixTimeline = fakeTimeline).apply { + givenFetchMemberResult(Result.failure(IllegalStateException("Some error"))) + } + val presenter = RoomFlowPresenter(room) + + Truth.assertThat(room.areMembersFetched).isFalse() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + Truth.assertThat(room.areMembersFetched).isFalse() + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/changelog.d/251.feature b/changelog.d/251.feature index 8c7bb95fd7..209e6e6f71 100644 --- a/changelog.d/251.feature +++ b/changelog.d/251.feature @@ -1 +1,2 @@ Implement Room Details screen +Implement Room Member List screen diff --git a/features/createroom/impl/build.gradle.kts b/features/createroom/impl/build.gradle.kts index 366cc1e0bd..6f2544822c 100644 --- a/features/createroom/impl/build.gradle.kts +++ b/features/createroom/impl/build.gradle.kts @@ -47,7 +47,7 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.elementresources) implementation(projects.libraries.uiStrings) - implementation(projects.features.selectusers.api) + implementation(projects.features.userlist.api) api(projects.features.createroom.api) testImplementation(libs.test.junit) @@ -56,7 +56,8 @@ dependencies { testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.features.selectusers.impl) + testImplementation(projects.features.userlist.impl) + testImplementation(projects.features.userlist.test) androidTestImplementation(libs.test.junitext) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/AllMatrixUsersDataSource.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/AllMatrixUsersDataSource.kt new file mode 100644 index 0000000000..6bbdfb5e93 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/AllMatrixUsersDataSource.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.createroom.impl + +import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.ui.model.MatrixUser +import javax.inject.Inject + +// TODO this is empty as we currently don't have an endpoint to perform user search +class AllMatrixUsersDataSource @Inject constructor() : MatrixUserDataSource { + override suspend fun search(query: String): List { + return emptyList() + } + + override suspend fun getProfile(userId: UserId): MatrixUser? { + return null + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenter.kt index 51b2928862..da51a36335 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenter.kt @@ -17,31 +17,38 @@ package io.element.android.features.createroom.impl.addpeople import androidx.compose.runtime.Composable -import io.element.android.features.selectusers.api.SelectUsersPresenter -import io.element.android.features.selectusers.api.SelectUsersPresenterArgs -import io.element.android.features.selectusers.api.SelectionMode +import io.element.android.features.userlist.api.SelectionMode +import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.features.userlist.api.UserListPresenter +import io.element.android.features.userlist.api.UserListPresenterArgs import io.element.android.libraries.architecture.Presenter import javax.inject.Inject +import javax.inject.Named class AddPeoplePresenter @Inject constructor( - private val selectUsersPresenterFactory: SelectUsersPresenter.Factory, + private val userListPresenterFactory: UserListPresenter.Factory, + @Named("AllUsers") private val matrixUserDataSource: MatrixUserDataSource, ) : Presenter { - private val selectUsersPresenter by lazy { - selectUsersPresenterFactory.create(SelectUsersPresenterArgs(SelectionMode.Multiple)) + private val userListPresenter by lazy { + userListPresenterFactory.create( + UserListPresenterArgs(selectionMode = SelectionMode.Multiple), + matrixUserDataSource, + ) } @Composable override fun present(): AddPeopleState { - val selectUsersState = selectUsersPresenter.present() + val userListState = userListPresenter.present() fun handleEvents(event: AddPeopleEvents) { // do nothing for now } return AddPeopleState( - selectUsersState = selectUsersState, + userListState = userListState, eventSink = ::handleEvents, ) } } + diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleState.kt index 8212d02cc4..8605e1aba6 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleState.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleState.kt @@ -16,9 +16,9 @@ package io.element.android.features.createroom.impl.addpeople -import io.element.android.features.selectusers.api.SelectUsersState +import io.element.android.features.userlist.api.UserListState data class AddPeopleState( - val selectUsersState: SelectUsersState, + val userListState: UserListState, val eventSink: (AddPeopleEvents) -> Unit, ) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleStateProvider.kt index 6f1679e252..cfbf7941ce 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleStateProvider.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleStateProvider.kt @@ -17,22 +17,22 @@ package io.element.android.features.createroom.impl.addpeople import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.selectusers.api.SelectionMode -import io.element.android.features.selectusers.api.aListOfSelectedUsers -import io.element.android.features.selectusers.api.aSelectUsersState +import io.element.android.features.userlist.api.SelectionMode +import io.element.android.features.userlist.api.aListOfSelectedUsers +import io.element.android.features.userlist.api.aUserListState open class AddPeopleStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aAddPeopleState(), aAddPeopleState().copy( - selectUsersState = aSelectUsersState().copy( + userListState = aUserListState().copy( selectedUsers = aListOfSelectedUsers(), selectionMode = SelectionMode.Multiple, ) ), aAddPeopleState().copy( - selectUsersState = aSelectUsersState().copy( + userListState = aUserListState().copy( selectedUsers = aListOfSelectedUsers(), isSearchActive = true, selectionMode = SelectionMode.Multiple, @@ -42,6 +42,6 @@ open class AddPeopleStateProvider : PreviewParameterProvider { } fun aAddPeopleState() = AddPeopleState( - selectUsersState = aSelectUsersState(), + userListState = aUserListState(), eventSink = {} ) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt index 56b3c975c5..56a16b24f9 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt @@ -29,8 +29,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import io.element.android.features.userlist.api.UserListView import io.element.android.features.createroom.impl.R -import io.element.android.features.selectusers.api.SelectUsersView import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight @@ -52,9 +52,9 @@ fun AddPeopleView( Scaffold( topBar = { - if (!state.selectUsersState.isSearchActive) { + if (!state.userListState.isSearchActive) { AddPeopleViewTopBar( - hasSelectedUsers = state.selectUsersState.selectedUsers.isNotEmpty(), + hasSelectedUsers = state.userListState.selectedUsers.isNotEmpty(), onBackPressed = onBackPressed, onNextPressed = onNextPressed, ) @@ -66,9 +66,9 @@ fun AddPeopleView( .fillMaxSize() .padding(padding), ) { - SelectUsersView( + UserListView( modifier = Modifier.fillMaxWidth(), - state = state.selectUsersState, + state = state.userListState, ) } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomModule.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomModule.kt new file mode 100644 index 0000000000..c5f2d0ca06 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomModule.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.features.createroom.impl.di + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Binds +import dagger.Module +import io.element.android.features.createroom.impl.AllMatrixUsersDataSource +import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.libraries.di.AppScope +import javax.inject.Named + +@Module +@ContributesTo(AppScope::class) +interface CreateRoomModule { + + @Binds + @Named("AllUsers") + fun bindAllUserListDataSource(dataSource: AllMatrixUsersDataSource): MatrixUserDataSource + +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt index 2f3f3ded4a..1250031710 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt @@ -17,25 +17,31 @@ package io.element.android.features.createroom.impl.root import androidx.compose.runtime.Composable -import io.element.android.features.selectusers.api.SelectUsersPresenter -import io.element.android.features.selectusers.api.SelectUsersPresenterArgs -import io.element.android.features.selectusers.api.SelectionMode +import io.element.android.features.userlist.api.SelectionMode +import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.features.userlist.api.UserListPresenter +import io.element.android.features.userlist.api.UserListPresenterArgs import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.ui.model.MatrixUser import timber.log.Timber import javax.inject.Inject +import javax.inject.Named class CreateRoomRootPresenter @Inject constructor( - private val presenterFactory: SelectUsersPresenter.Factory, + private val presenterFactory: UserListPresenter.Factory, + @Named("AllUsers") private val matrixUserDataSource: MatrixUserDataSource, ) : Presenter { private val presenter by lazy { - presenterFactory.create(SelectUsersPresenterArgs(SelectionMode.Single)) + presenterFactory.create( + UserListPresenterArgs(selectionMode = SelectionMode.Single), + matrixUserDataSource, + ) } @Composable override fun present(): CreateRoomRootState { - val selectUsersState = presenter.present() + val userListState = presenter.present() fun handleEvents(event: CreateRoomRootEvents) { when (event) { @@ -45,7 +51,7 @@ class CreateRoomRootPresenter @Inject constructor( } return CreateRoomRootState( - selectUsersState = selectUsersState, + userListState = userListState, eventSink = ::handleEvents, ) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt index a57d6aaaf6..d5d75fcfae 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt @@ -16,9 +16,9 @@ package io.element.android.features.createroom.impl.root -import io.element.android.features.selectusers.api.SelectUsersState +import io.element.android.features.userlist.api.UserListState data class CreateRoomRootState( - val selectUsersState: SelectUsersState, + val userListState: UserListState, val eventSink: (CreateRoomRootEvents) -> Unit, ) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt index 678f02476c..d7c18085dc 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt @@ -17,7 +17,7 @@ package io.element.android.features.createroom.impl.root import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.selectusers.api.aSelectUsersState +import io.element.android.features.userlist.api.aUserListState open class CreateRoomRootStateProvider : PreviewParameterProvider { override val values: Sequence @@ -28,5 +28,5 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider createNode(buildContext) + NavTarget.RoomDetails -> createNode(buildContext, listOf(callback)) + NavTarget.RoomMemberList -> createNode(buildContext) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt index 8ba373756b..937d755df5 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.platform.LocalContext import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode @@ -40,6 +41,12 @@ class RoomDetailsNode @AssistedInject constructor( private val room: MatrixRoom, ) : Node(buildContext, plugins = plugins) { + private val callback = plugins().firstOrNull() + + private fun openRoomMemberList() { + callback?.openRoomMemberList() + } + private fun onShareRoom(context: Context) { val alias = room.alias ?: room.alternativeAliases.firstOrNull() val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) } @@ -64,6 +71,7 @@ class RoomDetailsNode @AssistedInject constructor( modifier = modifier, goBack = { navigateUp() }, onShareRoom = { onShareRoom(context) }, + openRoomMemberList = ::openRoomMemberList, ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt index f038787909..80ae4426d9 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -17,8 +17,16 @@ package io.element.android.features.roomdetails.impl import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.room.MatrixRoom +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import javax.inject.Inject class RoomDetailsPresenter @Inject constructor( @@ -29,13 +37,24 @@ class RoomDetailsPresenter @Inject constructor( override fun present(): RoomDetailsState { // fun handleEvents(event: RoomDetailsEvent) {} + var memberCount: Async by remember { mutableStateOf(Async.Loading()) } + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + memberCount = runCatching { room.memberCount() } + .fold( + onSuccess = { Async.Success(it) }, + onFailure = { Async.Failure(it) } + ) + } + } + return RoomDetailsState( roomId = room.roomId.value, roomName = room.name ?: room.displayName, roomAlias = room.alias, roomAvatarUrl = room.avatarUrl, roomTopic = room.topic, - memberCount = room.members.size, + memberCount = memberCount, isEncrypted = room.isEncrypted, // eventSink = ::handleEvents ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt index ccad10bbc4..78ee70529d 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt @@ -16,13 +16,15 @@ package io.element.android.features.roomdetails.impl +import io.element.android.libraries.architecture.Async + data class RoomDetailsState( val roomId: String, val roomName: String, val roomAlias: String?, val roomAvatarUrl: String?, val roomTopic: String?, - val memberCount: Int, + val memberCount: Async, val isEncrypted: Boolean, // val eventSink: (RoomDetailsEvent) -> Unit ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt index 29ecc4f995..c91db4d3a8 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt @@ -17,6 +17,7 @@ package io.element.android.features.roomdetails.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async open class RoomDetailsStateProvider : PreviewParameterProvider { override val values: Sequence @@ -25,6 +26,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider aRoomDetailsState().copy(roomTopic = null), aRoomDetailsState().copy(isEncrypted = false), aRoomDetailsState().copy(roomAlias = null), + aRoomDetailsState().copy(memberCount = Async.Failure(Throwable())), // Add other state here ) } @@ -39,7 +41,7 @@ fun aRoomDetailsState() = RoomDetailsState( "|| MAIL iki/Marketing " + "|| MAI iki/Marketing " + "|| MAI iki/Marketing...", - memberCount = 32, + memberCount = Async.Success(32), isEncrypted = true, // eventSink = {} ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index 322bc5f8ac..e4d4ac5609 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -42,6 +42,8 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.isLoading import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarData @@ -62,6 +64,7 @@ fun RoomDetailsView( state: RoomDetailsState, goBack: () -> Unit, onShareRoom: () -> Unit, + openRoomMemberList: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -87,7 +90,12 @@ fun RoomDetailsView( TopicSection(roomTopic = state.roomTopic) } - MembersSection(memberCount = state.memberCount) + val memberCount = (state.memberCount as? Async.Success)?.state + MembersSection( + memberCount = memberCount, + isLoading = state.memberCount.isLoading(), + openRoomMemberList = openRoomMemberList + ) if (state.isEncrypted) { SecuritySection() @@ -148,12 +156,19 @@ internal fun TopicSection(roomTopic: String, modifier: Modifier = Modifier) { } @Composable -internal fun MembersSection(memberCount: Int, modifier: Modifier = Modifier) { +internal fun MembersSection( + memberCount: Int?, + isLoading: Boolean, + modifier: Modifier = Modifier, + openRoomMemberList: () -> Unit +) { PreferenceCategory(modifier = modifier) { PreferenceText( title = stringResource(R.string.screen_room_details_people_title), icon = Icons.Outlined.Person, - currentValue = memberCount.toString(), + currentValue = memberCount?.toString(), + onClick = openRoomMemberList, + loadingCurrentValue = isLoading, ) PreferenceText( title = stringResource(R.string.screen_room_details_invite_people_title), @@ -200,5 +215,6 @@ private fun ContentToPreview(state: RoomDetailsState) { state = state, goBack = {}, onShareRoom = {}, + openRoomMemberList = {}, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt new file mode 100644 index 0000000000..49c98374f2 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.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.features.roomdetails.impl.di + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Binds +import dagger.Module +import io.element.android.features.roomdetails.impl.members.RoomMatrixUserDataSource +import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.libraries.di.RoomScope +import javax.inject.Named + +@Module +@ContributesTo(RoomScope::class) +interface RoomMemberModule { + + @Binds + @Named("RoomMembers") + fun bindRoomMemberUserListDataSource(dataSource: RoomMatrixUserDataSource): MatrixUserDataSource + +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMatrixUserDataSource.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMatrixUserDataSource.kt new file mode 100644 index 0000000000..b97dcd62e5 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMatrixUserDataSource.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.features.roomdetails.impl.members + +import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.ui.model.MatrixUser +import javax.inject.Inject + +class RoomMatrixUserDataSource @Inject constructor( + private val room: MatrixRoom +) : MatrixUserDataSource { + + override suspend fun search(query: String): List { + return room.members().filter { member -> + if (query.isBlank()) { + true + } else { + member.userId.contains(query, ignoreCase = true) || member.displayName?.contains(query, ignoreCase = true).orFalse() + } + }.map(::mapMemberToMatrixUser) + } + + override suspend fun getProfile(userId: UserId): MatrixUser? { + return null + } + + private fun mapMemberToMatrixUser(member: RoomMember): MatrixUser { + return MatrixUser( + id = UserId(member.userId), + username = member.displayName, + avatarData = AvatarData( + id = member.userId, + name = member.displayName, + url = member.avatarUrl + ) + ) + } + +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt new file mode 100644 index 0000000000..0f65b4657b --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.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.features.roomdetails.impl.members + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.ui.model.MatrixUser +import timber.log.Timber + +@ContributesNode(RoomScope::class) +class RoomMemberListNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: RoomMemberListPresenter, +) : Node(buildContext, plugins = plugins) { + + private fun onUserSelected(matrixUser: MatrixUser) { + Timber.d("TODO: implement user selection. User: $matrixUser") + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + RoomMemberListView( + state = state, + modifier = modifier, + onBackPressed = { navigateUp() }, + onUserSelected = ::onUserSelected, + ) + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt new file mode 100644 index 0000000000..8c3a873ade --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.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.features.roomdetails.impl.members + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import io.element.android.features.userlist.api.SelectionMode +import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.features.userlist.api.UserListPresenter +import io.element.android.features.userlist.api.UserListPresenterArgs +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.ui.model.MatrixUser +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Named + +class RoomMemberListPresenter @Inject constructor( + private val userListPresenterFactory: UserListPresenter.Factory, + @Named("RoomMembers") private val matrixUserDataSource: MatrixUserDataSource, +) : Presenter { + + private val userListPresenter by lazy { + userListPresenterFactory.create( + UserListPresenterArgs(selectionMode = SelectionMode.Single), + matrixUserDataSource, + ) + } + + @Composable + override fun present(): RoomMemberListState { + val userListState = userListPresenter.present() + val allUsers = remember { mutableStateOf>>(Async.Loading()) } + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + allUsers.value = Async.Success(matrixUserDataSource.search("").toImmutableList()) + } + } + return RoomMemberListState( + allUsers = allUsers.value, + userListState = userListState + ) + } +} + diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt new file mode 100644 index 0000000000..f5e5bd3efb --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.roomdetails.impl.members + +import io.element.android.features.userlist.api.UserListState +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.ui.model.MatrixUser +import kotlinx.collections.immutable.ImmutableList + +data class RoomMemberListState( + val allUsers: Async>, + val userListState: UserListState, +// val eventSink: (AddPeopleEvents) -> Unit, +) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt new file mode 100644 index 0000000000..fc98ae7544 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt @@ -0,0 +1,42 @@ +/* + * 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.features.roomdetails.impl.members + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.userlist.api.aUserListState +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.model.MatrixUser +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal class RoomMemberListStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRoomMemberListState(allUsers = Async.Success(persistentListOf(aMatrixUser()))), + aRoomMemberListState(allUsers = Async.Loading()) + ) +} + +internal fun aRoomMemberListState( + searchResults: ImmutableList = persistentListOf(), + allUsers: Async> = Async.Uninitialized, +) = + RoomMemberListState( + userListState = aUserListState().copy(searchResults = searchResults), + allUsers = allUsers, + ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt new file mode 100644 index 0000000000..e2c41e34b3 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt @@ -0,0 +1,145 @@ +/* + * 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.features.roomdetails.impl.members + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.features.roomdetails.impl.R +import io.element.android.features.userlist.api.SearchSingleUserResultItem +import io.element.android.features.userlist.api.UserListView +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.isLoading +import io.element.android.libraries.designsystem.ElementTextStyles +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.ui.model.MatrixUser + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RoomMemberListView( + state: RoomMemberListState, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, + onUserSelected: (MatrixUser) -> Unit = {}, +) { + Scaffold( + topBar = { + if (!state.userListState.isSearchActive) { + RoomMemberListTopBar(onBackPressed = onBackPressed) + } + } + ) { padding -> + Column( + modifier = modifier + .fillMaxWidth() + .padding(padding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + UserListView( + state = state.userListState, + onUserSelected = onUserSelected, + ) + + if (!state.userListState.isSearchActive) { + if (state.allUsers is Async.Success) { + LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) { + item { + val memberCount = state.allUsers.state.count() + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + text = pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount), + style = ElementTextStyles.Regular.callout, + color = MaterialTheme.colorScheme.secondary, + textAlign = TextAlign.Start, + ) + } + items(state.allUsers.state) { matrixUser -> + SearchSingleUserResultItem( + modifier = Modifier.fillMaxWidth(), + matrixUser = matrixUser, + onClick = { onUserSelected(matrixUser) } + ) + } + } + } else if (state.allUsers.isLoading()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RoomMemberListTopBar( + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, +) { + CenterAlignedTopAppBar( + modifier = modifier, + title = { + Text( + text = stringResource(R.string.screen_room_details_people_title), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + }, + navigationIcon = { BackButton(onClick = onBackPressed) }, + ) +} + +@Preview +@Composable +fun RoomMemberListLightPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun RoomMemberListDarkPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: RoomMemberListState) { + RoomMemberListView(state) +} diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml index d373ed9bd1..82c0c1c418 100644 --- a/features/roomdetails/impl/src/main/res/values/localazy.xml +++ b/features/roomdetails/impl/src/main/res/values/localazy.xml @@ -1,5 +1,9 @@ + + "1 person" + "%1$d people" + "Messages are secured with locks. Only you and the recipients have the unique keys to unlock them." "Message encryption enabled" "Invite people" diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt index f77b606119..7679e1eb7d 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt @@ -21,6 +21,7 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth import io.element.android.features.roomdetails.impl.RoomDetailsPresenter +import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -42,8 +43,25 @@ class RoomDetailsPresenterTests { Truth.assertThat(initialState.roomName).isEqualTo(room.name) Truth.assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl) Truth.assertThat(initialState.roomTopic).isEqualTo(room.topic) - Truth.assertThat(initialState.memberCount).isEqualTo(room.members.count()) + Truth.assertThat(initialState.memberCount).isEqualTo(Async.Loading(null)) Truth.assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - room member count is calculated asynchronously`() = runTest { + val room = aMatrixRoom() + val presenter = RoomDetailsPresenter(room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.memberCount).isEqualTo(Async.Loading(null)) + + val finalState = awaitItem() + Truth.assertThat(finalState.memberCount).isEqualTo(Async.Success(0)) } } @@ -56,6 +74,24 @@ class RoomDetailsPresenterTests { }.test { val initialState = awaitItem() Truth.assertThat(initialState.roomName).isEqualTo(room.displayName) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - can handle error while fetching member count`() = runTest { + val room = aMatrixRoom(name = null).apply { + givenFetchMemberResult(Result.failure(Throwable())) + } + val presenter = RoomDetailsPresenter(room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + Truth.assertThat(awaitItem().memberCount).isInstanceOf(Async.Failure::class.java) + + cancelAndIgnoreRemainingEvents() } } } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt new file mode 100644 index 0000000000..3564daa5f1 --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.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.features.roomdetails.members + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth +import io.element.android.features.roomdetails.impl.members.RoomMemberListPresenter +import io.element.android.features.userlist.api.SelectionMode +import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.features.userlist.api.UserListPresenter +import io.element.android.features.userlist.api.UserListPresenterArgs +import io.element.android.features.userlist.impl.DefaultUserListPresenter +import io.element.android.features.userlist.test.FakeMatrixUserDataSource +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import kotlinx.coroutines.test.runTest +import okhttp3.internal.toImmutableList +import org.junit.Test + +class RoomMemberListPresenterTests { + + @Test + fun `present - search is done automatically on start, but is async`() = runTest { + val searchResult = listOf(aMatrixUser()) + val userListDataSource = FakeMatrixUserDataSource().apply { + givenSearchResult(searchResult) + } + val userListFactory = object : UserListPresenter.Factory { + override fun create(args: UserListPresenterArgs, dataSource: MatrixUserDataSource) = DefaultUserListPresenter(args, dataSource) + } + val presenter = RoomMemberListPresenter(userListFactory, userListDataSource) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.allUsers).isInstanceOf(Async.Loading::class.java) + Truth.assertThat(initialState.userListState.isSearchActive).isFalse() + Truth.assertThat(initialState.userListState.searchResults).isEmpty() + Truth.assertThat(initialState.userListState.selectionMode).isEqualTo(SelectionMode.Single) + + val loadedState = awaitItem() + Truth.assertThat((loadedState.allUsers as? Async.Success)?.state).isEqualTo(searchResult.toImmutableList()) + } + } + +} diff --git a/features/selectusers/api/build.gradle.kts b/features/userlist/api/build.gradle.kts similarity index 93% rename from features/selectusers/api/build.gradle.kts rename to features/userlist/api/build.gradle.kts index d46ed2fbf1..7410de4224 100644 --- a/features/selectusers/api/build.gradle.kts +++ b/features/userlist/api/build.gradle.kts @@ -19,7 +19,7 @@ plugins { } android { - namespace = "io.element.android.features.selectusers.api" + namespace = "io.element.android.features.userlist.api" } dependencies { diff --git a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/MatrixUserDataSource.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/MatrixUserDataSource.kt new file mode 100644 index 0000000000..08eddfd7e9 --- /dev/null +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/MatrixUserDataSource.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.features.userlist.api + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.ui.model.MatrixUser + +interface MatrixUserDataSource { + suspend fun search(query: String): List + suspend fun getProfile(userId: UserId): MatrixUser? +} diff --git a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersEvents.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListEvents.kt similarity index 68% rename from features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersEvents.kt rename to features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListEvents.kt index e0ee6ddf68..f648a14d74 100644 --- a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersEvents.kt +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListEvents.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package io.element.android.features.selectusers.api +package io.element.android.features.userlist.api import io.element.android.libraries.matrix.ui.model.MatrixUser -sealed interface SelectUsersEvents { - data class UpdateSearchQuery(val query: String) : SelectUsersEvents - data class AddToSelection(val matrixUser: MatrixUser) : SelectUsersEvents - data class RemoveFromSelection(val matrixUser: MatrixUser) : SelectUsersEvents - data class OnSearchActiveChanged(val active: Boolean) : SelectUsersEvents +sealed interface UserListEvents { + data class UpdateSearchQuery(val query: String) : UserListEvents + data class AddToSelection(val matrixUser: MatrixUser) : UserListEvents + data class RemoveFromSelection(val matrixUser: MatrixUser) : UserListEvents + data class OnSearchActiveChanged(val active: Boolean) : UserListEvents } diff --git a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersPresenter.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListPresenter.kt similarity index 76% rename from features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersPresenter.kt rename to features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListPresenter.kt index be85455d09..c328efd44e 100644 --- a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersPresenter.kt +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListPresenter.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package io.element.android.features.selectusers.api +package io.element.android.features.userlist.api import io.element.android.libraries.architecture.Presenter -interface SelectUsersPresenter : Presenter { +interface UserListPresenter : Presenter { interface Factory { - fun create(args: SelectUsersPresenterArgs): SelectUsersPresenter + fun create(args: UserListPresenterArgs, matrixUserDataSource: MatrixUserDataSource): UserListPresenter } } diff --git a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersPresenterArgs.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListPresenterArgs.kt similarity index 88% rename from features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersPresenterArgs.kt rename to features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListPresenterArgs.kt index 543e73b77e..9c8a40504b 100644 --- a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersPresenterArgs.kt +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListPresenterArgs.kt @@ -14,9 +14,9 @@ * limitations under the License. */ -package io.element.android.features.selectusers.api +package io.element.android.features.userlist.api -data class SelectUsersPresenterArgs( +data class UserListPresenterArgs( val selectionMode: SelectionMode, ) diff --git a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersState.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListState.kt similarity index 89% rename from features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersState.kt rename to features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListState.kt index 2a1a2c48e3..80de1e991f 100644 --- a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersState.kt +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListState.kt @@ -14,20 +14,20 @@ * limitations under the License. */ -package io.element.android.features.selectusers.api +package io.element.android.features.userlist.api import androidx.compose.foundation.lazy.LazyListState import io.element.android.libraries.matrix.ui.model.MatrixUser import kotlinx.collections.immutable.ImmutableList -data class SelectUsersState( +data class UserListState( val searchQuery: String, val searchResults: ImmutableList, val selectedUsers: ImmutableList, val selectedUsersListState: LazyListState, val isSearchActive: Boolean, val selectionMode: SelectionMode, - val eventSink: (SelectUsersEvents) -> Unit, + val eventSink: (UserListEvents) -> Unit, ) { val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple } diff --git a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersStateProvider.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListStateProvider.kt similarity index 82% rename from features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersStateProvider.kt rename to features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListStateProvider.kt index 93209a632c..d97a4537ed 100644 --- a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersStateProvider.kt +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListStateProvider.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.selectusers.api +package io.element.android.features.userlist.api import androidx.compose.foundation.lazy.LazyListState import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -22,25 +22,25 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.ui.model.MatrixUser import kotlinx.collections.immutable.persistentListOf -open class SelectUsersStateProvider : PreviewParameterProvider { - override val values: Sequence +open class UserListStateProvider : PreviewParameterProvider { + override val values: Sequence get() = sequenceOf( - aSelectUsersState(), - aSelectUsersState().copy( + aUserListState(), + aUserListState().copy( isSearchActive = false, selectedUsers = aListOfSelectedUsers(), selectionMode = SelectionMode.Multiple, ), - aSelectUsersState().copy(isSearchActive = true), - aSelectUsersState().copy(isSearchActive = true, searchQuery = "someone"), - aSelectUsersState().copy(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple), - aSelectUsersState().copy( + aUserListState().copy(isSearchActive = true), + aUserListState().copy(isSearchActive = true, searchQuery = "someone"), + aUserListState().copy(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple), + aUserListState().copy( isSearchActive = true, searchQuery = "@someone:matrix.org", selectedUsers = aListOfSelectedUsers(), searchResults = aListOfResults(), ), - aSelectUsersState().copy( + aUserListState().copy( isSearchActive = true, searchQuery = "@someone:matrix.org", selectionMode = SelectionMode.Multiple, @@ -50,7 +50,7 @@ open class SelectUsersStateProvider : PreviewParameterProvider ) } -fun aSelectUsersState() = SelectUsersState( +fun aUserListState() = UserListState( isSearchActive = false, searchQuery = "", searchResults = persistentListOf(), diff --git a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersView.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListView.kt similarity index 92% rename from features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersView.kt rename to features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListView.kt index 41a5360936..bc355a0a26 100644 --- a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersView.kt +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListView.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.selectusers.api +package io.element.android.features.userlist.api import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -65,8 +65,8 @@ import kotlinx.collections.immutable.ImmutableList import io.element.android.libraries.ui.strings.R as StringR @Composable -fun SelectUsersView( - state: SelectUsersState, +fun UserListView( + state: UserListState, modifier: Modifier = Modifier, onUserSelected: (MatrixUser) -> Unit = {}, onUserDeselected: (MatrixUser) -> Unit = {}, @@ -82,14 +82,14 @@ fun SelectUsersView( selectedUsersListState = state.selectedUsersListState, active = state.isSearchActive, isMultiSelectionEnabled = state.isMultiSelectionEnabled, - onActiveChanged = { state.eventSink(SelectUsersEvents.OnSearchActiveChanged(it)) }, - onTextChanged = { state.eventSink(SelectUsersEvents.UpdateSearchQuery(it)) }, + onActiveChanged = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) }, + onTextChanged = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) }, onUserSelected = { - state.eventSink(SelectUsersEvents.AddToSelection(it)) + state.eventSink(UserListEvents.AddToSelection(it)) onUserSelected(it) }, onUserDeselected = { - state.eventSink(SelectUsersEvents.RemoveFromSelection(it)) + state.eventSink(UserListEvents.RemoveFromSelection(it)) onUserDeselected(it) }, ) @@ -100,7 +100,7 @@ fun SelectUsersView( modifier = Modifier.padding(16.dp), selectedUsers = state.selectedUsers, onUserRemoved = { - state.eventSink(SelectUsersEvents.RemoveFromSelection(it)) + state.eventSink(UserListEvents.RemoveFromSelection(it)) onUserDeselected(it) }, ) @@ -297,15 +297,15 @@ fun SelectedUser( @Preview @Composable -internal fun SelectUsersViewLightPreview(@PreviewParameter(SelectUsersStateProvider::class) state: SelectUsersState) = +internal fun UserListViewLightPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -internal fun SelectUsersViewDarkPreview(@PreviewParameter(SelectUsersStateProvider::class) state: SelectUsersState) = +internal fun UserListViewDarkPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) = ElementPreviewDark { ContentToPreview(state) } @Composable -private fun ContentToPreview(state: SelectUsersState) { - SelectUsersView(state = state) +private fun ContentToPreview(state: UserListState) { + UserListView(state = state) } diff --git a/features/selectusers/impl/build.gradle.kts b/features/userlist/impl/build.gradle.kts similarity index 92% rename from features/selectusers/impl/build.gradle.kts rename to features/userlist/impl/build.gradle.kts index baac7d2d2f..0eaca78a18 100644 --- a/features/selectusers/impl/build.gradle.kts +++ b/features/userlist/impl/build.gradle.kts @@ -23,7 +23,7 @@ plugins { } android { - namespace = "io.element.android.features.selectusers.impl" + namespace = "io.element.android.features.userlist.impl" } anvil { @@ -41,7 +41,7 @@ dependencies { implementation(projects.libraries.elementresources) implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) - api(projects.features.selectusers.api) + api(projects.features.userlist.api) ksp(libs.showkase.processor) testImplementation(libs.test.junit) @@ -52,6 +52,7 @@ dependencies { testImplementation(libs.test.turbine) testImplementation(libs.test.mockk) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.features.userlist.test) androidTestImplementation(libs.test.junitext) } diff --git a/features/selectusers/impl/src/main/kotlin/io/element/android/features/selectusers/impl/DefaultSelectUsersPresenter.kt b/features/userlist/impl/src/main/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenter.kt similarity index 73% rename from features/selectusers/impl/src/main/kotlin/io/element/android/features/selectusers/impl/DefaultSelectUsersPresenter.kt rename to features/userlist/impl/src/main/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenter.kt index e1135cd1a2..567d183e15 100644 --- a/features/selectusers/impl/src/main/kotlin/io/element/android/features/selectusers/impl/DefaultSelectUsersPresenter.kt +++ b/features/userlist/impl/src/main/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenter.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.selectusers.impl +package io.element.android.features.userlist.impl import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState @@ -31,10 +31,11 @@ import com.squareup.anvil.annotations.ContributesBinding import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import io.element.android.features.selectusers.api.SelectUsersEvents -import io.element.android.features.selectusers.api.SelectUsersPresenter -import io.element.android.features.selectusers.api.SelectUsersPresenterArgs -import io.element.android.features.selectusers.api.SelectUsersState +import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.features.userlist.api.UserListEvents +import io.element.android.features.userlist.api.UserListPresenterArgs +import io.element.android.features.userlist.api.UserListState +import io.element.android.features.userlist.api.UserListPresenter import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.MatrixPatterns import io.element.android.libraries.matrix.api.core.UserId @@ -45,18 +46,19 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -class DefaultSelectUsersPresenter @AssistedInject constructor( - @Assisted val args: SelectUsersPresenterArgs, -) : SelectUsersPresenter { +class DefaultUserListPresenter @AssistedInject constructor( + @Assisted val args: UserListPresenterArgs, + @Assisted val matrixUserDataSource: MatrixUserDataSource, +) : UserListPresenter { @AssistedFactory @ContributesBinding(SessionScope::class) - interface DefaultSelectUsersFactory : SelectUsersPresenter.Factory { - override fun create(args: SelectUsersPresenterArgs): DefaultSelectUsersPresenter + interface DefaultUserListFactory : UserListPresenter.Factory { + override fun create(args: UserListPresenterArgs, matrixUserDataSource: MatrixUserDataSource): DefaultUserListPresenter } @Composable - override fun present(): SelectUsersState { + override fun present(): UserListState { val localCoroutineScope = rememberCoroutineScope() var isSearchActive by rememberSaveable { mutableStateOf(false) } val selectedUsers: MutableState> = remember { @@ -68,17 +70,17 @@ class DefaultSelectUsersPresenter @AssistedInject constructor( mutableStateOf(persistentListOf()) } - fun handleEvents(event: SelectUsersEvents) { + fun handleEvents(event: UserListEvents) { when (event) { - is SelectUsersEvents.OnSearchActiveChanged -> isSearchActive = event.active - is SelectUsersEvents.UpdateSearchQuery -> searchQuery = event.query - is SelectUsersEvents.AddToSelection -> { + is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active + is UserListEvents.UpdateSearchQuery -> searchQuery = event.query + is UserListEvents.AddToSelection -> { if (event.matrixUser !in selectedUsers.value) { selectedUsers.value = selectedUsers.value.plus(event.matrixUser).toImmutableList() } localCoroutineScope.scrollToFirstSelectedUser(selectedUsersListState) } - is SelectUsersEvents.RemoveFromSelection -> selectedUsers.value = selectedUsers.value.minus(event.matrixUser).toImmutableList() + is UserListEvents.RemoveFromSelection -> selectedUsers.value = selectedUsers.value.minus(event.matrixUser).toImmutableList() } } @@ -95,7 +97,7 @@ class DefaultSelectUsersPresenter @AssistedInject constructor( } } - return SelectUsersState( + return UserListState( searchQuery = searchQuery, searchResults = searchResults.value, selectedUsers = selectedUsers.value.reversed().toImmutableList(), @@ -106,11 +108,11 @@ class DefaultSelectUsersPresenter @AssistedInject constructor( ) } - private fun performSearch(query: String): ImmutableList { + private suspend fun performSearch(query: String): ImmutableList { val isMatrixId = MatrixPatterns.isUserId(query) - val results = mutableListOf()// TODO trigger /search request + val results = matrixUserDataSource.search(query).toMutableList() if (isMatrixId && results.none { it.id.value == query }) { - val getProfileResult: MatrixUser? = null // TODO trigger /profile request + val getProfileResult: MatrixUser? = matrixUserDataSource.getProfile(UserId(query)) val profile = getProfileResult ?: MatrixUser(UserId(query)) results.add(0, profile) } diff --git a/features/selectusers/impl/src/test/kotlin/io/element/android/features/selectusers/impl/DefaultSelectUsersPresenterTests.kt b/features/userlist/impl/src/test/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenterTests.kt similarity index 68% rename from features/selectusers/impl/src/test/kotlin/io/element/android/features/selectusers/impl/DefaultSelectUsersPresenterTests.kt rename to features/userlist/impl/src/test/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenterTests.kt index f5b0b43d0e..1cae186d56 100644 --- a/features/selectusers/impl/src/test/kotlin/io/element/android/features/selectusers/impl/DefaultSelectUsersPresenterTests.kt +++ b/features/userlist/impl/src/test/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenterTests.kt @@ -14,16 +14,17 @@ * limitations under the License. */ -package io.element.android.features.selectusers.impl +package io.element.android.features.userlist.impl import androidx.compose.foundation.lazy.LazyListState import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.features.selectusers.api.SelectUsersEvents -import io.element.android.features.selectusers.api.SelectUsersPresenterArgs -import io.element.android.features.selectusers.api.SelectionMode +import io.element.android.features.userlist.api.UserListEvents +import io.element.android.features.userlist.api.UserListPresenterArgs +import io.element.android.features.userlist.api.SelectionMode +import io.element.android.features.userlist.test.FakeMatrixUserDataSource import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser @@ -34,11 +35,16 @@ import kotlinx.coroutines.test.runTest import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) -class DefaultSelectUsersPresenterTests { +class DefaultUserListPresenterTests { + + private val userListDataSource = FakeMatrixUserDataSource() @Test fun `present - initial state for single selection`() = runTest { - val presenter = DefaultSelectUsersPresenter(SelectUsersPresenterArgs(SelectionMode.Single)) + val presenter = DefaultUserListPresenter( + UserListPresenterArgs(selectionMode = SelectionMode.Single), + userListDataSource + ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -53,7 +59,10 @@ class DefaultSelectUsersPresenterTests { @Test fun `present - initial state for multiple selection`() = runTest { - val presenter = DefaultSelectUsersPresenter(SelectUsersPresenterArgs(SelectionMode.Multiple)) + val presenter = DefaultUserListPresenter( + UserListPresenterArgs(selectionMode = SelectionMode.Multiple), + userListDataSource + ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -68,26 +77,29 @@ class DefaultSelectUsersPresenterTests { @Test fun `present - update search query`() = runTest { - val presenter = DefaultSelectUsersPresenter(SelectUsersPresenterArgs(SelectionMode.Single)) + val presenter = DefaultUserListPresenter( + UserListPresenterArgs(selectionMode = SelectionMode.Single), + userListDataSource + ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink(SelectUsersEvents.OnSearchActiveChanged(true)) + initialState.eventSink(UserListEvents.OnSearchActiveChanged(true)) assertThat(awaitItem().isSearchActive).isTrue() val matrixIdQuery = "@name:matrix.org" - initialState.eventSink(SelectUsersEvents.UpdateSearchQuery(matrixIdQuery)) + initialState.eventSink(UserListEvents.UpdateSearchQuery(matrixIdQuery)) assertThat(awaitItem().searchQuery).isEqualTo(matrixIdQuery) assertThat(awaitItem().searchResults).containsExactly(MatrixUser(UserId(matrixIdQuery))) val notMatrixIdQuery = "name" - initialState.eventSink(SelectUsersEvents.UpdateSearchQuery(notMatrixIdQuery)) + initialState.eventSink(UserListEvents.UpdateSearchQuery(notMatrixIdQuery)) assertThat(awaitItem().searchQuery).isEqualTo(notMatrixIdQuery) assertThat(awaitItem().searchResults).isEmpty() - initialState.eventSink(SelectUsersEvents.OnSearchActiveChanged(false)) + initialState.eventSink(UserListEvents.OnSearchActiveChanged(false)) assertThat(awaitItem().isSearchActive).isFalse() } } @@ -97,7 +109,10 @@ class DefaultSelectUsersPresenterTests { mockkConstructor(LazyListState::class) coJustRun { anyConstructed().scrollToItem(index = any()) } - val presenter = DefaultSelectUsersPresenter(SelectUsersPresenterArgs(SelectionMode.Single)) + val presenter = DefaultUserListPresenter( + UserListPresenterArgs(selectionMode = SelectionMode.Single), + userListDataSource + ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -108,23 +123,23 @@ class DefaultSelectUsersPresenterTests { val userABis = aMatrixUser("userA", "A") val userC = aMatrixUser("userC", "C") - initialState.eventSink(SelectUsersEvents.AddToSelection(userA)) + initialState.eventSink(UserListEvents.AddToSelection(userA)) assertThat(awaitItem().selectedUsers).containsExactly(userA) - initialState.eventSink(SelectUsersEvents.AddToSelection(userB)) + initialState.eventSink(UserListEvents.AddToSelection(userB)) // the last added user should be presented first assertThat(awaitItem().selectedUsers).containsExactly(userB, userA) - initialState.eventSink(SelectUsersEvents.AddToSelection(userABis)) - initialState.eventSink(SelectUsersEvents.AddToSelection(userC)) + initialState.eventSink(UserListEvents.AddToSelection(userABis)) + initialState.eventSink(UserListEvents.AddToSelection(userC)) // duplicated users should be ignored assertThat(awaitItem().selectedUsers).containsExactly(userC, userB, userA) - initialState.eventSink(SelectUsersEvents.RemoveFromSelection(userB)) + initialState.eventSink(UserListEvents.RemoveFromSelection(userB)) assertThat(awaitItem().selectedUsers).containsExactly(userC, userA) - initialState.eventSink(SelectUsersEvents.RemoveFromSelection(userA)) + initialState.eventSink(UserListEvents.RemoveFromSelection(userA)) assertThat(awaitItem().selectedUsers).containsExactly(userC) - initialState.eventSink(SelectUsersEvents.RemoveFromSelection(userC)) + initialState.eventSink(UserListEvents.RemoveFromSelection(userC)) assertThat(awaitItem().selectedUsers).isEmpty() } } diff --git a/features/userlist/test/build.gradle.kts b/features/userlist/test/build.gradle.kts new file mode 100644 index 0000000000..56ac66c154 --- /dev/null +++ b/features/userlist/test/build.gradle.kts @@ -0,0 +1,32 @@ +/* + * 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.features.userlist.test" +} + +dependencies { + implementation(projects.libraries.matrixui) + implementation(projects.libraries.matrix.api) + api(projects.features.userlist.api) + api(libs.coroutines.core) +} diff --git a/features/userlist/test/src/main/kotlin/io/element/android/features/userlist/test/FakeMatrixUserDataSource.kt b/features/userlist/test/src/main/kotlin/io/element/android/features/userlist/test/FakeMatrixUserDataSource.kt new file mode 100644 index 0000000000..db6297ec05 --- /dev/null +++ b/features/userlist/test/src/main/kotlin/io/element/android/features/userlist/test/FakeMatrixUserDataSource.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.features.userlist.test + +import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.ui.model.MatrixUser + +class FakeMatrixUserDataSource : MatrixUserDataSource { + + private var searchResult: List = emptyList() + private var profile: MatrixUser? = null + + override suspend fun search(query: String): List = searchResult + + override suspend fun getProfile(userId: UserId): MatrixUser? = profile + + fun givenSearchResult(users: List) { + this.searchResult = users + } + + fun givenUserProfile(matrixUser: MatrixUser?) { + this.profile = matrixUser + } +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt index 236ead4b0c..bb74dff2a9 100644 --- a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt @@ -57,3 +57,7 @@ suspend fun (suspend () -> Result).executeResult(state: MutableState Async.isLoading(): Boolean { + return this is Async.Loading +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt index 7d8d219cbf..96dfdbaba7 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt @@ -25,7 +25,9 @@ import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.progressSemantics import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BugReport import androidx.compose.material3.MaterialTheme @@ -39,6 +41,7 @@ import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.Text @Composable @@ -47,6 +50,7 @@ fun PreferenceText( modifier: Modifier = Modifier, subtitle: String? = null, currentValue: String? = null, + loadingCurrentValue: Boolean = false, icon: ImageVector? = null, tintColor: Color? = null, onClick: () -> Unit = {}, @@ -56,11 +60,13 @@ fun PreferenceText( modifier = modifier .fillMaxWidth() .defaultMinSize(minHeight = minHeight) - .padding(end = preferencePaddingHorizontal) - .clickable { onClick() }, + .clickable { onClick() } + .padding(end = preferencePaddingHorizontal), ) { Row( - modifier = Modifier.fillMaxWidth().padding(vertical = preferencePaddingVertical) + modifier = Modifier + .fillMaxWidth() + .padding(vertical = preferencePaddingVertical) ) { PreferenceIcon(icon = icon, tintColor = tintColor) Column(modifier = Modifier @@ -88,7 +94,11 @@ fun PreferenceText( if (currentValue != null) { Text(currentValue, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary) Spacer(Modifier.width(16.dp)) + } else if (loadingCurrentValue) { + CircularProgressIndicator(modifier = Modifier.progressSemantics().size(20.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(16.dp)) } + } } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 07d466d428..bd76d95b35 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -31,9 +31,11 @@ interface MatrixRoom: Closeable { val alternativeAliases: List val topic: String? val avatarUrl: String? - val members: List val isEncrypted: Boolean + suspend fun members() : List + suspend fun memberCount(): Int + fun syncUpdateFlow(): Flow fun timeline(): MatrixTimeline diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index eb3bc1c7d2..291aa57392 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -17,6 +17,7 @@ package io.element.android.libraries.matrix.impl.room import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.data.tryOrNull 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.room.MatrixRoom @@ -24,10 +25,12 @@ import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.SlidingSyncRoom @@ -43,10 +46,32 @@ class RustMatrixRoom( private val coroutineDispatchers: CoroutineDispatchers, ) : MatrixRoom { + private var loadMembersJob: Job? = null + private var cachedMembers: List = emptyList() + + override suspend fun members(): List { + return cachedMembers.ifEmpty { + if (loadMembersJob == null) { + loadMembersJob = coroutineScope.launch(coroutineDispatchers.io) { + cachedMembers = tryOrNull { + innerRoom.members().map(RoomMemberMapper::map) + } ?: emptyList() + } + } + loadMembersJob?.join() + loadMembersJob = null + cachedMembers + } + } + + override suspend fun memberCount(): Int { + return members().size + } + override fun syncUpdateFlow(): Flow { return slidingSyncUpdateFlow .filter { - it.rooms.contains(innerRoom.id()) + it.rooms.contains(roomId.value) } .map { System.currentTimeMillis() @@ -95,9 +120,6 @@ class RustMatrixRoom( return innerRoom.avatarUrl() } - override val members: List - get() = innerRoom.members().map(RoomMemberMapper::map) - override val isEncrypted: Boolean get() = innerRoom.isEncrypted() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt index 15a79c3586..e942f31b76 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt @@ -87,14 +87,6 @@ class RustMatrixTimeline( override fun initialize() { Timber.v("Init timeline for room ${matrixRoom.roomId}") - coroutineScope.launch { - matrixRoom.fetchMembers() - .onFailure { - Timber.e(it, "Fail to fetch members for room ${matrixRoom.roomId}") - }.onSuccess { - Timber.v("Success fetching members for room ${matrixRoom.roomId}") - } - } coroutineScope.launch { val result = addListener(innerTimelineListener) result diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 32e73121c8..b8ea695f47 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -34,13 +34,18 @@ class FakeMatrixRoom( override val displayName: String = "", override val topic: String? = null, override val avatarUrl: String? = null, - override val members: List = emptyList(), override val isEncrypted: Boolean = false, override val alias: String? = null, override val alternativeAliases: List = emptyList(), + private val members: List = emptyList(), private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(), ) : MatrixRoom { + private var fetchMemberResult: Result = Result.success(Unit) + + var areMembersFetched: Boolean = false + private set + override fun syncUpdateFlow(): Flow { return emptyFlow() } @@ -50,7 +55,11 @@ class FakeMatrixRoom( } override suspend fun fetchMembers(): Result { - return Result.success(Unit) + return fetchMemberResult.also { result -> + if (result.isSuccess) { + areMembersFetched = true + } + } } override suspend fun userDisplayName(userId: String): Result { @@ -61,6 +70,18 @@ class FakeMatrixRoom( TODO("Not yet implemented") } + override suspend fun members(): List { + return members + } + + override suspend fun memberCount(): Int { + if (fetchMemberResult.isSuccess) { + return members.count() + } else { + throw fetchMemberResult.exceptionOrNull()!! + } + } + override suspend fun sendMessage(message: String): Result { delay(100) return Result.success(Unit) @@ -94,4 +115,8 @@ class FakeMatrixRoom( } override fun close() = Unit + + fun givenFetchMemberResult(result: Result) { + fetchMemberResult = result + } } diff --git a/libraries/ui-strings/src/main/res/values-ro/translations.xml b/libraries/ui-strings/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..6878d7aab4 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml @@ -0,0 +1,10 @@ + + + "Confirmare" + "Creați o cameră" + "Gata" + "OK" + "Raportează conținutul" + "Începe discuția" + "Vezi sursa" + \ 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 a28d8bb7bc..7ae90b9b53 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -48,6 +48,7 @@ "About" "Audio" "Bubbles" + "Creating room…" "Decryption error" "Developer options" "(edited)" @@ -122,11 +123,19 @@ "Rageshake to report bug" "You seem to be shaking the phone in frustration. Would you like to open the bug report screen?" - "Reporting this message will send it’s unique ‘event ID’ to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images." + "This message will be reported to your homeserver’s administrator. They will not be able to read any encrypted messages." "Reason for reporting this content" "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" + "Block user" + "Check if you want to hide all current and future messages from this user" "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" diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_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.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..27f36f9248 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6893108aabbf06f43af90f17b6b1a8a521312559f407647ce08db06ecb9a8f84 +size 22033 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_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.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..32d8a266d5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7e1f7feb544a84e6e66f995683038abb9e7465583fade4e77c254a4264d03b9f +size 11985 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_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.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b493c070d2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e7b5bd916d4d3067b5013400b4ac864fab560e96fcb75ec895684021a00b8ba +size 21808 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_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.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9ef4e1ee89 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:309c0c0a650435cfbc0825025f6f852bdb51899ff9d37cf4cf9db00d56fb1176 +size 11933 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview_0_null_4,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.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a3331e0b02 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9fe9b98fbdd3fd9c7789463f14345f6f65f9a18eedf2229d14c62a3bee6711e3 +size 70058 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview_0_null_4,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.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2c0511cb21 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:48f138bc8a63ac25152491c974c68d6e024ded04b973f174fb5b9586523217ca +size 64593 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 781807d057..155a42836a 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -61,7 +61,9 @@ { "name": ":features:roomdetails:impl", "includeRegex": [ - "screen_room_details_.*" + "screen_room_details_.*", + "screen_room_member_list_.*", + "screen_dm_details_.*" ] } ] From 3950f3fda5adf22ee6fd3bbe0a5ad23df425088e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 4 Apr 2023 23:11:38 +0000 Subject: [PATCH 026/107] Update dependency com.bumble.appyx:core to v1.1.2 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aa7ed975b7..80f67bab6b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,7 +38,7 @@ datetime = "0.4.0" serialization_json = "1.5.0" showkase = "1.0.0-beta17" jsoup = "1.15.4" -appyx = "1.1.1" +appyx = "1.1.2" dependencycheck = "8.2.1" stem = "2.3.0" sqldelight = "1.5.5" From 7bf41d12e202033f7d01c88169f6376db16f185b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Apr 2023 10:58:18 +0200 Subject: [PATCH 027/107] Add ability to download only English string (for developer). This is default behavior. --- tools/localazy/README.md | 8 +++- tools/localazy/downloadStrings.sh | 20 ++++++++-- tools/localazy/generateLocalazyConfig.py | 48 +++++++++++++----------- 3 files changed, 49 insertions(+), 27 deletions(-) diff --git a/tools/localazy/README.md b/tools/localazy/README.md index dcb45c5be7..968d92047d 100644 --- a/tools/localazy/README.md +++ b/tools/localazy/README.md @@ -20,7 +20,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/downloadStrings.sh b/tools/localazy/downloadStrings.sh index 163aab8e31..5892a87c38 100755 --- a/tools/localazy/downloadStrings.sh +++ b/tools/localazy/downloadStrings.sh @@ -18,12 +18,24 @@ set -e -echo "Generating the configuration file for localazy..." -./tools/localazy/generateLocalazyConfig.py +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 "Deleting all existing localazy.xml and translations.xml files..." +echo "Generating the configuration file for localazy..." +./tools/localazy/generateLocalazyConfig.py $allFiles + +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 829541462e..13b76e7d57 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" @@ -35,20 +37,21 @@ for entry in config["modules"]: "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 = { + "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" + ] + } + allActions.append(actionTranslation) + allRegexToExcludeFromMainModule.extend(entry["includeRegex"]) # Append configuration for the main string module: default language mainAction = { @@ -62,16 +65,17 @@ mainAction = { # 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 = { + "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) # Generate the configuration for localazy result = { From d0298392c4783fbe4f92a30021c80e43a7bd511a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Apr 2023 11:04:18 +0200 Subject: [PATCH 028/107] Add GitHub action to sync Localazy strings. --- .github/workflows/sync-localazy.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/sync-localazy.yml diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml new file mode 100644 index 0000000000..3efbb41f27 --- /dev/null +++ b/.github/workflows/sync-localazy.yml @@ -0,0 +1,28 @@ +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.8 + uses: actions/setup-python@v4 + with: + python-version: 3.8 + - name: Run Localazy script + run: ./tools/localazy/downloadStrings.sh --all + - name: Create Pull Request for Strings + uses: peter-evans/create-pull-request@v4 + with: + commit-message: Sync Strings from Localazy + title: Sync Strings + body: | + - Update Strings from Localazy + branch: sync-localazy + base: develop From 45cea6cc3b79c9e91091d7d4c2bc8bddd19e5f82 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Apr 2023 11:11:47 +0200 Subject: [PATCH 029/107] Invoke `./tools/localazy/downloadStrings.sh --all` --- .../src/main/res/values-es/translations.xml | 6 + .../src/main/res/values-it/translations.xml | 6 + .../src/main/res/values-ro/translations.xml | 6 + .../src/main/res/values-es/translations.xml | 20 +++ .../src/main/res/values-it/translations.xml | 20 +++ .../src/main/res/values-ro/translations.xml | 20 +++ .../src/main/res/values-es/translations.xml | 8 + .../src/main/res/values-it/translations.xml | 8 + .../src/main/res/values-ro/translations.xml | 8 + .../src/main/res/values-es/translations.xml | 5 + .../src/main/res/values-it/translations.xml | 5 + .../src/main/res/values-ro/translations.xml | 5 + .../src/main/res/values-es/translations.xml | 5 + .../src/main/res/values-it/translations.xml | 5 + .../src/main/res/values-ro/translations.xml | 5 + .../src/main/res/values-es/translations.xml | 14 ++ .../src/main/res/values-it/translations.xml | 14 ++ .../src/main/res/values-ro/translations.xml | 14 ++ .../src/main/res/values-es/translations.xml | 25 +++ .../src/main/res/values-it/translations.xml | 21 +++ .../src/main/res/values-ro/translations.xml | 22 +++ .../impl/src/main/res/values/localazy.xml | 6 + .../src/main/res/values-es/translations.xml | 61 +++++++ .../src/main/res/values-it/translations.xml | 61 +++++++ .../src/main/res/values-ro/translations.xml | 61 +++++++ .../src/main/res/values-es/translations.xml | 19 +++ .../src/main/res/values-it/translations.xml | 19 +++ .../src/main/res/values-ro/translations.xml | 19 +++ .../src/main/res/values-es/translations.xml | 17 ++ .../src/main/res/values-it/translations.xml | 17 ++ .../src/main/res/values-ro/translations.xml | 17 ++ .../src/main/res/values-de/translations.xml | 1 + .../src/main/res/values-es/translations.xml | 156 ++++++++++++++++++ .../src/main/res/values-fr/translations.xml | 1 + .../src/main/res/values-it/translations.xml | 148 +++++++++++++++++ .../src/main/res/values-ro/translations.xml | 150 ++++++++++++++++- .../src/main/res/values/localazy.xml | 11 +- 37 files changed, 995 insertions(+), 11 deletions(-) create mode 100644 features/createroom/impl/src/main/res/values-es/translations.xml create mode 100644 features/createroom/impl/src/main/res/values-it/translations.xml create mode 100644 features/createroom/impl/src/main/res/values-ro/translations.xml create mode 100644 features/login/impl/src/main/res/values-es/translations.xml create mode 100644 features/login/impl/src/main/res/values-it/translations.xml create mode 100644 features/login/impl/src/main/res/values-ro/translations.xml create mode 100644 features/logout/api/src/main/res/values-es/translations.xml create mode 100644 features/logout/api/src/main/res/values-it/translations.xml create mode 100644 features/logout/api/src/main/res/values-ro/translations.xml create mode 100644 features/onboarding/impl/src/main/res/values-es/translations.xml create mode 100644 features/onboarding/impl/src/main/res/values-it/translations.xml create mode 100644 features/onboarding/impl/src/main/res/values-ro/translations.xml create mode 100644 features/rageshake/api/src/main/res/values-es/translations.xml create mode 100644 features/rageshake/api/src/main/res/values-it/translations.xml create mode 100644 features/rageshake/api/src/main/res/values-ro/translations.xml create mode 100644 features/rageshake/impl/src/main/res/values-es/translations.xml create mode 100644 features/rageshake/impl/src/main/res/values-it/translations.xml create mode 100644 features/rageshake/impl/src/main/res/values-ro/translations.xml create mode 100644 features/roomdetails/impl/src/main/res/values-es/translations.xml create mode 100644 features/roomdetails/impl/src/main/res/values-it/translations.xml create mode 100644 features/roomdetails/impl/src/main/res/values-ro/translations.xml create mode 100644 features/roomlist/impl/src/main/res/values-es/translations.xml create mode 100644 features/roomlist/impl/src/main/res/values-it/translations.xml create mode 100644 features/roomlist/impl/src/main/res/values-ro/translations.xml create mode 100644 features/verifysession/impl/src/main/res/values-es/translations.xml create mode 100644 features/verifysession/impl/src/main/res/values-it/translations.xml create mode 100644 features/verifysession/impl/src/main/res/values-ro/translations.xml create mode 100644 libraries/textcomposer/src/main/res/values-es/translations.xml create mode 100644 libraries/textcomposer/src/main/res/values-it/translations.xml create mode 100644 libraries/textcomposer/src/main/res/values-ro/translations.xml create mode 100644 libraries/ui-strings/src/main/res/values-es/translations.xml create mode 100644 libraries/ui-strings/src/main/res/values-it/translations.xml diff --git a/features/createroom/impl/src/main/res/values-es/translations.xml b/features/createroom/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..f6248df74e --- /dev/null +++ b/features/createroom/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,6 @@ + + + "Nueva sala" + "Invitar gente" + "Añadir personas" + \ No newline at end of file diff --git a/features/createroom/impl/src/main/res/values-it/translations.xml b/features/createroom/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..ea0c0b10e1 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,6 @@ + + + "Nuova stanza" + "Invita persone" + "Aggiungi persone" + \ No newline at end of file diff --git a/features/createroom/impl/src/main/res/values-ro/translations.xml b/features/createroom/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..98839a883e --- /dev/null +++ b/features/createroom/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,6 @@ + + + "Cameră nouă" + "Invitați persoane" + "Adaugați persoane" + \ No newline at end of file diff --git a/features/login/impl/src/main/res/values-es/translations.xml b/features/login/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..a299083994 --- /dev/null +++ b/features/login/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,20 @@ + + + "No hemos podido acceder a este servidor. Comprueba que has introducido correctamente la dirección del servidor. Si la dirección es correcta, ponte en contacto con el administrador del servidor para obtener más ayuda." + "Este servidor no soporta sliding sync." + "Dirección del homeserver" + "Solo puedes conectarte a un servidor que soporte sliding sync. El administrador de tu servidor tendrá que configurarlo. %1$s" + "Continuar" + "¿Cuál es la dirección de tu servidor?" + "Selecciona tu servidor" + "Esta cuenta ha sido desactivada." + "Usuario y/o contraseña incorrectos" + "Este no es un id de usuario válido. Formato esperado: \'@user:homeserver.org\'" + "El servidor seleccionado no admite contraseñas ni inicio de sesión OIDC. Póngase en contacto con su administrador o elija otro homeserver." + "Introduce tus datos" + "Contraseña" + "Donde viven tus conversaciones" + "Continuar" + "¡Hola de nuevo!" + "Usuario" + \ No newline at end of file diff --git a/features/login/impl/src/main/res/values-it/translations.xml b/features/login/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..429f156883 --- /dev/null +++ b/features/login/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,20 @@ + + + "Non siamo riusciti a raggiungere questo homserver. Verifica di aver inserito correttamente l\'URL del server domestico. Se l\'URL è corretto, contatta l\'amministratore del tuo server domestico per ulteriore assistenza." + "Questo server attualmente non supporta la sincronizzazione scorrevole." + "URL dell\'homeserver" + "Puoi connetterti solo a un server esistente che supporta la sincronizzazione scorrevole. L\'amministratore del tuo server domestico dovrà configurarlo. %1$s" + "Continua" + "Qual è l\'indirizzo del tuo server?" + "Seleziona il tuo server" + "Questo profilo è stato disattivato." + "Nome utente e/o password errati" + "Questo non è un identificatore utente valido. Formato previsto: \'@user:homeserver.org\'" + "L\'homeserver selezionato non supporta la password o l\'accesso OIDC. Contatta il tuo amministratore o scegli un altro homeserver." + "Inserisci i tuoi dati" + "Password" + "Dove vivono le tue conversazioni" + "Continua" + "Bentornato!" + "Nome utente" + \ No newline at end of file diff --git a/features/login/impl/src/main/res/values-ro/translations.xml b/features/login/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..2b5cce6829 --- /dev/null +++ b/features/login/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,20 @@ + + + "Nu am putut accesa acest homeserver. Te rugăm să verifici că ai introdus corect adresa URL a homeserver-ului. Dacă adresa URL este corectă, contactează administratorul homeserver-ului pentru ajutor suplimentar." + "Momentan acest server nu oferă suport pentru sliding sync." + "Adresa URL a homeserver-ului" + "Vă putețo conecta numai la un server existent care oferă suport pentru sliding sync. Administratorul homeserver-ului dumneavoastră va trebui să îl configureze. %1$s" + "Continuați" + "Care este adresa serverului dumneavoastră?" + "Selectați serverul" + "Acest cont a fost dezactivat." + "Utilizator și/sau parolă incorecte" + "Acesta nu este un identificator de utilizator valid. Format așteptat: „@user:homeserver.org”" + "Homeserver-ul selectat nu acceptă autentificarea prin parola sau OIDC. Te rugăm să contactezi administratorul sau să alegi un alt homeserver." + "Introduceți detaliile" + "Parolă" + "Locul unde trăiesc conversațiile tale" + "Continuați" + "Bine ați revenit!" + "Utilizator" + \ No newline at end of file diff --git a/features/logout/api/src/main/res/values-es/translations.xml b/features/logout/api/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..9072ab88a2 --- /dev/null +++ b/features/logout/api/src/main/res/values-es/translations.xml @@ -0,0 +1,8 @@ + + + "¿Estás seguro de que quieres cerrar sesión?" + "Cerrar sesión" + "Cerrar sesión" + "Cerrando sesión…" + "Cerrar sesión" + \ No newline at end of file diff --git a/features/logout/api/src/main/res/values-it/translations.xml b/features/logout/api/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..8b01a02780 --- /dev/null +++ b/features/logout/api/src/main/res/values-it/translations.xml @@ -0,0 +1,8 @@ + + + "Sei sicuro di voler uscire?" + "Esci" + "Esci" + "Uscita in corso..." + "Esci" + \ No newline at end of file diff --git a/features/logout/api/src/main/res/values-ro/translations.xml b/features/logout/api/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..8befb1b1dd --- /dev/null +++ b/features/logout/api/src/main/res/values-ro/translations.xml @@ -0,0 +1,8 @@ + + + "Sunteți sigur că vreți să vă deconectați?" + "Deconectați-vă" + "Deconectați-vă" + "Deconectare în curs…" + "Deconectați-vă" + \ No newline at end of file diff --git a/features/onboarding/impl/src/main/res/values-es/translations.xml b/features/onboarding/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..235fb4558a --- /dev/null +++ b/features/onboarding/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,5 @@ + + + "Bienvenido a la beta de %1$s. Vitaminado, para mayor rapidez y sencillez." + "Siéntente en tu Elemento" + \ No newline at end of file diff --git a/features/onboarding/impl/src/main/res/values-it/translations.xml b/features/onboarding/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..cd3c6a696c --- /dev/null +++ b/features/onboarding/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,5 @@ + + + "Benvenuto nella beta di %1$s. Potenziato in velocità e semplicità." + "Sii nel tuo elemento" + \ No newline at end of file diff --git a/features/onboarding/impl/src/main/res/values-ro/translations.xml b/features/onboarding/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..03d967ab75 --- /dev/null +++ b/features/onboarding/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,5 @@ + + + "Bun venit la versiunea beta a %1$s. Supraalimentat, pentru viteză și simplitate." + "Fii în Elementul tău" + \ No newline at end of file diff --git a/features/rageshake/api/src/main/res/values-es/translations.xml b/features/rageshake/api/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..26ff483b91 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-es/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s se cerró inesperadamente la última vez que se lo usaste. ¿Quieres compartir un informe de error con nosotros?" + "Parece que sacudes el teléfono con frustración. ¿Quieres abrir la pantalla de informe de errores?" + \ No newline at end of file diff --git a/features/rageshake/api/src/main/res/values-it/translations.xml b/features/rageshake/api/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..e6ef37d287 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-it/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s si è chiuso inaspettatamente l\'ultima volta che è stato usato. Vuoi condividere con noi un rapporto sull\'arresto anomalo?" + "Sembra che tu stia scuotendo il telefono per la frustrazione. Vuoi aprire la schermata di segnalazione dei problemi?" + \ No newline at end of file diff --git a/features/rageshake/api/src/main/res/values-ro/translations.xml b/features/rageshake/api/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..17180d5145 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-ro/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s s-a blocat ultima dată când a fost folosit. Doriți să ne trimiteți un raport?" + "Se pare că scuturați telefonul de frustrare. Doriți să deschdeți ecranul de raportare a unei erori?" + \ No newline at end of file diff --git a/features/rageshake/impl/src/main/res/values-es/translations.xml b/features/rageshake/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..527376f268 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,14 @@ + + + "Adjuntar captura de pantalla" + "Podéis poneros en contacto conmigo para resolver dudas relacionadas" + "Editar captura de pantalla" + "Describe el problema. ¿Qué hiciste? ¿Qué esperabas que ocurriera? ¿Qué ocurrió en realidad? Por favor, detállalo todo lo que puedas." + "Describe el error..." + "Si es posible, escriba la descripción en inglés." + "Enviar registros de fallos" + "Enviar registros para ayudar" + "Enviar captura de pantalla" + "Para comprobar que todo funciona correctamente, se enviarán registros de fallos con su mensaje. Serán privados. Para enviar sólo tu mensaje, desactiva esta opción." + "%1$s se cerró inesperadamente la última vez que se lo usaste. ¿Quieres compartir un informe de error con nosotros?" + \ No newline at end of file diff --git a/features/rageshake/impl/src/main/res/values-it/translations.xml b/features/rageshake/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..60397f8719 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,14 @@ + + + "Allega istantanea schermo" + "Potete contattarmi per qualsiasi altra domanda" + "Modifica istantanea schermo" + "Descrivi il bug. Che cosa hai fatto? Cosa ti aspettavi che accadesse? Cosa è effettivamente accaduto. Si prega di inserire il maggior numero di dettagli possibile." + "Descrivi il problema..." + "Se possibile, scrivere la descrizione in inglese." + "Invia i log degli arresti anomali" + "Invia i log per aiutarci" + "Invia istantanea schermo" + "Per verificare che le cose funzionino come previsto, i log verranno inviati con il tuo messaggio. Questi saranno privati. Per inviare solo il tuo messaggio, disattiva questa impostazione." + "%1$s si è chiuso inaspettatamente l\'ultima volta che è stato usato. Vuoi condividere con noi un rapporto sull\'arresto anomalo?" + \ No newline at end of file diff --git a/features/rageshake/impl/src/main/res/values-ro/translations.xml b/features/rageshake/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..6b66ea417e --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,14 @@ + + + "Atașați o captură de ecran" + "Puteți să mă contactați dacă aveți întrebări suplimentare" + "Editați captura de ecran" + "Vă rugăm să descrieți eroarea. Ce ați făcut? Ce vă aşteptați să se întâmple? Ce s-a întâmplat de fapt. Vă rugam să intrați în cât mai multe detalii cu putință." + "Descrieți eroarea…" + "Dacă posibil, vă rugăm să scrieți descrierea în engleză." + "Trimiteți log-uri" + "Trimiteți log-uri pentru a ajuta" + "Trimiteți captură de ecran" + "Pentru a verifica că lucrurile funcționează conform așteptărilor, log-uri vor fi trimise împreună cu mesajul. Acestea vor fi private. Pentru a trimite doar mesajul, dezactivați această setare." + "%1$s s-a blocat ultima dată când a fost folosit. Dorești să ne trimiti un raport?" + \ No newline at end of file diff --git a/features/roomdetails/impl/src/main/res/values-es/translations.xml b/features/roomdetails/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..817d3613ae --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,25 @@ + + + + "0 personas" + "Una persona" + + + + "%1$d personas" + + "Bloquear" + "Los usuarios bloqueados no podrán enviarte mensajes y se ocultarán todos sus mensajes. Puedes revertir esta acción en cualquier momento." + "Bloquear usuario" + "Desbloquear" + "Al desbloquear al usuario, podrás volver a ver todos sus mensajes." + "Desbloquear usuario" + "Los mensajes están protegidos con \"candados\". Sólo tú y los destinatarios tenéis las llaves únicas para abrirlos." + "Cifrado de mensajes activado" + "Invitar a otras personas" + "Salir de la sala" + "Personas" + "Seguridad" + "Compartir sala" + "Tema" + \ No newline at end of file diff --git a/features/roomdetails/impl/src/main/res/values-it/translations.xml b/features/roomdetails/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..9a980b79a9 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,21 @@ + + + + "1 persona" + "%1$d persone" + + "Blocca" + "Gli utenti bloccati non saranno in grado di inviarti messaggi e tutti i loro messaggi 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" + "I messaggi sono protetti da lucchetti. Solo tu e i destinatari avete le chiavi univoche per sbloccarli." + "Crittografia messaggi abilitata" + "Invita persone" + "Esci dalla stanza" + "Persone" + "Sicurezza" + "Condividi stanza" + "Oggetto" + \ No newline at end of file diff --git a/features/roomdetails/impl/src/main/res/values-ro/translations.xml b/features/roomdetails/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..db6777fb7f --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,22 @@ + + + + "o persoană" + + "%1$d persoane" + + "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" + "Mesajele sunt securizate cu încuietori. Doar dumneavoastră și destinatarii aveți cheile unice pentru a le debloca." + "Criptarea mesajelor este activată" + "Invitați persoane" + "Părăsiți camera" + "Persoane" + "Securitate" + "Partajați camera" + "Subiect" + \ No newline at end of file diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml index 82c0c1c418..f63757a8e3 100644 --- a/features/roomdetails/impl/src/main/res/values/localazy.xml +++ b/features/roomdetails/impl/src/main/res/values/localazy.xml @@ -4,6 +4,12 @@ "1 person" "%1$d people" + "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" "Messages are secured with locks. Only you and the recipients have the unique keys to unlock them." "Message encryption enabled" "Invite people" diff --git a/features/roomlist/impl/src/main/res/values-es/translations.xml b/features/roomlist/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..079ffb2e72 --- /dev/null +++ b/features/roomlist/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,61 @@ + + + "Crear una nueva conversación o sala" + "Todos los chats" + "Parece que estás usando un nuevo dispositivo. Verifica que eres tú para acceder a tus mensajes cifrados." + "Accede a tu historial de mensajes" + "(el avatar también cambió)" + "%1$s cambió su avatar" + "Cambiaste tu avatar" + "%1$s cambió su nombre de %2$s a %3$s" + "Cambiaste tu nombre de %1$s a %2$s" + "%1$s eliminó su nombre (era %2$s)" + "Eliminaste tu nombre (era %1$s)" + "%1$s cambió su nombre a %2$s" + "Cambiaste tu nombre a %1$s" + "%1$s cambió el avatar de la sala" + "Cambiaste el avatar de la sala" + "%1$s eliminó el avatar de la sala" + "Eliminaste el avatar de la sala" + "%1$s expulsó permanentemente a %2$s" + "Expulsaste permanentemente a %1$s" + "%1$s creó la sala" + "Tú creaste la sala" + "%1$s invitó a %2$s" + "%1$s aceptó la invitación" + "Aceptaste la invitación" + "Invitaste a %1$s" + "%1$s te invitó." + "%1$s se unió a la sala" + "Te uniste a la sala" + "%1$s solicitó unirse" + "%1$s permitió que %2$s se uniera" + "%1$s te permitió unirte" + "Solicitaste unirte" + "%1$s rechazó la solicitud de %2$s para unirse" + "Rechazaste la solicitud de %1$s para unirte" + "%1$s rechazó su solicitud para unirte" + "%1$s ya no está interesado en unirse" + "Cancelaste tu solicitud de unirte" + "%1$s salió de la sala" + "Saliste de la sala" + "%1$s cambió el nombre de la sala a: %2$s" + "Cambiaste el nombre de la sala a: %1$s" + "%1$s eliminó el nombre de la sala" + "Eliminaste el nombre de la sala" + "%1$s rechazó la invitación" + "Rechazaste la invitación" + "%1$s echó a %2$s" + "Echaste a %1$s" + "%1$s envió una invitación a %2$s para unirse a la sala" + "Enviaste una invitación a %1$s para unirse a la sala" + "%1$s revocó la invitación a %2$s para unirse a la sala" + "Revocaste la invitación de %1$s para unirse a la sala" + "%1$s cambió el tema a: %2$s" + "Cambiaste el tema a: %1$s" + "%1$s eliminó el tema de la sala" + "Eliminaste el tema de la sala" + "%1$s readmitió a %2$s" + "Readmitiste a %1$s" + "%1$s realizó un cambio desconocido en su membresía" + \ No newline at end of file diff --git a/features/roomlist/impl/src/main/res/values-it/translations.xml b/features/roomlist/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..20bf487937 --- /dev/null +++ b/features/roomlist/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,61 @@ + + + "Crea una nuova conversazione o stanza" + "Tutte le conversazioni" + "Sembra che tu stia utilizzando un nuovo dispositivo. Verifica di essere tu per accedere ai tuoi messaggi crittografati." + "Accedi alla cronologia dei messaggi" + "(anche l\'avatar è stato cambiato)" + "%1$s ha cambiato il proprio avatar" + "Hai cambiato il tuo avatar" + "%1$s ha cambiato il proprio nome visualizzato da %2$s a %3$s" + "Hai cambiato il tuo nome visualizzato da %1$s a %2$s" + "%1$s ha rimosso il proprio nome visualizzato (era %2$s)" + "Hai rimosso il tuo nome visualizzato (era %1$s)" + "%1$s ha impostato il proprio nome visualizzato su %2$s" + "Hai impostato il tuo nome visualizzato su %1$s" + "%1$s ha cambiato l\'avatar della stanza" + "Hai cambiato l\'avatar della stanza" + "%1$s ha rimosso l\'avatar della stanza" + "Hai rimosso l\'avatar della stanza" + "%1$s ha rimosso %2$s" + "Hai rimosso %1$s" + "%1$s ha creato la stanza" + "Hai creato la stanza" + "%1$s ha invitato %2$s" + "%1$s ha accettato l\'invito" + "Hai accettato l\'invito" + "Hai invitato %1$s" + "%1$s ti ha invitato" + "%1$s si è unito alla stanza" + "Ti sei unito alla stanza" + "%1$s ha chiesto di unirsi" + "%1$s ha permesso a %2$s di unirsi" + "%1$s ti ha permesso di unirti" + "Hai richiesto di unirti" + "%1$s ha rifiutato la richiesta di unirsi di %2$s" + "Hai rifiutato la richiesta di unirsi di %1$s" + "%1$s ha rifiutato la tua richiesta di unirti" + "%1$s non è più interessato a partecipare" + "Hai annullato la tua richiesta di unirti" + "%1$s ha lasciato la stanza" + "Hai lasciato la stanza" + "%1$s ha cambiato il nome della stanza in: %2$s" + "Hai cambiato il nome della stanza in: %1$s" + "%1$s ha rimosso il nome della stanza" + "Hai rimosso il nome della stanza" + "%1$s ha rifiutato l\'invito" + "Hai rifiutato l\'invito" + "%1$s ha rimosso %2$s" + "Hai rimosso %1$s" + "%1$s ha inviato un invito a %2$s per unirsi alla stanza" + "Hai inviato un invito a %1$s per unirsi alla stanza" + "%1$s ha revocato l\'invito di %2$s ad unirsi alla stanza." + "Hai revocato l\'invito a %1$s a universi alla stanza" + "%1$s ha cambiato l\'oggetto in: %2$s" + "Hai cambiato l\'oggetto in: %1$s" + "%1$s ha rimosso l\'oggetto della stanza" + "Hai rimosso l\'oggetto della stanza" + "%1$s ha sbloccato %2$s" + "Hai sbloccato %1$s" + "%1$s ha apportato una modifica sconosciuta alla propria iscrizione" + \ No newline at end of file diff --git a/features/roomlist/impl/src/main/res/values-ro/translations.xml b/features/roomlist/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..89760b3497 --- /dev/null +++ b/features/roomlist/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,61 @@ + + + "Creați o conversație sau o cameră nouă" + "Toate conversatiile" + "Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea pentru acces la mesajele dumneavoastră criptate." + "Accesați istoricul mesajelor" + "(s-a schimbat si avatarul)" + "%1$s și-a schimbat avatarul" + "V-ați schimbat avatarul" + "%1$s și-a schimbat numele din %2$s în %3$s" + "V-ați schimbat numele din %1$s în %2$s" + "%1$s și-a sters numele (era %2$s)" + "V-ați sters numele (era %1$s)" + "%1$s și-a schimbat numele %2$s" + "V-ați schimbat numele în %1$s" + "%1$s a schimbat avatarul camerei" + "Ați schimbat avatarul camerei" + "%1$s a șters avatarul camerei" + "Ați șters avatarul camerei" + "%1$s a adăugat o interdicție pentru %2$s" + "Ați adăugat o interdicție pentru %1$s" + "%1$s a creat camera" + "Ați creat camera" + "%1$s l-a invitat pe %2$s" + "%1$s a acceptat invitația" + "Ați acceptat invitația" + "L-ați invitat pe %1$s" + "%1$s v-a invitat" + "%1$s a intrat în cameră" + "Ați intrat în cameră" + "%1$s a solicitat să se alăture camerei" + "%1$s i-a permis lui %2$s să se alăture camerei" + "%1$s v-a permis să vă alăturați camerei" + "Ați solicitat să vă alăturați camerei" + "%1$s a respins solicitarea de alăturare a lui %2$s" + "Ați respins solicitarea de alăturare a lui %1$s" + "%1$s a respins cererea dumneavoastră de alăturare" + "%1$s nu mai este interesat să se alăture camerei" + "Ați anulat cererea de alăturare" + "%1$s a părăsit camera" + "Ați părăsit camera" + "%1$s a schimbat numele camerei în: %2$s" + "Ați schimbat numele camerei în: %1$s" + "%1$s a sters numele camerei" + "Ați șters numele camerei" + "%1$s a respins invitația" + "Ați respins invitația" + "%1$s l-a îndepărtat pe %2$s" + "L-ați îndepărtat pe %1$s" + "%1$s a trimis o invitație către %2$s pentru a se alătura camerei" + "Ați trimis o invitație către %1$s pentru a se alătura camerei" + "%1$s a revocat invitația pentru %2$s de a se alătura camerei" + "Ați revocat invitația pentru %1$s de a se alătura camerei" + "%1$s a schimbat subiectul în: %2$s" + "Ați schimbat subiectul în: %1$s" + "%1$s a șters subiectul camerei" + "Ați șters subiectul camerei" + "%1$s a anulat interdicția pentru %2$s" + "Ați anulat interdicția pentru %1$s" + "%1$s a făcut o modificare necunoscută asupra calității sale de membru" + \ No newline at end of file diff --git a/features/verifysession/impl/src/main/res/values-es/translations.xml b/features/verifysession/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..839c945e24 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,19 @@ + + + "Algo no fue bien. Se agotó el tiempo de espera de la solicitud o se rechazó." + "Verificación cancelada" + "Confirma que los emojis que aparecen a continuación coinciden con los que aparecen en tu otra sesión." + "Comparar emojis" + "Tu nueva sesión ya está verificada. Tienes acceso a tus mensajes cifrados y otros usuarios lo considerarán de confianza." + "Demuestra que eres tú para acceder a tu historial de mensajes cifrados." + "Abrir una sesión existente" + "Reintentar la verificación" + "Estoy listo" + "Comenzar" + "Esperando a que coincida" + "Compara los emoji, asegurándote de que aparecen en el mismo orden." + "No coinciden" + "Coinciden" + "Acepta la solicitud para iniciar el proceso de verificación en tu otra sesión para continuar." + "A la espera de aceptar la solicitud" + \ No newline at end of file diff --git a/features/verifysession/impl/src/main/res/values-it/translations.xml b/features/verifysession/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..3d8a46d581 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,19 @@ + + + "C\'è qualcosa che non va. La richiesta è scaduta o è stata rifiutata." + "Verifica annullata" + "Verifica che gli emoji sottostanti corrispondano a quelli mostrati nell\'altra sessione." + "Confronta le emoji" + "La tua nuova sessione è ora verificata. Ha accesso ai tuoi messaggi crittografati e gli altri utenti la vedranno come attendibile." + "Dimostra la tua identità per accedere alla cronologia dei messaggi crittografati." + "Apri una sessione esistente" + "Riprova la verifica" + "Sono pronto" + "Inizia" + "In attesa di un riscontro" + "Confronta le emoji uniche, assicurandoti che appaiano nello stesso ordine." + "Non corrispondono" + "Corrispondono" + "Accetta la richiesta di avviare il processo di verifica nell\'altra sessione per continuare." + "In attesa di accettare la richiesta" + \ No newline at end of file diff --git a/features/verifysession/impl/src/main/res/values-ro/translations.xml b/features/verifysession/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..f2bade56fc --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,19 @@ + + + "Ceva nu este în regulă. Fie cererea a expirat, fie a fost respinsă." + "Verificare anulată" + "Confirmați că emoticoanele de mai jos se potrivesc cu cele afișate în cealaltă sesiune." + "Comparați emoticoanele" + "Noua dumneavoastră sesiune este acum verificată. Are acces la mesajele dumneavoastră criptate, iar alți utilizatori vă vor vedea ca fiind de încredere." + "Demonstrați-vă identitatea pentru a accesa istoricul mesajelor criptate." + "Deschideți o sesiune existentă" + "Reîncercați verificarea" + "Sunt pregătit" + "Începeți" + "Se așteaptă confirmarea" + "Comparăți emoticoalene asigurându-vă că apar în aceeași ordine." + "Nu se potrivesc" + "Se potrivesc" + "Acceptați solicitarea de a începe procesul de verificare în cealaltă sesiune pentru a continua." + "Se așteptă acceptarea cererii" + \ No newline at end of file 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 0000000000..7392bd2b31 --- /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 0000000000..54ca270f28 --- /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 0000000000..b053e0ecaa --- /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 64684b7905..22c60db481 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 0000000000..d05dc300d2 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-es/translations.xml @@ -0,0 +1,156 @@ + + + "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" + + "No hay miembros" + "%1$d miembro" + + + + "%1$d miembros" + + + "No hay cambios en la sala" + "%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 b75da2740b..5bdce47d20 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,5 @@ "Confirmer" + "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 0000000000..80fed2e6f8 --- /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 6878d7aab4..1872bb057f 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 7ae90b9b53..28cef98c60 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -38,6 +38,7 @@ "Save" "Search" "Send" + "Share" "Share link" "Skip" "Start" @@ -49,6 +50,7 @@ "Audio" "Bubbles" "Creating room…" + "Left room" "Decryption error" "Developer options" "(edited)" @@ -128,12 +130,6 @@ "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" "Block user" "Check if you want to hide all current and future messages from this user" "Block" @@ -143,8 +139,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 From 43c070b01b7001874be0324944c9aae33f990d34 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Apr 2023 11:22:53 +0200 Subject: [PATCH 030/107] Add key naming rules for dialogs. --- tools/localazy/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/localazy/README.md b/tools/localazy/README.md index ed47efd2c3..58bd137932 100644 --- a/tools/localazy/README.md +++ b/tools/localazy/README.md @@ -27,6 +27,7 @@ For code clarity and in order to download strings to the correct module, here ar - 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`. From b410fc6983e740b68c5cef33fafa1d9c83e453b0 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Apr 2023 11:33:46 +0200 Subject: [PATCH 031/107] Add a section about placeholders. --- tools/localazy/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/localazy/README.md b/tools/localazy/README.md index 58bd137932..395961f91b 100644 --- a/tools/localazy/README.md +++ b/tools/localazy/README.md @@ -7,6 +7,7 @@ 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) @@ -40,6 +41,10 @@ For code clarity and in order to download strings to the correct module, here ar 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). From 830946e0de01b05a4c5d2203b333d2486a9c6fb6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Apr 2023 11:44:55 +0200 Subject: [PATCH 032/107] Ensure ellipsis char is used (fix lint issue). --- .../logout/api/src/main/res/values-it/translations.xml | 2 +- .../impl/src/main/res/values-es/translations.xml | 2 +- .../impl/src/main/res/values-it/translations.xml | 2 +- .../textcomposer/src/main/res/values-es/translations.xml | 2 +- .../ui-strings/src/main/res/values-es/translations.xml | 4 ++-- .../ui-strings/src/main/res/values-it/translations.xml | 2 +- tools/localazy/generateLocalazyConfig.py | 9 +++++++++ 7 files changed, 16 insertions(+), 7 deletions(-) diff --git a/features/logout/api/src/main/res/values-it/translations.xml b/features/logout/api/src/main/res/values-it/translations.xml index 8b01a02780..7d1a3ae304 100644 --- a/features/logout/api/src/main/res/values-it/translations.xml +++ b/features/logout/api/src/main/res/values-it/translations.xml @@ -3,6 +3,6 @@ "Sei sicuro di voler uscire?" "Esci" "Esci" - "Uscita in corso..." + "Uscita in corso…" "Esci" \ No newline at end of file diff --git a/features/rageshake/impl/src/main/res/values-es/translations.xml b/features/rageshake/impl/src/main/res/values-es/translations.xml index 527376f268..0b1a374b97 100644 --- a/features/rageshake/impl/src/main/res/values-es/translations.xml +++ b/features/rageshake/impl/src/main/res/values-es/translations.xml @@ -4,7 +4,7 @@ "Podéis poneros en contacto conmigo para resolver dudas relacionadas" "Editar captura de pantalla" "Describe el problema. ¿Qué hiciste? ¿Qué esperabas que ocurriera? ¿Qué ocurrió en realidad? Por favor, detállalo todo lo que puedas." - "Describe el error..." + "Describe el error…" "Si es posible, escriba la descripción en inglés." "Enviar registros de fallos" "Enviar registros para ayudar" diff --git a/features/rageshake/impl/src/main/res/values-it/translations.xml b/features/rageshake/impl/src/main/res/values-it/translations.xml index 60397f8719..c8a15eeedf 100644 --- a/features/rageshake/impl/src/main/res/values-it/translations.xml +++ b/features/rageshake/impl/src/main/res/values-it/translations.xml @@ -4,7 +4,7 @@ "Potete contattarmi per qualsiasi altra domanda" "Modifica istantanea schermo" "Descrivi il bug. Che cosa hai fatto? Cosa ti aspettavi che accadesse? Cosa è effettivamente accaduto. Si prega di inserire il maggior numero di dettagli possibile." - "Descrivi il problema..." + "Descrivi il problema…" "Se possibile, scrivere la descrizione in inglese." "Invia i log degli arresti anomali" "Invia i log per aiutarci" diff --git a/libraries/textcomposer/src/main/res/values-es/translations.xml b/libraries/textcomposer/src/main/res/values-es/translations.xml index 7392bd2b31..e302765a58 100644 --- a/libraries/textcomposer/src/main/res/values-es/translations.xml +++ b/libraries/textcomposer/src/main/res/values-es/translations.xml @@ -2,7 +2,7 @@ "Lista de puntos" "Bloque de código" - "Mensaje..." + "Mensaje…" "Aplicar formato negrita" "Aplicar formato cursiva" "Aplicar formato tachado" diff --git a/libraries/ui-strings/src/main/res/values-es/translations.xml b/libraries/ui-strings/src/main/res/values-es/translations.xml index d05dc300d2..dbbf33933d 100644 --- a/libraries/ui-strings/src/main/res/values-es/translations.xml +++ b/libraries/ui-strings/src/main/res/values-es/translations.xml @@ -49,7 +49,7 @@ "Acerca de" "Sonido" "Burbujas" - "Creando sala..." + "Creando sala…" "Saliste de la sala" "Error de descifrado" "Opciones de desarrollador" @@ -92,7 +92,7 @@ "Verificación cancelada" "Verificación completada" "Vídeo" - "Esperando..." + "Esperando…" "Confirmar" "Error" "Terminado" diff --git a/libraries/ui-strings/src/main/res/values-it/translations.xml b/libraries/ui-strings/src/main/res/values-it/translations.xml index 80fed2e6f8..96d0648d3b 100644 --- a/libraries/ui-strings/src/main/res/values-it/translations.xml +++ b/libraries/ui-strings/src/main/res/values-it/translations.xml @@ -49,7 +49,7 @@ "Informazioni" "Audio" "Fumetti" - "Creazione stanza..." + "Creazione stanza…" "Hai lasciato la stanza" "Errore di decrittazione" "Opzioni sviluppatore" diff --git a/tools/localazy/generateLocalazyConfig.py b/tools/localazy/generateLocalazyConfig.py index 13b76e7d57..c5f99aca2c 100755 --- a/tools/localazy/generateLocalazyConfig.py +++ b/tools/localazy/generateLocalazyConfig.py @@ -20,6 +20,11 @@ regexToAlwaysExclude = [ ".*_ios" ] +# 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 @@ -33,6 +38,7 @@ for entry in config["modules"]: "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)), + "replacements": replacements, "conditions": [ "equals: ${languageCode}, en" ] @@ -46,6 +52,7 @@ for entry in config["modules"]: "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)), + "replacements": replacements, "conditions": [ "!equals: ${languageCode}, en" ] @@ -58,6 +65,7 @@ mainAction = { "type": "android", "output": "libraries/ui-strings/src/main/res/values/localazy.xml", "excludeKeys": list(map(lambda i: "REGEX:" + i, allRegexToExcludeFromMainModule + regexToAlwaysExclude)), + "replacements": replacements, "conditions": [ "equals: ${languageCode}, en" ] @@ -71,6 +79,7 @@ if allFiles: "type": "android", "output": "libraries/ui-strings/src/main/res/values-${langAndroidResNoScript}/translations.xml", "excludeKeys": list(map(lambda i: "REGEX:" + i, allRegexToExcludeFromMainModule + regexToAlwaysExclude)), + "replacements": replacements, "conditions": [ "!equals: ${languageCode}, en" ] From 9e1a259f966b8d6dd9ecc52443a5a02b16ed3e89 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 5 Apr 2023 12:02:10 +0200 Subject: [PATCH 033/107] Use RetryDialog --- .../impl/root/CreateRoomRootView.kt | 8 +- .../components/dialogs/ErrorDialog.kt | 11 +-- .../components/dialogs/RetryDialog.kt | 97 +++++++++++++++++++ 3 files changed, 101 insertions(+), 15 deletions(-) create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt index cc7fa853be..146c447996 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt @@ -44,7 +44,7 @@ import io.element.android.features.createroom.impl.R import io.element.android.features.userlist.api.UserListView import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.components.ProgressDialog -import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.components.dialogs.RetryDialog import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar @@ -109,12 +109,10 @@ fun CreateRoomRootView( ProgressDialog(text = stringResource(id = StringR.string.common_creating_room)) } is Async.Failure -> { - ErrorDialog( + RetryDialog( content = stringResource(id = StringR.string.screen_start_chat_error_starting_chat), - dismissText = stringResource(id = StringR.string.action_cancel), - submitText = stringResource(id = StringR.string.action_retry), onDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) }, - onSubmit = { state.eventSink(CreateRoomRootEvents.RetryStartDM) }, + onRetry = { state.eventSink(CreateRoomRootEvents.RetryStartDM) }, ) } else -> Unit diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt index da1aeb836e..ce1730ed0c 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt @@ -37,9 +37,7 @@ fun ErrorDialog( modifier: Modifier = Modifier, title: String = ErrorDialogDefaults.title, submitText: String = ErrorDialogDefaults.submitText, - dismissText: String? = null, onDismiss: () -> Unit = {}, - onSubmit: () -> Unit = onDismiss, shape: Shape = AlertDialogDefaults.shape, containerColor: Color = AlertDialogDefaults.containerColor, iconContentColor: Color = AlertDialogDefaults.iconContentColor, @@ -57,17 +55,10 @@ fun ErrorDialog( Text(content) }, confirmButton = { - TextButton(onClick = onSubmit) { + TextButton(onClick = onDismiss) { Text(submitText) } }, - dismissButton = dismissText?.let { - { - TextButton(onClick = onDismiss) { - Text(it) - } - } - }, shape = shape, containerColor = containerColor, iconContentColor = iconContentColor, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt new file mode 100644 index 0000000000..ebfa8effc8 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt @@ -0,0 +1,97 @@ +/* + * 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.designsystem.components.dialogs + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.R as StringR + +@Composable +fun RetryDialog( + content: String, + modifier: Modifier = Modifier, + title: String = RetryDialogDefaults.title, + retryText: String = RetryDialogDefaults.retryText, + dismissText: String = RetryDialogDefaults.dismissText, + onRetry: () -> Unit = {}, + onDismiss: () -> Unit = {}, + shape: Shape = AlertDialogDefaults.shape, + containerColor: Color = AlertDialogDefaults.containerColor, + iconContentColor: Color = AlertDialogDefaults.iconContentColor, + titleContentColor: Color = AlertDialogDefaults.titleContentColor, + textContentColor: Color = AlertDialogDefaults.textContentColor, + tonalElevation: Dp = AlertDialogDefaults.TonalElevation, +) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { + Text(title) + }, + text = { + Text(content) + }, + confirmButton = { + TextButton(onClick = onRetry) { + Text(retryText) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(dismissText) + } + }, + shape = shape, + containerColor = containerColor, + iconContentColor = iconContentColor, + titleContentColor = titleContentColor, + textContentColor = textContentColor, + tonalElevation = tonalElevation, + ) +} + +object RetryDialogDefaults { + val title: String @Composable get() = stringResource(id = StringR.string.dialog_title_error) + val retryText: String @Composable get() = stringResource(id = StringR.string.action_retry) + val dismissText: String @Composable get() = stringResource(id = StringR.string.action_cancel) +} + +@Preview +@Composable +internal fun RetryDialogLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun RetryDialogDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + RetryDialog( + content = "Content", + ) +} From e316fcef0404b714234995f2424ce959dcbb814c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Apr 2023 12:23:47 +0200 Subject: [PATCH 034/107] Create baseAction for shared values. --- tools/localazy/generateLocalazyConfig.py | 25 ++++++++++-------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/tools/localazy/generateLocalazyConfig.py b/tools/localazy/generateLocalazyConfig.py index c5f99aca2c..e2bc310e1e 100755 --- a/tools/localazy/generateLocalazyConfig.py +++ b/tools/localazy/generateLocalazyConfig.py @@ -20,9 +20,12 @@ regexToAlwaysExclude = [ ".*_ios" ] -# Replacement done in all string values -replacements = { - "...": "…" +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 @@ -33,12 +36,10 @@ allActions = [] # 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)), - "replacements": replacements, "conditions": [ "equals: ${languageCode}, en" ] @@ -47,12 +48,10 @@ for entry in config["modules"]: allActions.append(action) # Create action for the translations if allFiles: - actionTranslation = { - "type": "android", + 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)), - "replacements": replacements, "conditions": [ "!equals: ${languageCode}, en" ] @@ -61,11 +60,9 @@ for entry in config["modules"]: 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)), - "replacements": replacements, "conditions": [ "equals: ${languageCode}, en" ] @@ -75,11 +72,9 @@ allActions.append(mainAction) if allFiles: # Append configuration for the main string module: translations - mainActionTranslation = { - "type": "android", + mainActionTranslation = baseAction | { "output": "libraries/ui-strings/src/main/res/values-${langAndroidResNoScript}/translations.xml", "excludeKeys": list(map(lambda i: "REGEX:" + i, allRegexToExcludeFromMainModule + regexToAlwaysExclude)), - "replacements": replacements, "conditions": [ "!equals: ${languageCode}, en" ] From 79d8a51ba79139638d1732feee23e9c558ae37db Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Apr 2023 12:25:17 +0200 Subject: [PATCH 035/107] Invoke `./tools/localazy/downloadStrings.sh --all` --- .../impl/src/main/res/values-es/translations.xml | 4 ---- .../ui-strings/src/main/res/values-es/translations.xml | 8 -------- 2 files changed, 12 deletions(-) diff --git a/features/roomdetails/impl/src/main/res/values-es/translations.xml b/features/roomdetails/impl/src/main/res/values-es/translations.xml index 817d3613ae..ba4327000b 100644 --- a/features/roomdetails/impl/src/main/res/values-es/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-es/translations.xml @@ -1,11 +1,7 @@ - "0 personas" "Una persona" - - - "%1$d personas" "Bloquear" diff --git a/libraries/ui-strings/src/main/res/values-es/translations.xml b/libraries/ui-strings/src/main/res/values-es/translations.xml index dbbf33933d..4b14f3a4a7 100644 --- a/libraries/ui-strings/src/main/res/values-es/translations.xml +++ b/libraries/ui-strings/src/main/res/values-es/translations.xml @@ -116,19 +116,11 @@ "¿Seguro que quieres salir de la habitación?" "%1$s Android" - "No hay miembros" "%1$d miembro" - - - "%1$d miembros" - "No hay cambios en la sala" "%1$d cambio en la sala" - - - "%1$d cambios en la sala" "Agitar con fuerza para informar de un error" From 16382a3eeff759426084a8844658696381cd1dfc Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 5 Apr 2023 13:29:00 +0200 Subject: [PATCH 036/107] screenshots tests --- ...aultGroup_RetryDialogDarkPreview_0_null,NEXUS_5,1.0,en].png | 3 +++ ...ultGroup_RetryDialogLightPreview_0_null,NEXUS_5,1.0,en].png | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 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 create mode 100644 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 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 0000000000..62dcdf219c --- /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 0000000000..e1de911d46 --- /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 From 831eb7c8400b3af9089c3a50056a5976be5f5837 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 5 Apr 2023 13:54:24 +0200 Subject: [PATCH 037/107] exclude fakes from code coverage --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index d228aa2acb..0da25dc1bd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -229,7 +229,7 @@ koverMerged { target = kotlinx.kover.api.VerificationTarget.CLASS overrideClassFilter { includes += "*Presenter" - excludes += "*TemplatePresenter" + excludes += "*Fake*Presenter" } bound { minValue = 90 From 9064481b4c8f56af7630922a5f69c887c3304813 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 5 Apr 2023 15:36:41 +0200 Subject: [PATCH 038/107] [Room Details] Leave room (#296) * Add leave room functionality to the Room Details screen * Add snackbar message throught `SnackbarDistpacher` --- .../io/element/android/x/di/AppModule.kt | 7 + appnav/build.gradle.kts | 1 + .../android/appnav/LoggedInEventProcessor.kt | 72 +++++++++ .../android/appnav/LoggedInFlowNode.kt | 13 ++ .../io/element/android/appnav/RoomFlowNode.kt | 18 ++- changelog.d/286.feature | 1 + .../roomdetails/api/RoomDetailsEntryPoint.kt | 4 +- .../impl/DefaultRoomDetailsEntryPoint.kt | 5 +- .../roomdetails/impl/RoomDetailsEvent.kt | 6 +- .../roomdetails/impl/RoomDetailsPresenter.kt | 39 ++++- .../roomdetails/impl/RoomDetailsState.kt | 27 +++- .../impl/RoomDetailsStateProvider.kt | 4 +- .../roomdetails/impl/RoomDetailsView.kt | 46 +++++- .../roomdetails/RoomDetailsPresenterTests.kt | 138 +++++++++++++++++- .../features/roomlist/impl/RoomListEvents.kt | 1 - .../roomlist/impl/RoomListPresenter.kt | 19 ++- .../features/roomlist/impl/RoomListState.kt | 3 +- .../roomlist/impl/RoomListStateProvider.kt | 6 +- .../features/roomlist/impl/RoomListView.kt | 24 +-- .../roomlist/impl/RoomListPresenterTests.kt | 35 +---- .../impl/VerifySelfSessionPresenterTests.kt | 2 + .../components/dialogs/ConfirmationDialog.kt | 4 +- .../designsystem/utils/SnackbarDispatcher.kt | 72 +++++++++ .../libraries/matrix/api/MatrixClient.kt | 3 + .../libraries/matrix/api/room/MatrixRoom.kt | 4 + .../matrix/api/room/RoomMembershipObserver.kt | 40 +++++ .../libraries/matrix/impl/RustMatrixClient.kt | 8 +- .../matrix/impl/di/SessionMatrixModule.kt | 8 + .../matrix/impl/room/RustMatrixRoom.kt | 7 + .../libraries/matrix/test/FakeMatrixClient.kt | 5 + .../matrix/test/room/FakeMatrixRoom.kt | 9 ++ .../android/samples/minimal/RoomListScreen.kt | 4 +- 32 files changed, 564 insertions(+), 71 deletions(-) create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt create mode 100644 changelog.d/286.feature create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcher.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt diff --git a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt index c553f3f7f0..4a9ee85fc8 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt @@ -22,6 +22,7 @@ import dagger.Module import dagger.Provides import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SingleIn @@ -78,4 +79,10 @@ object AppModule { diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() ) } + + @Provides + @SingleIn(AppScope::class) + fun provideSnackbarDispatcher(): SnackbarDispatcher { + return SnackbarDispatcher() + } } diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index ea4c2d9a94..1cfcc40510 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.designsystem) implementation(projects.libraries.matrixui) + implementation(projects.libraries.uiStrings) implementation(projects.features.verifysession.api) implementation(projects.features.roomdetails.api) implementation(projects.tests.uitests) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt new file mode 100644 index 0000000000..f1d87330e1 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt @@ -0,0 +1,72 @@ +/* + * 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.appnav + +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.SnackbarMessage +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.VerificationFlowState +import io.element.android.libraries.ui.strings.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.coroutines.coroutineContext + +class LoggedInEventProcessor @Inject constructor( + private val snackbarDispatcher: SnackbarDispatcher, + roomMembershipObserver: RoomMembershipObserver, + sessionVerificationService: SessionVerificationService, +) { + + private var observingJob: Job? = null + + private val displayLeftRoomMessage = roomMembershipObserver.updates + .map { !it.isUserInRoom } + + private val displayVerificationSuccessfulMessage = sessionVerificationService.verificationFlowState + .map { it == VerificationFlowState.Finished } + + fun observeEvents(coroutineScope: CoroutineScope) { + observingJob = coroutineScope.launch { + displayLeftRoomMessage.onEach { + displayMessage(R.string.common_current_user_left_room) + }.launchIn(this) + + displayVerificationSuccessfulMessage + .drop(1) + .onEach { + displayMessage(R.string.common_verification_complete) + }.launchIn(this) + } + } + + fun stopObserving() { + observingJob?.cancel() + observingJob = null + } + + private suspend fun displayMessage(message: Int) { + snackbarDispatcher.post(SnackbarMessage(message)) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 4b2d653d79..1028c6f16d 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -46,13 +46,17 @@ import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.inputs import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.MatrixClient 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.ui.di.MatrixUIBindings import io.element.android.services.appnavstate.api.AppNavigationStateService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.parcelize.Parcelize +import kotlin.coroutines.coroutineContext @ContributesNode(AppScope::class) class LoggedInFlowNode @AssistedInject constructor( @@ -63,6 +67,8 @@ class LoggedInFlowNode @AssistedInject constructor( private val createRoomEntryPoint: CreateRoomEntryPoint, private val appNavigationStateService: AppNavigationStateService, private val verifySessionEntryPoint: VerifySessionEntryPoint, + private val coroutineScope: CoroutineScope, + snackbarDispatcher: SnackbarDispatcher, ) : BackstackNode( backstack = BackStack( initialElement = NavTarget.RoomList, @@ -87,6 +93,11 @@ class LoggedInFlowNode @AssistedInject constructor( ) : NodeInputs private val inputs: Inputs = inputs() + private val loggedInFlowProcessor = LoggedInEventProcessor( + snackbarDispatcher, + inputs.matrixClient.roomMembershipObserver(), + inputs.matrixClient.sessionVerificationService(), + ) override fun onBuilt() { super.onBuilt() @@ -99,6 +110,7 @@ class LoggedInFlowNode @AssistedInject constructor( appNavigationStateService.onNavigateToSession(inputs.matrixClient.sessionId) // TODO We do not support Space yet, so directly navigate to main space appNavigationStateService.onNavigateToSpace(MAIN_SPACE) + loggedInFlowProcessor.observeEvents(coroutineScope) }, onDestroy = { val imageLoaderFactory = bindings().notLoggedInImageLoaderFactory() @@ -106,6 +118,7 @@ class LoggedInFlowNode @AssistedInject constructor( plugins().forEach { it.onFlowReleased(inputs.matrixClient) } appNavigationStateService.onLeavingSpace() appNavigationStateService.onLeavingSession() + loggedInFlowProcessor.stopObserving() } ) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt index 14b90b0064..69c02d500e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt @@ -18,6 +18,7 @@ package io.element.android.appnav import android.os.Parcelable import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import com.bumble.appyx.core.composable.Children import com.bumble.appyx.core.lifecycle.subscribe @@ -38,7 +39,12 @@ import io.element.android.libraries.architecture.animation.rememberDefaultTransi import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.services.appnavstate.api.AppNavigationStateService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.parcelize.Parcelize import timber.log.Timber @@ -49,6 +55,8 @@ class RoomFlowNode @AssistedInject constructor( private val messagesEntryPoint: MessagesEntryPoint, private val roomDetailsEntryPoint: RoomDetailsEntryPoint, private val appNavigationStateService: AppNavigationStateService, + roomMembershipObserver: RoomMembershipObserver, + coroutineScope: CoroutineScope, ) : BackstackNode( backstack = BackStack( initialElement = NavTarget.Messages, @@ -68,6 +76,7 @@ class RoomFlowNode @AssistedInject constructor( ) : NodeInputs private val inputs: Inputs = inputs() + private val timeline = inputs.room.timeline() private val roomFlowPresenter = RoomFlowPresenter(inputs.room) @@ -85,6 +94,13 @@ class RoomFlowNode @AssistedInject constructor( appNavigationStateService.onLeavingRoom() } ) + + roomMembershipObserver.updates + .filter { update -> update.roomId == inputs.room.roomId && !update.isUserInRoom } + .onEach { + navigateUp() + } + .launchIn(coroutineScope) } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -97,7 +113,7 @@ class RoomFlowNode @AssistedInject constructor( }) } NavTarget.RoomDetails -> { - roomDetailsEntryPoint.createNode(this, buildContext) + roomDetailsEntryPoint.createNode(this, buildContext, emptyList()) } } } diff --git a/changelog.d/286.feature b/changelog.d/286.feature new file mode 100644 index 0000000000..0a193367fd --- /dev/null +++ b/changelog.d/286.feature @@ -0,0 +1 @@ +Add leave room functionality to the Room Details screen. diff --git a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt index e56cc7e705..560e9d5dd7 100644 --- a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt +++ b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt @@ -18,9 +18,9 @@ package io.element.android.features.roomdetails.api import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint interface RoomDetailsEntryPoint : FeatureEntryPoint { - fun createNode(parentNode: Node, buildContext: BuildContext): Node - + fun createNode(parentNode: Node, buildContext: BuildContext, plugins: List): Node } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt index a52575606c..eebdbea062 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt @@ -18,6 +18,7 @@ package io.element.android.features.roomdetails.impl import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.libraries.architecture.createNode @@ -26,7 +27,7 @@ import javax.inject.Inject @ContributesBinding(AppScope::class) class DefaultRoomDetailsEntryPoint @Inject constructor() : RoomDetailsEntryPoint { - override fun createNode(parentNode: Node, buildContext: BuildContext): Node { - return parentNode.createNode(buildContext) + override fun createNode(parentNode: Node, buildContext: BuildContext, plugins: List): Node { + return parentNode.createNode(buildContext, plugins) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt index ad6b4ea78f..3ef87d17e0 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt @@ -16,4 +16,8 @@ package io.element.android.features.roomdetails.impl -sealed interface RoomDetailsEvent +sealed interface RoomDetailsEvent { + data class LeaveRoom(val needsConfirmation: Boolean) : RoomDetailsEvent + object ClearLeaveRoomWarning : RoomDetailsEvent + object ClearError : RoomDetailsEvent +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt index 80ae4426d9..48dce4609c 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -21,22 +21,31 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject class RoomDetailsPresenter @Inject constructor( private val room: MatrixRoom, + private val roomMembershipObserver: RoomMembershipObserver, ) : Presenter { @Composable override fun present(): RoomDetailsState { -// fun handleEvents(event: RoomDetailsEvent) {} - + val coroutineScope = rememberCoroutineScope() + var leaveRoomWarning by remember { + mutableStateOf(null) + } + var error by remember { + mutableStateOf(null) + } var memberCount: Async by remember { mutableStateOf(Async.Loading()) } LaunchedEffect(Unit) { withContext(Dispatchers.IO) { @@ -47,6 +56,28 @@ class RoomDetailsPresenter @Inject constructor( ) } } + fun handleEvents(event: RoomDetailsEvent) { + when (event) { + is RoomDetailsEvent.LeaveRoom -> { + if (event.needsConfirmation) { + leaveRoomWarning = LeaveRoomWarning.computeLeaveRoomWarning(room.isPublic, memberCount) + } else { + coroutineScope.launch(Dispatchers.IO) { + room.leave() + .onSuccess { + roomMembershipObserver.notifyUserLeftRoom(room.roomId) + }.onFailure { + error = RoomDetailsError.AlertGeneric + } + leaveRoomWarning = null + } + } + } + is RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning = null + RoomDetailsEvent.ClearError -> error = null + } + } + return RoomDetailsState( roomId = room.roomId.value, @@ -56,7 +87,9 @@ class RoomDetailsPresenter @Inject constructor( roomTopic = room.topic, memberCount = memberCount, isEncrypted = room.isEncrypted, -// eventSink = ::handleEvents + displayLeaveRoomWarning = leaveRoomWarning, + error = error, + eventSink = ::handleEvents ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt index 78ee70529d..a9d3dc2fb1 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt @@ -17,6 +17,9 @@ package io.element.android.features.roomdetails.impl import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.isLoading + +import io.element.android.libraries.matrix.api.room.MatrixRoom data class RoomDetailsState( val roomId: String, @@ -26,5 +29,27 @@ data class RoomDetailsState( val roomTopic: String?, val memberCount: Async, val isEncrypted: Boolean, -// val eventSink: (RoomDetailsEvent) -> Unit + val displayLeaveRoomWarning: LeaveRoomWarning?, + val error: RoomDetailsError?, + val eventSink: (RoomDetailsEvent) -> Unit ) + +sealed class LeaveRoomWarning { + object Generic : LeaveRoomWarning() + object PrivateRoom : LeaveRoomWarning() + object LastUserInRoom : LeaveRoomWarning() + + companion object { + fun computeLeaveRoomWarning(isPublic: Boolean, memberCount: Async): LeaveRoomWarning { + return when { + !isPublic -> PrivateRoom + (memberCount as? Async.Success)?.state == 1 -> LastUserInRoom + else -> Generic + } + } + } +} + +sealed interface RoomDetailsError { + object AlertGeneric : RoomDetailsError +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt index c91db4d3a8..2629d76aed 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt @@ -43,5 +43,7 @@ fun aRoomDetailsState() = RoomDetailsState( "|| MAI iki/Marketing...", memberCount = Async.Success(32), isEncrypted = true, -// eventSink = {} + displayLeaveRoomWarning = null, + error = null, + eventSink = {} ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index e4d4ac5609..d5b2a4a9e4 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -49,6 +49,8 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceText import io.element.android.libraries.designsystem.preview.ElementPreviewDark @@ -57,6 +59,7 @@ import io.element.android.libraries.designsystem.theme.LocalColors import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.ui.strings.R as StringR @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -101,7 +104,24 @@ fun RoomDetailsView( SecuritySection() } - OtherActionsSection() + OtherActionsSection(onLeaveRoom = { + state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) + }) + + if (state.displayLeaveRoomWarning != null) { + ConfirmLeaveRoomDialog( + leaveRoomWarning = state.displayLeaveRoomWarning, + onConfirmLeave = { state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) }, + onDismiss = { state.eventSink(RoomDetailsEvent.ClearLeaveRoomWarning) } + ) + } + + if (state.error != null) { + ErrorDialog( + content = stringResource(StringR.string.error_unknown), + onDismiss = { state.eventSink(RoomDetailsEvent.ClearError) } + ) + } } } } @@ -189,16 +209,38 @@ internal fun SecuritySection(modifier: Modifier = Modifier) { } @Composable -internal fun OtherActionsSection(modifier: Modifier = Modifier) { +internal fun OtherActionsSection(onLeaveRoom: () -> Unit, modifier: Modifier = Modifier) { PreferenceCategory(showDivider = false, modifier = modifier) { PreferenceText( title = stringResource(R.string.screen_room_details_leave_room_title), icon = ImageVector.vectorResource(R.drawable.ic_door_open), tintColor = LocalColors.current.textActionCritical, + onClick = onLeaveRoom, ) } } +@Composable +internal fun ConfirmLeaveRoomDialog( + leaveRoomWarning: LeaveRoomWarning, + onConfirmLeave: () -> Unit, + onDismiss: () -> Unit +) { + val content = stringResource( + when (leaveRoomWarning) { + LeaveRoomWarning.PrivateRoom -> StringR.string.leave_room_alert_private_subtitle + LeaveRoomWarning.LastUserInRoom -> StringR.string.leave_room_alert_empty_subtitle + LeaveRoomWarning.Generic -> StringR.string.leave_room_alert_subtitle + } + ) + ConfirmationDialog( + content = content, + submitText = stringResource(StringR.string.action_leave), + onSubmitClicked = onConfirmLeave, + onDismiss = onDismiss, + ) +} + @Preview @Composable fun RoomDetailsLightPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) = diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt index 7679e1eb7d..41404b7354 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt @@ -20,21 +20,37 @@ import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth +import io.element.android.features.roomdetails.impl.LeaveRoomWarning +import io.element.android.features.roomdetails.impl.RoomDetailsEvent import io.element.android.features.roomdetails.impl.RoomDetailsPresenter import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.take import kotlinx.coroutines.test.runTest import org.junit.Test +@ExperimentalCoroutinesApi class RoomDetailsPresenterTests { + + private val roomMembershipObserver = RoomMembershipObserver(A_SESSION_ID) + @Test fun `present - initial state is created from room info`() = runTest { val room = aMatrixRoom() - val presenter = RoomDetailsPresenter(room) + val presenter = RoomDetailsPresenter(room, roomMembershipObserver) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -53,7 +69,7 @@ class RoomDetailsPresenterTests { @Test fun `present - room member count is calculated asynchronously`() = runTest { val room = aMatrixRoom() - val presenter = RoomDetailsPresenter(room) + val presenter = RoomDetailsPresenter(room, roomMembershipObserver) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -68,7 +84,7 @@ class RoomDetailsPresenterTests { @Test fun `present - initial state with no room name`() = runTest { val room = aMatrixRoom(name = null) - val presenter = RoomDetailsPresenter(room) + val presenter = RoomDetailsPresenter(room, roomMembershipObserver) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -84,7 +100,7 @@ class RoomDetailsPresenterTests { val room = aMatrixRoom(name = null).apply { givenFetchMemberResult(Result.failure(Throwable())) } - val presenter = RoomDetailsPresenter(room) + val presenter = RoomDetailsPresenter(room, roomMembershipObserver) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -94,6 +110,100 @@ class RoomDetailsPresenterTests { cancelAndIgnoreRemainingEvents() } } + + @Test + fun `present - Leave with confirmation on private room shows a specific warning`() = runTest { + val room = aMatrixRoom(isPublic = false) + val presenter = RoomDetailsPresenter(room, roomMembershipObserver) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // Allow room member count to load + skipItems(1) + + initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) + val confirmationState = awaitItem() + Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.PrivateRoom) + } + } + + @Test + fun `present - Leave with confirmation on empty room shows a specific warning`() = runTest { + val room = aMatrixRoom(members = listOf(aRoomMember())) + val presenter = RoomDetailsPresenter(room, roomMembershipObserver) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // Allow room member count to load + skipItems(1) + + initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) + val confirmationState = awaitItem() + Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.LastUserInRoom) + } + } + + @Test + fun `present - Leave with confirmation shows a generic warning`() = runTest { + val room = aMatrixRoom() + val presenter = RoomDetailsPresenter(room, roomMembershipObserver) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // Allow room member count to load + skipItems(1) + + initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) + val confirmationState = awaitItem() + Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.Generic) + } + } + + @Test + fun `present - Leave without confirmation leaves the room`() = runTest { + val room = aMatrixRoom() + val presenter = RoomDetailsPresenter(room, roomMembershipObserver) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // Allow room member count to load + skipItems(1) + + initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) + + cancelAndIgnoreRemainingEvents() + } + + // Membership observer should receive a 'left room' change + roomMembershipObserver.updates.take(1) + .onEach { update -> Truth.assertThat(update.change).isEqualTo(MembershipChange.LEFT) } + .collect() + } + + @Test + fun `present - ClearError removes any error present`() = runTest { + val room = aMatrixRoom().apply { + givenLeaveRoomError(Throwable()) + } + val presenter = RoomDetailsPresenter(room, roomMembershipObserver) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // Allow room member count to load + skipItems(1) + + initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) + val errorState = awaitItem() + Truth.assertThat(errorState.error).isNotNull() + errorState.eventSink(RoomDetailsEvent.ClearError) + Truth.assertThat(awaitItem().error).isNull() + } + } } fun aMatrixRoom( @@ -104,6 +214,7 @@ fun aMatrixRoom( avatarUrl: String? = "https://matrix.org/avatar.jpg", members: List = emptyList(), isEncrypted: Boolean = true, + isPublic: Boolean = true, ) = FakeMatrixRoom( roomId = roomId, name = name, @@ -112,4 +223,23 @@ fun aMatrixRoom( avatarUrl = avatarUrl, members = members, isEncrypted = isEncrypted, + isPublic = isPublic, +) + +fun aRoomMember( + userId: UserId = A_USER_ID, + displayName: String? = null, + avatarUrl: String? = null, + membership: RoomMembershipState = RoomMembershipState.JOIN, + isNameAmbiguous: Boolean = false, + powerLevel: Long = 0L, + normalizedPowerLevel: Long = 0L +) = RoomMember( + userId = userId.value, + displayName = displayName, + avatarUrl = avatarUrl, + membership = membership, + isNameAmbiguous = isNameAmbiguous, + powerLevel = powerLevel, + normalizedPowerLevel = normalizedPowerLevel, ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt index 47c34d6a5c..299c670eb4 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt @@ -20,5 +20,4 @@ sealed interface RoomListEvents { data class UpdateFilter(val newFilter: String) : RoomListEvents data class UpdateVisibleRange(val range: IntRange) : RoomListEvents object DismissRequestVerificationPrompt : RoomListEvents - object ClearSuccessfulVerificationMessage : RoomListEvents } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index 7d9a62f2d7..ac37dfe30d 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -34,12 +34,14 @@ import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.handleSnackbarMessage import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomSummary import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus -import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.ui.model.MatrixUser import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -56,8 +58,11 @@ class RoomListPresenter @Inject constructor( private val lastMessageTimestampFormatter: LastMessageTimestampFormatter, private val roomLastMessageFormatter: RoomLastMessageFormatter, private val sessionVerificationService: SessionVerificationService, + private val snackbarDispatcher: SnackbarDispatcher, ) : Presenter { + private val roomMembershipObserver: RoomMembershipObserver = client.roomMembershipObserver() + @Composable override fun present(): RoomListState { val matrixUser: MutableState = remember { @@ -86,19 +91,11 @@ class RoomListPresenter @Inject constructor( derivedStateOf { sessionVerifiedStatus == SessionVerifiedStatus.NotVerified && !verificationPromptDismissed } } - // Current verification flow status, if any (initial, requesting, accepted, etc.) - val currentVerificationFlowStatus by sessionVerificationService.verificationFlowState.collectAsState() - // We only care about the 'Finished' state to display the 'verification success' message - val presentVerificationSuccessfulMessage = remember { - derivedStateOf { currentVerificationFlowStatus == VerificationFlowState.Finished } - } - fun handleEvents(event: RoomListEvents) { when (event) { is RoomListEvents.UpdateFilter -> filter = event.newFilter is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range) RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true - RoomListEvents.ClearSuccessfulVerificationMessage -> sessionVerificationService.reset() } } @@ -106,12 +103,14 @@ class RoomListPresenter @Inject constructor( filteredRoomSummaries.value = updateFilteredRoomSummaries(roomSummaries, filter) } + val snackbarMessage = handleSnackbarMessage(snackbarDispatcher) + return RoomListState( matrixUser = matrixUser.value, roomList = filteredRoomSummaries.value, filter = filter, - presentVerificationSuccessfulMessage = presentVerificationSuccessfulMessage.value, displayVerificationPrompt = displayVerificationPrompt, + snackbarMessage = snackbarMessage, eventSink = ::handleEvents ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt index 122c5e5506..a14ef74e94 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt @@ -18,6 +18,7 @@ package io.element.android.features.roomlist.impl import androidx.compose.runtime.Immutable import io.element.android.features.roomlist.impl.model.RoomListRoomSummary +import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.matrix.ui.model.MatrixUser import kotlinx.collections.immutable.ImmutableList @@ -26,7 +27,7 @@ data class RoomListState( val matrixUser: MatrixUser?, val roomList: ImmutableList, val filter: String, - val presentVerificationSuccessfulMessage: Boolean, val displayVerificationPrompt: Boolean, + val snackbarMessage: SnackbarMessage?, val eventSink: (RoomListEvents) -> Unit ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt index 68db631675..d1ca647d62 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt @@ -20,17 +20,19 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.ui.model.MatrixUser import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import io.element.android.libraries.ui.strings.R as StringR open class RoomListStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aRoomListState(), aRoomListState().copy(displayVerificationPrompt = true), - aRoomListState().copy(presentVerificationSuccessfulMessage = true), + aRoomListState().copy(snackbarMessage = SnackbarMessage(StringR.string.common_verification_complete)), ) } @@ -39,7 +41,7 @@ internal fun aRoomListState() = RoomListState( roomList = aRoomListRoomSummaryList(), filter = "filter", eventSink = {}, - presentVerificationSuccessfulMessage = false, + snackbarMessage = null, displayVerificationPrompt = false, ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt index 8d31b17a14..1f49d8db0f 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -40,10 +40,11 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -67,6 +68,7 @@ import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.launch import io.element.android.libraries.designsystem.R as DrawableR import io.element.android.libraries.ui.strings.R as StringR @@ -130,14 +132,18 @@ fun RoomListContent( } val snackbarHostState = remember { SnackbarHostState() } - val verificationCompleteMessage = stringResource(StringR.string.common_verification_complete) - LaunchedEffect(state.presentVerificationSuccessfulMessage) { - if (state.presentVerificationSuccessfulMessage) { - snackbarHostState.showSnackbar( - message = verificationCompleteMessage, - duration = SnackbarDuration.Short, - ) - state.eventSink(RoomListEvents.ClearSuccessfulVerificationMessage) + val snackbarMessageText = if (state.snackbarMessage != null ) { + stringResource(state.snackbarMessage.messageResId) + } else null + val coroutineScope = rememberCoroutineScope() + if (snackbarMessageText != null) { + SideEffect { + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = snackbarMessageText, + duration = SnackbarDuration.Short, + ) + } } } diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index 1e8c15bf6d..3f3e43e2e7 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -24,8 +24,8 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus -import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -49,6 +49,7 @@ class RoomListPresenterTests { createDateFormatter(), FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), + SnackbarDispatcher(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -75,6 +76,7 @@ class RoomListPresenterTests { createDateFormatter(), FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), + SnackbarDispatcher(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -95,6 +97,7 @@ class RoomListPresenterTests { createDateFormatter(), FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), + SnackbarDispatcher(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -119,6 +122,7 @@ class RoomListPresenterTests { createDateFormatter(), FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), + SnackbarDispatcher(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -148,6 +152,7 @@ class RoomListPresenterTests { createDateFormatter(), FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), + SnackbarDispatcher(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -182,6 +187,7 @@ class RoomListPresenterTests { createDateFormatter(), FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), + SnackbarDispatcher(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -230,6 +236,7 @@ class RoomListPresenterTests { givenIsReady(true) givenVerifiedStatus(SessionVerifiedStatus.NotVerified) }, + SnackbarDispatcher(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -242,32 +249,6 @@ class RoomListPresenterTests { } } - @Test - fun `present - presentVerificationSuccessfulMessage & ClearVerificationSuccesfulMessage`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource() - val presenter = RoomListPresenter( - FakeMatrixClient( - sessionId = A_SESSION_ID, - roomSummaryDataSource = roomSummaryDataSource - ), - createDateFormatter(), - FakeRoomLastMessageFormatter(), - FakeSessionVerificationService().apply { - givenIsReady(true) - givenVerificationFlowState(VerificationFlowState.Finished) - }, - ) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - skipItems(1) - val displayMessageItem = awaitItem() - Truth.assertThat(displayMessageItem.presentVerificationSuccessfulMessage).isTrue() - displayMessageItem.eventSink(RoomListEvents.ClearSuccessfulVerificationMessage) - Truth.assertThat(awaitItem().presentVerificationSuccessfulMessage).isFalse() - } - } - private fun createDateFormatter(): LastMessageTimestampFormatter { return FakeLastMessageTimestampFormatter().apply { givenFormat(A_FORMATTED_DATE) diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt index 6c79c1b762..4007f4c8cf 100644 --- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt @@ -27,9 +27,11 @@ import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationEmoji import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test +@ExperimentalCoroutinesApi class VerifySelfSessionPresenterTests { @Test diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt index ff434c9748..07a85e0645 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt @@ -37,11 +37,11 @@ import io.element.android.libraries.ui.strings.R as StringR @Composable fun ConfirmationDialog( - title: String, content: String, onSubmitClicked: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, + title: String? = null, submitText: String = stringResource(id = StringR.string.action_ok), cancelText: String = stringResource(id = StringR.string.action_cancel), thirdButtonText: String? = null, @@ -60,7 +60,7 @@ fun ConfirmationDialog( modifier = modifier, onDismissRequest = onDismiss, title = { - Text(text = title) + if (title != null) { Text(text = title) } }, text = { Text(content) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcher.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcher.kt new file mode 100644 index 0000000000..1131777398 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcher.kt @@ -0,0 +1,72 @@ +/* + * 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.designsystem.utils + +import androidx.annotation.StringRes +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class SnackbarDispatcher { + private val mutex = Mutex() + + private val snackbarState = MutableStateFlow(null) + val snackbarMessage: Flow = snackbarState + + suspend fun post(message: SnackbarMessage) { + mutex.withLock { + snackbarState.update { message } + } + } + + suspend fun clear() { + mutex.withLock { + snackbarState.update { null } + } + } +} + +@Composable +fun handleSnackbarMessage( + snackbarDispatcher: SnackbarDispatcher +): SnackbarMessage? { + val snackbarMessage by snackbarDispatcher.snackbarMessage.collectAsState(initial = null) + LaunchedEffect(snackbarMessage) { + if (snackbarMessage != null) { + launch(Dispatchers.Main) { + snackbarDispatcher.clear() + } + } + } + return snackbarMessage +} + +data class SnackbarMessage( + @StringRes val messageResId: Int, + val duration: SnackbarDuration = SnackbarDuration.Short, + @StringRes val actionResId: Int? = null, + val action: () -> Unit = {}, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 9ce5502843..8a991771a5 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -20,6 +20,7 @@ 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.media.MediaResolver import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource import io.element.android.libraries.matrix.api.verification.SessionVerificationService import java.io.Closeable @@ -43,4 +44,6 @@ interface MatrixClient : Closeable { ): Result fun onSlidingSyncUpdate() + + fun roomMembershipObserver(): RoomMembershipObserver } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index bd76d95b35..60ff09a15b 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -32,8 +32,10 @@ interface MatrixRoom: Closeable { val topic: String? val avatarUrl: String? val isEncrypted: Boolean + val isPublic: Boolean suspend fun members() : List + suspend fun memberCount(): Int fun syncUpdateFlow(): Flow @@ -53,4 +55,6 @@ interface MatrixRoom: Closeable { suspend fun replyMessage(eventId: EventId, message: String): Result suspend fun redactEvent(eventId: EventId, reason: String? = null): Result + + fun leave(): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt new file mode 100644 index 0000000000..42b0996bb1 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.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.matrix.api.room + +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.timeline.item.event.MembershipChange +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class RoomMembershipObserver( + private val sessionId: SessionId, +) { + data class RoomMembershipUpdate( + val roomId: RoomId, + val isUserInRoom: Boolean, + val change: MembershipChange, + ) + + private val _updates = MutableSharedFlow(replay = 1) + val updates = _updates.asSharedFlow() + + fun notifyUserLeftRoom(roomId: RoomId) { + _updates.tryEmit(RoomMembershipUpdate(roomId, false, MembershipChange.LEFT)) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 378fec56c7..2fe34bfa55 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.impl.media.RustMediaResolver +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.impl.room.RustMatrixRoom import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource import io.element.android.libraries.matrix.impl.sync.SlidingSyncObserverProxy @@ -89,6 +90,7 @@ class RustMatrixClient constructor( requiredState = listOf( RequiredState(key = "m.room.avatar", value = ""), RequiredState(key = "m.room.encryption", value = ""), + RequiredState(key = "m.room.join_rules", value = ""), ) ) .filters(slidingSyncFilters) @@ -128,6 +130,8 @@ class RustMatrixClient constructor( private val mediaResolver = RustMediaResolver(this) private val isSyncing = AtomicBoolean(false) + private val roomMembershipObserver = RoomMembershipObserver(sessionId) + init { client.setDelegate(clientDelegate) rustRoomSummaryDataSource.init() @@ -150,7 +154,7 @@ class RustMatrixClient constructor( slidingSyncRoom = slidingSyncRoom, innerRoom = fullRoom, coroutineScope = coroutineScope, - coroutineDispatchers = dispatchers + coroutineDispatchers = dispatchers, ) } @@ -243,6 +247,8 @@ class RustMatrixClient constructor( } } + override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver + private fun File.deleteSessionDirectory(userID: String): Boolean { // Rust sanitises the user ID replacing invalid characters with an _ val sanitisedUserID = userID.replace(":", "_") diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt index 3079c75dc8..b49050cdc2 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt @@ -22,7 +22,9 @@ import dagger.Provides import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SingleIn import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver @Module @ContributesTo(SessionScope::class) @@ -32,4 +34,10 @@ object SessionMatrixModule { fun providesRustSessionVerificationService(matrixClient: MatrixClient): SessionVerificationService { return matrixClient.sessionVerificationService() } + + @Provides + @SingleIn(SessionScope::class) + fun provideRoomMembershipObserver(matrixClient: MatrixClient): RoomMembershipObserver { + return matrixClient.roomMembershipObserver() + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 291aa57392..fd5a63cb3b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -129,6 +129,9 @@ class RustMatrixRoom( override val alternativeAliases: List get() = innerRoom.alternativeAliases() + override val isPublic: Boolean + get() = innerRoom.isPublic() + override suspend fun fetchMembers(): Result = withContext(coroutineDispatchers.io) { runCatching { innerRoom.fetchMembers() @@ -179,4 +182,8 @@ class RustMatrixRoom( innerRoom.redact(eventId.value, reason, transactionId) } } + + override fun leave(): Result { + return runCatching { innerRoom.leave() } + } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 9272e794b4..998c7cdea4 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -21,6 +21,7 @@ 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.media.MediaResolver import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.test.media.FakeMediaResolver @@ -81,4 +82,8 @@ class FakeMatrixClient( override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService override fun onSlidingSyncUpdate() {} + + override fun roomMembershipObserver(): RoomMembershipObserver { + return RoomMembershipObserver(A_SESSION_ID) + } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index b8ea695f47..1c6d580a3c 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -37,6 +37,7 @@ class FakeMatrixRoom( override val isEncrypted: Boolean = false, override val alias: String? = null, override val alternativeAliases: List = emptyList(), + override val isPublic: Boolean = true, private val members: List = emptyList(), private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(), ) : MatrixRoom { @@ -46,6 +47,8 @@ class FakeMatrixRoom( var areMembersFetched: Boolean = false private set + private var leaveRoomError: Throwable? = null + override fun syncUpdateFlow(): Flow { return emptyFlow() } @@ -114,8 +117,14 @@ class FakeMatrixRoom( return Result.success(Unit) } + override fun leave(): Result = leaveRoomError?.let { Result.failure(it) } ?: Result.success(Unit) + override fun close() = Unit + fun givenLeaveRoomError(throwable: Throwable?) { + this.leaveRoomError = throwable + } + fun givenFetchMemberResult(result: Result) { fetchMemberResult = result } 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 6c95dee210..5dce2feafe 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 @@ -26,6 +26,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 @@ -47,7 +48,8 @@ class RoomListScreen( matrixClient, DefaultLastMessageTimestampFormatter(dateTimeProvider, dateFormatters), DefaultRoomLastMessageFormatter(context, matrixClient), - sessionVerificationService + sessionVerificationService, + SnackbarDispatcher(), ) @Composable From 54a1729bd885df0574494ad4c6988f4f2747827a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 5 Apr 2023 13:56:35 +0000 Subject: [PATCH 039/107] Update peter-evans/create-pull-request action to v5 --- .github/workflows/sync-localazy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml index 3efbb41f27..e3c745b8f6 100644 --- a/.github/workflows/sync-localazy.yml +++ b/.github/workflows/sync-localazy.yml @@ -18,7 +18,7 @@ jobs: - name: Run Localazy script run: ./tools/localazy/downloadStrings.sh --all - name: Create Pull Request for Strings - uses: peter-evans/create-pull-request@v4 + uses: peter-evans/create-pull-request@v5 with: commit-message: Sync Strings from Localazy title: Sync Strings From 8ce6af7951d28f39dc0e913b8bb9f4063c089fff Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 13 Mar 2023 14:37:25 +0100 Subject: [PATCH 040/107] Setup Google services Gradle plugin. --- app/build.gradle.kts | 1 + build.gradle.kts | 1 + 2 files changed, 2 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1e3de32f22..316f8ab507 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 { diff --git a/build.gradle.kts b/build.gradle.kts index d228aa2acb..38a11235df 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ import org.jetbrains.kotlin.cli.common.toBooleanLenient buildscript { dependencies { classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10") + classpath("com.google.gms:google-services:4.3.15") } } From bec72cbc22722246f3b8d20b5ed4a7be69ddc5d5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 13 Mar 2023 15:17:44 +0100 Subject: [PATCH 041/107] Configure com.google.firebase:firebase-bom and add dependency on `firebase-messaging-ktx` --- app/build.gradle.kts | 3 +++ gradle/libs.versions.toml | 4 ++-- plugins/build.gradle.kts | 4 +++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 316f8ab507..fdf0fcf0a3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -223,6 +223,9 @@ dependencies { 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 80f67bab6b..8cce1df9e6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,6 @@ [versions] # Project android_gradle_plugin = "7.4.2" -firebase_gradle_plugin = "3.2.0" kotlin = "1.8.10" ksp = "1.8.10-1.0.9" molecule = "0.8.0" @@ -55,8 +54,9 @@ dependencygraph = "0.10" [libraries] # Project android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" } -firebase_gradle_plugin = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebase_gradle_plugin" } kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +# https://firebase.google.com/docs/android/setup#available-libraries +google_firebase_bom = "com.google.firebase:firebase-bom:31.2.3" # AndroidX androidx_material = { module = "com.google.android.material:material", version.ref = "material" } diff --git a/plugins/build.gradle.kts b/plugins/build.gradle.kts index 6c77f11ac2..d4324432f3 100644 --- a/plugins/build.gradle.kts +++ b/plugins/build.gradle.kts @@ -27,6 +27,8 @@ repositories { dependencies { implementation(libs.android.gradle.plugin) implementation(libs.kotlin.gradle.plugin) - implementation(libs.firebase.gradle.plugin) + implementation(platform(libs.google.firebase.bom)) + // FIXME: using the bom ^, it should not be necessary to provide the version v... + implementation("com.google.firebase:firebase-appdistribution-gradle:4.0.0") implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location)) } From 08fb6c0a90af3a8ee388f549e58b379940760710 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 13 Mar 2023 15:21:03 +0100 Subject: [PATCH 042/107] Add google-services.json files to the project. --- app/src/debug/google-services.json | 49 ++++++++++++++++++++++++++++ app/src/nightly/google-services.json | 2 +- app/src/release/google-services.json | 40 +++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 app/src/debug/google-services.json create mode 100644 app/src/release/google-services.json diff --git a/app/src/debug/google-services.json b/app/src/debug/google-services.json new file mode 100644 index 0000000000..d9aa72f7ba --- /dev/null +++ b/app/src/debug/google-services.json @@ -0,0 +1,49 @@ +{ + "project_info": { + "project_number": "912726360885", + "firebase_url": "https://vector-alpha.firebaseio.com", + "project_id": "vector-alpha", + "storage_bucket": "vector-alpha.appspot.com" + }, + + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:912726360885:android:def0a4e454042e9b00427c", + "android_client_info": { + "package_name": "io.element.android.x.debug" + } + }, + "oauth_client": [ + { + "client_id": "912726360885-hvgoj23p6plt7hikhtdrakihojghaftv.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "io.element.android.x.debug", + "certificate_hash": "41bd63b3b612a15d9ba36a5245c393f2a9b992d1" + } + }, + { + "client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} diff --git a/app/src/nightly/google-services.json b/app/src/nightly/google-services.json index 09bfee08f7..31b022b3f2 100644 --- a/app/src/nightly/google-services.json +++ b/app/src/nightly/google-services.json @@ -37,4 +37,4 @@ } ], "configuration_version": "1" -} \ No newline at end of file +} diff --git a/app/src/release/google-services.json b/app/src/release/google-services.json new file mode 100644 index 0000000000..16fd1e855c --- /dev/null +++ b/app/src/release/google-services.json @@ -0,0 +1,40 @@ +{ + "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:d097de99a4c23d2700427c", + "android_client_info": { + "package_name": "io.element.android.x" + } + }, + "oauth_client": [ + { + "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" +} From aa2bb224e57a78c1fb80e49efc0a5657614d5493 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 14 Mar 2023 09:30:00 +0100 Subject: [PATCH 043/107] Add a link to a video presenting Anvil. --- docs/_developer_onboarding.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/_developer_onboarding.md b/docs/_developer_onboarding.md index 9b07f37daf..5b43b00922 100644 --- a/docs/_developer_onboarding.md +++ b/docs/_developer_onboarding.md @@ -251,7 +251,8 @@ Main libraries and frameworks used in this application: - Navigation state with [Appyx](https://bumble-tech.github.io/appyx/). Please watch [this video](https://www.droidcon.com/2022/11/15/model-driven-navigation-with-appyx-from-zero-to-hero/) to learn more about Appyx! -- DI: [Dagger](https://dagger.dev/) and [Anvil](https://github.com/square/anvil) +- DI: [Dagger](https://dagger.dev/) and [Anvil](https://github.com/square/anvil). Please + watch [this video](https://www.droidcon.com/2022/06/28/dagger-anvil-learning-to-love-dependency-injection/) to learn more about Anvil! - Reactive State management with Compose runtime and [Molecule](https://github.com/cashapp/molecule) Some patterns are inspired by [Circuit](https://slackhq.github.io/circuit/) From 64f4740029d818bfd99530ef197e88d6f0ba7458 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 14 Mar 2023 14:57:14 +0100 Subject: [PATCH 044/107] Import some stuff about Push and notification from Element Android - WIP --- app/build.gradle.kts | 2 + .../io/element/android/x/di/AppModule.kt | 16 + gradle/libs.versions.toml | 6 +- .../libraries/core/cache/CircularCache.kt | 41 + .../libraries/di/DefaultPreferences.kt | 21 + libraries/push/api/build.gradle.kts | 28 + .../push/api/src/main/AndroidManifest.xml | 64 ++ .../android/libraries/push/api/PushService.kt | 23 + .../push/api/model/BackgroundSyncMode.kt | 48 ++ .../libraries/push/api/store/PushDataStore.kt | 40 + libraries/push/impl/build.gradle.kts | 64 ++ .../push/impl/src/main/AndroidManifest.xml | 64 ++ .../libraries/push/impl/AutoAcceptInvites.kt | 49 ++ .../libraries/push/impl/DefaultPushService.kt | 40 + .../impl/EnsureFcmTokenIsRetrievedUseCase.kt | 44 ++ .../android/libraries/push/impl/FcmHelper.kt | 49 ++ .../libraries/push/impl/GoogleFcmHelper.kt | 101 +++ .../push/impl/GuardServiceStarter.kt | 31 + .../push/impl/KeepInternalDistributor.kt | 30 + .../libraries/push/impl/PushersManager.kt | 124 +++ .../push/impl/RegisterUnifiedPushUseCase.kt | 68 ++ .../libraries/push/impl/UnifiedPushHelper.kt | 180 +++++ .../libraries/push/impl/UnifiedPushStore.kt | 77 ++ .../push/impl/UnregisterUnifiedPushUseCase.kt | 49 ++ .../impl/VectorFirebaseMessagingService.kt | 64 ++ .../libraries/push/impl/VectorPushHandler.kt | 188 +++++ .../VectorUnifiedPushMessagingReceiver.kt | 117 +++ .../libraries/push/impl/config/PushConfig.kt | 41 + .../di/FirebaseMessagingServiceBindings.kt | 26 + ...torUnifiedPushMessagingReceiverBindings.kt | 26 + .../libraries/push/impl/model/PushData.kt | 30 + .../libraries/push/impl/model/PushDataFcm.kt | 43 + .../push/impl/model/PushDataUnifiedPush.kt | 60 ++ .../notifications/FilteredEventDetector.kt | 57 ++ .../notifications/NotifiableEventProcessor.kt | 62 ++ .../notifications/NotifiableEventResolver.kt | 264 +++++++ .../impl/notifications/NotificationAction.kt | 39 + .../notifications/NotificationActionIds.kt | 41 + .../notifications/NotificationBitmapLoader.kt | 95 +++ .../NotificationBroadcastReceiver.kt | 247 ++++++ .../NotificationBroadcastReceiverBindings.kt | 25 + .../notifications/NotificationDisplayer.kt | 48 ++ .../NotificationDrawerManager.kt | 241 ++++++ .../NotificationEventPersistence.kt | 76 ++ .../notifications/NotificationEventQueue.kt | 152 ++++ .../impl/notifications/NotificationFactory.kt | 138 ++++ .../notifications/NotificationRenderer.kt | 133 ++++ .../impl/notifications/NotificationState.kt | 62 ++ .../impl/notifications/NotificationUtils.kt | 744 ++++++++++++++++++ .../notifications/OutdatedEventDetector.kt | 44 ++ .../push/impl/notifications/ProcessedEvent.kt | 31 + .../impl/notifications/RoomEventGroupInfo.kt | 35 + .../notifications/RoomGroupMessageCreator.kt | 166 ++++ .../SummaryGroupMessageCreator.kt | 157 ++++ .../notifications/TestNotificationReceiver.kt | 30 + .../model/InviteNotifiableEvent.kt | 33 + .../notifications/model/NotifiableEvent.kt | 31 + .../model/NotifiableMessageEvent.kt | 60 ++ .../model/SimpleNotifiableEvent.kt | 31 + .../libraries/push/impl/parser/PushParser.kt | 56 ++ .../push/impl/store/DefaultPushDataStore.kt | 133 ++++ .../drawable-xxhdpi/element_logo_green.xml | 22 + .../ic_material_done_all_white.png | Bin 0 -> 398 bytes .../res/drawable-xxhdpi/ic_notification.png | Bin 0 -> 1269 bytes .../vector_notification_accept_invitation.png | Bin 0 -> 473 bytes .../vector_notification_quick_reply.png | Bin 0 -> 269 bytes .../vector_notification_reject_invitation.png | Bin 0 -> 309 bytes .../push/impl/src/main/res/values/colors.xml | 22 + .../push/impl/src/main/res/values/dimens.xml | 21 + settings.gradle.kts | 10 + 70 files changed, 5158 insertions(+), 2 deletions(-) create mode 100644 libraries/core/src/main/kotlin/io/element/android/libraries/core/cache/CircularCache.kt create mode 100644 libraries/di/src/main/kotlin/io/element/android/libraries/di/DefaultPreferences.kt create mode 100644 libraries/push/api/build.gradle.kts create mode 100644 libraries/push/api/src/main/AndroidManifest.xml create mode 100644 libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt create mode 100644 libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/model/BackgroundSyncMode.kt create mode 100644 libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/store/PushDataStore.kt create mode 100644 libraries/push/impl/build.gradle.kts create mode 100644 libraries/push/impl/src/main/AndroidManifest.xml create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/AutoAcceptInvites.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/EnsureFcmTokenIsRetrievedUseCase.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/FcmHelper.kt create mode 100755 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/GoogleFcmHelper.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/GuardServiceStarter.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/KeepInternalDistributor.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/RegisterUnifiedPushUseCase.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushHelper.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushStore.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnregisterUnifiedPushUseCase.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorFirebaseMessagingService.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorUnifiedPushMessagingReceiver.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/config/PushConfig.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/FirebaseMessagingServiceBindings.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/VectorUnifiedPushMessagingReceiverBindings.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushData.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataFcm.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataUnifiedPush.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationAction.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverBindings.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDrawerManager.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt create mode 100755 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/InviteNotifiableEvent.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/parser/PushParser.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt create mode 100644 libraries/push/impl/src/main/res/drawable-xxhdpi/element_logo_green.xml create mode 100755 libraries/push/impl/src/main/res/drawable-xxhdpi/ic_material_done_all_white.png create mode 100644 libraries/push/impl/src/main/res/drawable-xxhdpi/ic_notification.png create mode 100755 libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_accept_invitation.png create mode 100755 libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_quick_reply.png create mode 100755 libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_reject_invitation.png create mode 100644 libraries/push/impl/src/main/res/values/colors.xml create mode 100644 libraries/push/impl/src/main/res/values/dimens.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fdf0fcf0a3..0b8833efbb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -214,10 +214,12 @@ dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3") implementation(libs.appyx.core) implementation(libs.androidx.splash) + implementation(libs.androidx.core) implementation(libs.androidx.corektx) implementation(libs.androidx.lifecycle.runtime) implementation(libs.androidx.activity.compose) implementation(libs.androidx.startup) + implementation(libs.androidx.preference) implementation(libs.coil) implementation(platform(libs.network.okhttp.bom)) diff --git a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt index 4a9ee85fc8..d12d139a73 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt @@ -17,6 +17,9 @@ package io.element.android.x.di import android.content.Context +import android.content.SharedPreferences +import android.content.res.Resources +import androidx.preference.PreferenceManager import com.squareup.anvil.annotations.ContributesTo import dagger.Module import dagger.Provides @@ -25,6 +28,7 @@ import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.DefaultPreferences import io.element.android.libraries.di.SingleIn import io.element.android.x.BuildConfig import io.element.android.x.R @@ -47,6 +51,11 @@ object AppModule { return File(context.filesDir, "sessions") } + @Provides + fun providesResources(@ApplicationContext context: Context): Resources { + return context.resources + } + @Provides @SingleIn(AppScope::class) fun providesAppCoroutineScope(): CoroutineScope { @@ -69,6 +78,13 @@ object AppModule { okHttpLoggingLevel = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.BASIC, ) + @Provides + @SingleIn(AppScope::class) + @DefaultPreferences + fun providesDefaultSharedPreferences(@ApplicationContext context: Context): SharedPreferences { + return PreferenceManager.getDefaultSharedPreferences(context) + } + @Provides @SingleIn(AppScope::class) fun providesCoroutineDispatchers(): CoroutineDispatchers { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8cce1df9e6..e2fc6db401 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ molecule = "0.8.0" # AndroidX material = "1.8.0" -corektx = "1.9.0" +core = "1.9.0" datastore = "1.0.0" constraintlayout = "2.1.4" recyclerview = "1.3.0" @@ -60,7 +60,8 @@ google_firebase_bom = "com.google.firebase:firebase-bom:31.2.3" # AndroidX androidx_material = { module = "com.google.android.material:material", version.ref = "material" } -androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "corektx" } +androidx_core = { module = "androidx.core:core", version.ref = "core" } +androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" } androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } @@ -73,6 +74,7 @@ androidx_security_crypto = "androidx.security:security-crypto:1.0.0" androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" } androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } androidx_startup = { module = "androidx.startup:startup-runtime", version.ref = "startup" } +androidx_preference = "androidx.preference:preference:1.2.0" androidx_compose_bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" } diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/cache/CircularCache.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/cache/CircularCache.kt new file mode 100644 index 0000000000..f5305f006b --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/cache/CircularCache.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.core.cache + +/** + * A FIFO circular buffer of T. + * This class is not thread safe. + */ +class CircularCache(cacheSize: Int, factory: (Int) -> Array) { + + companion object { + inline fun create(cacheSize: Int) = CircularCache(cacheSize) { Array(cacheSize) { null } } + } + + private val cache = factory(cacheSize) + private var writeIndex = 0 + + fun contains(value: T): Boolean = cache.contains(value) + + fun put(value: T) { + if (writeIndex == cache.size) { + writeIndex = 0 + } + cache[writeIndex] = value + writeIndex++ + } +} diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/DefaultPreferences.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/DefaultPreferences.kt new file mode 100644 index 0000000000..2a4f9b8ac1 --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/DefaultPreferences.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.di + +import javax.inject.Qualifier + +@Qualifier annotation class DefaultPreferences diff --git a/libraries/push/api/build.gradle.kts b/libraries/push/api/build.gradle.kts new file mode 100644 index 0000000000..27a6827364 --- /dev/null +++ b/libraries/push/api/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.push.api" +} + +dependencies { + implementation(libs.androidx.corektx) + implementation(libs.coroutines.core) +} diff --git a/libraries/push/api/src/main/AndroidManifest.xml b/libraries/push/api/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..1d6f459d91 --- /dev/null +++ b/libraries/push/api/src/main/AndroidManifest.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt new file mode 100644 index 0000000000..3ed22d7dae --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.api + +interface PushService { + fun setCurrentRoom(roomId: String?) + fun setCurrentThread(threadId: String?) + fun notificationStyleChanged() +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/model/BackgroundSyncMode.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/model/BackgroundSyncMode.kt new file mode 100644 index 0000000000..3fb4841aba --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/model/BackgroundSyncMode.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.libraries.push.api.model + +/** + * Different strategies for Background sync, only applicable to F-Droid version of the app. + */ +enum class BackgroundSyncMode { + /** + * In this mode background syncs are scheduled via Workers, meaning that the system will have control on the periodicity + * of syncs when battery is low or when the phone is idle (sync will occur in allowed maintenance windows). After completion + * the sync work will schedule another one. + */ + FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY, + + /** + * This mode requires the app to be exempted from battery optimization. Alarms will be launched and will wake up the app + * in order to perform the background sync as a foreground service. After completion the service will schedule another alarm + */ + FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME, + + /** + * The app won't sync in background. + */ + FDROID_BACKGROUND_SYNC_MODE_DISABLED; + + companion object { + const val DEFAULT_SYNC_DELAY_SECONDS = 60 + const val DEFAULT_SYNC_TIMEOUT_SECONDS = 6 + + fun fromString(value: String?): BackgroundSyncMode = values().firstOrNull { it.name == value } + ?: FDROID_BACKGROUND_SYNC_MODE_DISABLED + } +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/store/PushDataStore.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/store/PushDataStore.kt new file mode 100644 index 0000000000..823ea0c88c --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/store/PushDataStore.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.api.store + +import io.element.android.libraries.push.api.model.BackgroundSyncMode +import kotlinx.coroutines.flow.Flow + +interface PushDataStore { + val pushCounterFlow: Flow + + fun areNotificationEnabledForDevice(): Boolean + fun setNotificationEnabledForDevice(enabled: Boolean) + + fun backgroundSyncTimeOut(): Int + fun setBackgroundSyncTimeout(timeInSecond: Int) + fun backgroundSyncDelay(): Int + fun setBackgroundSyncDelay(timeInSecond: Int) + fun isBackgroundSyncEnabled(): Boolean + fun setFdroidSyncBackgroundMode(mode: BackgroundSyncMode) + fun getFdroidSyncBackgroundMode(): BackgroundSyncMode + + /** + * Return true if Pin code is disabled, or if user set the settings to see full notification content. + */ + fun useCompleteNotificationFormat(): Boolean +} diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts new file mode 100644 index 0000000000..11950ad5f1 --- /dev/null +++ b/libraries/push/impl/build.gradle.kts @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) + kotlin("plugin.serialization") version "1.8.10" +} + +android { + namespace = "io.element.android.libraries.push.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + implementation(libs.androidx.corektx) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.lifecycle.process) + implementation(libs.serialization.json) + + implementation(projects.libraries.architecture) + implementation(projects.libraries.analytics.api) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.push.api) + + implementation(projects.services.toolbox.api) + + + api("me.gujun.android:span:1.7") { + exclude(group = "com.android.support", module = "support-annotations") + } + + implementation(platform(libs.google.firebase.bom)) + implementation("com.google.firebase:firebase-messaging-ktx") + + // UnifiedPush + api("com.github.UnifiedPush:android-connector:2.1.1") + + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.coroutines.test) +} diff --git a/libraries/push/impl/src/main/AndroidManifest.xml b/libraries/push/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..1d6f459d91 --- /dev/null +++ b/libraries/push/impl/src/main/AndroidManifest.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/AutoAcceptInvites.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/AutoAcceptInvites.kt new file mode 100644 index 0000000000..cc2b9100ec --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/AutoAcceptInvites.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +// TODO Move away +/** + * This interface defines 2 flags so you can handle auto accept invites. + * At the moment we only have [CompileTimeAutoAcceptInvites] implementation. + */ +interface AutoAcceptInvites { + /** + * Enable auto-accept invites. It means, as soon as you got an invite from the sync, it will try to join it. + */ + val isEnabled: Boolean + + /** + * Hide invites from the UI (from notifications, notification count and room list). By default invites are hidden when [isEnabled] is true + */ + val hideInvites: Boolean + get() = isEnabled +} + +fun AutoAcceptInvites.showInvites() = !hideInvites + +/** + * Simple compile time implementation of AutoAcceptInvites flags. + */ +@ContributesBinding(AppScope::class) +class CompileTimeAutoAcceptInvites @Inject constructor() : AutoAcceptInvites { + override val isEnabled = false +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt new file mode 100644 index 0000000000..cf7a5f377b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.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 + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultPushService @Inject constructor( + private val notificationDrawerManager: NotificationDrawerManager, +) : PushService { + override fun setCurrentRoom(roomId: String?) { + notificationDrawerManager.setCurrentRoom(roomId) + } + + override fun setCurrentThread(threadId: String?) { + notificationDrawerManager.setCurrentThread(threadId) + } + + override fun notificationStyleChanged() { + notificationDrawerManager.notificationStyleChanged() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/EnsureFcmTokenIsRetrievedUseCase.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/EnsureFcmTokenIsRetrievedUseCase.kt new file mode 100644 index 0000000000..fa5e6a0e5d --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/EnsureFcmTokenIsRetrievedUseCase.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl + +import javax.inject.Inject + +class EnsureFcmTokenIsRetrievedUseCase @Inject constructor( + private val unifiedPushHelper: UnifiedPushHelper, + private val fcmHelper: FcmHelper, + // private val activeSessionHolder: ActiveSessionHolder, +) { + + fun execute(pushersManager: PushersManager, registerPusher: Boolean) { + if (unifiedPushHelper.isEmbeddedDistributor()) { + fcmHelper.ensureFcmTokenIsRetrieved(pushersManager, shouldAddHttpPusher(registerPusher)) + } + } + + private fun shouldAddHttpPusher(registerPusher: Boolean) = if (registerPusher) { + /* + TODO EAx + val currentSession = activeSessionHolder.getActiveSession() + val currentPushers = currentSession.pushersService().getPushers() + currentPushers.none { it.deviceId == currentSession.sessionParams.deviceId } + */ + true + } else { + false + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/FcmHelper.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/FcmHelper.kt new file mode 100644 index 0000000000..9b8b6c2281 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/FcmHelper.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl + +interface FcmHelper { + fun isFirebaseAvailable(): Boolean + + /** + * Retrieves the FCM registration token. + * + * @return the FCM token or null if not received from FCM. + */ + fun getFcmToken(): String? + + /** + * Store FCM token to the SharedPrefs. + * + * @param token the token to store. + */ + fun storeFcmToken(token: String?) + + /** + * onNewToken may not be called on application upgrade, so ensure my shared pref is set. + * + * @param pushersManager the instance to register the pusher on. + * @param registerPusher whether the pusher should be registered. + */ + fun ensureFcmTokenIsRetrieved(pushersManager: PushersManager, registerPusher: Boolean) + + /* + fun onEnterForeground(activeSessionHolder: ActiveSessionHolder) + + fun onEnterBackground(activeSessionHolder: ActiveSessionHolder) + */ +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/GoogleFcmHelper.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/GoogleFcmHelper.kt new file mode 100755 index 0000000000..3d602aeb9b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/GoogleFcmHelper.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.element.android.libraries.push.impl + +import android.content.Context +import android.content.SharedPreferences +import android.widget.Toast +import androidx.core.content.edit +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.firebase.messaging.FirebaseMessaging +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.DefaultPreferences +import timber.log.Timber +import javax.inject.Inject +import io.element.android.libraries.ui.strings.R as StringR + +/** + * This class store the FCM token in SharedPrefs and ensure this token is retrieved. + * It has an alter ego in the fdroid variant. + */ +@ContributesBinding(AppScope::class) +class GoogleFcmHelper @Inject constructor( + @ApplicationContext private val context: Context, + @DefaultPreferences private val sharedPrefs: SharedPreferences, +) : FcmHelper { + override fun isFirebaseAvailable(): Boolean = true + + override fun getFcmToken(): String? { + return sharedPrefs.getString(PREFS_KEY_FCM_TOKEN, null) + } + + override fun storeFcmToken(token: String?) { + sharedPrefs.edit { + putString(PREFS_KEY_FCM_TOKEN, token) + } + } + + override fun ensureFcmTokenIsRetrieved(pushersManager: PushersManager, registerPusher: Boolean) { + // 'app should always check the device for a compatible Google Play services APK before accessing Google Play services features' + if (checkPlayServices(context)) { + try { + FirebaseMessaging.getInstance().token + .addOnSuccessListener { token -> + storeFcmToken(token) + if (registerPusher) { + pushersManager.enqueueRegisterPusherWithFcmKey(token) + } + } + .addOnFailureListener { e -> + Timber.e(e, "## ensureFcmTokenIsRetrieved() : failed") + } + } catch (e: Throwable) { + Timber.e(e, "## ensureFcmTokenIsRetrieved() : failed") + } + } else { + Toast.makeText(context, StringR.string.no_valid_google_play_services_apk, Toast.LENGTH_SHORT).show() + Timber.e("No valid Google Play Services found. Cannot use FCM.") + } + } + + /** + * Check the device to make sure it has the Google Play Services APK. If + * it doesn't, display a dialog that allows users to download the APK from + * the Google Play Store or enable it in the device's system settings. + */ + private fun checkPlayServices(context: Context): Boolean { + val apiAvailability = GoogleApiAvailability.getInstance() + val resultCode = apiAvailability.isGooglePlayServicesAvailable(context) + return resultCode == ConnectionResult.SUCCESS + } + + /* + override fun onEnterForeground(activeSessionHolder: ActiveSessionHolder) { + // No op + } + + override fun onEnterBackground(activeSessionHolder: ActiveSessionHolder) { + // No op + } + */ + + companion object { + private const val PREFS_KEY_FCM_TOKEN = "FCM_TOKEN" + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/GuardServiceStarter.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/GuardServiceStarter.kt new file mode 100644 index 0000000000..42993828a9 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/GuardServiceStarter.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 + +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/KeepInternalDistributor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/KeepInternalDistributor.kt new file mode 100644 index 0000000000..d351067e52 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/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 + +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/PushersManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt new file mode 100644 index 0000000000..2e87f360a5 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl + +import io.element.android.libraries.push.impl.config.PushConfig +import io.element.android.libraries.toolbox.api.appname.AppNameProvider +import java.util.UUID +import javax.inject.Inject + +internal const val DEFAULT_PUSHER_FILE_TAG = "mobile" + +// TODO EAx Communicate with the SDK +class PushersManager @Inject constructor( + private val unifiedPushHelper: UnifiedPushHelper, + // private val activeSessionHolder: ActiveSessionHolder, + // private val localeProvider: LocaleProvider, + private val appNameProvider: AppNameProvider, + // private val getDeviceInfoUseCase: GetDeviceInfoUseCase, +) { + suspend fun testPush() { + /* + val currentSession = activeSessionHolder.getActiveSession() + + currentSession.pushersService().testPush( + unifiedPushHelper.getPushGateway() ?: return, + PushConfig.pusher_app_id, + unifiedPushHelper.getEndpointOrToken().orEmpty(), + TEST_EVENT_ID + ) + + */ + } + + fun enqueueRegisterPusherWithFcmKey(pushKey: String): UUID { + return enqueueRegisterPusher(pushKey, PushConfig.pusher_http_url) + } + + fun enqueueRegisterPusher( + pushKey: String, + gateway: String + ): UUID { + /* + val currentSession = activeSessionHolder.getActiveSession() + val pusher = createHttpPusher(pushKey, gateway) + return currentSession.pushersService().enqueueAddHttpPusher(pusher) + + */ + // TODO EAx + TODO() + } + + private fun createHttpPusher( + pushKey: String, + gateway: String + ): Any = TODO() + /* + HttpPusher( + pushkey = pushKey, + appId = PushConfig.pusher_app_id, + profileTag = DEFAULT_PUSHER_FILE_TAG + "_" + abs(activeSessionHolder.getActiveSession().myUserId.hashCode()), + lang = localeProvider.current().language, + appDisplayName = appNameProvider.getAppName(), + deviceDisplayName = getDeviceInfoUseCase.execute().displayName().orEmpty(), + url = gateway, + enabled = true, + deviceId = activeSessionHolder.getActiveSession().sessionParams.deviceId ?: "MOBILE", + append = false, + withEventIdOnly = true, + ) + + */ + + suspend fun registerEmailForPush(email: String) { + TODO() + /* + val currentSession = activeSessionHolder.getActiveSession() + val appName = appNameProvider.getAppName() + currentSession.pushersService().addEmailPusher( + email = email, + lang = localeProvider.current().language, + emailBranding = appName, + appDisplayName = appName, + deviceDisplayName = currentSession.sessionParams.deviceId ?: "MOBILE" + ) + + */ + } + + fun getPusherForCurrentSession() {}/*: Pusher? { + val session = activeSessionHolder.getSafeActiveSession() ?: return null + val deviceId = session.sessionParams.deviceId + return session.pushersService().getPushers().firstOrNull { it.deviceId == deviceId } + } + */ + + + suspend fun unregisterEmailPusher(email: String) { + // val currentSession = activeSessionHolder.getSafeActiveSession() ?: return + // currentSession.pushersService().removeEmailPusher(email) + } + + suspend fun unregisterPusher(pushKey: String) { + // val currentSession = activeSessionHolder.getSafeActiveSession() ?: return + // currentSession.pushersService().removeHttpPusher(pushKey, PushConfig.pusher_app_id) + } + + companion object { + const val TEST_EVENT_ID = "\$THIS_IS_A_FAKE_EVENT_ID" + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/RegisterUnifiedPushUseCase.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/RegisterUnifiedPushUseCase.kt new file mode 100644 index 0000000000..e9f8cb985f --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/RegisterUnifiedPushUseCase.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 + +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/UnifiedPushHelper.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushHelper.kt new file mode 100644 index 0000000000..368f2e2336 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushHelper.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl + +import android.content.Context +import io.element.android.libraries.androidutils.system.getApplicationLabel +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.push.impl.config.PushConfig +import io.element.android.libraries.toolbox.api.strings.StringProvider +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.unifiedpush.android.connector.UnifiedPush +import timber.log.Timber +import java.net.URL +import javax.inject.Inject +import io.element.android.libraries.ui.strings.R as StringR + +class UnifiedPushHelper @Inject constructor( + @ApplicationContext private val context: Context, + private val unifiedPushStore: UnifiedPushStore, + // private val matrix: Matrix, + private val fcmHelper: FcmHelper, + private val stringProvider: StringProvider, +) { + + /* TODO EAx + @MainThread + fun showSelectDistributorDialog( + context: Context, + onDistributorSelected: (String) -> Unit, + ) { + val internalDistributorName = stringProvider.getString( + if (fcmHelper.isFirebaseAvailable()) { + StringR.string.unifiedpush_distributor_fcm_fallback + } else { + StringR.string.unifiedpush_distributor_background_sync + } + ) + + val distributors = UnifiedPush.getDistributors(context) + val distributorsName = distributors.map { + if (it == context.packageName) { + internalDistributorName + } else { + context.getApplicationLabel(it) + } + } + + MaterialAlertDialogBuilder(context) + .setTitle(stringProvider.getString(StringR.string.unifiedpush_getdistributors_dialog_title)) + .setItems(distributorsName.toTypedArray()) { _, which -> + val distributor = distributors[which] + onDistributorSelected(distributor) + } + .setOnCancelListener { + // we do not want to change the distributor on behalf of the user + if (UnifiedPush.getDistributor(context).isEmpty()) { + // By default, use internal solution (fcm/background sync) + onDistributorSelected(context.packageName) + } + } + .setCancelable(true) + .show() + } + + */ + + @Serializable + internal data class DiscoveryResponse( + @SerialName("unifiedpush") val unifiedpush: DiscoveryUnifiedPush = DiscoveryUnifiedPush() + ) + + @Serializable + internal data class DiscoveryUnifiedPush( + @SerialName("gateway") val gateway: String = "" + ) + + suspend fun storeCustomOrDefaultGateway( + endpoint: String, + onDoneRunnable: Runnable? = null + ) { + // if we use the embedded distributor, + // register app_id type upfcm on sygnal + // the pushkey if FCM key + if (UnifiedPush.getDistributor(context) == context.packageName) { + unifiedPushStore.storePushGateway(PushConfig.pusher_http_url) + onDoneRunnable?.run() + return + } + /* TODO EAx UnifiedPush + // else, unifiedpush, and pushkey is an endpoint + val gateway = PushConfig.default_push_gateway_http_url + val parsed = URL(endpoint) + val custom = "${parsed.protocol}://${parsed.host}/_matrix/push/v1/notify" + Timber.i("Testing $custom") + try { + val response = matrix.rawService().getUrl(custom, CacheStrategy.NoCache) + tryOrNull { Json.decodeFromString(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(StringR.string.unifiedpush_distributor_fcm_fallback) + isBackgroundSync() -> stringProvider.getString(StringR.string.unifiedpush_distributor_background_sync) + else -> context.getApplicationLabel(UnifiedPush.getDistributor(context)) + } + } + + fun isEmbeddedDistributor(): Boolean { + return isInternalDistributor() && fcmHelper.isFirebaseAvailable() + } + + fun isBackgroundSync(): Boolean { + return isInternalDistributor() && !fcmHelper.isFirebaseAvailable() + } + + private fun isInternalDistributor(): Boolean { + return UnifiedPush.getDistributor(context).isEmpty() || + UnifiedPush.getDistributor(context) == context.packageName + } + + fun getPrivacyFriendlyUpEndpoint(): String? { + val endpoint = getEndpointOrToken() + if (endpoint.isNullOrEmpty()) return null + if (isEmbeddedDistributor()) { + return endpoint + } + return try { + val parsed = URL(endpoint) + "${parsed.protocol}://${parsed.host}/***" + } catch (e: Exception) { + Timber.e(e, "Error parsing unifiedpush endpoint") + null + } + } + + fun getEndpointOrToken(): String? { + return if (isEmbeddedDistributor()) fcmHelper.getFcmToken() + else unifiedPushStore.getEndpoint() + } + + fun getPushGateway(): String? { + return if (isEmbeddedDistributor()) PushConfig.pusher_http_url + else unifiedPushStore.getPushGateway() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushStore.kt new file mode 100644 index 0000000000..5e2e33d7d3 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushStore.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.DefaultPreferences +import javax.inject.Inject + +/** + * TODO EAx Store in BDD (for multisession) + */ +class UnifiedPushStore @Inject constructor( + @ApplicationContext val context: Context, + @DefaultPreferences private val defaultPrefs: SharedPreferences, +) { + /** + * Retrieves the UnifiedPush Endpoint. + * + * @return the UnifiedPush Endpoint or null if not received + */ + fun getEndpoint(): String? { + return defaultPrefs.getString(PREFS_ENDPOINT_OR_TOKEN, null) + } + + /** + * Store UnifiedPush Endpoint to the SharedPrefs. + * + * @param endpoint the endpoint to store + */ + fun storeUpEndpoint(endpoint: String?) { + defaultPrefs.edit { + putString(PREFS_ENDPOINT_OR_TOKEN, endpoint) + } + } + + /** + * Retrieves the Push Gateway. + * + * @return the Push Gateway or null if not defined + */ + fun getPushGateway(): String? { + return defaultPrefs.getString(PREFS_PUSH_GATEWAY, null) + } + + /** + * Store Push Gateway to the SharedPrefs. + * + * @param gateway the push gateway to store + */ + fun storePushGateway(gateway: String?) { + defaultPrefs.edit { + putString(PREFS_PUSH_GATEWAY, gateway) + } + } + + companion object { + private const val PREFS_ENDPOINT_OR_TOKEN = "UP_ENDPOINT_OR_TOKEN" + private const val PREFS_PUSH_GATEWAY = "PUSH_GATEWAY" + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnregisterUnifiedPushUseCase.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnregisterUnifiedPushUseCase.kt new file mode 100644 index 0000000000..34c78a237e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnregisterUnifiedPushUseCase.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 + +import android.content.Context +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.push.api.model.BackgroundSyncMode +import io.element.android.libraries.push.api.store.PushDataStore +import org.unifiedpush.android.connector.UnifiedPush +import timber.log.Timber +import javax.inject.Inject + +class UnregisterUnifiedPushUseCase @Inject constructor( + @ApplicationContext private val context: Context, + private val pushDataStore: PushDataStore, + private val unifiedPushStore: UnifiedPushStore, + private val unifiedPushHelper: UnifiedPushHelper, +) { + + suspend fun execute(pushersManager: PushersManager?) { + val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME + pushDataStore.setFdroidSyncBackgroundMode(mode) + try { + unifiedPushHelper.getEndpointOrToken()?.let { + Timber.d("Removing $it") + pushersManager?.unregisterPusher(it) + } + } catch (e: Exception) { + Timber.d(e, "Probably unregistering a non existing pusher") + } + unifiedPushStore.storeUpEndpoint(null) + unifiedPushStore.storePushGateway(null) + UnifiedPush.unregisterApp(context) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorFirebaseMessagingService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorFirebaseMessagingService.kt new file mode 100644 index 0000000000..09dbf28a33 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorFirebaseMessagingService.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 + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.push.api.store.PushDataStore +import io.element.android.libraries.push.impl.config.PushConfig +import io.element.android.libraries.push.impl.di.FirebaseMessagingServiceBindings +import io.element.android.libraries.push.impl.parser.PushParser +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("Push", LoggerTag.SYNC) + +class VectorFirebaseMessagingService : FirebaseMessagingService() { + @Inject lateinit var fcmHelper: FcmHelper + @Inject lateinit var pushDataStore: PushDataStore + // @Inject lateinit var activeSessionHolder: ActiveSessionHolder + @Inject lateinit var pushersManager: PushersManager + @Inject lateinit var pushParser: PushParser + @Inject lateinit var vectorPushHandler: VectorPushHandler + @Inject lateinit var unifiedPushHelper: UnifiedPushHelper + + override fun onCreate() { + super.onCreate() + applicationContext.bindings().inject(this) + } + + override fun onNewToken(token: String) { + Timber.tag(loggerTag.value).d("New Firebase token") + fcmHelper.storeFcmToken(token) + if ( + pushDataStore.areNotificationEnabledForDevice() && + // TODO EAx activeSessionHolder.hasActiveSession() && + unifiedPushHelper.isEmbeddedDistributor() + ) { + pushersManager.enqueueRegisterPusher(token, PushConfig.pusher_http_url) + } + } + + override fun onMessageReceived(message: RemoteMessage) { + Timber.tag(loggerTag.value).d("New Firebase message") + pushParser.parsePushDataFcm(message.data).let { + vectorPushHandler.handle(it) + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt new file mode 100644 index 0000000000..9fad5a37bc --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl + +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import io.element.android.libraries.androidutils.network.WifiDetector +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.push.api.store.PushDataStore +import io.element.android.libraries.push.impl.model.PushData +import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver +import io.element.android.libraries.push.impl.notifications.NotificationActionIds +import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager +import io.element.android.libraries.push.impl.store.DefaultPushDataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("Push", LoggerTag.SYNC) + +class VectorPushHandler @Inject constructor( + private val notificationDrawerManager: NotificationDrawerManager, + private val notifiableEventResolver: NotifiableEventResolver, + // private val activeSessionHolder: ActiveSessionHolder, + private val pushDataStore: PushDataStore, + private val defaultPushDataStore: DefaultPushDataStore, + private val actionIds: NotificationActionIds, + @ApplicationContext private val context: Context, + private val buildMeta: BuildMeta +) { + + private val coroutineScope = CoroutineScope(SupervisorJob()) + private val wifiDetector: WifiDetector = WifiDetector(context) + + // UI handler + private val mUIHandler by lazy { + Handler(Looper.getMainLooper()) + } + + /** + * Called when message is received. + * + * @param pushData the data received in the push. + */ + fun handle(pushData: PushData) { + Timber.tag(loggerTag.value).d("## handling pushData") + + if (buildMeta.lowPrivacyLoggingEnabled) { + Timber.tag(loggerTag.value).d("## pushData: $pushData") + } + + runBlocking { + defaultPushDataStore.incrementPushCounter() + } + + // Diagnostic Push + if (pushData.eventId == PushersManager.TEST_EVENT_ID) { + val intent = Intent(actionIds.push) + LocalBroadcastManager.getInstance(context).sendBroadcast(intent) + return + } + + if (!pushDataStore.areNotificationEnabledForDevice()) { + Timber.tag(loggerTag.value).i("Notification are disabled for this device") + return + } + + mUIHandler.post { + if (ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + // we are in foreground, let the sync do the things? + Timber.tag(loggerTag.value).d("PUSH received in a foreground state, ignore") + } else { + coroutineScope.launch(Dispatchers.IO) { handleInternal(pushData) } + } + } + } + + /** + * Internal receive method. + * + * @param pushData Object containing message data. + */ + private suspend fun handleInternal(pushData: PushData) { + try { + if (buildMeta.lowPrivacyLoggingEnabled) { + Timber.tag(loggerTag.value).d("## handleInternal() : $pushData") + } else { + Timber.tag(loggerTag.value).d("## handleInternal()") + } + + /* TODO EAx + val session = activeSessionHolder.getOrInitializeSession() + + if (session == null) { + Timber.tag(loggerTag.value).w("## Can't sync from push, no current session") + } else { + if (isEventAlreadyKnown(pushData)) { + Timber.tag(loggerTag.value).d("Ignoring push, event already known") + } else { + // Try to get the Event content faster + Timber.tag(loggerTag.value).d("Requesting event in fast lane") + getEventFastLane(session, pushData) + + Timber.tag(loggerTag.value).d("Requesting background sync") + session.syncService().requireBackgroundSync() + } + } + + */ + } catch (e: Exception) { + Timber.tag(loggerTag.value).e(e, "## handleInternal() failed") + } + } + + /* TODO EAx + private suspend fun getEventFastLane(session: Session, pushData: PushData) { + pushData.roomId ?: return + pushData.eventId ?: return + + if (wifiDetector.isConnectedToWifi().not()) { + Timber.tag(loggerTag.value).d("No WiFi network, do not get Event") + return + } + + Timber.tag(loggerTag.value).d("Fast lane: start request") + val event = tryOrNull { session.eventService().getEvent(pushData.roomId, pushData.eventId) } ?: return + + val resolvedEvent = notifiableEventResolver.resolveInMemoryEvent(session, event, canBeReplaced = true) + + if (resolvedEvent is NotifiableMessageEvent) { + // If the room is currently displayed, we will not show a notification, so no need to get the Event faster + if (notificationDrawerManager.shouldIgnoreMessageEventInRoom(resolvedEvent)) { + return + } + } + + resolvedEvent + ?.also { Timber.tag(loggerTag.value).d("Fast lane: notify drawer") } + ?.let { + notificationDrawerManager.updateEvents { it.onNotifiableEventReceived(resolvedEvent) } + } + } + + */ + + // check if the event was not yet received + // a previous catchup might have already retrieved the notified event + private fun isEventAlreadyKnown(pushData: PushData): Boolean { + /* TODO EAx + if (pushData.eventId != null && pushData.roomId != null) { + try { + val session = activeSessionHolder.getSafeActiveSession() ?: return false + val room = session.getRoom(pushData.roomId) ?: return false + return room.getTimelineEvent(pushData.eventId) != null + } catch (e: Exception) { + Timber.tag(loggerTag.value).e(e, "## isEventAlreadyKnown() : failed to check if the event was already defined") + } + } + + */ + return false + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorUnifiedPushMessagingReceiver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorUnifiedPushMessagingReceiver.kt new file mode 100644 index 0000000000..0fcdbafb69 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorUnifiedPushMessagingReceiver.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl + +import android.content.Context +import android.content.Intent +import android.widget.Toast +import io.element.android.libraries.architecture.bindings + +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.push.api.model.BackgroundSyncMode +import io.element.android.libraries.push.api.store.PushDataStore +import io.element.android.libraries.push.impl.di.VectorUnifiedPushMessagingReceiverBindings +import io.element.android.libraries.push.impl.parser.PushParser +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.unifiedpush.android.connector.MessagingReceiver +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("Push", LoggerTag.SYNC) + +class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { + @Inject lateinit var pushersManager: PushersManager + @Inject lateinit var pushParser: PushParser + + //@Inject lateinit var activeSessionHolder: ActiveSessionHolder + @Inject lateinit var pushDataStore: PushDataStore + @Inject lateinit var vectorPushHandler: VectorPushHandler + @Inject lateinit var guardServiceStarter: GuardServiceStarter + @Inject lateinit var unifiedPushStore: UnifiedPushStore + @Inject lateinit var unifiedPushHelper: UnifiedPushHelper + + private val coroutineScope = CoroutineScope(SupervisorJob()) + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + // Inject + context.applicationContext.bindings().inject(this) + } + + /** + * Called when message is received. + * + * @param context the Android context + * @param message the message + * @param instance connection, for multi-account + */ + override fun onMessage(context: Context, message: ByteArray, instance: String) { + Timber.tag(loggerTag.value).d("New message") + pushParser.parsePushDataUnifiedPush(message)?.let { + vectorPushHandler.handle(it) + } ?: run { + Timber.tag(loggerTag.value).w("Invalid received data Json format") + } + } + + override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { + Timber.tag(loggerTag.value).i("onNewEndpoint: adding $endpoint") + if (pushDataStore.areNotificationEnabledForDevice() /* TODO EAx && activeSessionHolder.hasActiveSession() */) { + // If the endpoint has changed + // or the gateway has changed + if (unifiedPushHelper.getEndpointOrToken() != endpoint) { + unifiedPushStore.storeUpEndpoint(endpoint) + coroutineScope.launch { + unifiedPushHelper.storeCustomOrDefaultGateway(endpoint) { + unifiedPushHelper.getPushGateway()?.let { + pushersManager.enqueueRegisterPusher(endpoint, it) + } + } + } + } else { + Timber.tag(loggerTag.value).i("onNewEndpoint: skipped") + } + } + val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_DISABLED + pushDataStore.setFdroidSyncBackgroundMode(mode) + guardServiceStarter.stop() + } + + override fun onRegistrationFailed(context: Context, instance: String) { + Toast.makeText(context, "Push service registration failed", Toast.LENGTH_SHORT).show() + val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME + pushDataStore.setFdroidSyncBackgroundMode(mode) + guardServiceStarter.start() + } + + override fun onUnregistered(context: Context, instance: String) { + Timber.tag(loggerTag.value).d("Unifiedpush: Unregistered") + val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME + pushDataStore.setFdroidSyncBackgroundMode(mode) + guardServiceStarter.start() + runBlocking { + try { + pushersManager.unregisterPusher(unifiedPushHelper.getEndpointOrToken().orEmpty()) + } catch (e: Exception) { + Timber.tag(loggerTag.value).d("Probably unregistering a non existing pusher") + } + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/config/PushConfig.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/config/PushConfig.kt new file mode 100644 index 0000000000..d2d1c96506 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/config/PushConfig.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.config + +object PushConfig { + /** + * It is the push gateway for FCM embedded distributor. + * Note: pusher_http_url should have path '/_matrix/push/v1/notify' --> + */ + const val pusher_http_url: String = "https://matrix.org/_matrix/push/v1/notify" + + /** + * It is the push gateway for UnifiedPush. + * Note: default_push_gateway_http_url should have path '/_matrix/push/v1/notify' + */ + const val default_push_gateway_http_url: String = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify" + + /** + * Note: pusher_app_id cannot exceed 64 chars. + */ + const val pusher_app_id: String = "im.vector.app.android" + + /** + * Set to true to allow external push distributor such as Ntfy. + */ + const val allowExternalUnifiedPushDistributors: Boolean = false +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/FirebaseMessagingServiceBindings.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/FirebaseMessagingServiceBindings.kt new file mode 100644 index 0000000000..1de015b770 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/FirebaseMessagingServiceBindings.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.di + +import com.squareup.anvil.annotations.ContributesTo +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.push.impl.VectorFirebaseMessagingService + +@ContributesTo(AppScope::class) +interface FirebaseMessagingServiceBindings { + fun inject(service: VectorFirebaseMessagingService) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/VectorUnifiedPushMessagingReceiverBindings.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/VectorUnifiedPushMessagingReceiverBindings.kt new file mode 100644 index 0000000000..1a70d94ee4 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/VectorUnifiedPushMessagingReceiverBindings.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.di + +import com.squareup.anvil.annotations.ContributesTo +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.push.impl.VectorUnifiedPushMessagingReceiver + +@ContributesTo(AppScope::class) +interface VectorUnifiedPushMessagingReceiverBindings { + fun inject(receiver: VectorUnifiedPushMessagingReceiver) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushData.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushData.kt new file mode 100644 index 0000000000..9a91f1f1ac --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushData.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.model + +/** + * Represent parsed data that the app has received from a Push content. + * + * @property eventId The Event ID. If not null, it will not be empty, and will have a valid format. + * @property roomId The Room ID. If not null, it will not be empty, and will have a valid format. + * @property unread Number of unread message. + */ +data class PushData( + val eventId: String?, + val roomId: String?, + val unread: Int?, +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataFcm.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataFcm.kt new file mode 100644 index 0000000000..0e37c14e12 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataFcm.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.model + +import io.element.android.libraries.matrix.api.core.MatrixPatterns + +/** + * In this case, the format is: + *
+ * {
+ *     "event_id":"$anEventId",
+ *     "room_id":"!aRoomId",
+ *     "unread":"1",
+ *     "prio":"high"
+ * }
+ * 
+ * . + */ +data class PushDataFcm( + val eventId: String?, + val roomId: String?, + var unread: Int?, +) + +fun PushDataFcm.toPushData() = PushData( + eventId = eventId?.takeIf { MatrixPatterns.isEventId(it) }, + roomId = roomId?.takeIf { MatrixPatterns.isRoomId(it) }, + unread = unread +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataUnifiedPush.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataUnifiedPush.kt new file mode 100644 index 0000000000..c4227b3db2 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataUnifiedPush.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.model + +import io.element.android.libraries.matrix.api.core.MatrixPatterns +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * In this case, the format is: + *
+ * {
+ *     "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) }, + roomId = notification?.roomId?.takeIf { MatrixPatterns.isRoomId(it) }, + unread = notification?.counts?.unread +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt new file mode 100644 index 0000000000..6e92c2ec60 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import javax.inject.Inject + +class FilteredEventDetector @Inject constructor( + //private val activeSessionDataSource: ActiveSessionDataSource +) { + + /** + * Returns true if the given event should be ignored. + * Used to skip notifications if a non expected message is received. + */ + fun shouldBeIgnored(notifiableEvent: NotifiableEvent): Boolean { + /* TODO EAx + val session = activeSessionDataSource.currentValue?.orNull() ?: return false + + if (notifiableEvent is NotifiableMessageEvent) { + val room = session.getRoom(notifiableEvent.roomId) ?: return false + val timelineEvent = room.getTimelineEvent(notifiableEvent.eventId) ?: return false + return timelineEvent.shouldBeIgnored() + } + + */ + return false + } + + /** + * Whether the timeline event should be ignored. + */ + /* + private fun TimelineEvent.shouldBeIgnored(): Boolean { + if (root.isVoiceMessage()) { + val audioEvent = root.asMessageAudioEvent() + // if the event is a voice message related to a voice broadcast, only show the event on the first chunk. + return audioEvent.isVoiceBroadcast() && audioEvent?.sequence != 1 + } + + return false + } + */ +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt new file mode 100644 index 0000000000..91b62eba0e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.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.AutoAcceptInvites +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom +import timber.log.Timber +import javax.inject.Inject + +private typealias ProcessedEvents = List> + +class NotifiableEventProcessor @Inject constructor( + private val outdatedDetector: OutdatedEventDetector, + private val autoAcceptInvites: AutoAcceptInvites +) { + + fun process(queuedEvents: List, currentRoomId: String?, currentThreadId: String?, renderedEvents: ProcessedEvents): ProcessedEvents { + val processedEvents = queuedEvents.map { + val type = when (it) { + is InviteNotifiableEvent -> if (autoAcceptInvites.hideInvites) ProcessedEvent.Type.REMOVE else ProcessedEvent.Type.KEEP + is NotifiableMessageEvent -> when { + it.shouldIgnoreMessageEventInRoom(currentRoomId, currentThreadId) -> { + ProcessedEvent.Type.REMOVE + .also { Timber.d("notification message removed due to currently viewing the same room or thread") } + } + outdatedDetector.isMessageOutdated(it) -> ProcessedEvent.Type.REMOVE + .also { Timber.d("notification message removed due to being read") } + else -> ProcessedEvent.Type.KEEP + } + is SimpleNotifiableEvent -> when (it.type) { + /*EventType.REDACTION*/ "m.room.redaction" -> ProcessedEvent.Type.REMOVE + else -> ProcessedEvent.Type.KEEP + } + } + ProcessedEvent(type, it) + } + + val removedEventsDiff = renderedEvents.filter { renderedEvent -> + queuedEvents.none { it.eventId == renderedEvent.event.eventId } + }.map { ProcessedEvent(ProcessedEvent.Type.REMOVE, it.event) } + + return removedEventsDiff + processedEvents + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt new file mode 100644 index 0000000000..778fe20d7b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt @@ -0,0 +1,264 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.toolbox.api.strings.StringProvider +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.toolbox.api.systemclock.SystemClock +import javax.inject.Inject + +/** + * The notifiable event resolver is able to create a NotifiableEvent (view model for notifications) from an sdk Event. + * It is used as a bridge between the Event Thread and the NotificationDrawerManager. + * The NotifiableEventResolver is the only aware of session/store, the NotificationDrawerManager has no knowledge of that, + * this pattern allow decoupling between the object responsible of displaying notifications and the matrix sdk. + */ +class NotifiableEventResolver @Inject constructor( + private val stringProvider: StringProvider, + // private val noticeEventFormatter: NoticeEventFormatter, + // private val displayableEventFormatter: DisplayableEventFormatter, + private val clock: SystemClock, + private val buildMeta: BuildMeta, +) { + + suspend fun resolveEvent(/*event: Event, session: Session, isNoisy: Boolean*/): NotifiableEvent? { + return TODO() + /* + val roomID = event.roomId ?: return null + val eventId = event.eventId ?: return null + if (event.getClearType() == EventType.STATE_ROOM_MEMBER) { + return resolveStateRoomEvent(event, session, canBeReplaced = false, isNoisy = isNoisy) + } + val timelineEvent = session.getRoom(roomID)?.getTimelineEvent(eventId) ?: return null + return when { + event.supportsNotification() || event.type == EventType.ENCRYPTED -> { + resolveMessageEvent(timelineEvent, session, canBeReplaced = false, isNoisy = isNoisy) + } + else -> { + // If the event can be displayed, display it as is + Timber.w("NotifiableEventResolver Received an unsupported event matching a bing rule") + // TODO Better event text display + val bodyPreview = event.type ?: EventType.MISSING_TYPE + + SimpleNotifiableEvent( + session.myUserId, + eventId = event.eventId!!, + editedEventId = timelineEvent.getEditedEventId(), + noisy = false, // will be updated + timestamp = event.originServerTs ?: clock.epochMillis(), + description = bodyPreview, + title = stringProvider.getString(StringR.string.notification_unknown_new_event), + soundName = null, + type = event.type, + canBeReplaced = false + ) + } + } + + */ + } + + suspend fun resolveInMemoryEvent(/*session: Session, event: Event, canBeReplaced: Boolean*/): NotifiableEvent? { + TODO() + /* + if (!event.supportsNotification()) return null + + // Ignore message edition + if (event.isEdition()) return null + + val actions = session.pushRuleService().getActions(event) + val notificationAction = actions.toNotificationAction() + + return if (notificationAction.shouldNotify) { + val user = session.getUserOrDefault(event.senderId!!) + + val timelineEvent = TimelineEvent( + root = event, + localId = -1, + eventId = event.eventId!!, + displayIndex = 0, + senderInfo = SenderInfo( + userId = user.userId, + displayName = user.toMatrixItem().getBestName(), + isUniqueDisplayName = true, + avatarUrl = user.avatarUrl + ) + ) + resolveMessageEvent(timelineEvent, session, canBeReplaced = canBeReplaced, isNoisy = !notificationAction.soundName.isNullOrBlank()) + } else { + Timber.d("Matched push rule is set to not notify") + null + } + + */ + } + + private suspend fun resolveMessageEvent(/*event: TimelineEvent, session: Session, canBeReplaced: Boolean, isNoisy: Boolean*/): NotifiableMessageEvent? { + TODO() + /* + // The event only contains an eventId, and roomId (type is m.room.*) , we need to get the displayable content (names, avatar, text, etc...) + val room = session.getRoom(event.root.roomId!! /*roomID cannot be null*/) + + return if (room == null) { + Timber.e("## Unable to resolve room for eventId [$event]") + // Ok room is not known in store, but we can still display something + val body = displayableEventFormatter.format(event, isDm = false, appendAuthor = false) + val roomName = stringProvider.getString(StringR.string.notification_unknown_room_name) + val senderDisplayName = event.senderInfo.disambiguatedDisplayName + + NotifiableMessageEvent( + eventId = event.root.eventId!!, + editedEventId = event.getEditedEventId(), + canBeReplaced = canBeReplaced, + timestamp = event.root.originServerTs ?: 0, + noisy = isNoisy, + senderName = senderDisplayName, + senderId = event.root.senderId, + body = body.toString(), + imageUriString = event.fetchImageIfPresent(session)?.toString(), + roomId = event.root.roomId!!, + threadId = event.root.getRootThreadEventId(), + roomName = roomName, + matrixID = session.myUserId + ) + } else { + event.attemptToDecryptIfNeeded(session) + // only convert encrypted messages to NotifiableMessageEvents + when { + event.root.supportsNotification() -> { + val body = displayableEventFormatter.format(event, isDm = room.roomSummary()?.isDirect.orFalse(), appendAuthor = false).toString() + val roomName = room.roomSummary()?.displayName ?: "" + val senderDisplayName = event.senderInfo.disambiguatedDisplayName + + NotifiableMessageEvent( + eventId = event.root.eventId!!, + editedEventId = event.getEditedEventId(), + canBeReplaced = canBeReplaced, + timestamp = event.root.originServerTs ?: 0, + noisy = isNoisy, + senderName = senderDisplayName, + senderId = event.root.senderId, + body = body, + imageUriString = event.fetchImageIfPresent(session)?.toString(), + roomId = event.root.roomId!!, + threadId = event.root.getRootThreadEventId(), + roomName = roomName, + roomIsDirect = room.roomSummary()?.isDirect ?: false, + roomAvatarPath = session.contentUrlResolver() + .resolveThumbnail( + room.roomSummary()?.avatarUrl, + 250, + 250, + ContentUrlResolver.ThumbnailMethod.SCALE + ), + senderAvatarPath = session.contentUrlResolver() + .resolveThumbnail( + event.senderInfo.avatarUrl, + 250, + 250, + ContentUrlResolver.ThumbnailMethod.SCALE + ), + matrixID = session.myUserId, + soundName = null + ) + } + else -> null + } + } + + */ + } + + /* + private suspend fun TimelineEvent.attemptToDecryptIfNeeded(session: Session) { + if (root.isEncrypted() && root.mxDecryptionResult == null) { + // TODO use a global event decryptor? attache to session and that listen to new sessionId? + // for now decrypt sync + try { + val result = session.cryptoService().decryptEvent(root, root.roomId + UUID.randomUUID().toString()) + root.mxDecryptionResult = OlmDecryptionResult( + payload = result.clearEvent, + senderKey = result.senderCurve25519Key, + keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) }, + forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain, + isSafe = result.isSafe + ) + } catch (ignore: MXCryptoError) { + } + } + } + */ + + /* + private suspend fun TimelineEvent.fetchImageIfPresent(session: Session): Uri? { + return when { + root.isEncrypted() && root.mxDecryptionResult == null -> null + root.isImageMessage() -> downloadAndExportImage(session) + else -> null + } + } + */ + + /* + private suspend fun TimelineEvent.downloadAndExportImage(session: Session): Uri? { + return kotlin.runCatching { + getVectorLastMessageContent()?.takeAs()?.let { imageMessage -> + val fileService = session.fileService() + fileService.downloadFile(imageMessage) + fileService.getTemporarySharableURI(imageMessage) + } + }.onFailure { + Timber.e(it, "Failed to download and export image for notification") + }.getOrNull() + } + */ + + /* + private fun resolveStateRoomEvent(event: Event, session: Session, canBeReplaced: Boolean, isNoisy: Boolean): NotifiableEvent? { + val content = event.content?.toModel() ?: return null + val roomId = event.roomId ?: return null + val dName = event.senderId?.let { session.roomService().getRoomMember(it, roomId)?.displayName } + if (Membership.INVITE == content.membership) { + val roomSummary = session.getRoomSummary(roomId) + val body = noticeEventFormatter.format(event, dName, isDm = roomSummary?.isDirect.orFalse()) + ?: stringProvider.getString(StringR.string.notification_new_invitation) + return InviteNotifiableEvent( + session.myUserId, + eventId = event.eventId!!, + editedEventId = null, + canBeReplaced = canBeReplaced, + roomId = roomId, + roomName = roomSummary?.displayName, + timestamp = event.originServerTs ?: 0, + noisy = isNoisy, + title = stringProvider.getString(StringR.string.notification_new_invitation), + description = body.toString(), + soundName = null, // will be set later + type = event.getClearType() + ) + } else { + Timber.e("## unsupported notifiable event for eventId [${event.eventId}]") + if (buildMeta.lowPrivacyLoggingEnabled) { + Timber.e("## unsupported notifiable event for event [$event]") + } + // TODO generic handling? + } + return null + } + */ +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationAction.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationAction.kt new file mode 100644 index 0000000000..31ec28d023 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationAction.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.element.android.libraries.push.impl.notifications + +data class NotificationAction( + val shouldNotify: Boolean, + val highlight: Boolean, + val soundName: String? +) + +/* +fun List.toNotificationAction(): NotificationAction { + var shouldNotify = false + var highlight = false + var sound: String? = null + forEach { action -> + when (action) { + is Action.Notify -> shouldNotify = true + is Action.DoNotNotify -> shouldNotify = false + is Action.Highlight -> highlight = action.highlight + is Action.Sound -> sound = action.sound + } + } + return NotificationAction(shouldNotify, highlight, sound) +} + */ diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt new file mode 100644 index 0000000000..b2a7129998 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.core.meta.BuildMeta +import javax.inject.Inject + +/** + * Util class for creating notifications. + * Note: Cannot inject ColorProvider in the constructor, because it requires an Activity + */ + +data class NotificationActionIds @Inject constructor( + private val buildMeta: BuildMeta, +) { + + val join = "${buildMeta.applicationId}.NotificationActions.JOIN_ACTION" + val reject = "${buildMeta.applicationId}.NotificationActions.REJECT_ACTION" + val quickLaunch = "${buildMeta.applicationId}.NotificationActions.QUICK_LAUNCH_ACTION" + val markRoomRead = "${buildMeta.applicationId}.NotificationActions.MARK_ROOM_READ_ACTION" + val smartReply = "${buildMeta.applicationId}.NotificationActions.SMART_REPLY_ACTION" + val dismissSummary = "${buildMeta.applicationId}.NotificationActions.DISMISS_SUMMARY_ACTION" + val dismissRoom = "${buildMeta.applicationId}.NotificationActions.DISMISS_ROOM_NOTIF_ACTION" + val tapToView = "${buildMeta.applicationId}.NotificationActions.TAP_TO_VIEW_ACTION" + val diagnostic = "${buildMeta.applicationId}.NotificationActions.DIAGNOSTIC" + val push = "${buildMeta.applicationId}.PUSH" +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt new file mode 100644 index 0000000000..d0ab023789 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.content.Context +import android.graphics.Bitmap +import android.os.Build +import androidx.annotation.WorkerThread +import androidx.core.graphics.drawable.IconCompat +import io.element.android.libraries.di.ApplicationContext +import timber.log.Timber +import javax.inject.Inject + +class NotificationBitmapLoader @Inject constructor( + @ApplicationContext private val context: Context +) { + + /** + * Get icon of a room. + */ + @WorkerThread + fun getRoomBitmap(path: String?): Bitmap? { + if (path == null) { + return null + } + return loadRoomBitmap(path) + } + + @WorkerThread + private fun loadRoomBitmap(path: String): Bitmap? { + return try { + null + /* TODO Notification + Glide.with(context) + .asBitmap() + .load(path) + .format(DecodeFormat.PREFER_ARGB_8888) + .signature(ObjectKey("room-icon-notification")) + .submit() + .get() + */ + } catch (e: Exception) { + Timber.e(e, "decodeFile failed") + null + } + } + + /** + * Get icon of a user. + * Before Android P, this does nothing because the icon won't be used + */ + @WorkerThread + fun getUserIcon(path: String?): IconCompat? { + if (path == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return null + } + + return loadUserIcon(path) + } + + @WorkerThread + private fun loadUserIcon(path: String): IconCompat? { + return try { + null + /* TODO Notification + val bitmap = Glide.with(context) + .asBitmap() + .load(path) + .transform(CircleCrop()) + .format(DecodeFormat.PREFER_ARGB_8888) + .signature(ObjectKey("user-icon-notification")) + .submit() + .get() + IconCompat.createWithBitmap(bitmap) + */ + } catch (e: Exception) { + Timber.e(e, "decodeFile failed") + null + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt new file mode 100644 index 0000000000..ea46654d88 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt @@ -0,0 +1,247 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.RemoteInput +import io.element.android.libraries.analytics.api.AnalyticsTracker +import io.element.android.libraries.analytics.api.plan.JoinedRoom +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject + +import io.element.android.libraries.ui.strings.R as StringR + +/** + * Receives actions broadcast by notification (on click, on dismiss, inline replies, etc.). + */ +class NotificationBroadcastReceiver : BroadcastReceiver() { + + @Inject lateinit var notificationDrawerManager: NotificationDrawerManager + //@Inject lateinit var activeSessionHolder: ActiveSessionHolder + @Inject lateinit var analyticsTracker: AnalyticsTracker + @Inject lateinit var clock: SystemClock + @Inject lateinit var actionIds: NotificationActionIds + + override fun onReceive(context: Context?, intent: Intent?) { + if (intent == null || context == null) return + context.bindings().inject(this) + Timber.v("NotificationBroadcastReceiver received : $intent") + when (intent.action) { + actionIds.smartReply -> + handleSmartReply(intent, context) + actionIds.dismissRoom -> + intent.getStringExtra(KEY_ROOM_ID)?.let { roomId -> + notificationDrawerManager.updateEvents { it.clearMessagesForRoom(roomId) } + } + actionIds.dismissSummary -> + notificationDrawerManager.clearAllEvents() + actionIds.markRoomRead -> + intent.getStringExtra(KEY_ROOM_ID)?.let { roomId -> + notificationDrawerManager.updateEvents { it.clearMessagesForRoom(roomId) } + handleMarkAsRead(roomId) + } + actionIds.join -> { + intent.getStringExtra(KEY_ROOM_ID)?.let { roomId -> + notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomId) } + handleJoinRoom(roomId) + } + } + actionIds.reject -> { + intent.getStringExtra(KEY_ROOM_ID)?.let { roomId -> + notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomId) } + handleRejectRoom(roomId) + } + } + } + } + + private fun handleJoinRoom(roomId: String) { + /* + activeSessionHolder.getSafeActiveSession()?.let { session -> + val room = session.getRoom(roomId) + if (room != null) { + session.coroutineScope.launch { + tryOrNull { + session.roomService().joinRoom(room.roomId) + analyticsTracker.capture(room.roomSummary().toAnalyticsJoinedRoom(JoinedRoom.Trigger.Notification)) + } + } + } + } + + */ + } + + private fun handleRejectRoom(roomId: String) { + /* + activeSessionHolder.getSafeActiveSession()?.let { session -> + session.coroutineScope.launch { + tryOrNull { session.roomService().leaveRoom(roomId) } + } + } + + */ + } + + private fun handleMarkAsRead(roomId: String) { + /* + activeSessionHolder.getActiveSession().let { session -> + val room = session.getRoom(roomId) + if (room != null) { + session.coroutineScope.launch { + tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT, mainTimeLineOnly = false) } + } + } + } + + */ + } + + private fun handleSmartReply(intent: Intent, context: Context) { + val message = getReplyMessage(intent) + val roomId = intent.getStringExtra(KEY_ROOM_ID) + val threadId = intent.getStringExtra(KEY_THREAD_ID) + + if (message.isNullOrBlank() || roomId.isNullOrBlank()) { + // ignore this event + // Can this happen? should we update notification? + return + } + /* + activeSessionHolder.getActiveSession().let { session -> + session.getRoom(roomId)?.let { room -> + sendMatrixEvent(message, threadId, session, room, context) + } + } + + */ + } + + /* + private fun sendMatrixEvent(message: String, threadId: String?, session: Session, room: Room, context: Context?) { + if (threadId != null) { + room.relationService().replyInThread( + rootThreadEventId = threadId, + replyInThreadText = message, + ) + } else { + room.sendService().sendTextMessage(message) + } + + // Create a new event to be displayed in the notification drawer, right now + + val notifiableMessageEvent = NotifiableMessageEvent( + // Generate a Fake event id + eventId = UUID.randomUUID().toString(), + editedEventId = null, + noisy = false, + timestamp = clock.epochMillis(), + senderName = session.roomService().getRoomMember(session.myUserId, room.roomId)?.displayName + ?: context?.getString(StringR.string.notification_sender_me), + senderId = session.myUserId, + body = message, + imageUriString = null, + roomId = room.roomId, + threadId = threadId, + roomName = room.roomSummary()?.displayName ?: room.roomId, + roomIsDirect = room.roomSummary()?.isDirect == true, + outGoingMessage = true, + canBeReplaced = false + ) + + notificationDrawerManager.updateEvents { it.onNotifiableEventReceived(notifiableMessageEvent) } + + /* + // TODO Error cannot be managed the same way than in Riot + + val event = Event(mxMessage, session.credentials.userId, roomId) + room.storeOutgoingEvent(event) + room.sendEvent(event, object : MatrixCallback { + override fun onSuccess(info: Void?) { + Timber.v("Send message : onSuccess ") + } + + override fun onNetworkError(e: Exception) { + Timber.e(e, "Send message : onNetworkError") + onSmartReplyFailed(e.localizedMessage) + } + + override fun onMatrixError(e: MatrixError) { + Timber.v("Send message : onMatrixError " + e.message) + if (e is MXCryptoError) { + Toast.makeText(context, e.detailedErrorDescription, Toast.LENGTH_SHORT).show() + onSmartReplyFailed(e.detailedErrorDescription) + } else { + Toast.makeText(context, e.localizedMessage, Toast.LENGTH_SHORT).show() + onSmartReplyFailed(e.localizedMessage) + } + } + + override fun onUnexpectedError(e: Exception) { + Timber.e(e, "Send message : onUnexpectedError " + e.message) + onSmartReplyFailed(e.message) + } + + + fun onSmartReplyFailed(reason: String?) { + val notifiableMessageEvent = NotifiableMessageEvent( + event.eventId, + false, + clock.epochMillis(), + session.myUser?.displayname + ?: context?.getString(StringR.string.notification_sender_me), + session.myUserId, + message, + roomId, + room.getRoomDisplayName(context), + room.isDirect) + notifiableMessageEvent.outGoingMessage = true + notifiableMessageEvent.outGoingMessageFailed = true + + VectorApp.getInstance().notificationDrawerManager.onNotifiableEventReceived(notifiableMessageEvent) + VectorApp.getInstance().notificationDrawerManager.refreshNotificationDrawer(null) + } + }) + */ + } + + */ + + private fun getReplyMessage(intent: Intent?): String? { + if (intent != null) { + val remoteInput = RemoteInput.getResultsFromIntent(intent) + if (remoteInput != null) { + return remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString() + } + } + return null + } + + companion object { + const val KEY_ROOM_ID = "roomID" + const val KEY_THREAD_ID = "threadID" + const val KEY_TEXT_REPLY = "key_text_reply" + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverBindings.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverBindings.kt new file mode 100644 index 0000000000..ae936e693b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverBindings.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +import com.squareup.anvil.annotations.ContributesTo +import io.element.android.libraries.di.AppScope + +@ContributesTo(AppScope::class) +interface NotificationBroadcastReceiverBindings { + fun inject(receiver: NotificationBroadcastReceiver) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt new file mode 100644 index 0000000000..7f9ec73343 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.app.Notification +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import io.element.android.libraries.di.ApplicationContext +import timber.log.Timber +import javax.inject.Inject + +class NotificationDisplayer @Inject constructor( + @ApplicationContext context: Context, +) { + + private val notificationManager = NotificationManagerCompat.from(context) + + fun showNotificationMessage(tag: String?, id: Int, notification: Notification) { + notificationManager.notify(tag, id, notification) + } + + fun cancelNotificationMessage(tag: String?, id: Int) { + notificationManager.cancel(tag, id) + } + + fun cancelAllNotifications() { + // Keep this try catch (reported by GA) + try { + notificationManager.cancelAll() + } catch (e: Exception) { + Timber.e(e, "## cancelAllNotifications() failed") + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDrawerManager.kt new file mode 100644 index 0000000000..c40680a1fd --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDrawerManager.kt @@ -0,0 +1,241 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.element.android.libraries.push.impl.notifications + +import android.content.Context +import android.os.Handler +import android.os.HandlerThread +import androidx.annotation.WorkerThread +import io.element.android.libraries.androidutils.throttler.FirstThrottler +import io.element.android.libraries.core.cache.CircularCache +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.push.api.store.PushDataStore +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom +import timber.log.Timber +import javax.inject.Inject + +/** + * The NotificationDrawerManager receives notification events as they arrived (from event stream or fcm) and + * organise them in order to display them in the notification drawer. + * Events can be grouped into the same notification, old (already read) events can be removed to do some cleaning. + */ +@SingleIn(AppScope::class) +class NotificationDrawerManager @Inject constructor( + @ApplicationContext context: Context, + private val notificationDisplayer: NotificationDisplayer, + private val pushDataStore: PushDataStore, + // private val activeSessionDataSource: ActiveSessionDataSource, + private val notifiableEventProcessor: NotifiableEventProcessor, + private val notificationRenderer: NotificationRenderer, + private val notificationEventPersistence: NotificationEventPersistence, + private val filteredEventDetector: FilteredEventDetector, + private val buildMeta: BuildMeta, +) { + + private val handlerThread: HandlerThread = HandlerThread("NotificationDrawerManager", Thread.MIN_PRIORITY) + private var backgroundHandler: Handler + + // TODO Multi-session: this will have to be improved + /* + private val currentSession: Session? + get() = activeSessionDataSource.currentValue?.orNull() + + */ + + /** + * Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events. + */ + private val notificationState by lazy { createInitialNotificationState() } + private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size) + private var currentRoomId: String? = null + private var currentThreadId: String? = null + private val firstThrottler = FirstThrottler(200) + + private var useCompleteNotificationFormat = pushDataStore.useCompleteNotificationFormat() + + init { + handlerThread.start() + backgroundHandler = Handler(handlerThread.looper) + } + + private fun createInitialNotificationState(): NotificationState { + val queuedEvents = notificationEventPersistence.loadEvents(factory = { rawEvents -> + NotificationEventQueue(rawEvents.toMutableList(), seenEventIds = CircularCache.create(cacheSize = 25)) + }) + val renderedEvents = queuedEvents.rawEvents().map { ProcessedEvent(ProcessedEvent.Type.KEEP, it) }.toMutableList() + return NotificationState(queuedEvents, renderedEvents) + } + + /** + Should be called as soon as a new event is ready to be displayed. + The notification corresponding to this event will not be displayed until + #refreshNotificationDrawer() is called. + Events might be grouped and there might not be one notification per event! + */ + fun NotificationEventQueue.onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { + if (!pushDataStore.areNotificationEnabledForDevice()) { + Timber.i("Notification are disabled for this device") + return + } + // If we support multi session, event list should be per userId + // Currently only manage single session + if (buildMeta.lowPrivacyLoggingEnabled) { + Timber.d("onNotifiableEventReceived(): $notifiableEvent") + } else { + Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}") + } + + if (filteredEventDetector.shouldBeIgnored(notifiableEvent)) { + Timber.d("onNotifiableEventReceived(): ignore the event") + return + } + + add(notifiableEvent) + } + + /** + * Clear all known events and refresh the notification drawer. + */ + fun clearAllEvents() { + updateEvents { it.clear() } + } + + /** + * Should be called when the application is currently opened and showing timeline for the given roomId. + * Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room. + */ + fun setCurrentRoom(roomId: String?) { + updateEvents { + val hasChanged = roomId != currentRoomId + currentRoomId = roomId + if (hasChanged && roomId != null) { + it.clearMessagesForRoom(roomId) + } + } + } + + /** + * Should be called when the application is currently opened and showing timeline for the given threadId. + * Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room. + */ + fun setCurrentThread(threadId: String?) { + updateEvents { + val hasChanged = threadId != currentThreadId + currentThreadId = threadId + currentRoomId?.let { roomId -> + if (hasChanged && threadId != null) { + it.clearMessagesForThread(roomId, threadId) + } + } + } + } + + fun notificationStyleChanged() { + updateEvents { + val newSettings = pushDataStore.useCompleteNotificationFormat() + if (newSettings != useCompleteNotificationFormat) { + // Settings has changed, remove all current notifications + notificationDisplayer.cancelAllNotifications() + useCompleteNotificationFormat = newSettings + } + } + } + + fun updateEvents(action: NotificationDrawerManager.(NotificationEventQueue) -> Unit) { + notificationState.updateQueuedEvents(this) { queuedEvents, _ -> + action(queuedEvents) + } + refreshNotificationDrawer() + } + + private fun refreshNotificationDrawer() { + // Implement last throttler + val canHandle = firstThrottler.canHandle() + Timber.v("refreshNotificationDrawer(), delay: ${canHandle.waitMillis()} ms") + backgroundHandler.removeCallbacksAndMessages(null) + + backgroundHandler.postDelayed( + { + try { + refreshNotificationDrawerBg() + } catch (throwable: Throwable) { + // It can happen if for instance session has been destroyed. It's a bit ugly to try catch like this, but it's safer + Timber.w(throwable, "refreshNotificationDrawerBg failure") + } + }, + canHandle.waitMillis() + ) + } + + @WorkerThread + private fun refreshNotificationDrawerBg() { + Timber.v("refreshNotificationDrawerBg()") + val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents -> + notifiableEventProcessor.process(queuedEvents.rawEvents(), currentRoomId, currentThreadId, renderedEvents).also { + queuedEvents.clearAndAdd(it.onlyKeptEvents()) + } + } + + if (notificationState.hasAlreadyRendered(eventsToRender)) { + Timber.d("Skipping notification update due to event list not changing") + } else { + notificationState.clearAndAddRenderedEvents(eventsToRender) + // TODO EAx + //val session = currentSession ?: return + //renderEvents(session, eventsToRender) + persistEvents() + } + } + + private fun persistEvents() { + notificationState.queuedEvents { queuedEvents -> + notificationEventPersistence.persistEvents(queuedEvents) + } + } + + private fun renderEvents(/*session: Session, eventsToRender: List>*/) { + /* TODO EAx + val user = session.getUserOrDefault(session.myUserId) + // myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash + val myUserDisplayName = user.toMatrixItem().getBestName() + val myUserAvatarUrl = session.contentUrlResolver().resolveThumbnail( + contentUrl = user.avatarUrl, + width = avatarSize, + height = avatarSize, + method = ContentUrlResolver.ThumbnailMethod.SCALE + ) + notificationRenderer.render(session.myUserId, myUserDisplayName, myUserAvatarUrl, useCompleteNotificationFormat, eventsToRender) + + */ + } + + fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean { + return resolvedEvent.shouldIgnoreMessageEventInRoom(currentRoomId, currentThreadId) + } + + companion object { + const val SUMMARY_NOTIFICATION_ID = 0 + const val ROOM_MESSAGES_NOTIFICATION_ID = 1 + const val ROOM_EVENT_NOTIFICATION_ID = 2 + const val ROOM_INVITATION_NOTIFICATION_ID = 3 + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt new file mode 100644 index 0000000000..c8ba481323 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.content.Context +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import javax.inject.Inject + +// TODO Multi-account +private const val ROOMS_NOTIFICATIONS_FILE_NAME = "im.vector.notifications.cache" +private const val KEY_ALIAS_SECRET_STORAGE = "notificationMgr" + +class NotificationEventPersistence @Inject constructor( + @ApplicationContext private val context: Context, + // private val matrix: Matrix, +) { + + fun loadEvents(factory: (List) -> NotificationEventQueue): NotificationEventQueue { + try { + val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME) + if (file.exists()) { + file.inputStream().use { + val events: ArrayList? = null // TODO EAx matrix.secureStorageService().loadSecureSecret(it, KEY_ALIAS_SECRET_STORAGE) + if (events != null) { + return factory(events) + } + } + } + } catch (e: Throwable) { + Timber.e(e, "## Failed to load cached notification info") + } + return factory(emptyList()) + } + + fun persistEvents(queuedEvents: NotificationEventQueue) { + if (queuedEvents.isEmpty()) { + deleteCachedRoomNotifications(context) + return + } + try { + val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME) + if (!file.exists()) file.createNewFile() + FileOutputStream(file).use { + // TODO EAx + // matrix.secureStorageService().securelyStoreObject(queuedEvents.rawEvents(), KEY_ALIAS_SECRET_STORAGE, it) + } + } catch (e: Throwable) { + Timber.e(e, "## Failed to save cached notification info") + } + } + + private fun deleteCachedRoomNotifications(context: Context) { + val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME) + if (file.exists()) { + file.delete() + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt new file mode 100644 index 0000000000..7766ed04e8 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.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 io.element.android.libraries.core.cache.CircularCache +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import timber.log.Timber + +data class NotificationEventQueue( + private val queue: MutableList, + /** + * 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) + } + } + } + } + + 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.eventId == notifiableEvent.eventId } + } + + private fun findEdited(notifiableEvent: NotifiableEvent): NotifiableEvent? { + return notifiableEvent.editedEventId?.let { editedId -> + queue.firstOrNull { + it.eventId == editedId || it.editedEventId == editedId + } + } + } + + private fun replace(replace: NotifiableEvent, with: NotifiableEvent) { + queue.remove(replace) + queue.add( + when (with) { + is InviteNotifiableEvent -> with.copy(isUpdated = true) + is NotifiableMessageEvent -> with.copy(isUpdated = true) + is SimpleNotifiableEvent -> with.copy(isUpdated = true) + } + ) + } + + fun clearMemberShipNotificationForRoom(roomId: String) { + Timber.d("clearMemberShipOfRoom $roomId") + queue.removeAll { it is InviteNotifiableEvent && it.roomId == roomId } + } + + fun clearMessagesForRoom(roomId: String) { + Timber.d("clearMessageEventOfRoom $roomId") + queue.removeAll { it is NotifiableMessageEvent && it.roomId == roomId } + } + + fun clearMessagesForThread(roomId: String, threadId: String) { + Timber.d("clearMessageEventOfThread $roomId, $threadId") + queue.removeAll { it is NotifiableMessageEvent && it.roomId == roomId && it.threadId == threadId } + } + + fun rawEvents(): List = queue +} + +private fun MutableList.replace(eventId: String, block: (NotifiableEvent) -> NotifiableEvent) { + val indexToReplace = indexOfFirst { it.eventId == eventId } + if (indexToReplace == -1) { + return + } + set(indexToReplace, block(get(indexToReplace))) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt new file mode 100644 index 0000000000..f935a36366 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.app.Notification +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import javax.inject.Inject + +private typealias ProcessedMessageEvents = List> + +class NotificationFactory @Inject constructor( + private val notificationUtils: NotificationUtils, + private val roomGroupMessageCreator: RoomGroupMessageCreator, + private val summaryGroupMessageCreator: SummaryGroupMessageCreator +) { + + fun Map.toNotifications(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(messageEvents, roomId, myUserDisplayName, myUserAvatarUrl) + } + } + } + } + + private fun ProcessedMessageEvents.hasNoEventsToDisplay() = isEmpty() || all { + it.type == ProcessedEvent.Type.REMOVE || it.event.canNotBeDisplayed() + } + + private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted + + @JvmName("toNotificationsInviteNotifiableEvent") + fun List>.toNotifications(myUserId: String): List { + return map { (processed, event) -> + when (processed) { + ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.roomId) + ProcessedEvent.Type.KEEP -> OneShotNotification.Append( + notificationUtils.buildRoomInvitationNotification(event, myUserId), + OneShotNotification.Append.Meta( + key = event.roomId, + summaryLine = event.description, + isNoisy = event.noisy, + timestamp = event.timestamp + ) + ) + } + } + } + + @JvmName("toNotificationsSimpleNotifiableEvent") + fun List>.toNotifications(myUserId: String): List { + return map { (processed, event) -> + when (processed) { + ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId) + ProcessedEvent.Type.KEEP -> OneShotNotification.Append( + notificationUtils.buildSimpleEventNotification(event, myUserId), + OneShotNotification.Append.Meta( + key = event.eventId, + summaryLine = event.description, + isNoisy = event.noisy, + timestamp = event.timestamp + ) + ) + } + } + } + + fun createSummaryNotification( + roomNotifications: List, + 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( + roomNotifications = roomMeta, + invitationNotifications = invitationMeta, + simpleNotifications = simpleMeta, + useCompleteNotificationFormat = useCompleteNotificationFormat + ) + ) + } + } +} + +sealed interface RoomNotification { + data class Removed(val roomId: String) : RoomNotification + data class Message(val notification: Notification, val meta: Meta) : RoomNotification { + data class Meta( + val summaryLine: CharSequence, + val messageCount: Int, + val latestTimestamp: Long, + val roomId: String, + val shouldBing: Boolean + ) + } +} + +sealed interface OneShotNotification { + data class Removed(val key: String) : OneShotNotification + data class Append(val notification: Notification, val meta: Meta) : OneShotNotification { + data class Meta( + val key: String, + val summaryLine: CharSequence, + val isNoisy: Boolean, + val timestamp: Long, + ) + } +} + +sealed interface SummaryNotification { + object Removed : SummaryNotification + data class Update(val notification: Notification) : SummaryNotification +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt new file mode 100644 index 0000000000..8b5fa70365 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.element.android.libraries.push.impl.notifications + +import androidx.annotation.WorkerThread +import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager.Companion.ROOM_EVENT_NOTIFICATION_ID +import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager.Companion.ROOM_INVITATION_NOTIFICATION_ID +import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager.Companion.ROOM_MESSAGES_NOTIFICATION_ID +import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager.Companion.SUMMARY_NOTIFICATION_ID +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import timber.log.Timber +import javax.inject.Inject + +class NotificationRenderer @Inject constructor( + private val notificationDisplayer: NotificationDisplayer, + private val notificationFactory: NotificationFactory, +) { + + @WorkerThread + fun render( + myUserId: String, + myUserDisplayName: String, + myUserAvatarUrl: String?, + useCompleteNotificationFormat: Boolean, + eventsToProcess: List> + ) { + val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType() + with(notificationFactory) { + val roomNotifications = roomEvents.toNotifications(myUserDisplayName, myUserAvatarUrl) + val invitationNotifications = invitationEvents.toNotifications(myUserId) + val simpleNotifications = simpleEvents.toNotifications(myUserId) + val summaryNotification = createSummaryNotification( + roomNotifications = roomNotifications, + invitationNotifications = invitationNotifications, + simpleNotifications = simpleNotifications, + useCompleteNotificationFormat = useCompleteNotificationFormat + ) + + // Remove summary first to avoid briefly displaying it after dismissing the last notification + if (summaryNotification == SummaryNotification.Removed) { + Timber.d("Removing summary notification") + notificationDisplayer.cancelNotificationMessage(null, SUMMARY_NOTIFICATION_ID) + } + + roomNotifications.forEach { wrapper -> + when (wrapper) { + is RoomNotification.Removed -> { + Timber.d("Removing room messages notification ${wrapper.roomId}") + notificationDisplayer.cancelNotificationMessage(wrapper.roomId, ROOM_MESSAGES_NOTIFICATION_ID) + } + is RoomNotification.Message -> if (useCompleteNotificationFormat) { + Timber.d("Updating room messages notification ${wrapper.meta.roomId}") + notificationDisplayer.showNotificationMessage(wrapper.meta.roomId, ROOM_MESSAGES_NOTIFICATION_ID, wrapper.notification) + } + } + } + + invitationNotifications.forEach { wrapper -> + when (wrapper) { + is OneShotNotification.Removed -> { + Timber.d("Removing invitation notification ${wrapper.key}") + notificationDisplayer.cancelNotificationMessage(wrapper.key, ROOM_INVITATION_NOTIFICATION_ID) + } + is OneShotNotification.Append -> if (useCompleteNotificationFormat) { + Timber.d("Updating invitation notification ${wrapper.meta.key}") + notificationDisplayer.showNotificationMessage(wrapper.meta.key, ROOM_INVITATION_NOTIFICATION_ID, wrapper.notification) + } + } + } + + simpleNotifications.forEach { wrapper -> + when (wrapper) { + is OneShotNotification.Removed -> { + Timber.d("Removing simple notification ${wrapper.key}") + notificationDisplayer.cancelNotificationMessage(wrapper.key, ROOM_EVENT_NOTIFICATION_ID) + } + is OneShotNotification.Append -> if (useCompleteNotificationFormat) { + Timber.d("Updating simple notification ${wrapper.meta.key}") + notificationDisplayer.showNotificationMessage(wrapper.meta.key, ROOM_EVENT_NOTIFICATION_ID, wrapper.notification) + } + } + } + + // Update summary last to avoid briefly displaying it before other notifications + if (summaryNotification is SummaryNotification.Update) { + Timber.d("Updating summary notification") + notificationDisplayer.showNotificationMessage(null, SUMMARY_NOTIFICATION_ID, summaryNotification.notification) + } + } + } +} + +private fun List>.groupByType(): GroupedNotificationEvents { + val roomIdToEventMap: MutableMap>> = LinkedHashMap() + val simpleEvents: MutableList> = ArrayList() + val invitationEvents: MutableList> = ArrayList() + forEach { + when (val event = it.event) { + is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType()) + is NotifiableMessageEvent -> { + val roomEvents = roomIdToEventMap.getOrPut(event.roomId) { ArrayList() } + roomEvents.add(it.castedToEventType()) + } + is SimpleNotifiableEvent -> simpleEvents.add(it.castedToEventType()) + } + } + return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents) +} + +@Suppress("UNCHECKED_CAST") +private fun ProcessedEvent.castedToEventType(): ProcessedEvent = this as ProcessedEvent + +data class GroupedNotificationEvents( + val roomEvents: Map>>, + val simpleEvents: List>, + val invitationEvents: List> +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt new file mode 100644 index 0000000000..808bf4114b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent + +class NotificationState( + /** + * The notifiable events queued for rendering or currently rendered. + * + * This is our source of truth for notifications, any changes to this list will be rendered as notifications. + * When events are removed the previously rendered notifications will be cancelled. + * When adding or updating, the notifications will be notified. + * + * Events are unique by their properties, we should be careful not to insert multiple events with the same event-id. + */ + private val queuedEvents: NotificationEventQueue, + + /** + * The last known rendered notifiable events. + * We keep track of them in order to know which events have been removed from the eventList + * allowing us to cancel any notifications previous displayed by now removed events + */ + private val renderedEvents: MutableList>, +) { + + fun updateQueuedEvents( + drawerManager: NotificationDrawerManager, + action: NotificationDrawerManager.(NotificationEventQueue, List>) -> T + ): T { + return synchronized(queuedEvents) { + action(drawerManager, queuedEvents, renderedEvents) + } + } + + fun clearAndAddRenderedEvents(eventsToRender: List>) { + renderedEvents.clear() + renderedEvents.addAll(eventsToRender) + } + + fun hasAlreadyRendered(eventsToRender: List>) = renderedEvents == eventsToRender + + fun queuedEvents(block: (NotificationEventQueue) -> Unit) { + synchronized(queuedEvents) { + block(queuedEvents) + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt new file mode 100755 index 0000000000..fe2b9ccfd8 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt @@ -0,0 +1,744 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("UNUSED_PARAMETER") + +package io.element.android.libraries.push.impl.notifications + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import androidx.core.content.getSystemService +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.DrawableRes +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import io.element.android.libraries.androidutils.intent.PendingIntentCompat +import io.element.android.libraries.androidutils.system.startNotificationChannelSettingsIntent +import io.element.android.libraries.androidutils.uri.createIgnoredUri +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.toolbox.api.strings.StringProvider +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.element.android.libraries.toolbox.api.systemclock.SystemClock +import timber.log.Timber +import javax.inject.Inject +import io.element.android.libraries.ui.strings.R as StringR + +// TODO EAx Split into factories +@SingleIn(AppScope::class) +class NotificationUtils @Inject constructor( + @ApplicationContext private val context: Context, + // private val vectorPreferences: VectorPreferences, + private val stringProvider: StringProvider, + private val clock: SystemClock, + private val actionIds: NotificationActionIds, + private val buildMeta: BuildMeta, +) { + + companion object { + /* ========================================================================================== + * IDs for notifications + * ========================================================================================== */ + + /** + * Identifier of the foreground notification used to keep the application alive + * when it runs in background. + * This notification, which is not removable by the end user, displays what + * the application is doing while in background. + */ + const val NOTIFICATION_ID_FOREGROUND_SERVICE = 61 + + /* ========================================================================================== + * IDs for channels + * ========================================================================================== */ + + // on devices >= android O, we need to define a channel for each notifications + private const val LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID = "LISTEN_FOR_EVENTS_NOTIFICATION_CHANNEL_ID" + + private const val NOISY_NOTIFICATION_CHANNEL_ID = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID" + + const val SILENT_NOTIFICATION_CHANNEL_ID = "DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID_V2" + private const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_NOTIFICATION_CHANNEL_ID_V2" + + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O) + fun supportNotificationChannels() = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + + fun openSystemSettingsForSilentCategory(activity: Activity) { + startNotificationChannelSettingsIntent(activity, SILENT_NOTIFICATION_CHANNEL_ID) + } + + fun openSystemSettingsForNoisyCategory(activity: Activity) { + startNotificationChannelSettingsIntent(activity, NOISY_NOTIFICATION_CHANNEL_ID) + } + + fun openSystemSettingsForCallCategory(activity: Activity) { + startNotificationChannelSettingsIntent(activity, CALL_NOTIFICATION_CHANNEL_ID) + } + } + + private val notificationManager = NotificationManagerCompat.from(context) + + /* ========================================================================================== + * Channel names + * ========================================================================================== */ + + /** + * Create notification channels. + */ + fun createNotificationChannels() { + if (!supportNotificationChannels()) { + return + } + + val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) + + // Migration - the noisy channel was deleted and recreated when sound preference was changed (id was DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_BASE + // + currentTimeMillis). + // Now the sound can only be change directly in system settings, so for app upgrading we are deleting this former channel + // Starting from this version the channel will not be dynamic + for (channel in notificationManager.notificationChannels) { + val channelId = channel.id + val legacyBaseName = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_BASE" + if (channelId.startsWith(legacyBaseName)) { + notificationManager.deleteNotificationChannel(channelId) + } + } + // Migration - Remove deprecated channels + for (channelId in listOf("DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID", "CALL_NOTIFICATION_CHANNEL_ID")) { + notificationManager.getNotificationChannel(channelId)?.let { + notificationManager.deleteNotificationChannel(channelId) + } + } + + /** + * Default notification importance: shows everywhere, makes noise, but does not visually + * intrude. + */ + notificationManager.createNotificationChannel(NotificationChannel( + NOISY_NOTIFICATION_CHANNEL_ID, + stringProvider.getString(StringR.string.notification_noisy_notifications).ifEmpty { "Noisy notifications" }, + NotificationManager.IMPORTANCE_DEFAULT + ) + .apply { + description = stringProvider.getString(StringR.string.notification_noisy_notifications) + enableVibration(true) + enableLights(true) + lightColor = accentColor + }) + + /** + * Low notification importance: shows everywhere, but is not intrusive. + */ + notificationManager.createNotificationChannel(NotificationChannel( + SILENT_NOTIFICATION_CHANNEL_ID, + stringProvider.getString(StringR.string.notification_silent_notifications).ifEmpty { "Silent notifications" }, + NotificationManager.IMPORTANCE_LOW + ) + .apply { + description = stringProvider.getString(StringR.string.notification_silent_notifications) + setSound(null, null) + enableLights(true) + lightColor = accentColor + }) + + notificationManager.createNotificationChannel(NotificationChannel( + LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID, + stringProvider.getString(StringR.string.notification_listening_for_events).ifEmpty { "Listening for events" }, + NotificationManager.IMPORTANCE_MIN + ) + .apply { + description = stringProvider.getString(StringR.string.notification_listening_for_events) + setSound(null, null) + setShowBadge(false) + }) + + notificationManager.createNotificationChannel(NotificationChannel( + CALL_NOTIFICATION_CHANNEL_ID, + stringProvider.getString(StringR.string.call).ifEmpty { "Call" }, + NotificationManager.IMPORTANCE_HIGH + ) + .apply { + description = stringProvider.getString(StringR.string.call) + setSound(null, null) + enableLights(true) + lightColor = accentColor + }) + } + + fun getChannel(channelId: String): NotificationChannel? { + return notificationManager.getNotificationChannel(channelId) + } + + fun getChannelForIncomingCall(fromBg: Boolean): NotificationChannel? { + val notificationChannel = if (fromBg) CALL_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID + return getChannel(notificationChannel) + } + + /** + * Build a notification for a Room. + */ + fun buildMessagesListNotification( + messageStyle: NotificationCompat.MessagingStyle, + roomInfo: RoomEventGroupInfo, + threadId: String?, + largeIcon: Bitmap?, + lastMessageTimestamp: Long, + senderDisplayNameForReplyCompat: String?, + tickerText: String + ): Notification { + val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) + // Build the pending intent for when the notification is clicked + val openIntent = when { + threadId != null && + true /** TODO EAx vectorPreferences.areThreadMessagesEnabled() */ + -> buildOpenThreadIntent(roomInfo, threadId) + else -> buildOpenRoomIntent(roomInfo.roomId) + } + + val smallIcon = R.drawable.ic_notification + + val channelID = if (roomInfo.shouldBing) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID + return NotificationCompat.Builder(context, channelID) + .setOnlyAlertOnce(roomInfo.isUpdated) + .setWhen(lastMessageTimestamp) + // MESSAGING_STYLE sets title and content for API 16 and above devices. + .setStyle(messageStyle) + // A category allows groups of notifications to be ranked and filtered – per user or system settings. + // For example, alarm notifications should display before promo notifications, or message from known contact + // that can be displayed in not disturb mode if white listed (the later will need compat28.x) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + // ID of the corresponding shortcut, for conversation features under API 30+ + .setShortcutId(roomInfo.roomId) + // Title for API < 16 devices. + .setContentTitle(roomInfo.roomDisplayName) + // Content for API < 16 devices. + .setContentText(stringProvider.getString(StringR.string.notification_new_messages)) + // Number of new notifications for API <24 (M and below) devices. + .setSubText( + stringProvider.getQuantityString( + StringR.plurals.room_new_messages_notification, + messageStyle.messages.size, + messageStyle.messages.size + ) + ) + // Auto-bundling is enabled for 4 or more notifications on API 24+ (N+) + // devices and all Wear devices. But we want a custom grouping, so we specify the groupID + // TODO Group should be current user display name + .setGroup(buildMeta.applicationName) + // In order to avoid notification making sound twice (due to the summary notification) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) + .setSmallIcon(smallIcon) + // Set primary color (important for Wear 2.0 Notifications). + .setColor(accentColor) + // Sets priority for 25 and below. For 26 and above, 'priority' is deprecated for + // 'importance' which is set in the NotificationChannel. The integers representing + // 'priority' are different from 'importance', so make sure you don't mix them. + .apply { + if (roomInfo.shouldBing) { + // Compat + priority = NotificationCompat.PRIORITY_DEFAULT + /* + vectorPreferences.getNotificationRingTone()?.let { + setSound(it) + } + */ + setLights(accentColor, 500, 500) + } else { + priority = NotificationCompat.PRIORITY_LOW + } + + // Add actions and notification intents + // Mark room as read + val markRoomReadIntent = Intent(context, NotificationBroadcastReceiver::class.java) + markRoomReadIntent.action = actionIds.markRoomRead + markRoomReadIntent.data = createIgnoredUri(roomInfo.roomId) + markRoomReadIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId) + val markRoomReadPendingIntent = PendingIntent.getBroadcast( + context, + clock.epochMillis().toInt(), + markRoomReadIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + ) + + NotificationCompat.Action.Builder( + R.drawable.ic_material_done_all_white, + stringProvider.getString(StringR.string.action_mark_room_read), markRoomReadPendingIntent + ) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) + .setShowsUserInterface(false) + .build() + .let { addAction(it) } + + // Quick reply + if (!roomInfo.hasSmartReplyError) { + buildQuickReplyIntent(roomInfo.roomId, threadId, senderDisplayNameForReplyCompat)?.let { replyPendingIntent -> + val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY) + .setLabel(stringProvider.getString(StringR.string.action_quick_reply)) + .build() + NotificationCompat.Action.Builder( + R.drawable.vector_notification_quick_reply, + stringProvider.getString(StringR.string.action_quick_reply), replyPendingIntent + ) + .addRemoteInput(remoteInput) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) + .setShowsUserInterface(false) + .build() + .let { addAction(it) } + } + } + + if (openIntent != null) { + setContentIntent(openIntent) + } + + if (largeIcon != null) { + setLargeIcon(largeIcon) + } + + val intent = Intent(context, NotificationBroadcastReceiver::class.java) + intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId) + intent.action = actionIds.dismissRoom + val pendingIntent = PendingIntent.getBroadcast( + context.applicationContext, + clock.epochMillis().toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + ) + setDeleteIntent(pendingIntent) + } + .setTicker(tickerText) + .build() + } + + fun buildRoomInvitationNotification( + inviteNotifiableEvent: InviteNotifiableEvent, + matrixId: String + ): Notification { + val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) + // Build the pending intent for when the notification is clicked + val smallIcon = R.drawable.ic_notification + val channelID = if (inviteNotifiableEvent.noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID + + return NotificationCompat.Builder(context, channelID) + .setOnlyAlertOnce(true) + .setContentTitle(inviteNotifiableEvent.roomName ?: buildMeta.applicationName) + .setContentText(inviteNotifiableEvent.description) + .setGroup(buildMeta.applicationName) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) + .setSmallIcon(smallIcon) + .setColor(accentColor) + .apply { + val roomId = inviteNotifiableEvent.roomId + // offer to type a quick reject button + val rejectIntent = Intent(context, NotificationBroadcastReceiver::class.java) + rejectIntent.action = actionIds.reject + rejectIntent.data = createIgnoredUri("$roomId&$matrixId") + rejectIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId) + val rejectIntentPendingIntent = PendingIntent.getBroadcast( + context, + clock.epochMillis().toInt(), + rejectIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + ) + + addAction( + R.drawable.vector_notification_reject_invitation, + stringProvider.getString(StringR.string.action_reject), + rejectIntentPendingIntent + ) + + // offer to type a quick accept button + val joinIntent = Intent(context, NotificationBroadcastReceiver::class.java) + joinIntent.action = actionIds.join + joinIntent.data = createIgnoredUri("$roomId&$matrixId") + joinIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId) + val joinIntentPendingIntent = PendingIntent.getBroadcast( + context, + clock.epochMillis().toInt(), + joinIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + ) + addAction( + R.drawable.vector_notification_accept_invitation, + stringProvider.getString(StringR.string.action_join), + joinIntentPendingIntent + ) + + /* + val contentIntent = HomeActivity.newIntent( + context, + firstStartMainActivity = true, + inviteNotificationRoomId = inviteNotifiableEvent.roomId + ) + contentIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that + contentIntent.data = createIgnoredUri(inviteNotifiableEvent.eventId) + setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE)) + + */ + + if (inviteNotifiableEvent.noisy) { + // Compat + priority = NotificationCompat.PRIORITY_DEFAULT + /* + vectorPreferences.getNotificationRingTone()?.let { + setSound(it) + } + + */ + setLights(accentColor, 500, 500) + } else { + priority = NotificationCompat.PRIORITY_LOW + } + setAutoCancel(true) + } + .build() + } + + fun buildSimpleEventNotification( + simpleNotifiableEvent: SimpleNotifiableEvent, + matrixId: String + ): Notification { + val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) + // Build the pending intent for when the notification is clicked + val smallIcon = R.drawable.ic_notification + + val channelID = if (simpleNotifiableEvent.noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID + + return NotificationCompat.Builder(context, channelID) + .setOnlyAlertOnce(true) + .setContentTitle(buildMeta.applicationName) + .setContentText(simpleNotifiableEvent.description) + .setGroup(buildMeta.applicationName) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) + .setSmallIcon(smallIcon) + .setColor(accentColor) + .setAutoCancel(true) + .apply { + /* TODO EAx + val contentIntent = HomeActivity.newIntent(context, firstStartMainActivity = true) + contentIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that + contentIntent.data = createIgnoredUri(simpleNotifiableEvent.eventId) + setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE)) + */ + if (simpleNotifiableEvent.noisy) { + // Compat + priority = NotificationCompat.PRIORITY_DEFAULT + /* + vectorPreferences.getNotificationRingTone()?.let { + setSound(it) + } + + */ + setLights(accentColor, 500, 500) + } else { + priority = NotificationCompat.PRIORITY_LOW + } + setAutoCancel(true) + } + .build() + } + + private fun buildOpenRoomIntent(roomId: String): PendingIntent? { + return null + /* + val roomIntentTap = RoomDetailActivity.newIntent(context, TimelineArgs(roomId = roomId, switchToParentSpace = true), true) + roomIntentTap.action = actionIds.tapToView + // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that + roomIntentTap.data = createIgnoredUri("openRoom?$roomId") + + // Recreate the back stack + return TaskStackBuilder.create(context) + .addNextIntentWithParentStack(HomeActivity.newIntent(context, firstStartMainActivity = false)) + .addNextIntent(roomIntentTap) + .getPendingIntent( + clock.epochMillis().toInt(), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + ) + */ + } + + private fun buildOpenThreadIntent(roomInfo: RoomEventGroupInfo, threadId: String?): PendingIntent? { + return null + /* + val threadTimelineArgs = ThreadTimelineArgs( + startsThread = false, + roomId = roomInfo.roomId, + rootThreadEventId = threadId, + showKeyboard = false, + displayName = roomInfo.roomDisplayName, + avatarUrl = null, + roomEncryptionTrustLevel = null, + ) + val threadIntentTap = ThreadsActivity.newIntent( + context = context, + threadTimelineArgs = threadTimelineArgs, + threadListArgs = null, + firstStartMainActivity = true, + ) + threadIntentTap.action = actionIds.tapToView + // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that + threadIntentTap.data = createIgnoredUri("openThread?$threadId") + + val roomIntent = RoomDetailActivity.newIntent( + context = context, + timelineArgs = TimelineArgs( + roomId = roomInfo.roomId, + switchToParentSpace = true + ), + firstStartMainActivity = false + ) + // Recreate the back stack + return TaskStackBuilder.create(context) + .addNextIntentWithParentStack(HomeActivity.newIntent(context, firstStartMainActivity = false)) + .addNextIntentWithParentStack(roomIntent) + .addNextIntent(threadIntentTap) + .getPendingIntent( + clock.epochMillis().toInt(), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + ) + */ + } + + private fun buildOpenHomePendingIntentForSummary(): PendingIntent { + TODO() + /* + val intent = HomeActivity.newIntent(context, firstStartMainActivity = false, clearNotification = true) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + intent.data = createIgnoredUri("tapSummary") + val mainIntent = MainActivity.getIntentWithNextIntent(context, intent) + return PendingIntent.getActivity( + context, + Random.nextInt(1000), + mainIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + ) + */ + } + + /* + Direct reply is new in Android N, and Android already handles the UI, so the right pending intent + here will ideally be a Service/IntentService (for a long running background task) or a BroadcastReceiver, + which runs on the UI thread. It also works without unlocking, making the process really fluid for the user. + However, for Android devices running Marshmallow and below (API level 23 and below), + it will be more appropriate to use an activity. Since you have to provide your own UI. + */ + private fun buildQuickReplyIntent(roomId: String, threadId: String?, senderName: String?): PendingIntent? { + val intent: Intent + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + intent = Intent(context, NotificationBroadcastReceiver::class.java) + intent.action = actionIds.smartReply + intent.data = createIgnoredUri(roomId) + intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId) + threadId?.let { + intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it) + } + + return PendingIntent.getBroadcast( + context, + clock.epochMillis().toInt(), + intent, + // PendingIntents attached to actions with remote inputs must be mutable + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_MUTABLE + ) + } else { + /* + TODO + if (!LockScreenActivity.isDisplayingALockScreenActivity()) { + // start your activity for Android M and below + val quickReplyIntent = Intent(context, LockScreenActivity::class.java) + quickReplyIntent.putExtra(LockScreenActivity.EXTRA_ROOM_ID, roomId) + quickReplyIntent.putExtra(LockScreenActivity.EXTRA_SENDER_NAME, senderName ?: "") + + // the action must be unique else the parameters are ignored + quickReplyIntent.action = QUICK_LAUNCH_ACTION + quickReplyIntent.data = createIgnoredUri($roomId") + return PendingIntent.getActivity(context, 0, quickReplyIntent, PendingIntentCompat.FLAG_IMMUTABLE) + } + */ + } + return null + } + + // // Number of new notifications for API <24 (M and below) devices. + /** + * Build the summary notification. + */ + fun buildSummaryListNotification( + style: NotificationCompat.InboxStyle?, + compatSummary: String, + noisy: Boolean, + lastMessageTimestamp: Long + ): Notification { + val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) + val smallIcon = R.drawable.ic_notification + + return NotificationCompat.Builder(context, if (noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID) + .setOnlyAlertOnce(true) + // used in compat < N, after summary is built based on child notifications + .setWhen(lastMessageTimestamp) + .setStyle(style) + .setContentTitle(buildMeta.applicationName) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setSmallIcon(smallIcon) + // set content text to support devices running API level < 24 + .setContentText(compatSummary) + .setGroup(buildMeta.applicationName) + // set this notification as the summary for the group + .setGroupSummary(true) + .setColor(accentColor) + .apply { + if (noisy) { + // Compat + priority = NotificationCompat.PRIORITY_DEFAULT + /* + vectorPreferences.getNotificationRingTone()?.let { + setSound(it) + } + */ + setLights(accentColor, 500, 500) + } else { + // compat + priority = NotificationCompat.PRIORITY_LOW + } + } + .setContentIntent(buildOpenHomePendingIntentForSummary()) + .setDeleteIntent(getDismissSummaryPendingIntent()) + .build() + } + + private fun getDismissSummaryPendingIntent(): PendingIntent { + val intent = Intent(context, NotificationBroadcastReceiver::class.java) + intent.action = actionIds.dismissSummary + intent.data = createIgnoredUri("deleteSummary") + return PendingIntent.getBroadcast( + context.applicationContext, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + ) + } + + fun showNotificationMessage(tag: String?, id: Int, notification: Notification) { + notificationManager.notify(tag, id, notification) + } + + fun cancelNotificationMessage(tag: String?, id: Int) { + notificationManager.cancel(tag, id) + } + + /** + * Cancel the foreground notification service. + */ + fun cancelNotificationForegroundService() { + notificationManager.cancel(NOTIFICATION_ID_FOREGROUND_SERVICE) + } + + /** + * Cancel all the notification. + */ + fun cancelAllNotifications() { + // Keep this try catch (reported by GA) + try { + notificationManager.cancelAll() + } catch (e: Exception) { + Timber.e(e, "## cancelAllNotifications() failed") + } + } + + @SuppressLint("LaunchActivityFromNotification") + fun displayDiagnosticNotification() { + val testActionIntent = Intent(context, TestNotificationReceiver::class.java) + testActionIntent.action = actionIds.diagnostic + val testPendingIntent = PendingIntent.getBroadcast( + context, + 0, + testActionIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + ) + + notificationManager.notify( + "DIAGNOSTIC", + 888, + NotificationCompat.Builder(context, NOISY_NOTIFICATION_CHANNEL_ID) + .setContentTitle(buildMeta.applicationName) + .setContentText(stringProvider.getString(StringR.string.settings_troubleshoot_test_push_notification_content)) + .setSmallIcon(R.drawable.ic_notification) + .setLargeIcon(getBitmap(context, R.drawable.element_logo_green)) + .setColor(ContextCompat.getColor(context, R.color.notification_accent_color)) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setAutoCancel(true) + .setContentIntent(testPendingIntent) + .build() + ) + } + + private fun getBitmap(context: Context, @DrawableRes drawableRes: Int): Bitmap? { + val drawable = ResourcesCompat.getDrawable(context.resources, drawableRes, null) ?: return null + val canvas = Canvas() + val bitmap = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888) + canvas.setBitmap(bitmap) + drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) + drawable.draw(canvas) + return bitmap + } + + /** + * Return true it the user has enabled the do not disturb mode. + */ + fun isDoNotDisturbModeOn(): Boolean { + // We cannot use NotificationManagerCompat here. + val setting = context.getSystemService()!!.currentInterruptionFilter + + return setting == NotificationManager.INTERRUPTION_FILTER_NONE || + setting == NotificationManager.INTERRUPTION_FILTER_ALARMS + } + + /* + private fun getActionText(@StringRes stringRes: Int, @AttrRes colorRes: Int): Spannable { + return SpannableString(context.getText(stringRes)).apply { + val foregroundColorSpan = ForegroundColorSpan(ThemeUtils.getColor(context, colorRes)) + setSpan(foregroundColorSpan, 0, length, 0) + } + } + */ + + private fun ensureTitleNotEmpty(title: String?): CharSequence { + if (title.isNullOrBlank()) { + return buildMeta.applicationName + } + + return title + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt new file mode 100644 index 0000000000..1d82fc31e4 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import javax.inject.Inject + +class OutdatedEventDetector @Inject constructor( + /// private val activeSessionDataSource: ActiveSessionDataSource +) { + + /** + * Returns true if the given event is outdated. + * Used to clean up notifications if a displayed message has been read on an + * other device. + */ + fun isMessageOutdated(notifiableEvent: NotifiableEvent): Boolean { + /* TODO EAx + val session = activeSessionDataSource.currentValue?.orNull() ?: return false + + if (notifiableEvent is NotifiableMessageEvent) { + val eventID = notifiableEvent.eventId + val roomID = notifiableEvent.roomId + val room = session.getRoom(roomID) ?: return false + return room.readService().isEventRead(eventID) + } + + */ + return false + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt new file mode 100644 index 0000000000..2e91ca3467 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +data class ProcessedEvent( + val type: Type, + val event: T +) { + enum class Type { + KEEP, + REMOVE + } +} + +fun List>.onlyKeptEvents() = mapNotNull { processedEvent -> + processedEvent.event.takeIf { processedEvent.type == ProcessedEvent.Type.KEEP } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt new file mode 100644 index 0000000000..b09482264b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +/** + * Data class to hold information about a group of notifications for a room. + */ +data class RoomEventGroupInfo( + val roomId: String, + val roomDisplayName: String = "", + val isDirect: Boolean = false +) { + // An event in the list has not yet been display + var hasNewEvent: Boolean = false + + // true if at least one on the not yet displayed event is noisy + var shouldBing: Boolean = false + var customSound: String? = null + var hasSmartReplyError: Boolean = false + var isUpdated: Boolean = false +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt new file mode 100644 index 0000000000..34e8da9723 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.graphics.Bitmap +import androidx.core.app.NotificationCompat +import androidx.core.app.Person +import io.element.android.libraries.toolbox.api.strings.StringProvider +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import me.gujun.android.span.Span +import me.gujun.android.span.span +import timber.log.Timber +import javax.inject.Inject +import io.element.android.libraries.ui.strings.R as StringR + +class RoomGroupMessageCreator @Inject constructor( + private val bitmapLoader: NotificationBitmapLoader, + private val stringProvider: StringProvider, + private val notificationUtils: NotificationUtils +) { + + fun createRoomMessage(events: List, roomId: String, userDisplayName: String, userAvatarUrl: String?): RoomNotification.Message { + val lastKnownRoomEvent = events.last() + val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderName ?: "" + val roomIsGroup = !lastKnownRoomEvent.roomIsDirect + val style = NotificationCompat.MessagingStyle( + Person.Builder() + .setName(userDisplayName) + .setIcon(bitmapLoader.getUserIcon(userAvatarUrl)) + .setKey(lastKnownRoomEvent.matrixID) + .build() + ).also { + it.conversationTitle = roomName.takeIf { roomIsGroup } + it.isGroupConversation = roomIsGroup + it.addMessagesFromEvents(events) + } + + val tickerText = if (roomIsGroup) { + stringProvider.getString(StringR.string.notification_ticker_text_group, roomName, events.last().senderName, events.last().description) + } else { + stringProvider.getString(StringR.string.notification_ticker_text_dm, events.last().senderName, events.last().description) + } + + val largeBitmap = getRoomBitmap(events) + + val lastMessageTimestamp = events.last().timestamp + val smartReplyErrors = events.filter { it.isSmartReplyError() } + val messageCount = (events.size - smartReplyErrors.size) + val meta = RoomNotification.Message.Meta( + summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, roomIsDirect = !roomIsGroup), + messageCount = messageCount, + latestTimestamp = lastMessageTimestamp, + roomId = roomId, + shouldBing = events.any { it.noisy } + ) + return RoomNotification.Message( + notificationUtils.buildMessagesListNotification( + style, + RoomEventGroupInfo(roomId, roomName, isDirect = !roomIsGroup).also { + it.hasSmartReplyError = smartReplyErrors.isNotEmpty() + it.shouldBing = meta.shouldBing + it.customSound = events.last().soundName + it.isUpdated = events.last().isUpdated + }, + threadId = lastKnownRoomEvent.threadId, + largeIcon = largeBitmap, + lastMessageTimestamp, + userDisplayName, + tickerText + ), + meta + ) + } + + private fun NotificationCompat.MessagingStyle.addMessagesFromEvents(events: List) { + events.forEach { event -> + val senderPerson = if (event.outGoingMessage) { + null + } else { + Person.Builder() + .setName(event.senderName) + .setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath)) + .setKey(event.senderId) + .build() + } + when { + event.isSmartReplyError() -> addMessage( + stringProvider.getString(StringR.string.notification_inline_reply_failed), + event.timestamp, + senderPerson + ) + else -> { + val message = NotificationCompat.MessagingStyle.Message(event.body, event.timestamp, senderPerson).also { message -> + event.imageUri?.let { + message.setData("image/", it) + } + } + addMessage(message) + } + } + } + } + + private fun createRoomMessagesGroupSummaryLine(events: List, roomName: String, roomIsDirect: Boolean): CharSequence { + return try { + when (events.size) { + 1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDirect) + else -> { + stringProvider.getQuantityString( + StringR.plurals.notification_compat_summary_line_for_room, + events.size, + roomName, + events.size + ) + } + } + } catch (e: Throwable) { + // String not found or bad format + Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER failed to resolve string") + roomName + } + } + + private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): Span { + return if (roomIsDirect) { + span { + span { + textStyle = "bold" + +String.format("%s: ", event.senderName) + } + +(event.description) + } + } else { + span { + span { + textStyle = "bold" + +String.format("%s: %s ", roomName, event.senderName) + } + +(event.description) + } + } + } + + private fun getRoomBitmap(events: List): Bitmap? { + // Use the last event (most recent?) + return events.lastOrNull() + ?.roomAvatarPath + ?.let { bitmapLoader.getRoomBitmap(it) } + } +} + +private fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt new file mode 100644 index 0000000000..864fd6c42b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.app.Notification +import androidx.core.app.NotificationCompat +import io.element.android.libraries.toolbox.api.strings.StringProvider +import javax.inject.Inject + +import io.element.android.libraries.ui.strings.R as StringR + +/** + * ======== Build summary notification ========= + * On Android 7.0 (API level 24) and higher, the system automatically builds a summary for + * your group using snippets of text from each notification. The user can expand this + * notification to see each separate notification. + * To support older versions, which cannot show a nested group of notifications, + * you must create an extra notification that acts as the summary. + * This appears as the only notification and the system hides all the others. + * So this summary should include a snippet from all the other notifications, + * which the user can tap to open your app. + * The behavior of the group summary may vary on some device types such as wearables. + * To ensure the best experience on all devices and versions, always include a group summary when you create a group + * https://developer.android.com/training/notify-user/group + */ +class SummaryGroupMessageCreator @Inject constructor( + private val stringProvider: StringProvider, + private val notificationUtils: NotificationUtils +) { + + fun createSummaryNotification( + roomNotifications: List, + 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(StringR.plurals.notification_compat_summary_title, nbEvents, nbEvents) + summaryInboxStyle.setBigContentTitle(sumTitle) + // TODO get latest event? + .setSummaryText(stringProvider.getQuantityString(StringR.plurals.notification_unread_notified_messages, nbEvents, nbEvents)) + return if (useCompleteNotificationFormat) { + notificationUtils.buildSummaryListNotification( + summaryInboxStyle, + sumTitle, + noisy = summaryIsNoisy, + lastMessageTimestamp = lastMessageTimestamp + ) + } else { + processSimpleGroupSummary( + summaryIsNoisy, + messageCount, + simpleNotifications.size, + invitationNotifications.size, + roomNotifications.size, + lastMessageTimestamp + ) + } + } + + private fun processSimpleGroupSummary( + summaryIsNoisy: Boolean, + messageEventsCount: Int, + simpleEventsCount: Int, + invitationEventsCount: Int, + roomCount: Int, + lastMessageTimestamp: Long + ): Notification { + // Add the simple events as message (?) + val messageNotificationCount = messageEventsCount + simpleEventsCount + + val privacyTitle = if (invitationEventsCount > 0) { + val invitationsStr = stringProvider.getQuantityString(StringR.plurals.notification_invitations, invitationEventsCount, invitationEventsCount) + if (messageNotificationCount > 0) { + // Invitation and message + val messageStr = stringProvider.getQuantityString( + StringR.plurals.room_new_messages_notification, + messageNotificationCount, messageNotificationCount + ) + if (roomCount > 1) { + // In several rooms + val roomStr = stringProvider.getQuantityString( + StringR.plurals.notification_unread_notified_messages_in_room_rooms, + roomCount, roomCount + ) + stringProvider.getString( + StringR.string.notification_unread_notified_messages_in_room_and_invitation, + messageStr, + roomStr, + invitationsStr + ) + } else { + // In one room + stringProvider.getString( + StringR.string.notification_unread_notified_messages_and_invitation, + messageStr, + invitationsStr + ) + } + } else { + // Only invitation + invitationsStr + } + } else { + // No invitation, only messages + val messageStr = stringProvider.getQuantityString( + StringR.plurals.room_new_messages_notification, + messageNotificationCount, messageNotificationCount + ) + if (roomCount > 1) { + // In several rooms + val roomStr = stringProvider.getQuantityString(StringR.plurals.notification_unread_notified_messages_in_room_rooms, roomCount, roomCount) + stringProvider.getString(StringR.string.notification_unread_notified_messages_in_room, messageStr, roomStr) + } else { + // In one room + messageStr + } + } + return notificationUtils.buildSummaryListNotification( + style = null, + compatSummary = privacyTitle, + noisy = summaryIsNoisy, + lastMessageTimestamp = lastMessageTimestamp + ) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt new file mode 100644 index 0000000000..42c0fe61af --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.localbroadcastmanager.content.LocalBroadcastManager + +class TestNotificationReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + // Internal broadcast to any one interested + LocalBroadcastManager.getInstance(context).sendBroadcast(intent) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/InviteNotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/InviteNotifiableEvent.kt new file mode 100644 index 0000000000..3c49ba742f --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/InviteNotifiableEvent.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.element.android.libraries.push.impl.notifications.model + +data class InviteNotifiableEvent( + val matrixID: String?, + override val eventId: String, + override val editedEventId: String?, + override val canBeReplaced: Boolean, + val roomId: String, + val roomName: String?, + val noisy: Boolean, + val title: String, + val description: String, + val type: String?, + val timestamp: Long, + val soundName: String?, + override val isRedacted: Boolean = false, + override val isUpdated: Boolean = false +) : NotifiableEvent diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt new file mode 100644 index 0000000000..bcbf614659 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.element.android.libraries.push.impl.notifications.model + +import java.io.Serializable + +/** + * Parent interface for all events which can be displayed as a Notification. + */ +sealed interface NotifiableEvent : Serializable { + val eventId: String + val editedEventId: String? + + // Used to know if event should be replaced with the one coming from eventstream + val canBeReplaced: Boolean + val isRedacted: Boolean + val isUpdated: Boolean +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt new file mode 100644 index 0000000000..52f3ad3be6 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.element.android.libraries.push.impl.notifications.model + +import android.net.Uri + +data class NotifiableMessageEvent( + override val eventId: String, + override val editedEventId: String?, + override val canBeReplaced: Boolean, + val noisy: Boolean, + val timestamp: Long, + val senderName: String?, + val senderId: String?, + val body: String?, + // We cannot use Uri? type here, as that could trigger a + // NotSerializableException when persisting this to storage + val imageUriString: String?, + val roomId: String, + val threadId: String?, + val roomName: String?, + val roomIsDirect: Boolean = false, + val roomAvatarPath: String? = null, + val senderAvatarPath: String? = null, + val matrixID: String? = null, + val soundName: String? = null, + // This is used for >N notification, as the result of a smart reply + val outGoingMessage: Boolean = false, + val outGoingMessageFailed: Boolean = false, + override val isRedacted: Boolean = false, + override val isUpdated: Boolean = false +) : NotifiableEvent { + + val type: String = /* EventType.MESSAGE */ "m.room.message" + val description: String = body ?: "" + val title: String = senderName ?: "" + + val imageUri: Uri? + get() = imageUriString?.let { Uri.parse(it) } +} + +fun NotifiableMessageEvent.shouldIgnoreMessageEventInRoom(currentRoomId: String?, currentThreadId: String?): Boolean { + return when (currentRoomId) { + null -> false + else -> roomId == currentRoomId && threadId == currentThreadId + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt new file mode 100644 index 0000000000..e1d5c3347b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.element.android.libraries.push.impl.notifications.model + +data class SimpleNotifiableEvent( + val matrixID: String?, + override val eventId: String, + override val editedEventId: String?, + val noisy: Boolean, + val title: String, + val description: String, + val type: String?, + val timestamp: Long, + val soundName: String?, + override var canBeReplaced: Boolean, + override val isRedacted: Boolean = false, + override val isUpdated: Boolean = false +) : NotifiableEvent diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/parser/PushParser.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/parser/PushParser.kt new file mode 100644 index 0000000000..7413264d5d --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/parser/PushParser.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.parser + +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.push.impl.model.PushData +import io.element.android.libraries.push.impl.model.PushDataFcm +import io.element.android.libraries.push.impl.model.PushDataUnifiedPush +import io.element.android.libraries.push.impl.model.toPushData +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import javax.inject.Inject + +/** + * Parse the received data from Push. Json format are different depending on the source. + * + * Notifications received by FCM are formatted by the matrix gateway [1]. The data send to FCM is the content + * of the "notification" attribute of the json sent to the gateway [2][3]. + * On the other side, with UnifiedPush, the content of the message received is the content posted to the push + * gateway endpoint [3]. + * + * *Note*: If we want to get the same content with FCM and unifiedpush, we can do a new sygnal pusher [4]. + * + * [1] https://github.com/matrix-org/sygnal/blob/main/sygnal/gcmpushkin.py + * [2] https://github.com/matrix-org/sygnal/blob/main/sygnal/gcmpushkin.py#L366 + * [3] https://spec.matrix.org/latest/push-gateway-api/ + * [4] https://github.com/p1gp1g/sygnal/blob/unifiedpush/sygnal/upfcmpushkin.py (Not tested for a while) + */ +class PushParser @Inject constructor() { + fun parsePushDataUnifiedPush(message: ByteArray): PushData? { + return tryOrNull { Json.decodeFromString(String(message)) }?.toPushData() + } + + fun parsePushDataFcm(message: Map): PushData { + val pushDataFcm = PushDataFcm( + eventId = message["event_id"], + roomId = message["room_id"], + unread = message["unread"]?.let { tryOrNull { Integer.parseInt(it) } }, + ) + return pushDataFcm.toPushData() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt new file mode 100644 index 0000000000..8474682ab6 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt @@ -0,0 +1,133 @@ +package io.element.android.libraries.push.impl.store + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.DefaultPreferences +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.push.api.model.BackgroundSyncMode +import io.element.android.libraries.push.api.store.PushDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +private val Context.dataStore: DataStore by preferencesDataStore(name = "push_store") + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultPushDataStore @Inject constructor( + @ApplicationContext private val context: Context, + @DefaultPreferences private val defaultPrefs: SharedPreferences, +) : PushDataStore { + private val pushCounter = intPreferencesKey("push_counter") + + override val pushCounterFlow: Flow = context.dataStore.data.map { preferences -> + preferences[pushCounter] ?: 0 + } + + suspend fun incrementPushCounter() { + context.dataStore.edit { settings -> + val currentCounterValue = settings[pushCounter] ?: 0 + settings[pushCounter] = currentCounterValue + 1 + } + } + + override fun areNotificationEnabledForDevice(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY, true) + } + + override fun setNotificationEnabledForDevice(enabled: Boolean) { + defaultPrefs.edit { + putBoolean(SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY, enabled) + } + } + + override fun backgroundSyncTimeOut(): Int { + return tryOrNull { + // The xml pref is saved as a string so use getString and parse + defaultPrefs.getString(SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY, null)?.toInt() + } ?: BackgroundSyncMode.DEFAULT_SYNC_TIMEOUT_SECONDS + } + + override fun setBackgroundSyncTimeout(timeInSecond: Int) { + defaultPrefs + .edit() + .putString(SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY, timeInSecond.toString()) + .apply() + } + + override fun backgroundSyncDelay(): Int { + return tryOrNull { + // The xml pref is saved as a string so use getString and parse + defaultPrefs.getString(SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY, null)?.toInt() + } ?: BackgroundSyncMode.DEFAULT_SYNC_DELAY_SECONDS + } + + override fun setBackgroundSyncDelay(timeInSecond: Int) { + defaultPrefs + .edit() + .putString(SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY, timeInSecond.toString()) + .apply() + } + + override fun isBackgroundSyncEnabled(): Boolean { + return getFdroidSyncBackgroundMode() != BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_DISABLED + } + + override fun setFdroidSyncBackgroundMode(mode: BackgroundSyncMode) { + defaultPrefs + .edit() + .putString(SETTINGS_FDROID_BACKGROUND_SYNC_MODE, mode.name) + .apply() + } + + override fun getFdroidSyncBackgroundMode(): BackgroundSyncMode { + return try { + val strPref = defaultPrefs + .getString(SETTINGS_FDROID_BACKGROUND_SYNC_MODE, BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY.name) + BackgroundSyncMode.values().firstOrNull { it.name == strPref } ?: BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY + } catch (e: Throwable) { + BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY + } + } + + /** + * Return true if Pin code is disabled, or if user set the settings to see full notification content. + */ + override fun useCompleteNotificationFormat(): Boolean { + return true + /* + return !useFlagPinCode() || + defaultPrefs.getBoolean(SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG, true) + */ + } + + companion object { + // notifications + const val SETTINGS_ENABLE_ALL_NOTIF_PREFERENCE_KEY = "SETTINGS_ENABLE_ALL_NOTIF_PREFERENCE_KEY" + const val SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY = "SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY" + + // background sync + const val SETTINGS_START_ON_BOOT_PREFERENCE_KEY = "SETTINGS_START_ON_BOOT_PREFERENCE_KEY" + const val SETTINGS_ENABLE_BACKGROUND_SYNC_PREFERENCE_KEY = "SETTINGS_ENABLE_BACKGROUND_SYNC_PREFERENCE_KEY" + const val SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY = "SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY" + const val SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY = "SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY" + + const val SETTINGS_FDROID_BACKGROUND_SYNC_MODE = "SETTINGS_FDROID_BACKGROUND_SYNC_MODE" + const val SETTINGS_BACKGROUND_SYNC_PREFERENCE_KEY = "SETTINGS_BACKGROUND_SYNC_PREFERENCE_KEY" + + const val SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG = "SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG" + + // notification method + const val SETTINGS_NOTIFICATION_METHOD_KEY = "SETTINGS_NOTIFICATION_METHOD_KEY" + } +} diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/element_logo_green.xml b/libraries/push/impl/src/main/res/drawable-xxhdpi/element_logo_green.xml new file mode 100644 index 0000000000..e9b119c969 --- /dev/null +++ b/libraries/push/impl/src/main/res/drawable-xxhdpi/element_logo_green.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_material_done_all_white.png b/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_material_done_all_white.png new file mode 100755 index 0000000000000000000000000000000000000000..1f3132a3f2f1ef1d4f4683e148fd351eec398569 GIT binary patch literal 398 zcmV;90df9`P)hdV05a26EH^Do>3VzkAd1j7p1$9mlKEvS8<$lOjC~rtLv7=+%qJ9CEuV( zSEHe6nx<)*rU}Pk{Y6Ypiv{D#MVl4ZDLH_jM4J@=Hz_$@(l-a-HYLYN+TsB8Q*vOm zgChx2PEAhdcR54lDCd802Fm#nXRVyS!8x)xSu_qEUO1~9dvU}=qQ|CKOfAw=Y|bSgrDIE*M32wXV#(VVG#2;9V!lm^$_XYl#W;baVj;qDlIHu6 z#5f+3ad~;LNU~q_0Fv|rqZUc#y1Jw|1DI%CxRJEgpPxkR`%a>$bk(*HW;Aqq7G$E4ZYbsNeFH$D1x3 z$C*1^e?oo3Hv!or`BIW2&`Xl@wSy60Vs>%X7Y<`jC?yd6(j|1!MnR51mbAvYrIR9v z)KmydMV>%h01XAj2BLstFsBYc_rm5B6Enay;;^EQKqp0O^2EAXh4>Sf)Qty{V0;sZ ze=_aPY%cGqBM=48%haam&x0odI!F#HPd3FgLxk!p4F^ z0kKvtu*A%$Cm^nejXMef-1Ao@G62sGb9EPGC5(?FeL3e2DFV-(KOR)46P& zEc4|Udghc7P`ubZzuRYssyCAa{r)B!$!YMJAlP;7!liSAx6>69C``S_Z??O6F-!K*pBnDXlxx8LWmcg@>B6qq(R9@8XS{daDS<>BtDXBUvB!MF zu5+uVqb5vxK}Q5+Rh(J+*y2g+QGJoEWxPkT(L1LBQA?F=Rs3WnJs~?`-1NNA)ER9~ zlpTjSK~qJawMvs?fOUe<#k0>?f(pyESpPg680hTSCJ+)EviU%p{{c4GcfsYJLD))^ zJN@`NM^QKcU9@7>bhdHVGQgpbNM0%5S3m-K8j9uzHkK`e*fxd}$`E+u(pU%C0@+V3 z-`Si-jfLogThdjseQW-~6ZFH<*~i46Qf5G^&onMg`6Anb!}?^gXE5hw*(Y#IK#bci zz>~DAS9qPy4-9Bq$JscRa7$g;Un~?p4FUwnV}rMDh>br8<{bK7{JTwe5R?+FQ^;w zs`H_XLUxW}Ly~wgh+CBpUA1W^Ylb*QTYr%v>4^lH5QH!HV^cn%R6yrvu<|U(5rjRZ z$Wrqisa+4~v#~z4(3+AK&BXjdpV0;Ay;WzWh;DV*{Z32++={K7B98DoYtP1`pqzkP zO_7r%;q6A6+13-sTOM{2*>TPObWa66>+IfgY;a_CjlBT3qe=}Gjx6ur3HTrVW{Fxt zHlGD$LX>0SkRt|NrGhuPoBESmuq;tmdoR9?LKavFGPw6=@&%O}X f5!@Uq=nBn0jKJO8Fdhew00000NkvXXu0mjfM&?#H literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..eb2be2518782887fc5a85e5e7458088b76b63198 GIT binary patch literal 473 zcmV;~0Ve*5P)n0!$1^!3wXZ3TqH33 zIbSAo5)cGI5ClOG+%o`F`=!Wvn3w@e&Ur+kL#WP#gl`PNp?PO?K-R}^Z7l9ts#c{;+-<8Y?QYL% ziW`;0IgxdWI7hOMiCgF59Lwqwx8|A~*1j5yrZ~ehFSRpH+png${phh9tc&y94YrB% z+`6kql~Uhd5>;_&2*2g3;=EU<8Yzf41q&7gL{u7si4;^EK@bE%5IXn)RHBsDDO{M4 P00000NkvXXu0mjf+Ct1x literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4af4ae634b4c805b2b13c209262bba92d4ef5bdb GIT binary patch literal 269 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY0wn)GsXhawu6VjQhEy=Vy==(G$SBbIuwHt% zp3_UKM>>291zL-BrWtE$WMrxY&q^|md-S)saec+%`%mT+^YR*%zN!3_;NI;JthdL` z{KCnCCY1w%imdJpA~Ld3FOd=(E`?RG0HH+`PU-POx#QYR|iePyTZek-J~XvsWJ zNp;6pnWrr{gH+WWS3OqoV4b69!Fg%ZjL8ar=LoBC`b|jln)7GMS;OOVHd`te@;#f8 z>^I4HhOyA|j6Ta%GFL2C6b3wAaO`BGUn!j9*xM|{uGv?7Zl}4O+1d4_3mOH1{$ucT L^>bP0l+XkKk9}#` literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..51b4401ca053ca8cad6e9903646709a2f44444df GIT binary patch literal 309 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY0wn)GsXhaw{&>1LhEy=VysgJ$~x+g}6OvsxLimbaeN-!xQItoH=3nd`|H@dF!kYp_dChV`b9AudQLRn7yg2 zv~UiON9*MGOYAy6D=_*g@;c3(5`S(Hi^X;wk8Ms*3SMP^!WTf z-E_DAd$kLMWY`Z`);MuKF9=d$?_mzLoFj7Xp~|_3ODg!(3;AT&wLoS^O+0tsvcZJ& zD(m_$dNm(!9@n+CTyDa$w$*a^-$!>qK3P{4end8+qbAwoFEAV!JYD@<);T3K0RXh> Be)#|Z literal 0 HcmV?d00001 diff --git a/libraries/push/impl/src/main/res/values/colors.xml b/libraries/push/impl/src/main/res/values/colors.xml new file mode 100644 index 0000000000..6e04238a1a --- /dev/null +++ b/libraries/push/impl/src/main/res/values/colors.xml @@ -0,0 +1,22 @@ + + + + + + #368BD6 + + diff --git a/libraries/push/impl/src/main/res/values/dimens.xml b/libraries/push/impl/src/main/res/values/dimens.xml new file mode 100644 index 0000000000..ce2fee2015 --- /dev/null +++ b/libraries/push/impl/src/main/res/values/dimens.xml @@ -0,0 +1,21 @@ + + + + + 50dp + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 944b17ab52..0c0e3ec2cf 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") } From bc7fb0a2bbcdb4de71cfe140319c62af77a39c0d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 15 Mar 2023 10:05:36 +0100 Subject: [PATCH 045/107] Push: be able to test Push Create `:libraries:network` --- .../android/libraries/push/api/PushService.kt | 2 + .../push/api/gateway/PushGatewayFailure.kt | 21 +++++++ libraries/push/impl/build.gradle.kts | 4 +- .../libraries/push/impl/DefaultPushService.kt | 5 ++ .../libraries/push/impl/PushersManager.kt | 20 +++---- .../push/impl/pushgateway/PushGatewayAPI.kt | 30 ++++++++++ .../impl/pushgateway/PushGatewayConfig.kt | 22 ++++++++ .../impl/pushgateway/PushGatewayDevice.kt | 34 +++++++++++ .../pushgateway/PushGatewayNotification.kt | 32 +++++++++++ .../impl/pushgateway/PushGatewayNotifyBody.kt | 29 ++++++++++ .../pushgateway/PushGatewayNotifyRequest.kt | 56 +++++++++++++++++++ .../pushgateway/PushGatewayNotifyResponse.kt | 26 +++++++++ 12 files changed, 269 insertions(+), 12 deletions(-) create mode 100644 libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayConfig.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt index 3ed22d7dae..335ed9426c 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt @@ -20,4 +20,6 @@ interface PushService { fun setCurrentRoom(roomId: String?) fun setCurrentThread(threadId: String?) fun notificationStyleChanged() + + suspend fun testPush() } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt new file mode 100644 index 0000000000..9e8acc4d8f --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.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.api.gateway + +sealed class PushGatewayFailure : Throwable(cause = null) { + object PusherRejected : PushGatewayFailure() +} diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index 11950ad5f1..d98be34759 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(libs.androidx.corektx) implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.lifecycle.process) + implementation(libs.network.retrofit) implementation(libs.serialization.json) implementation(projects.libraries.architecture) @@ -41,16 +42,17 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.di) implementation(projects.libraries.androidutils) + implementation(projects.libraries.network) implementation(projects.libraries.matrix.api) implementation(projects.libraries.push.api) implementation(projects.services.toolbox.api) - api("me.gujun.android:span:1.7") { exclude(group = "com.android.support", module = "support-annotations") } + implementation(platform(libs.google.firebase.bom)) implementation("com.google.firebase:firebase-messaging-ktx") 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 index cf7a5f377b..bf3a252315 100644 --- 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 @@ -25,6 +25,7 @@ import javax.inject.Inject @ContributesBinding(AppScope::class) class DefaultPushService @Inject constructor( private val notificationDrawerManager: NotificationDrawerManager, + private val pusherManager: PushersManager, ) : PushService { override fun setCurrentRoom(roomId: String?) { notificationDrawerManager.setCurrentRoom(roomId) @@ -37,4 +38,8 @@ class DefaultPushService @Inject constructor( override fun notificationStyleChanged() { notificationDrawerManager.notificationStyleChanged() } + + override suspend fun testPush() { + pusherManager.testPush() + } } 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 index 2e87f360a5..c75cc6f76b 100644 --- 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 @@ -17,6 +17,7 @@ package io.element.android.libraries.push.impl import io.element.android.libraries.push.impl.config.PushConfig +import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest import io.element.android.libraries.toolbox.api.appname.AppNameProvider import java.util.UUID import javax.inject.Inject @@ -30,19 +31,17 @@ class PushersManager @Inject constructor( // private val localeProvider: LocaleProvider, private val appNameProvider: AppNameProvider, // private val getDeviceInfoUseCase: GetDeviceInfoUseCase, + private val pushGatewayNotifyRequest: PushGatewayNotifyRequest, ) { suspend fun testPush() { - /* - val currentSession = activeSessionHolder.getActiveSession() - - currentSession.pushersService().testPush( - unifiedPushHelper.getPushGateway() ?: return, - PushConfig.pusher_app_id, - unifiedPushHelper.getEndpointOrToken().orEmpty(), - TEST_EVENT_ID + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = unifiedPushHelper.getPushGateway() ?: return, + appId = PushConfig.pusher_app_id, + pushKey = unifiedPushHelper.getEndpointOrToken().orEmpty(), + eventId = TEST_EVENT_ID + ) ) - - */ } fun enqueueRegisterPusherWithFcmKey(pushKey: String): UUID { @@ -107,7 +106,6 @@ class PushersManager @Inject constructor( } */ - suspend fun unregisterEmailPusher(email: String) { // val currentSession = activeSessionHolder.getSafeActiveSession() ?: return // currentSession.pushersService().removeEmailPusher(email) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt new file mode 100644 index 0000000000..02bd7850e9 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.element.android.libraries.push.impl.pushgateway + + +import retrofit2.http.Body +import retrofit2.http.POST + +internal interface PushGatewayAPI { + /** + * Ask the Push Gateway to send a push to the current device. + * + * Ref: https://matrix.org/docs/spec/push_gateway/r0.1.1#post-matrix-push-v1-notify + */ + @POST(PushGatewayConfig.URI_PUSH_GATEWAY_PREFIX_PATH + "notify") + suspend fun notify(@Body body: PushGatewayNotifyBody): PushGatewayNotifyResponse +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayConfig.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayConfig.kt new file mode 100644 index 0000000000..5cd46f873d --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayConfig.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.pushgateway + +object PushGatewayConfig { + // Push Gateway + const val URI_PUSH_GATEWAY_PREFIX_PATH = "_matrix/push/v1/" +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt new file mode 100644 index 0000000000..7adedfcfd2 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.pushgateway + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class PushGatewayDevice( + /** + * Required. The app_id given when the pusher was created. + */ + @SerialName("app_id") + val appId: String, + /** + * Required. The pushkey given when the pusher was created. + */ + @SerialName("pushkey") + val pushKey: String +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt new file mode 100644 index 0000000000..b7649f6800 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.pushgateway + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class PushGatewayNotification( + @SerialName("event_id") + val eventId: String, + + /** + * Required. This is an array of devices that the notification should be sent to. + */ + @SerialName("devices") + val devices: List +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt new file mode 100644 index 0000000000..ce41d2d83e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.pushgateway + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class PushGatewayNotifyBody( + /** + * Required. Information about the push notification + */ + @SerialName("notification") + val notification: PushGatewayNotification +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt new file mode 100644 index 0000000000..37e97a238c --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt @@ -0,0 +1,56 @@ +/* + * 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.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: String + ) + + 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, + devices = listOf( + PushGatewayDevice( + params.appId, + params.pushKey + ) + ) + ) + ) + ) + + if (response.rejectedPushKeys.contains(params.pushKey)) { + throw PushGatewayFailure.PusherRejected + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt new file mode 100644 index 0000000000..13d9cbad1d --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.pushgateway + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class PushGatewayNotifyResponse( + @SerialName("rejected") + val rejectedPushKeys: List +) From 7ad385965e0277d3eda9df67e91c65b4f101ecd2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 15 Mar 2023 14:39:00 +0100 Subject: [PATCH 046/107] Add todos --- .../android/libraries/push/impl/PushersManager.kt | 9 ++++++--- .../push/impl/VectorFirebaseMessagingService.kt | 2 +- .../android/libraries/push/impl/VectorPushHandler.kt | 4 ++++ .../android/libraries/push/impl/model/PushData.kt | 2 ++ 4 files changed, 13 insertions(+), 4 deletions(-) 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 index c75cc6f76b..e8058eab3f 100644 --- 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 @@ -44,14 +44,14 @@ class PushersManager @Inject constructor( ) } - fun enqueueRegisterPusherWithFcmKey(pushKey: String): UUID { + fun enqueueRegisterPusherWithFcmKey(pushKey: String)/*: UUID*/ { return enqueueRegisterPusher(pushKey, PushConfig.pusher_http_url) } fun enqueueRegisterPusher( pushKey: String, gateway: String - ): UUID { + ) /*: UUID*/ { /* val currentSession = activeSessionHolder.getActiveSession() val pusher = createHttpPusher(pushKey, gateway) @@ -59,7 +59,10 @@ class PushersManager @Inject constructor( */ // TODO EAx - TODO() + // TODO() + // Get all sessions + // Register pusher + // Close sessions } private fun createHttpPusher( diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorFirebaseMessagingService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorFirebaseMessagingService.kt index 09dbf28a33..e9bccf7cdd 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorFirebaseMessagingService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorFirebaseMessagingService.kt @@ -47,7 +47,7 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() { Timber.tag(loggerTag.value).d("New Firebase token") fcmHelper.storeFcmToken(token) if ( - pushDataStore.areNotificationEnabledForDevice() && + // pushDataStore.areNotificationEnabledForDevice() && // TODO EAx activeSessionHolder.hasActiveSession() && unifiedPushHelper.isEmbeddedDistributor() ) { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt index 9fad5a37bc..6a73cedf92 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt @@ -114,6 +114,10 @@ class VectorPushHandler @Inject constructor( } /* TODO EAx + - Open session + - get the event + - display the notif + val session = activeSessionHolder.getOrInitializeSession() if (session == null) { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushData.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushData.kt index 9a91f1f1ac..75bed1027b 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushData.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushData.kt @@ -27,4 +27,6 @@ data class PushData( val eventId: String?, val roomId: String?, val unread: Int?, + + // TODO EAx Client secret ) From cb0cc70102a18f4eecc02841e592a9eb95430d38 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 17 Mar 2023 13:28:57 +0100 Subject: [PATCH 047/107] Fix compilation after rebase --- libraries/push/impl/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index d98be34759..f9a265bcf7 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -37,7 +37,6 @@ dependencies { implementation(libs.serialization.json) implementation(projects.libraries.architecture) - implementation(projects.libraries.analytics.api) implementation(projects.libraries.uiStrings) implementation(projects.libraries.core) implementation(projects.libraries.di) @@ -46,6 +45,7 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.push.api) + implementation(projects.services.analytics.api) implementation(projects.services.toolbox.api) api("me.gujun.android:span:1.7") { From d7fd3e3b2279363b77ba8d2cdc16e37bc223cc1e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 20 Mar 2023 14:57:35 +0100 Subject: [PATCH 048/107] Remove manifest from api module --- .../push/api/src/main/AndroidManifest.xml | 64 ------------------- 1 file changed, 64 deletions(-) delete mode 100644 libraries/push/api/src/main/AndroidManifest.xml diff --git a/libraries/push/api/src/main/AndroidManifest.xml b/libraries/push/api/src/main/AndroidManifest.xml deleted file mode 100644 index 1d6f459d91..0000000000 --- a/libraries/push/api/src/main/AndroidManifest.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 42dfaca929812c3010e5debf0fa0453d0c4cc01f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 20 Mar 2023 15:50:14 +0100 Subject: [PATCH 049/107] Add BuildVersionSdkIntProvider --- .../api/sdk/BuildVersionSdkIntProvider.kt | 40 +++++++++++++++++++ .../sdk/DefaultBuildVersionSdkIntProvider.kt | 29 ++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/sdk/BuildVersionSdkIntProvider.kt create mode 100644 services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/sdk/DefaultBuildVersionSdkIntProvider.kt diff --git a/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/sdk/BuildVersionSdkIntProvider.kt b/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/sdk/BuildVersionSdkIntProvider.kt new file mode 100644 index 0000000000..38f2e2227c --- /dev/null +++ b/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/sdk/BuildVersionSdkIntProvider.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.services.toolbox.api.sdk + +import androidx.annotation.ChecksSdkIntAtLeast + +interface BuildVersionSdkIntProvider { + /** + * Return the current version of the Android SDK. + */ + fun get(): Int + + /** + * Checks the if the current OS version is equal or greater than [version]. + * @return A `non-null` result if true, `null` otherwise. + */ + @ChecksSdkIntAtLeast(parameter = 0, lambda = 1) + fun whenAtLeast(version: Int, result: () -> T): T? { + return if (get() >= version) { + result() + } else null + } + + @ChecksSdkIntAtLeast(parameter = 0) + fun isAtLeast(version: Int) = get() >= version +} diff --git a/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/sdk/DefaultBuildVersionSdkIntProvider.kt b/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/sdk/DefaultBuildVersionSdkIntProvider.kt new file mode 100644 index 0000000000..d4ac1ec739 --- /dev/null +++ b/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/sdk/DefaultBuildVersionSdkIntProvider.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.services.toolbox.impl.sdk + +import android.os.Build +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultBuildVersionSdkIntProvider @Inject constructor() : + BuildVersionSdkIntProvider { + override fun get() = Build.VERSION.SDK_INT +} From c133caf4428b77596cbbe18f346f8a2f3bef3870 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 20 Mar 2023 16:29:33 +0100 Subject: [PATCH 050/107] Fix compilation after rebase. --- .../android/libraries/push/impl/PushersManager.kt | 3 +-- .../android/libraries/push/impl/UnifiedPushHelper.kt | 2 +- .../impl/notifications/NotifiableEventResolver.kt | 4 ++-- .../notifications/NotificationBroadcastReceiver.kt | 12 +++--------- .../push/impl/notifications/NotificationUtils.kt | 9 +++++---- .../impl/notifications/RoomGroupMessageCreator.kt | 2 +- .../impl/notifications/SummaryGroupMessageCreator.kt | 2 +- 7 files changed, 14 insertions(+), 20 deletions(-) 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 index e8058eab3f..5525b04d4c 100644 --- 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 @@ -18,8 +18,7 @@ package io.element.android.libraries.push.impl import io.element.android.libraries.push.impl.config.PushConfig import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest -import io.element.android.libraries.toolbox.api.appname.AppNameProvider -import java.util.UUID +import io.element.android.services.toolbox.api.appname.AppNameProvider import javax.inject.Inject internal const val DEFAULT_PUSHER_FILE_TAG = "mobile" 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 index 368f2e2336..958cd474be 100644 --- 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 @@ -20,7 +20,7 @@ import android.content.Context import io.element.android.libraries.androidutils.system.getApplicationLabel import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.push.impl.config.PushConfig -import io.element.android.libraries.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.strings.StringProvider import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.unifiedpush.android.connector.UnifiedPush 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 index 778fe20d7b..d9c3a8a167 100644 --- 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 @@ -16,10 +16,10 @@ package io.element.android.libraries.push.impl.notifications import io.element.android.libraries.core.meta.BuildMeta -import io.element.android.libraries.toolbox.api.strings.StringProvider import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent -import io.element.android.libraries.toolbox.api.systemclock.SystemClock +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock import javax.inject.Inject /** 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 index ea46654d88..21a006e3f4 100644 --- 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 @@ -20,25 +20,19 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import androidx.core.app.RemoteInput -import io.element.android.libraries.analytics.api.AnalyticsTracker -import io.element.android.libraries.analytics.api.plan.JoinedRoom import io.element.android.libraries.architecture.bindings -import io.element.android.libraries.core.data.tryOrNull -import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent -import io.element.android.libraries.toolbox.api.systemclock.SystemClock -import kotlinx.coroutines.launch +import io.element.android.services.analytics.api.AnalyticsTracker +import io.element.android.services.toolbox.api.systemclock.SystemClock import timber.log.Timber -import java.util.UUID import javax.inject.Inject -import io.element.android.libraries.ui.strings.R as StringR - /** * Receives actions broadcast by notification (on click, on dismiss, inline replies, etc.). */ class NotificationBroadcastReceiver : BroadcastReceiver() { @Inject lateinit var notificationDrawerManager: NotificationDrawerManager + //@Inject lateinit var activeSessionHolder: ActiveSessionHolder @Inject lateinit var analyticsTracker: AnalyticsTracker @Inject lateinit var clock: SystemClock 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 index fe2b9ccfd8..3c5110b67a 100755 --- 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 @@ -23,7 +23,6 @@ import android.app.Activity import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager -import androidx.core.content.getSystemService import android.app.PendingIntent import android.content.Context import android.content.Intent @@ -36,6 +35,7 @@ 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.intent.PendingIntentCompat import io.element.android.libraries.androidutils.system.startNotificationChannelSettingsIntent @@ -45,10 +45,10 @@ import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SingleIn import io.element.android.libraries.push.impl.R -import io.element.android.libraries.toolbox.api.strings.StringProvider import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent -import io.element.android.libraries.toolbox.api.systemclock.SystemClock +import 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 import io.element.android.libraries.ui.strings.R as StringR @@ -219,7 +219,8 @@ class NotificationUtils @Inject constructor( // Build the pending intent for when the notification is clicked val openIntent = when { threadId != null && - true /** TODO EAx vectorPreferences.areThreadMessagesEnabled() */ + true + /** TODO EAx vectorPreferences.areThreadMessagesEnabled() */ -> buildOpenThreadIntent(roomInfo, threadId) else -> buildOpenRoomIntent(roomInfo.roomId) } 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 index 34e8da9723..2a2311e591 100644 --- 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 @@ -19,8 +19,8 @@ package io.element.android.libraries.push.impl.notifications import android.graphics.Bitmap import androidx.core.app.NotificationCompat import androidx.core.app.Person -import io.element.android.libraries.toolbox.api.strings.StringProvider import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.services.toolbox.api.strings.StringProvider import me.gujun.android.span.Span import me.gujun.android.span.span import timber.log.Timber 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 index 864fd6c42b..13598f343a 100644 --- 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 @@ -18,7 +18,7 @@ package io.element.android.libraries.push.impl.notifications import android.app.Notification import androidx.core.app.NotificationCompat -import io.element.android.libraries.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.strings.StringProvider import javax.inject.Inject import io.element.android.libraries.ui.strings.R as StringR From 500d4801a7c74fa81cb2a9b1568cd875ffb0cc7f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 21 Mar 2023 18:48:38 +0100 Subject: [PATCH 051/107] Add permission modules --- appnav/build.gradle.kts | 1 + features/roomlist/impl/build.gradle.kts | 2 + .../roomlist/impl/RoomListPresenter.kt | 14 ++ .../features/roomlist/impl/RoomListState.kt | 2 + .../roomlist/impl/RoomListStateProvider.kt | 4 +- .../features/roomlist/impl/RoomListView.kt | 42 ++++-- .../androidutils/system/SystemUtils.kt | 17 +++ libraries/permissions/api/build.gradle.kts | 30 ++++ .../permissions/api/PermissionsEvents.kt | 23 +++ .../permissions/api/PermissionsPresenter.kt | 23 +++ .../permissions/api/PermissionsState.kt | 29 ++++ .../permissions/api/PermissionsView.kt | 95 ++++++++++++ .../api/PermissionsViewStateProvider.kt | 38 +++++ .../android/libraries/permissions/api/Util.kt | 27 ++++ libraries/permissions/impl/build.gradle.kts | 67 +++++++++ .../impl/DefaultPermissionsPresenter.kt | 139 ++++++++++++++++++ .../impl/DefaultPermissionsStore.kt | 76 ++++++++++ .../permissions/impl/PermissionsStore.kt | 32 ++++ .../impl/DefaultPermissionsPresenterTest.kt | 45 ++++++ .../impl/InMemoryPermissionsStore.kt | 48 ++++++ libraries/push/impl/build.gradle.kts | 2 +- .../NotificationPermissionManager.kt | 68 +++++++++ .../kotlin/extension/DependencyHandleScope.kt | 2 + 23 files changed, 811 insertions(+), 15 deletions(-) create mode 100644 libraries/permissions/api/build.gradle.kts create mode 100644 libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt create mode 100644 libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsPresenter.kt create mode 100644 libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsState.kt create mode 100644 libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt create mode 100644 libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsViewStateProvider.kt create mode 100644 libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/Util.kt create mode 100644 libraries/permissions/impl/build.gradle.kts create mode 100644 libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt create mode 100644 libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsStore.kt create mode 100644 libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/PermissionsStore.kt create mode 100644 libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt create mode 100644 libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/InMemoryPermissionsStore.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/permission/NotificationPermissionManager.kt diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 1cfcc40510..b2d2b391d8 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) + implementation(projects.libraries.push.api) implementation(projects.libraries.designsystem) implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) diff --git a/features/roomlist/impl/build.gradle.kts b/features/roomlist/impl/build.gradle.kts index e865527b80..436961141e 100644 --- a/features/roomlist/impl/build.gradle.kts +++ b/features/roomlist/impl/build.gradle.kts @@ -41,11 +41,13 @@ dependencies { implementation(projects.anvilannotations) anvil(projects.anvilcodegen) implementation(projects.libraries.core) + implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.elementresources) + implementation(projects.libraries.permissions.api) implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) implementation(projects.libraries.dateformatter.api) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index ac37dfe30d..e77480f7c0 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -16,6 +16,8 @@ package io.element.android.features.roomlist.impl +import android.Manifest +import android.os.Build import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -43,6 +45,8 @@ import io.element.android.libraries.matrix.api.room.RoomSummary import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.ui.model.MatrixUser +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.api.createDummyPostNotificationPermissionsState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -59,6 +63,7 @@ class RoomListPresenter @Inject constructor( private val roomLastMessageFormatter: RoomLastMessageFormatter, private val sessionVerificationService: SessionVerificationService, private val snackbarDispatcher: SnackbarDispatcher, + private val permissionsPresenter: PermissionsPresenter, ) : Presenter { private val roomMembershipObserver: RoomMembershipObserver = client.roomMembershipObserver() @@ -105,12 +110,21 @@ class RoomListPresenter @Inject constructor( val snackbarMessage = handleSnackbarMessage(snackbarDispatcher) + // Ask for POST_NOTIFICATION PERMISSION on Android 13+ + val permissionsState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissionsPresenter.setParameter(Manifest.permission.POST_NOTIFICATIONS) + permissionsPresenter.present() + } else { + createDummyPostNotificationPermissionsState() + } + return RoomListState( matrixUser = matrixUser.value, roomList = filteredRoomSummaries.value, filter = filter, displayVerificationPrompt = displayVerificationPrompt, snackbarMessage = snackbarMessage, + permissionsState = permissionsState, eventSink = ::handleEvents ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt index a14ef74e94..2deb13ff2b 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.Immutable import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.matrix.ui.model.MatrixUser +import io.element.android.libraries.permissions.api.PermissionsState import kotlinx.collections.immutable.ImmutableList @Immutable @@ -29,5 +30,6 @@ data class RoomListState( val filter: String, val displayVerificationPrompt: Boolean, val snackbarMessage: SnackbarMessage?, + val permissionsState: PermissionsState, val eventSink: (RoomListEvents) -> Unit ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt index d1ca647d62..62fd918cca 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.ui.model.MatrixUser +import io.element.android.libraries.permissions.api.createDummyPostNotificationPermissionsState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import io.element.android.libraries.ui.strings.R as StringR @@ -40,9 +41,10 @@ internal fun aRoomListState() = RoomListState( matrixUser = MatrixUser(id = UserId("@id"), username = "User#1", avatarData = AvatarData("@id", "U")), roomList = aRoomListRoomSummaryList(), filter = "filter", - eventSink = {}, snackbarMessage = null, displayVerificationPrompt = false, + permissionsState = createDummyPostNotificationPermissionsState(), + eventSink = {} ) internal fun aRoomListRoomSummaryList(): ImmutableList { diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt index 1f49d8db0f..9567ef9464 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -16,6 +16,7 @@ package io.element.android.features.roomlist.impl +import android.app.Activity import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -48,6 +49,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -57,6 +59,7 @@ import androidx.compose.ui.unit.dp import io.element.android.features.roomlist.impl.components.RoomListTopBar import io.element.android.features.roomlist.impl.components.RoomSummaryRow import io.element.android.features.roomlist.impl.model.RoomListRoomSummary +import io.element.android.libraries.androidutils.system.openAppSettingsPage import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight @@ -69,6 +72,7 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.coroutines.launch +import io.element.android.libraries.permissions.api.PermissionsView import io.element.android.libraries.designsystem.R as DrawableR import io.element.android.libraries.ui.strings.R as StringR @@ -81,14 +85,24 @@ fun RoomListView( onVerifyClicked: () -> Unit = {}, onCreateRoomClicked: () -> Unit = {}, ) { - RoomListContent( - state = state, - modifier = modifier, - onRoomClicked = onRoomClicked, - onOpenSettings = onOpenSettings, - onVerifyClicked = onVerifyClicked, - onCreateRoomClicked = onCreateRoomClicked, - ) + val activity = LocalContext.current as? Activity + + Box(modifier = modifier) { + RoomListContent( + state = state, + modifier = Modifier, + onRoomClicked = onRoomClicked, + onOpenSettings = onOpenSettings, + onVerifyClicked = onVerifyClicked, + onCreateRoomClicked = onCreateRoomClicked, + ) + PermissionsView( + state = state.permissionsState, + openSystemSettings = { + activity?.let { openAppSettingsPage(it, "") } + } + ) + } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @@ -197,11 +211,13 @@ fun RoomListContent( } }, snackbarHost = { - SnackbarHost (snackbarHostState) { data -> - Snackbar( - snackbarData = data, - ) - } + SnackbarHost(snackbarHostState) { data -> + Snackbar( + snackbarData = data, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.primary + ) + } }, ) } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt index 800da0d5b3..8f01f28545 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt @@ -121,6 +121,23 @@ fun startNotificationSettingsIntent(context: Context, activityResultLauncher: Ac activityResultLauncher.launch(intent) } +fun openAppSettingsPage( + activity: Activity, + noActivityFoundMessage: String, +) { + try { + activity.startActivity( + Intent().apply { + action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + data = Uri.fromParts("package", activity.packageName, null) + } + ) + } catch (activityNotFoundException: ActivityNotFoundException) { + activity.toast(noActivityFoundMessage) + } +} + /** * Shows notification system settings for the given channel id. */ diff --git a/libraries/permissions/api/build.gradle.kts b/libraries/permissions/api/build.gradle.kts new file mode 100644 index 0000000000..d86f790a44 --- /dev/null +++ b/libraries/permissions/api/build.gradle.kts @@ -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. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.permissions.api" +} + +dependencies { + implementation(projects.libraries.architecture) + + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) +} diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt new file mode 100644 index 0000000000..5267520320 --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.permissions.api + +sealed interface PermissionsEvents { + object OpenSystemDialog : PermissionsEvents + object CloseDialog : PermissionsEvents + object OpenSystemSettings : PermissionsEvents +} diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsPresenter.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsPresenter.kt new file mode 100644 index 0000000000..98519411bd --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsPresenter.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.permissions.api + +import io.element.android.libraries.architecture.Presenter + +interface PermissionsPresenter : Presenter { + fun setParameter(permission: String) +} diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsState.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsState.kt new file mode 100644 index 0000000000..975674820b --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsState.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.permissions.api + +data class PermissionsState( + // For instance Manifest.permission.POST_NOTIFICATIONS + val permission: String, + val permissionGranted: Boolean, + val shouldShowRationale: Boolean, + val showDialog: Boolean, + val permissionAlreadyAsked: Boolean, + // If true, there is no need to ask again, the system dialog will not be displayed + val permissionAlreadyDenied: Boolean, + val eventSink: (PermissionsEvents) -> Unit +) diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt new file mode 100644 index 0000000000..440b7e3e72 --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.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.permissions.api + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight + +@Composable +fun PermissionsView( + state: PermissionsState, + modifier: Modifier = Modifier, + openSystemSettings: () -> Unit = {}, +) { + if (state.showDialog.not()) return + + if (state.permissionGranted) { + // Notification Granted, nothing to do + } else if (state.permissionAlreadyDenied) { + // In this case, tell the user to go to the settings + ConfirmationDialog( + modifier = modifier, + title = "System", + content = "In order to let the application display notification, please grant the permission to the system settings", + submitText = "Open settings", + onSubmitClicked = { + state.eventSink.invoke(PermissionsEvents.OpenSystemSettings) + openSystemSettings() + }, + onDismiss = { state.eventSink.invoke(PermissionsEvents.CloseDialog) }, + ) + } else { + val textToShow = if (state.shouldShowRationale) { + // TODO Move to state + // If the user has denied the permission but the rationale can be shown, + // then gently explain why the app requires this permission + // permissions_rationale_msg_notification + "To be able to receive notifications, please grant the permission. Else you will not be able to be alerted if you've got new messages." + } else { + // TODO Move to state + // If it's the first time the user lands on this feature, or the user + // doesn't want to be asked again for this permission, explain that the + // permission is required + "To be able to receive notifications, please grant the permission." + } + ConfirmationDialog( + modifier = modifier, + title = "Notifications", + content = textToShow, + submitText = "Request permission", + onSubmitClicked = { + state.eventSink.invoke(PermissionsEvents.OpenSystemDialog) + }, + onCancelClicked = { + state.eventSink.invoke(PermissionsEvents.CloseDialog) + }, + onDismiss = {} + ) + } +} + +@Preview +@Composable +fun PermissionsViewLightPreview(@PreviewParameter(PermissionsViewStateProvider::class) state: PermissionsState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun PermissionsViewDarkPreview(@PreviewParameter(PermissionsViewStateProvider::class) state: PermissionsState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: PermissionsState) { + PermissionsView( + state = state, + ) +} diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsViewStateProvider.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsViewStateProvider.kt new file mode 100644 index 0000000000..5cf74aca90 --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsViewStateProvider.kt @@ -0,0 +1,38 @@ +/* + * 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.permissions.api + +import android.Manifest +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class PermissionsViewStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aPermissionsState(), + // Add other state here + ) +} + +fun aPermissionsState() = PermissionsState( + permission = Manifest.permission.INTERNET, + permissionGranted = false, + shouldShowRationale = false, + showDialog = true, + permissionAlreadyAsked = false, + permissionAlreadyDenied = false, + eventSink = {} +) diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/Util.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/Util.kt new file mode 100644 index 0000000000..b35ce36380 --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/Util.kt @@ -0,0 +1,27 @@ +/* + * 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.permissions.api + +fun createDummyPostNotificationPermissionsState() = PermissionsState( + permission = "Manifest.permission.POST_NOTIFICATIONS", + permissionGranted = true, + shouldShowRationale = false, + showDialog = false, + permissionAlreadyAsked = false, + permissionAlreadyDenied = false, + eventSink = { } +) diff --git a/libraries/permissions/impl/build.gradle.kts b/libraries/permissions/impl/build.gradle.kts new file mode 100644 index 0000000000..43608d36e8 --- /dev/null +++ b/libraries/permissions/impl/build.gradle.kts @@ -0,0 +1,67 @@ +/* + * 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-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.libraries.permissions.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + anvil(projects.anvilcodegen) + implementation(projects.anvilannotations) + + implementation(libs.accompanist.permission) + implementation(libs.androidx.datastore.preferences) + + implementation(projects.libraries.core) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.elementresources) + implementation(projects.libraries.uiStrings) + api(projects.libraries.permissions.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + + + androidTestImplementation(libs.test.junitext) + + ksp(libs.showkase.processor) +} diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt new file mode 100644 index 0000000000..db56946922 --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt @@ -0,0 +1,139 @@ +/* + * 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.permissions.impl + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.api.PermissionsState +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultPermissionsPresenter @Inject constructor( + private val permissionsStore: PermissionsStore, +) : PermissionsPresenter { + + private lateinit var permission: String + + // TODO Move to the constructor. + override fun setParameter(permission: String) { + this.permission = permission + } + + @OptIn(ExperimentalPermissionsApi::class) + @SuppressLint("InlinedApi") + @Composable + override fun present(): PermissionsState { + val localCoroutineScope = rememberCoroutineScope() + + // To reset the store: resetStore() + + val isAlreadyDenied: Boolean by permissionsStore + .isPermissionDenied(permission) + .collectAsState(initial = false) + + val isAlreadyAsked: Boolean by permissionsStore + .isPermissionAsked(permission) + .collectAsState(initial = false) + + var permissionState: PermissionState? = null + + fun onPermissionResult(result: Boolean) { + Timber.tag("PERMISSION").w("onPermissionResult: $result") + localCoroutineScope.launch { + permissionsStore.setPermissionAsked(permission, true) + } + + if (!result) { + // Should show rational true -> denied. + if (permissionState?.status?.shouldShowRationale == true) { + Timber.tag("PERMISSION").w("onPermissionResult: reset the store") + localCoroutineScope.launch { + permissionsStore.setPermissionDenied(permission, true) + } + } + } + } + + permissionState = rememberPermissionState( + permission = permission, + onPermissionResult = ::onPermissionResult + ) + + LaunchedEffect(this) { + if (permissionState.status.isGranted) { + // User may have granted permission from the settings, to reset the store regarding this permission + permissionsStore.resetPermission(permission) + } + } + + val showDialog = rememberSaveable { mutableStateOf(true) } + + fun handleEvents(event: PermissionsEvents) { + Timber.tag("PERMISSION").w("New event: $event") + when (event) { + PermissionsEvents.CloseDialog -> { + showDialog.value = false + } + PermissionsEvents.OpenSystemDialog -> { + permissionState.launchPermissionRequest() + showDialog.value = false + } + PermissionsEvents.OpenSystemSettings -> { + showDialog.value = false + } + } + } + + return PermissionsState( + permission = permissionState.permission, + permissionGranted = permissionState.status.isGranted, + shouldShowRationale = permissionState.status.shouldShowRationale, + showDialog = showDialog.value, + permissionAlreadyAsked = isAlreadyAsked, + permissionAlreadyDenied = isAlreadyDenied, + eventSink = ::handleEvents + ).also { + Timber.tag("PERMISSION").w("New state: $it") + } + } + + @Composable + private fun resetStore() { + LaunchedEffect(this@DefaultPermissionsPresenter) { + launch { + permissionsStore.resetStore() + } + } + } +} diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsStore.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsStore.kt new file mode 100644 index 0000000000..9ee29b7a61 --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsStore.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. + */ + +package io.element.android.libraries.permissions.impl + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +private val Context.dataStore: DataStore by preferencesDataStore(name = "permissions_store") + +@ContributesBinding(AppScope::class) +class DefaultPermissionsStore @Inject constructor( + @ApplicationContext context: Context, +) : PermissionsStore { + private val store = context.dataStore + + override suspend fun setPermissionDenied(permission: String, value: Boolean) { + store.edit { prefs -> + prefs[getDeniedPreferenceKey(permission)] = value + } + } + + override fun isPermissionDenied(permission: String): Flow { + return store.data.map { + it[getDeniedPreferenceKey(permission)].orFalse() + } + } + + override suspend fun setPermissionAsked(permission: String, value: Boolean) { + store.edit { prefs -> + prefs[getAskedPreferenceKey(permission)] = value + } + } + + override fun isPermissionAsked(permission: String): Flow { + return store.data.map { + it[getAskedPreferenceKey(permission)].orFalse() + } + } + + override suspend fun resetPermission(permission: String) { + setPermissionAsked(permission, false) + setPermissionDenied(permission, false) + } + + override suspend fun resetStore() { + store.edit { it.clear() } + } + + private fun getDeniedPreferenceKey(permission: String) = booleanPreferencesKey("${permission}_denied") + private fun getAskedPreferenceKey(permission: String) = booleanPreferencesKey("${permission}_asked") +} diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/PermissionsStore.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/PermissionsStore.kt new file mode 100644 index 0000000000..25b41e2a71 --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/PermissionsStore.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.permissions.impl + +import kotlinx.coroutines.flow.Flow + +interface PermissionsStore { + suspend fun setPermissionDenied(permission: String, value: Boolean) + fun isPermissionDenied(permission: String): Flow + + suspend fun setPermissionAsked(permission: String, value: Boolean) + fun isPermissionAsked(permission: String): Flow + + suspend fun resetPermission(permission: String) + + // To debug + suspend fun resetStore() +} diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt new file mode 100644 index 0000000000..ba4c59b2e7 --- /dev/null +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.permissions.impl + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +const val A_PERMISSION = "A_PERMISSION" + +class DefaultPermissionsPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = DefaultPermissionsPresenter( + InMemoryPermissionsStore() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.setParameter(A_PERMISSION) + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.showDialog).isTrue() + } + } +} diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/InMemoryPermissionsStore.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/InMemoryPermissionsStore.kt new file mode 100644 index 0000000000..08a3f76130 --- /dev/null +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/InMemoryPermissionsStore.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.libraries.permissions.impl + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class InMemoryPermissionsStore( + permissionDenied: Boolean = false, + permissionAsked: Boolean = false, +) : PermissionsStore { + private val permissionDeniedFlow = MutableStateFlow(permissionDenied) + private val permissionAskedFlow = MutableStateFlow(permissionAsked) + + override suspend fun setPermissionDenied(permission: String, value: Boolean) { + permissionDeniedFlow.value = value + } + + override fun isPermissionDenied(permission: String): Flow = permissionDeniedFlow + + override suspend fun setPermissionAsked(permission: String, value: Boolean) { + permissionAskedFlow.value + } + + override fun isPermissionAsked(permission: String): Flow = permissionAskedFlow + + override suspend fun resetPermission(permission: String) { + setPermissionAsked(permission, false) + setPermissionDenied(permission, false) + } + + override suspend fun resetStore() { + } +} diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index f9a265bcf7..bb1c5bfa76 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -43,7 +43,7 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.network) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.push.api) + api(projects.libraries.push.api) implementation(projects.services.analytics.api) implementation(projects.services.toolbox.api) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/permission/NotificationPermissionManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/permission/NotificationPermissionManager.kt new file mode 100644 index 0000000000..e1fd17332e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/permission/NotificationPermissionManager.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.permission + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import io.element.android.libraries.di.ApplicationContext +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import javax.inject.Inject + +// TODO EAx move +class NotificationPermissionManager @Inject constructor( + private val sdkIntProvider: BuildVersionSdkIntProvider, + @ApplicationContext private val context: Context, +) { + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + fun isPermissionGranted(): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } + + /* + fun eventuallyRequestPermission( + activity: Activity, + requestPermissionLauncher: ActivityResultLauncher>, + showRationale: Boolean = true, + ignorePreference: Boolean = false, + ) { + if (!sdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) return + // if (!vectorPreferences.areNotificationEnabledForDevice() && !ignorePreference) return + checkPermissions( + listOf(Manifest.permission.POST_NOTIFICATIONS), + activity, + activityResultLauncher = requestPermissionLauncher, + if (showRationale) R.string.permissions_rationale_msg_notification else 0 + ) + } + */ + + fun eventuallyRevokePermission( + activity: Activity, + ) { + if (!sdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) return + activity.revokeSelfPermissionOnKill(Manifest.permission.POST_NOTIFICATIONS) + } +} diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index 314421ebc8..9f0cf92099 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -73,6 +73,8 @@ fun DependencyHandlerScope.allLibrariesImpl() { implementation(project(":libraries:matrixui")) implementation(project(":libraries:network")) implementation(project(":libraries:core")) + implementation(project(":libraries:permissions:impl")) + implementation(project(":libraries:push:impl")) implementation(project(":libraries:architecture")) implementation(project(":libraries:dateformatter:impl")) implementation(project(":libraries:di")) From dfc759685d15cd3228afdad8728aa4109b812ad1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 22 Mar 2023 10:10:19 +0100 Subject: [PATCH 052/107] Be able to test `PermissionsPresenterTest`. Create interface to abstract Accompanist implementation --- .../permissions/api/PermissionsEvents.kt | 1 - .../permissions/api/PermissionsView.kt | 2 +- .../AccompanistPermissionStateProvider.kt | 43 ++++++ .../impl/DefaultPermissionsPresenter.kt | 10 +- .../impl/DefaultPermissionsPresenterTest.kt | 145 +++++++++++++++++- .../impl/FakePermissionStateProvider.kt | 62 ++++++++ .../impl/InMemoryPermissionsStore.kt | 2 +- 7 files changed, 254 insertions(+), 11 deletions(-) create mode 100644 libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/AccompanistPermissionStateProvider.kt create mode 100644 libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/FakePermissionStateProvider.kt diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt index 5267520320..a0b2411459 100644 --- a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt @@ -19,5 +19,4 @@ package io.element.android.libraries.permissions.api sealed interface PermissionsEvents { object OpenSystemDialog : PermissionsEvents object CloseDialog : PermissionsEvents - object OpenSystemSettings : PermissionsEvents } diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt index 440b7e3e72..382d8e9653 100644 --- a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt @@ -42,7 +42,7 @@ fun PermissionsView( content = "In order to let the application display notification, please grant the permission to the system settings", submitText = "Open settings", onSubmitClicked = { - state.eventSink.invoke(PermissionsEvents.OpenSystemSettings) + state.eventSink.invoke(PermissionsEvents.CloseDialog) openSystemSettings() }, onDismiss = { state.eventSink.invoke(PermissionsEvents.CloseDialog) }, diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/AccompanistPermissionStateProvider.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/AccompanistPermissionStateProvider.kt new file mode 100644 index 0000000000..15acd868f2 --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/AccompanistPermissionStateProvider.kt @@ -0,0 +1,43 @@ +/* + * 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(ExperimentalPermissionsApi::class) + +package io.element.android.libraries.permissions.impl + +import androidx.compose.runtime.Composable +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.rememberPermissionState +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +interface PermissionStateProvider { + @Composable + fun provide(permission: String, onPermissionResult: (Boolean) -> Unit): PermissionState +} + +@ContributesBinding(AppScope::class) +class AccompanistPermissionStateProvider @Inject constructor() : PermissionStateProvider { + @Composable + override fun provide(permission: String, onPermissionResult: (Boolean) -> Unit): PermissionState { + return rememberPermissionState( + permission = permission, + onPermissionResult = onPermissionResult + ) + } +} diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt index db56946922..12eb5b81fd 100644 --- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt @@ -26,8 +26,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.shouldShowRationale import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope @@ -41,6 +41,7 @@ import javax.inject.Inject @ContributesBinding(AppScope::class) class DefaultPermissionsPresenter @Inject constructor( private val permissionsStore: PermissionsStore, + private val permissionStateProvider: PermissionStateProvider, ) : PermissionsPresenter { private lateinit var permission: String @@ -85,7 +86,7 @@ class DefaultPermissionsPresenter @Inject constructor( } } - permissionState = rememberPermissionState( + permissionState = permissionStateProvider.provide( permission = permission, onPermissionResult = ::onPermissionResult ) @@ -97,7 +98,7 @@ class DefaultPermissionsPresenter @Inject constructor( } } - val showDialog = rememberSaveable { mutableStateOf(true) } + val showDialog = rememberSaveable { mutableStateOf(permissionState.status !is PermissionStatus.Granted) } fun handleEvents(event: PermissionsEvents) { Timber.tag("PERMISSION").w("New event: $event") @@ -109,9 +110,6 @@ class DefaultPermissionsPresenter @Inject constructor( permissionState.launchPermissionRequest() showDialog.value = false } - PermissionsEvents.OpenSystemSettings -> { - showDialog.value = false - } } } diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt index ba4c59b2e7..be326a71ed 100644 --- a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt @@ -14,14 +14,17 @@ * limitations under the License. */ -@file:OptIn(ExperimentalCoroutinesApi::class) +@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalPermissionsApi::class) package io.element.android.libraries.permissions.impl import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.permissions.api.PermissionsEvents import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test @@ -31,8 +34,35 @@ const val A_PERMISSION = "A_PERMISSION" class DefaultPermissionsPresenterTest { @Test fun `present - initial state`() = runTest { + val permissionsStore = InMemoryPermissionsStore() + val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Granted) + val permissionStateProvider = FakePermissionStateProvider(permissionState) val presenter = DefaultPermissionsPresenter( - InMemoryPermissionsStore() + permissionsStore, + permissionStateProvider + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.setParameter(A_PERMISSION) + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.permission).isEqualTo(A_PERMISSION) + assertThat(initialState.permissionGranted).isTrue() + assertThat(initialState.shouldShowRationale).isFalse() + assertThat(initialState.permissionAlreadyAsked).isFalse() + assertThat(initialState.permissionAlreadyDenied).isFalse() + assertThat(initialState.showDialog).isFalse() + } + } + + @Test + fun `present - user closes dialog`() = runTest { + val permissionsStore = InMemoryPermissionsStore() + val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false)) + val permissionStateProvider = FakePermissionStateProvider(permissionState) + val presenter = DefaultPermissionsPresenter( + permissionsStore, + permissionStateProvider ) moleculeFlow(RecompositionClock.Immediate) { presenter.setParameter(A_PERMISSION) @@ -40,6 +70,117 @@ class DefaultPermissionsPresenterTest { }.test { val initialState = awaitItem() assertThat(initialState.showDialog).isTrue() + initialState.eventSink.invoke(PermissionsEvents.CloseDialog) + assertThat(awaitItem().showDialog).isFalse() + } + } + + @Test + fun `present - user does not grant permission`() = runTest { + val permissionsStore = InMemoryPermissionsStore() + val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false)) + val permissionStateProvider = FakePermissionStateProvider(permissionState) + val presenter = DefaultPermissionsPresenter( + permissionsStore, + permissionStateProvider + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.setParameter(A_PERMISSION) + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.showDialog).isTrue() + initialState.eventSink.invoke(PermissionsEvents.OpenSystemDialog) + assertThat(permissionState.launchPermissionRequestCalled).isTrue() + assertThat(awaitItem().showDialog).isFalse() + // User does not grant permission + permissionStateProvider.userGiveAnswer(answer = false, firstTime = true) + skipItems(1) + val state = awaitItem() + assertThat(state.permissionGranted).isFalse() + assertThat(state.showDialog).isFalse() + assertThat(state.permissionAlreadyDenied).isFalse() + assertThat(state.permissionAlreadyAsked).isTrue() + } + } + + @Test + fun `present - user does not grant permission second time`() = runTest { + val permissionsStore = InMemoryPermissionsStore() + val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = true)) + val permissionStateProvider = FakePermissionStateProvider(permissionState) + val presenter = DefaultPermissionsPresenter( + permissionsStore, + permissionStateProvider + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.setParameter(A_PERMISSION) + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.showDialog).isTrue() + initialState.eventSink.invoke(PermissionsEvents.OpenSystemDialog) + assertThat(permissionState.launchPermissionRequestCalled).isTrue() + assertThat(awaitItem().showDialog).isFalse() + // User does not grant permission + permissionStateProvider.userGiveAnswer(answer = false, firstTime = false) + skipItems(2) + val state = awaitItem() + assertThat(state.permissionGranted).isFalse() + assertThat(state.showDialog).isFalse() + assertThat(state.permissionAlreadyDenied).isTrue() + assertThat(state.permissionAlreadyAsked).isTrue() + } + } + + @Test + fun `present - user does not grant permission third time`() = runTest { + val permissionsStore = InMemoryPermissionsStore(permissionDenied = true, permissionAsked = true) + val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false)) + val permissionStateProvider = FakePermissionStateProvider(permissionState) + val presenter = DefaultPermissionsPresenter( + permissionsStore, + permissionStateProvider + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.setParameter(A_PERMISSION) + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.showDialog).isTrue() + assertThat(initialState.permissionGranted).isFalse() + assertThat(initialState.permissionAlreadyDenied).isTrue() + assertThat(initialState.permissionAlreadyAsked).isTrue() + } + } + + @Test + fun `present - user grants permission`() = runTest { + val permissionsStore = InMemoryPermissionsStore() + val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false)) + val permissionStateProvider = FakePermissionStateProvider(permissionState) + val presenter = DefaultPermissionsPresenter( + permissionsStore, + permissionStateProvider + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.setParameter(A_PERMISSION) + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.showDialog).isTrue() + initialState.eventSink.invoke(PermissionsEvents.OpenSystemDialog) + assertThat(permissionState.launchPermissionRequestCalled).isTrue() + assertThat(awaitItem().showDialog).isFalse() + // User grants permission + permissionStateProvider.userGiveAnswer(answer = true, firstTime = true) + skipItems(1) + val state = awaitItem() + assertThat(state.permissionGranted).isTrue() + assertThat(state.showDialog).isFalse() + assertThat(state.permissionAlreadyDenied).isFalse() + assertThat(state.permissionAlreadyAsked).isTrue() } } } diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/FakePermissionStateProvider.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/FakePermissionStateProvider.kt new file mode 100644 index 0000000000..2c67061811 --- /dev/null +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/FakePermissionStateProvider.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. + */ + +@file:OptIn(ExperimentalPermissionsApi::class) + +package io.element.android.libraries.permissions.impl + +import androidx.compose.runtime.* +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.PermissionStatus + +class FakePermissionStateProvider constructor( + private val permissionState: FakePermissionState +) : PermissionStateProvider { + private lateinit var onPermissionResult: (Boolean) -> Unit + + @OptIn(ExperimentalPermissionsApi::class) + @Composable + override fun provide(permission: String, onPermissionResult: (Boolean) -> Unit): PermissionState { + this.onPermissionResult = onPermissionResult + return permissionState + } + + fun userGiveAnswer(answer: Boolean, firstTime: Boolean) { + onPermissionResult.invoke(answer) + permissionState.givenPermissionStatus(answer, firstTime) + } +} + +@Stable +class FakePermissionState( + override val permission: String, + initialStatus: PermissionStatus, +) : PermissionState { + + override var status: PermissionStatus by mutableStateOf(initialStatus) + + var launchPermissionRequestCalled = false + private set + + override fun launchPermissionRequest() { + launchPermissionRequestCalled = true + } + + fun givenPermissionStatus(hasPermission: Boolean, shouldShowRationale: Boolean) { + status = if (hasPermission) PermissionStatus.Granted else PermissionStatus.Denied(shouldShowRationale) + } +} diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/InMemoryPermissionsStore.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/InMemoryPermissionsStore.kt index 08a3f76130..3f5d925ccd 100644 --- a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/InMemoryPermissionsStore.kt +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/InMemoryPermissionsStore.kt @@ -33,7 +33,7 @@ class InMemoryPermissionsStore( override fun isPermissionDenied(permission: String): Flow = permissionDeniedFlow override suspend fun setPermissionAsked(permission: String, value: Boolean) { - permissionAskedFlow.value + permissionAskedFlow.value = value } override fun isPermissionAsked(permission: String): Flow = permissionAskedFlow From b911229f53b3166aea7cf1071c786dfbe8db69db Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 22 Mar 2023 11:38:46 +0100 Subject: [PATCH 053/107] Create noop version for the minimal sample and test. --- features/roomlist/impl/build.gradle.kts | 1 + .../roomlist/impl/RoomListPresenterTests.kt | 8 ++++ libraries/permissions/noop/build.gradle.kts | 30 ++++++++++++++ .../noop/NoopPermissionsPresenter.kt | 39 +++++++++++++++++++ samples/minimal/build.gradle.kts | 1 + .../android/samples/minimal/RoomListScreen.kt | 3 ++ 6 files changed, 82 insertions(+) create mode 100644 libraries/permissions/noop/build.gradle.kts create mode 100644 libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenter.kt diff --git a/features/roomlist/impl/build.gradle.kts b/features/roomlist/impl/build.gradle.kts index 436961141e..79db4d5360 100644 --- a/features/roomlist/impl/build.gradle.kts +++ b/features/roomlist/impl/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { testImplementation(libs.test.robolectric) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.dateformatter.test) + testImplementation(projects.libraries.permissions.noop) androidTestImplementation(libs.test.junitext) } diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index 3f3e43e2e7..dc16984e53 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter import kotlinx.coroutines.test.runTest import org.junit.Test @@ -50,6 +51,7 @@ class RoomListPresenterTests { FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), SnackbarDispatcher(), + NoopPermissionsPresenter(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -77,6 +79,7 @@ class RoomListPresenterTests { FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), SnackbarDispatcher(), + NoopPermissionsPresenter(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -98,6 +101,7 @@ class RoomListPresenterTests { FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), SnackbarDispatcher(), + NoopPermissionsPresenter(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -123,6 +127,7 @@ class RoomListPresenterTests { FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), SnackbarDispatcher(), + NoopPermissionsPresenter(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -153,6 +158,7 @@ class RoomListPresenterTests { FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), SnackbarDispatcher(), + NoopPermissionsPresenter(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -188,6 +194,7 @@ class RoomListPresenterTests { FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), SnackbarDispatcher(), + NoopPermissionsPresenter(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -237,6 +244,7 @@ class RoomListPresenterTests { givenVerifiedStatus(SessionVerifiedStatus.NotVerified) }, SnackbarDispatcher(), + NoopPermissionsPresenter(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() diff --git a/libraries/permissions/noop/build.gradle.kts b/libraries/permissions/noop/build.gradle.kts new file mode 100644 index 0000000000..7319f73104 --- /dev/null +++ b/libraries/permissions/noop/build.gradle.kts @@ -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. + */ + +// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.permissions.noop" +} + +dependencies { + implementation(projects.libraries.architecture) + api(projects.libraries.permissions.api) +} diff --git a/libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenter.kt b/libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenter.kt new file mode 100644 index 0000000000..293edb2cac --- /dev/null +++ b/libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenter.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.permissions.noop + +import androidx.compose.runtime.Composable +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.api.PermissionsState + +class NoopPermissionsPresenter: PermissionsPresenter { + + override fun setParameter(permission: String) = Unit + + @Composable + override fun present(): PermissionsState { + return PermissionsState( + permission = "", + permissionGranted = false, + shouldShowRationale = false, + showDialog = false, + permissionAlreadyAsked = false, + permissionAlreadyDenied = false, + eventSink = {}, + ) + } +} diff --git a/samples/minimal/build.gradle.kts b/samples/minimal/build.gradle.kts index 9ad0df0c0c..8ad1c125d7 100644 --- a/samples/minimal/build.gradle.kts +++ b/samples/minimal/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.impl) + implementation(projects.libraries.permissions.noop) implementation(projects.libraries.sessionStorage.implMemory) implementation(projects.libraries.designsystem) implementation(projects.libraries.architecture) diff --git a/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 5dce2feafe..7d10663027 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 @@ -29,6 +29,7 @@ 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 io.element.android.libraries.permissions.noop.NoopPermissionsPresenter import kotlinx.coroutines.launch import kotlinx.datetime.Clock import kotlinx.datetime.TimeZone @@ -44,12 +45,14 @@ class RoomListScreen( private val dateTimeProvider = LocalDateTimeProvider(clock, timeZone) private val dateFormatters = DateFormatters(locale, clock, timeZone) private val sessionVerificationService = matrixClient.sessionVerificationService() + private val permissionsPresenter = NoopPermissionsPresenter() private val presenter = RoomListPresenter( matrixClient, DefaultLastMessageTimestampFormatter(dateTimeProvider, dateFormatters), DefaultRoomLastMessageFormatter(context, matrixClient), sessionVerificationService, SnackbarDispatcher(), + permissionsPresenter, ) @Composable From 581ec6a1afcf4222296e79c55fbaf32126c04629 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 22 Mar 2023 12:04:22 +0100 Subject: [PATCH 054/107] Use presenter factory --- features/roomlist/impl/build.gradle.kts | 1 + .../roomlist/impl/RoomListPresenter.kt | 21 +++++++++-------- .../roomlist/impl/RoomListPresenterTests.kt | 16 ++++++------- .../permissions/api/PermissionsPresenter.kt | 5 +++- .../impl/DefaultPermissionsPresenter.kt | 17 +++++++------- .../impl/DefaultPermissionsPresenterTest.kt | 12 +++++----- .../noop/NoopPermissionsPresenter.kt | 4 +--- .../noop/NoopPermissionsPresenterFactory.kt | 23 +++++++++++++++++++ .../android/samples/minimal/RoomListScreen.kt | 6 ++--- 9 files changed, 67 insertions(+), 38 deletions(-) create mode 100644 libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterFactory.kt diff --git a/features/roomlist/impl/build.gradle.kts b/features/roomlist/impl/build.gradle.kts index 79db4d5360..b679c2d823 100644 --- a/features/roomlist/impl/build.gradle.kts +++ b/features/roomlist/impl/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.elementresources) implementation(projects.libraries.permissions.api) + implementation(projects.libraries.permissions.noop) implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) implementation(projects.libraries.dateformatter.api) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index e77480f7c0..c38120161b 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -46,7 +46,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.ui.model.MatrixUser import io.element.android.libraries.permissions.api.PermissionsPresenter -import io.element.android.libraries.permissions.api.createDummyPostNotificationPermissionsState +import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -63,11 +63,20 @@ class RoomListPresenter @Inject constructor( private val roomLastMessageFormatter: RoomLastMessageFormatter, private val sessionVerificationService: SessionVerificationService, private val snackbarDispatcher: SnackbarDispatcher, - private val permissionsPresenter: PermissionsPresenter, + private val permissionsPresenterFactory: PermissionsPresenter.Factory, ) : Presenter { private val roomMembershipObserver: RoomMembershipObserver = client.roomMembershipObserver() + private val postNotificationPermissionsPresenter by lazy { + // Ask for POST_NOTIFICATION PERMISSION on Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS) + } else { + NoopPermissionsPresenter() + } + } + @Composable override fun present(): RoomListState { val matrixUser: MutableState = remember { @@ -110,13 +119,7 @@ class RoomListPresenter @Inject constructor( val snackbarMessage = handleSnackbarMessage(snackbarDispatcher) - // Ask for POST_NOTIFICATION PERMISSION on Android 13+ - val permissionsState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - permissionsPresenter.setParameter(Manifest.permission.POST_NOTIFICATIONS) - permissionsPresenter.present() - } else { - createDummyPostNotificationPermissionsState() - } + val permissionsState = postNotificationPermissionsPresenter.present() return RoomListState( matrixUser = matrixUser.value, diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index dc16984e53..18ead8f3e6 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -37,7 +37,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService -import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter +import io.element.android.libraries.permissions.noop.NoopPermissionsPresenterFactory import kotlinx.coroutines.test.runTest import org.junit.Test @@ -51,7 +51,7 @@ class RoomListPresenterTests { FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), SnackbarDispatcher(), - NoopPermissionsPresenter(), + NoopPermissionsPresenterFactory(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -79,7 +79,7 @@ class RoomListPresenterTests { FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), SnackbarDispatcher(), - NoopPermissionsPresenter(), + NoopPermissionsPresenterFactory(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -101,7 +101,7 @@ class RoomListPresenterTests { FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), SnackbarDispatcher(), - NoopPermissionsPresenter(), + NoopPermissionsPresenterFactory(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -127,7 +127,7 @@ class RoomListPresenterTests { FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), SnackbarDispatcher(), - NoopPermissionsPresenter(), + NoopPermissionsPresenterFactory(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -158,7 +158,7 @@ class RoomListPresenterTests { FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), SnackbarDispatcher(), - NoopPermissionsPresenter(), + NoopPermissionsPresenterFactory(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -194,7 +194,7 @@ class RoomListPresenterTests { FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), SnackbarDispatcher(), - NoopPermissionsPresenter(), + NoopPermissionsPresenterFactory(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -244,7 +244,7 @@ class RoomListPresenterTests { givenVerifiedStatus(SessionVerifiedStatus.NotVerified) }, SnackbarDispatcher(), - NoopPermissionsPresenter(), + NoopPermissionsPresenterFactory(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsPresenter.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsPresenter.kt index 98519411bd..c4ab065ca0 100644 --- a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsPresenter.kt +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsPresenter.kt @@ -19,5 +19,8 @@ package io.element.android.libraries.permissions.api import io.element.android.libraries.architecture.Presenter interface PermissionsPresenter : Presenter { - fun setParameter(permission: String) + + interface Factory { + fun create(permission: String): PermissionsPresenter + } } diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt index 12eb5b81fd..c09a595783 100644 --- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt @@ -30,25 +30,26 @@ import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.shouldShowRationale import com.squareup.anvil.annotations.ContributesBinding +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import io.element.android.libraries.di.AppScope import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsState import kotlinx.coroutines.launch import timber.log.Timber -import javax.inject.Inject -@ContributesBinding(AppScope::class) -class DefaultPermissionsPresenter @Inject constructor( +class DefaultPermissionsPresenter @AssistedInject constructor( + @Assisted val permission: String, private val permissionsStore: PermissionsStore, private val permissionStateProvider: PermissionStateProvider, ) : PermissionsPresenter { - private lateinit var permission: String - - // TODO Move to the constructor. - override fun setParameter(permission: String) { - this.permission = permission + @AssistedFactory + @ContributesBinding(AppScope::class) + interface Factory : PermissionsPresenter.Factory { + override fun create(permission: String): DefaultPermissionsPresenter } @OptIn(ExperimentalPermissionsApi::class) diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt index be326a71ed..10e6edf3f5 100644 --- a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt @@ -38,11 +38,11 @@ class DefaultPermissionsPresenterTest { val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Granted) val permissionStateProvider = FakePermissionStateProvider(permissionState) val presenter = DefaultPermissionsPresenter( + A_PERMISSION, permissionsStore, permissionStateProvider ) moleculeFlow(RecompositionClock.Immediate) { - presenter.setParameter(A_PERMISSION) presenter.present() }.test { val initialState = awaitItem() @@ -61,11 +61,11 @@ class DefaultPermissionsPresenterTest { val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false)) val permissionStateProvider = FakePermissionStateProvider(permissionState) val presenter = DefaultPermissionsPresenter( + A_PERMISSION, permissionsStore, permissionStateProvider ) moleculeFlow(RecompositionClock.Immediate) { - presenter.setParameter(A_PERMISSION) presenter.present() }.test { val initialState = awaitItem() @@ -81,11 +81,11 @@ class DefaultPermissionsPresenterTest { val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false)) val permissionStateProvider = FakePermissionStateProvider(permissionState) val presenter = DefaultPermissionsPresenter( + A_PERMISSION, permissionsStore, permissionStateProvider ) moleculeFlow(RecompositionClock.Immediate) { - presenter.setParameter(A_PERMISSION) presenter.present() }.test { val initialState = awaitItem() @@ -110,11 +110,11 @@ class DefaultPermissionsPresenterTest { val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = true)) val permissionStateProvider = FakePermissionStateProvider(permissionState) val presenter = DefaultPermissionsPresenter( + A_PERMISSION, permissionsStore, permissionStateProvider ) moleculeFlow(RecompositionClock.Immediate) { - presenter.setParameter(A_PERMISSION) presenter.present() }.test { val initialState = awaitItem() @@ -139,11 +139,11 @@ class DefaultPermissionsPresenterTest { val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false)) val permissionStateProvider = FakePermissionStateProvider(permissionState) val presenter = DefaultPermissionsPresenter( + A_PERMISSION, permissionsStore, permissionStateProvider ) moleculeFlow(RecompositionClock.Immediate) { - presenter.setParameter(A_PERMISSION) presenter.present() }.test { skipItems(1) @@ -161,11 +161,11 @@ class DefaultPermissionsPresenterTest { val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false)) val permissionStateProvider = FakePermissionStateProvider(permissionState) val presenter = DefaultPermissionsPresenter( + A_PERMISSION, permissionsStore, permissionStateProvider ) moleculeFlow(RecompositionClock.Immediate) { - presenter.setParameter(A_PERMISSION) presenter.present() }.test { val initialState = awaitItem() diff --git a/libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenter.kt b/libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenter.kt index 293edb2cac..653fe49268 100644 --- a/libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenter.kt +++ b/libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenter.kt @@ -20,9 +20,7 @@ import androidx.compose.runtime.Composable import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsState -class NoopPermissionsPresenter: PermissionsPresenter { - - override fun setParameter(permission: String) = Unit +class NoopPermissionsPresenter : PermissionsPresenter { @Composable override fun present(): PermissionsState { diff --git a/libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterFactory.kt b/libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterFactory.kt new file mode 100644 index 0000000000..b982969483 --- /dev/null +++ b/libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterFactory.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.permissions.noop + +import io.element.android.libraries.permissions.api.PermissionsPresenter + +class NoopPermissionsPresenterFactory : PermissionsPresenter.Factory { + override fun create(permission: String) = NoopPermissionsPresenter() +} 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 7d10663027..9d1c7a495d 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 @@ -29,7 +29,7 @@ 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 io.element.android.libraries.permissions.noop.NoopPermissionsPresenter +import io.element.android.libraries.permissions.noop.NoopPermissionsPresenterFactory import kotlinx.coroutines.launch import kotlinx.datetime.Clock import kotlinx.datetime.TimeZone @@ -45,14 +45,14 @@ class RoomListScreen( private val dateTimeProvider = LocalDateTimeProvider(clock, timeZone) private val dateFormatters = DateFormatters(locale, clock, timeZone) private val sessionVerificationService = matrixClient.sessionVerificationService() - private val permissionsPresenter = NoopPermissionsPresenter() + private val permissionsPresenterFactory = NoopPermissionsPresenterFactory() private val presenter = RoomListPresenter( matrixClient, DefaultLastMessageTimestampFormatter(dateTimeProvider, dateFormatters), DefaultRoomLastMessageFormatter(context, matrixClient), sessionVerificationService, SnackbarDispatcher(), - permissionsPresenter, + permissionsPresenterFactory, ) @Composable From 8a100500f03b2679b90aa0f5ec8b6a7da3138ca7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 28 Mar 2023 16:19:22 +0200 Subject: [PATCH 055/107] Temporary import strings. --- .../libraries/push/impl/GoogleFcmHelper.kt | 3 +- .../libraries/push/impl/UnifiedPushHelper.kt | 4 +- .../impl/notifications/NotificationUtils.kt | 28 ++++----- .../notifications/RoomGroupMessageCreator.kt | 10 +-- .../SummaryGroupMessageCreator.kt | 23 ++++--- .../impl/src/main/res/values/temporary.xml | 62 +++++++++++++++++++ 6 files changed, 95 insertions(+), 35 deletions(-) create mode 100644 libraries/push/impl/src/main/res/values/temporary.xml 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 index 3d602aeb9b..16ce8d73ab 100755 --- 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 @@ -28,7 +28,6 @@ import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.DefaultPreferences import timber.log.Timber import javax.inject.Inject -import io.element.android.libraries.ui.strings.R as StringR /** * This class store the FCM token in SharedPrefs and ensure this token is retrieved. @@ -69,7 +68,7 @@ class GoogleFcmHelper @Inject constructor( Timber.e(e, "## ensureFcmTokenIsRetrieved() : failed") } } else { - Toast.makeText(context, StringR.string.no_valid_google_play_services_apk, Toast.LENGTH_SHORT).show() + Toast.makeText(context, R.string.no_valid_google_play_services_apk, Toast.LENGTH_SHORT).show() Timber.e("No valid Google Play Services found. Cannot use FCM.") } } 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 index 958cd474be..a6b50a58dd 100644 --- 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 @@ -134,8 +134,8 @@ class UnifiedPushHelper @Inject constructor( fun getCurrentDistributorName(): String { return when { - isEmbeddedDistributor() -> stringProvider.getString(StringR.string.unifiedpush_distributor_fcm_fallback) - isBackgroundSync() -> stringProvider.getString(StringR.string.unifiedpush_distributor_background_sync) + isEmbeddedDistributor() -> stringProvider.getString(R.string.unifiedpush_distributor_fcm_fallback) + isBackgroundSync() -> stringProvider.getString(R.string.unifiedpush_distributor_background_sync) else -> context.getApplicationLabel(UnifiedPush.getDistributor(context)) } } 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 index 3c5110b67a..bfa80908bf 100755 --- 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 @@ -145,11 +145,11 @@ class NotificationUtils @Inject constructor( */ notificationManager.createNotificationChannel(NotificationChannel( NOISY_NOTIFICATION_CHANNEL_ID, - stringProvider.getString(StringR.string.notification_noisy_notifications).ifEmpty { "Noisy notifications" }, + stringProvider.getString(R.string.notification_noisy_notifications).ifEmpty { "Noisy notifications" }, NotificationManager.IMPORTANCE_DEFAULT ) .apply { - description = stringProvider.getString(StringR.string.notification_noisy_notifications) + description = stringProvider.getString(R.string.notification_noisy_notifications) enableVibration(true) enableLights(true) lightColor = accentColor @@ -160,11 +160,11 @@ class NotificationUtils @Inject constructor( */ notificationManager.createNotificationChannel(NotificationChannel( SILENT_NOTIFICATION_CHANNEL_ID, - stringProvider.getString(StringR.string.notification_silent_notifications).ifEmpty { "Silent notifications" }, + stringProvider.getString(R.string.notification_silent_notifications).ifEmpty { "Silent notifications" }, NotificationManager.IMPORTANCE_LOW ) .apply { - description = stringProvider.getString(StringR.string.notification_silent_notifications) + description = stringProvider.getString(R.string.notification_silent_notifications) setSound(null, null) enableLights(true) lightColor = accentColor @@ -172,22 +172,22 @@ class NotificationUtils @Inject constructor( notificationManager.createNotificationChannel(NotificationChannel( LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID, - stringProvider.getString(StringR.string.notification_listening_for_events).ifEmpty { "Listening for events" }, + stringProvider.getString(R.string.notification_listening_for_events).ifEmpty { "Listening for events" }, NotificationManager.IMPORTANCE_MIN ) .apply { - description = stringProvider.getString(StringR.string.notification_listening_for_events) + description = stringProvider.getString(R.string.notification_listening_for_events) setSound(null, null) setShowBadge(false) }) notificationManager.createNotificationChannel(NotificationChannel( CALL_NOTIFICATION_CHANNEL_ID, - stringProvider.getString(StringR.string.call).ifEmpty { "Call" }, + stringProvider.getString(R.string.call).ifEmpty { "Call" }, NotificationManager.IMPORTANCE_HIGH ) .apply { - description = stringProvider.getString(StringR.string.call) + description = stringProvider.getString(R.string.call) setSound(null, null) enableLights(true) lightColor = accentColor @@ -242,11 +242,11 @@ class NotificationUtils @Inject constructor( // Title for API < 16 devices. .setContentTitle(roomInfo.roomDisplayName) // Content for API < 16 devices. - .setContentText(stringProvider.getString(StringR.string.notification_new_messages)) + .setContentText(stringProvider.getString(R.string.notification_new_messages)) // Number of new notifications for API <24 (M and below) devices. .setSubText( stringProvider.getQuantityString( - StringR.plurals.room_new_messages_notification, + R.plurals.room_new_messages_notification, messageStyle.messages.size, messageStyle.messages.size ) @@ -292,7 +292,7 @@ class NotificationUtils @Inject constructor( NotificationCompat.Action.Builder( R.drawable.ic_material_done_all_white, - stringProvider.getString(StringR.string.action_mark_room_read), markRoomReadPendingIntent + stringProvider.getString(R.string.action_mark_room_read), markRoomReadPendingIntent ) .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) .setShowsUserInterface(false) @@ -373,7 +373,7 @@ class NotificationUtils @Inject constructor( addAction( R.drawable.vector_notification_reject_invitation, - stringProvider.getString(StringR.string.action_reject), + stringProvider.getString(R.string.action_reject), rejectIntentPendingIntent ) @@ -390,7 +390,7 @@ class NotificationUtils @Inject constructor( ) addAction( R.drawable.vector_notification_accept_invitation, - stringProvider.getString(StringR.string.action_join), + stringProvider.getString(R.string.action_join), joinIntentPendingIntent ) @@ -693,7 +693,7 @@ class NotificationUtils @Inject constructor( 888, NotificationCompat.Builder(context, NOISY_NOTIFICATION_CHANNEL_ID) .setContentTitle(buildMeta.applicationName) - .setContentText(stringProvider.getString(StringR.string.settings_troubleshoot_test_push_notification_content)) + .setContentText(stringProvider.getString(R.string.settings_troubleshoot_test_push_notification_content)) .setSmallIcon(R.drawable.ic_notification) .setLargeIcon(getBitmap(context, R.drawable.element_logo_green)) .setColor(ContextCompat.getColor(context, R.color.notification_accent_color)) 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 index 2a2311e591..360c5c1bb5 100644 --- 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 @@ -19,13 +19,13 @@ 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.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 -import io.element.android.libraries.ui.strings.R as StringR class RoomGroupMessageCreator @Inject constructor( private val bitmapLoader: NotificationBitmapLoader, @@ -50,9 +50,9 @@ class RoomGroupMessageCreator @Inject constructor( } val tickerText = if (roomIsGroup) { - stringProvider.getString(StringR.string.notification_ticker_text_group, roomName, events.last().senderName, events.last().description) + stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderName, events.last().description) } else { - stringProvider.getString(StringR.string.notification_ticker_text_dm, events.last().senderName, events.last().description) + stringProvider.getString(R.string.notification_ticker_text_dm, events.last().senderName, events.last().description) } val largeBitmap = getRoomBitmap(events) @@ -99,7 +99,7 @@ class RoomGroupMessageCreator @Inject constructor( } when { event.isSmartReplyError() -> addMessage( - stringProvider.getString(StringR.string.notification_inline_reply_failed), + stringProvider.getString(R.string.notification_inline_reply_failed), event.timestamp, senderPerson ) @@ -121,7 +121,7 @@ class RoomGroupMessageCreator @Inject constructor( 1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDirect) else -> { stringProvider.getQuantityString( - StringR.plurals.notification_compat_summary_line_for_room, + R.plurals.notification_compat_summary_line_for_room, events.size, roomName, events.size 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 index 13598f343a..86b741bbd7 100644 --- 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 @@ -18,11 +18,10 @@ package io.element.android.libraries.push.impl.notifications import android.app.Notification import androidx.core.app.NotificationCompat +import io.element.android.libraries.push.impl.R import io.element.android.services.toolbox.api.strings.StringProvider import javax.inject.Inject -import io.element.android.libraries.ui.strings.R as StringR - /** * ======== Build summary notification ========= * On Android 7.0 (API level 24) and higher, the system automatically builds a summary for @@ -66,10 +65,10 @@ class SummaryGroupMessageCreator @Inject constructor( // FIXME roomIdToEventMap.size is not correct, this is the number of rooms val nbEvents = roomNotifications.size + simpleNotifications.size - val sumTitle = stringProvider.getQuantityString(StringR.plurals.notification_compat_summary_title, nbEvents, nbEvents) + val sumTitle = stringProvider.getQuantityString(R.plurals.notification_compat_summary_title, nbEvents, nbEvents) summaryInboxStyle.setBigContentTitle(sumTitle) // TODO get latest event? - .setSummaryText(stringProvider.getQuantityString(StringR.plurals.notification_unread_notified_messages, nbEvents, nbEvents)) + .setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents)) return if (useCompleteNotificationFormat) { notificationUtils.buildSummaryListNotification( summaryInboxStyle, @@ -101,21 +100,21 @@ class SummaryGroupMessageCreator @Inject constructor( val messageNotificationCount = messageEventsCount + simpleEventsCount val privacyTitle = if (invitationEventsCount > 0) { - val invitationsStr = stringProvider.getQuantityString(StringR.plurals.notification_invitations, invitationEventsCount, invitationEventsCount) + val invitationsStr = stringProvider.getQuantityString(R.plurals.notification_invitations, invitationEventsCount, invitationEventsCount) if (messageNotificationCount > 0) { // Invitation and message val messageStr = stringProvider.getQuantityString( - StringR.plurals.room_new_messages_notification, + R.plurals.room_new_messages_notification, messageNotificationCount, messageNotificationCount ) if (roomCount > 1) { // In several rooms val roomStr = stringProvider.getQuantityString( - StringR.plurals.notification_unread_notified_messages_in_room_rooms, + R.plurals.notification_unread_notified_messages_in_room_rooms, roomCount, roomCount ) stringProvider.getString( - StringR.string.notification_unread_notified_messages_in_room_and_invitation, + R.string.notification_unread_notified_messages_in_room_and_invitation, messageStr, roomStr, invitationsStr @@ -123,7 +122,7 @@ class SummaryGroupMessageCreator @Inject constructor( } else { // In one room stringProvider.getString( - StringR.string.notification_unread_notified_messages_and_invitation, + R.string.notification_unread_notified_messages_and_invitation, messageStr, invitationsStr ) @@ -135,13 +134,13 @@ class SummaryGroupMessageCreator @Inject constructor( } else { // No invitation, only messages val messageStr = stringProvider.getQuantityString( - StringR.plurals.room_new_messages_notification, + R.plurals.room_new_messages_notification, messageNotificationCount, messageNotificationCount ) if (roomCount > 1) { // In several rooms - val roomStr = stringProvider.getQuantityString(StringR.plurals.notification_unread_notified_messages_in_room_rooms, roomCount, roomCount) - stringProvider.getString(StringR.string.notification_unread_notified_messages_in_room, messageStr, roomStr) + 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 diff --git a/libraries/push/impl/src/main/res/values/temporary.xml b/libraries/push/impl/src/main/res/values/temporary.xml new file mode 100644 index 0000000000..b560669f57 --- /dev/null +++ b/libraries/push/impl/src/main/res/values/temporary.xml @@ -0,0 +1,62 @@ + + + + No valid Google Play Services APK found. Notifications may not work properly. + Choose how to receive notifications + Google Services + Background synchronization + + Listening for events + Noisy notifications + Silent notifications + Call + New Messages + Mark as read + Join + Reject + You are viewing the notification! Click me! + %1$s: %2$s + %1$s: %2$s %3$s + ** Failed to send - please open room + %1$s in %2$s and %3$s" + %1$s and %2$s" + %1$s in %2$s" + + %d new message + %d new messages + + + %d unread notified message + %d unread notified messages + + + %d room + %d rooms + + + %d invitation + %d invitations + + + %1$s: %2$d message + %1$s: %2$d messages + + + %d notification + %d notifications + + From da4b49ce173fc5d9376ecba44ddb77d939be6875 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 29 Mar 2023 12:03:17 +0200 Subject: [PATCH 056/107] Implement Push client secret store and test it. --- .../libraries/push/impl/VectorPushHandler.kt | 5 +- .../impl/clientsecret/PushClientSecret.kt | 35 +++++++++ .../clientsecret/PushClientSecretFactory.kt | 21 ++++++ .../PushClientSecretFactoryImpl.kt | 28 +++++++ .../impl/clientsecret/PushClientSecretImpl.kt | 45 +++++++++++ .../clientsecret/PushClientSecretStore.kt | 24 ++++++ .../PushClientSecretStoreDataStore.kt | 62 +++++++++++++++ .../FakePushClientSecretFactory.kt | 29 +++++++ .../InMemoryPushClientSecretStore.kt | 39 ++++++++++ .../clientsecret/PushClientSecretImplTest.kt | 75 +++++++++++++++++++ 10 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecret.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactory.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactoryImpl.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImpl.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStore.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStoreDataStore.kt create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/FakePushClientSecretFactory.kt create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/InMemoryPushClientSecretStore.kt create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImplTest.kt diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt index 6a73cedf92..3e0d0a3d6d 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt @@ -28,6 +28,7 @@ import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.push.api.store.PushDataStore +import io.element.android.libraries.push.impl.clientsecret.PushClientSecret import io.element.android.libraries.push.impl.model.PushData import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver import io.element.android.libraries.push.impl.notifications.NotificationActionIds @@ -49,6 +50,7 @@ class VectorPushHandler @Inject constructor( // private val activeSessionHolder: ActiveSessionHolder, 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 @@ -114,7 +116,8 @@ class VectorPushHandler @Inject constructor( } /* TODO EAx - - Open session + - Retrieve secret and use pushClientSecret + - Open matching session - get the event - display the notif diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecret.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecret.kt new file mode 100644 index 0000000000..0db59e42f7 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecret.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.clientsecret + +interface PushClientSecret { + /** + * To call when registering a pusher. It will return the existing secret or create a new one. + */ + suspend fun getSecretForUser(userId: String): String + + /** + * To call when receiving a push containing a client secret. + * Return null if not found. + */ + suspend fun getUserIdFromSecret(clientSecret: String): String? + + /** + * To call when the user signs out. + */ + suspend fun resetSecretForUser(userId: String) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactory.kt new file mode 100644 index 0000000000..4ab6c775e3 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactory.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.clientsecret + +interface PushClientSecretFactory { + fun create(): String +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactoryImpl.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactoryImpl.kt new file mode 100644 index 0000000000..8a23409558 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactoryImpl.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +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 + +@ContributesBinding(AppScope::class) +class PushClientSecretFactoryImpl : PushClientSecretFactory { + override fun create(): String { + return UUID.randomUUID().toString() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImpl.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImpl.kt new file mode 100644 index 0000000000..96f4ee25fd --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImpl.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.clientsecret + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +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: String): 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): String? { + return pushClientSecretStore.getUserIdFromSecret(clientSecret) + } + + override suspend fun resetSecretForUser(userId: String) { + pushClientSecretStore.resetSecret(userId) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStore.kt new file mode 100644 index 0000000000..f283ab4607 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStore.kt @@ -0,0 +1,24 @@ +/* + * 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 PushClientSecretStore { + suspend fun storeSecret(userId: String, clientSecret: String) + suspend fun getSecret(userId: String): String? + suspend fun resetSecret(userId: String) + suspend fun getUserIdFromSecret(clientSecret: String): String? +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStoreDataStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStoreDataStore.kt new file mode 100644 index 0000000000..b3befee36b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStoreDataStore.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.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 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: String, clientSecret: String) { + context.dataStore.edit { settings -> + settings[getPreferenceKeyForUser(userId)] = clientSecret + } + } + + override suspend fun getSecret(userId: String): String? { + return context.dataStore.data.first()[getPreferenceKeyForUser(userId)] + } + + override suspend fun resetSecret(userId: String) { + context.dataStore.edit { settings -> + settings.remove(getPreferenceKeyForUser(userId)) + } + } + + override suspend fun getUserIdFromSecret(clientSecret: String): String? { + val keyValues = context.dataStore.data.first().asMap() + val matchingKey = keyValues.keys.firstOrNull { + keyValues[it] == clientSecret + } + return matchingKey?.name + } + + private fun getPreferenceKeyForUser(userId: String) = stringPreferencesKey(userId) +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/FakePushClientSecretFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/FakePushClientSecretFactory.kt new file mode 100644 index 0000000000..25823a57e8 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/FakePushClientSecretFactory.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.clientsecret + +private const val A_SECRET_PREFIX = "A_SECRET_" + +class FakePushClientSecretFactory : PushClientSecretFactory { + private var index = 0 + + override fun create() = getSecretForUser(index++) + + fun getSecretForUser(i: Int): String { + return A_SECRET_PREFIX + i + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/InMemoryPushClientSecretStore.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/InMemoryPushClientSecretStore.kt new file mode 100644 index 0000000000..0bc826398a --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/InMemoryPushClientSecretStore.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.clientsecret + +class InMemoryPushClientSecretStore : PushClientSecretStore { + private val secrets = mutableMapOf() + + fun getSecrets(): Map = secrets + + override suspend fun storeSecret(userId: String, clientSecret: String) { + secrets[userId] = clientSecret + } + + override suspend fun getSecret(userId: String): String? { + return secrets[userId] + } + + override suspend fun resetSecret(userId: String) { + secrets.remove(userId) + } + + override suspend fun getUserIdFromSecret(clientSecret: String): String? { + return secrets.keys.firstOrNull { secrets[it] == clientSecret } + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImplTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImplTest.kt new file mode 100644 index 0000000000..1a6d52e660 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImplTest.kt @@ -0,0 +1,75 @@ +/* + * 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 kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +private const val A_USER_ID_0 = "A_USER_ID_0" +private const val A_USER_ID_1 = "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, + ) + ) + } +} From 80fdd5f1273609cbc8c0aeb474413bbf8da05a65 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 29 Mar 2023 15:25:07 +0200 Subject: [PATCH 057/107] Use correct type (it's a type alias) --- .../matrix/impl/auth/RustMatrixAuthenticationService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index 13ebf1c0e8..aff83c3b5a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -59,7 +59,7 @@ class RustMatrixAuthenticationService @Inject constructor( } override suspend fun getLatestSessionId(): SessionId? = withContext(coroutineDispatchers.io) { - sessionStore.getLatestSession()?.userId?.let { UserId(it) } + sessionStore.getLatestSession()?.userId?.let { SessionId(it) } } override suspend fun restoreSession(sessionId: SessionId): Result = withContext(coroutineDispatchers.io) { From b2ce80da69ced0bd6c19dba5c55b6dbede00cbf9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 29 Mar 2023 15:40:34 +0200 Subject: [PATCH 058/107] Add a db query to get all the Sessions. --- .../android/libraries/sessionstorage/api/SessionStore.kt | 1 + .../sessionstorage/impl/memory/InMemorySessionStore.kt | 4 ++++ .../libraries/sessionstorage/impl/DatabaseSessionStore.kt | 6 ++++++ .../element/android/libraries/matrix/session/SessionData.sq | 3 +++ .../sessionstorage/impl/DatabaseSessionStoreTests.kt | 3 ++- 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt index de0ec2f727..1637bd809f 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 @@ -22,6 +22,7 @@ interface SessionStore { fun isLoggedIn(): 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) } diff --git a/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt b/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt index b73ffdeb9a..ce5b6e24f2 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 @@ -38,6 +38,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/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index 6c32bcd1f3..15c3024712 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 @@ -53,6 +53,12 @@ class DatabaseSessionStore @Inject constructor( ?.toApiModel() } + override suspend fun getAllSessions(): List { + return database.sessionDataQueries.selectAll() + .executeAsList() + .map { it.toApiModel() } + } + override suspend fun removeSession(sessionId: String) { database.sessionDataQueries.removeSession(sessionId) } diff --git a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq index d8fb15338c..ea8471a36a 100644 --- a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq +++ b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq @@ -10,6 +10,9 @@ CREATE TABLE SessionData ( selectFirst: SELECT * FROM SessionData LIMIT 1; +selectAll: +SELECT * FROM SessionData; + selectByUserId: SELECT * FROM SessionData WHERE userId = ?; diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt index 885c04af78..0260604f6e 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt @@ -57,6 +57,7 @@ class DatabaseSessionStoreTests { databaseSessionStore.storeData(aSessionData.toApiModel()) assertThat(database.sessionDataQueries.selectFirst().executeAsOneOrNull()).isEqualTo(aSessionData) + assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(1) } @Test @@ -88,6 +89,7 @@ class DatabaseSessionStoreTests { val foundSession = databaseSessionStore.getSession(aSessionData.userId)?.toDbModel() assertThat(foundSession).isEqualTo(aSessionData) + assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(2) } @Test @@ -107,5 +109,4 @@ class DatabaseSessionStoreTests { assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOneOrNull()).isNull() } - } From be8ce499d0338b9b29fe4c0a0d8ba4e91f652d9c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 29 Mar 2023 16:00:54 +0200 Subject: [PATCH 059/107] Register pusher - WIP --- .../libraries/matrix/api/MatrixClient.kt | 2 + .../matrix/api/pusher/PushersService.kt | 21 ++++++ .../matrix/api/pusher/SetHttpPusherData.kt | 28 ++++++++ .../libraries/matrix/impl/RustMatrixClient.kt | 7 +- .../matrix/impl/pushers/RustPushersService.kt | 49 +++++++++++++ .../libraries/push/impl/GoogleFcmHelper.kt | 5 +- .../libraries/push/impl/PushersManager.kt | 68 ++++++++++--------- .../impl/VectorFirebaseMessagingService.kt | 9 ++- .../VectorUnifiedPushMessagingReceiver.kt | 4 +- .../PushClientSecretFactoryImpl.kt | 3 +- 10 files changed, 160 insertions(+), 36 deletions(-) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/SetHttpPusherData.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 8a991771a5..9c0d0c17ac 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.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.media.MediaResolver +import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource @@ -33,6 +34,7 @@ interface MatrixClient : Closeable { fun stopSync() fun mediaResolver(): MediaResolver fun sessionVerificationService(): SessionVerificationService + fun pushersService(): PushersService suspend fun logout() suspend fun loadUserDisplayName(): Result suspend fun loadUserAvatarURLString(): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt new file mode 100644 index 0000000000..a868aeab85 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.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.matrix.api.pusher + +interface PushersService { + fun setHttpPusher(setHttpPusherData: SetHttpPusherData) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/SetHttpPusherData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/SetHttpPusherData.kt new file mode 100644 index 0000000000..43a90f5be2 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/SetHttpPusherData.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.api.pusher + +data class SetHttpPusherData( + val pushKey: String, + val appId: String, + val url: String, + val appDisplayName: String, + val deviceDisplayName: String, + val profileTag: String?, + val lang: String, + val defaultPayload: String, +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 2fe34bfa55..4acafa321e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -21,11 +21,13 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.media.MediaResolver +import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.impl.media.RustMediaResolver -import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.impl.pushers.RustPushersService import io.element.android.libraries.matrix.impl.room.RustMatrixRoom import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource import io.element.android.libraries.matrix.impl.sync.SlidingSyncObserverProxy @@ -60,6 +62,7 @@ class RustMatrixClient constructor( override val sessionId: UserId = UserId(client.userId()) private val verificationService = RustSessionVerificationService() + private val pushersService = RustPushersService(client) private var slidingSyncUpdateJob: Job? = null private val clientDelegate = object : ClientDelegate { @@ -162,6 +165,8 @@ class RustMatrixClient constructor( override fun sessionVerificationService(): SessionVerificationService = verificationService + override fun pushersService(): PushersService = pushersService + override fun startSync() { if (isSyncing.compareAndSet(false, true)) { slidingSyncObserverToken = slidingSync.sync() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt new file mode 100644 index 0000000000..39dd5c1d11 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.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.matrix.impl.pushers + +import io.element.android.libraries.matrix.api.pusher.PushersService +import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.HttpPusherData +import org.matrix.rustcomponents.sdk.PushFormat +import org.matrix.rustcomponents.sdk.PusherIdentifiers +import org.matrix.rustcomponents.sdk.PusherKind + +class RustPushersService( + private val client: Client, +) : PushersService { + override fun setHttpPusher(setHttpPusherData: SetHttpPusherData) { + client.setPusher( + identifiers = PusherIdentifiers( + pushkey = setHttpPusherData.pushKey, + appId = setHttpPusherData.appId + ), + kind = PusherKind.Http( + data = HttpPusherData( + url = setHttpPusherData.url, + format = PushFormat.EVENT_ID_ONLY, + defaultPayload = setHttpPusherData.defaultPayload + ) + ), + appDisplayName = setHttpPusherData.appDisplayName, + deviceDisplayName = setHttpPusherData.deviceDisplayName, + profileTag = setHttpPusherData.profileTag, + lang = setHttpPusherData.lang + ) + } +} 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 index 16ce8d73ab..b8dc1ad384 100755 --- 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 @@ -26,6 +26,7 @@ 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 @@ -58,7 +59,9 @@ class GoogleFcmHelper @Inject constructor( .addOnSuccessListener { token -> storeFcmToken(token) if (registerPusher) { - pushersManager.enqueueRegisterPusherWithFcmKey(token) + runBlocking {// TODO + pushersManager.enqueueRegisterPusherWithFcmKey(token) + } } } .addOnFailureListener { e -> 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 index 5525b04d4c..20cbd078e2 100644 --- 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 @@ -16,8 +16,13 @@ package io.element.android.libraries.push.impl +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +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.pushgateway.PushGatewayNotifyRequest +import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.services.toolbox.api.appname.AppNameProvider import javax.inject.Inject @@ -31,6 +36,9 @@ class PushersManager @Inject constructor( private val appNameProvider: AppNameProvider, // private val getDeviceInfoUseCase: GetDeviceInfoUseCase, private val pushGatewayNotifyRequest: PushGatewayNotifyRequest, + private val pushClientSecret: PushClientSecret, + private val sessionStore: SessionStore, + private val matrixAuthenticationService: MatrixAuthenticationService, ) { suspend fun testPush() { pushGatewayNotifyRequest.execute( @@ -43,47 +51,45 @@ class PushersManager @Inject constructor( ) } - fun enqueueRegisterPusherWithFcmKey(pushKey: String)/*: UUID*/ { + suspend fun enqueueRegisterPusherWithFcmKey(pushKey: String) { return enqueueRegisterPusher(pushKey, PushConfig.pusher_http_url) } - fun enqueueRegisterPusher( + suspend fun enqueueRegisterPusher( pushKey: String, gateway: String - ) /*: UUID*/ { - /* - val currentSession = activeSessionHolder.getActiveSession() - val pusher = createHttpPusher(pushKey, gateway) - return currentSession.pushersService().enqueueAddHttpPusher(pusher) - - */ - // TODO EAx - // TODO() - // Get all sessions - // Register pusher - // Close sessions + ) { + // Register the pusher for all the sessions + sessionStore.getAllSessions().forEach { sessionData -> + val client = matrixAuthenticationService.restoreSession(SessionId(sessionData.userId)).getOrNull() + client ?: return@forEach + client.pushersService().setHttpPusher(createHttpPusher(pushKey, gateway, sessionData.userId)) + // Close sessions? + } } - private fun createHttpPusher( + private suspend fun createHttpPusher( pushKey: String, - gateway: String - ): Any = TODO() - /* - HttpPusher( - pushkey = pushKey, - appId = PushConfig.pusher_app_id, - profileTag = DEFAULT_PUSHER_FILE_TAG + "_" + abs(activeSessionHolder.getActiveSession().myUserId.hashCode()), - lang = localeProvider.current().language, - appDisplayName = appNameProvider.getAppName(), - deviceDisplayName = getDeviceInfoUseCase.execute().displayName().orEmpty(), - url = gateway, - enabled = true, - deviceId = activeSessionHolder.getActiveSession().sessionParams.deviceId ?: "MOBILE", - append = false, - withEventIdOnly = true, - ) + gateway: String, + userId: String, + ): 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() diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorFirebaseMessagingService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorFirebaseMessagingService.kt index e9bccf7cdd..81afe726f2 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorFirebaseMessagingService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorFirebaseMessagingService.kt @@ -24,6 +24,9 @@ import io.element.android.libraries.push.api.store.PushDataStore import io.element.android.libraries.push.impl.config.PushConfig import io.element.android.libraries.push.impl.di.FirebaseMessagingServiceBindings import io.element.android.libraries.push.impl.parser.PushParser +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -38,6 +41,8 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() { @Inject lateinit var vectorPushHandler: VectorPushHandler @Inject lateinit var unifiedPushHelper: UnifiedPushHelper + private val coroutineScope = CoroutineScope(SupervisorJob()) + override fun onCreate() { super.onCreate() applicationContext.bindings().inject(this) @@ -51,7 +56,9 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() { // TODO EAx activeSessionHolder.hasActiveSession() && unifiedPushHelper.isEmbeddedDistributor() ) { - pushersManager.enqueueRegisterPusher(token, PushConfig.pusher_http_url) + coroutineScope.launch { + pushersManager.enqueueRegisterPusher(token, PushConfig.pusher_http_url) + } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorUnifiedPushMessagingReceiver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorUnifiedPushMessagingReceiver.kt index 0fcdbafb69..49a63ba4aa 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorUnifiedPushMessagingReceiver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorUnifiedPushMessagingReceiver.kt @@ -81,7 +81,9 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { coroutineScope.launch { unifiedPushHelper.storeCustomOrDefaultGateway(endpoint) { unifiedPushHelper.getPushGateway()?.let { - pushersManager.enqueueRegisterPusher(endpoint, it) + coroutineScope.launch { + pushersManager.enqueueRegisterPusher(endpoint, it) + } } } } 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 index 8a23409558..1d7a1e6247 100644 --- 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 @@ -19,9 +19,10 @@ 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 : PushClientSecretFactory { +class PushClientSecretFactoryImpl @Inject constructor() : PushClientSecretFactory { override fun create(): String { return UUID.randomUUID().toString() } From 1f09f5f0eb67ba7d78bb35ec07f6e28b95408e21 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 29 Mar 2023 18:04:32 +0200 Subject: [PATCH 060/107] Retrieve notification - WIP --- .../android/appnav/LoggedInFlowNode.kt | 7 +++ .../libraries/matrix/api/MatrixClient.kt | 2 + .../api/notification/NotificationData.kt | 27 ++++++++++ .../api/notification/NotificationService.kt | 21 ++++++++ .../libraries/matrix/impl/RustMatrixClient.kt | 5 ++ .../impl/notification/NotificationMapper.kt | 51 +++++++++++++++++++ .../notification/RustNotificationService.kt | 39 ++++++++++++++ libraries/push/api/build.gradle.kts | 1 + .../android/libraries/push/api/PushService.kt | 5 ++ .../libraries/push/impl/DefaultPushService.kt | 5 ++ .../libraries/push/impl/PushersManager.kt | 11 ++++ .../libraries/push/impl/VectorPushHandler.kt | 44 +++++++++++++--- .../libraries/push/impl/model/PushData.kt | 3 +- .../libraries/push/impl/model/PushDataFcm.kt | 17 ++++--- .../push/impl/model/PushDataUnifiedPush.kt | 11 ++-- .../libraries/push/impl/parser/PushParser.kt | 1 + 16 files changed, 228 insertions(+), 22 deletions(-) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 1028c6f16d..42d82913b8 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -52,9 +52,11 @@ import io.element.android.libraries.matrix.api.MatrixClient 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.ui.di.MatrixUIBindings +import io.element.android.libraries.push.api.PushService import io.element.android.services.appnavstate.api.AppNavigationStateService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.parcelize.Parcelize import kotlin.coroutines.coroutineContext @@ -69,6 +71,7 @@ class LoggedInFlowNode @AssistedInject constructor( private val verifySessionEntryPoint: VerifySessionEntryPoint, private val coroutineScope: CoroutineScope, snackbarDispatcher: SnackbarDispatcher, + private val pushService: PushService, ) : BackstackNode( backstack = BackStack( initialElement = NavTarget.RoomList, @@ -111,6 +114,10 @@ class LoggedInFlowNode @AssistedInject constructor( // TODO We do not support Space yet, so directly navigate to main space appNavigationStateService.onNavigateToSpace(MAIN_SPACE) loggedInFlowProcessor.observeEvents(coroutineScope) + runBlocking { + // TODO + pushService.registerPusher(inputs.matrixClient.sessionId) + } }, onDestroy = { val imageLoaderFactory = bindings().notLoggedInImageLoaderFactory() diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 9c0d0c17ac..82c7074729 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.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.media.MediaResolver +import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomMembershipObserver @@ -35,6 +36,7 @@ interface MatrixClient : Closeable { fun mediaResolver(): MediaResolver fun sessionVerificationService(): SessionVerificationService fun pushersService(): PushersService + fun notificationService(): NotificationService suspend fun logout() suspend fun loadUserDisplayName(): Result suspend fun loadUserAvatarURLString(): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt new file mode 100644 index 0000000000..27fc15c2c6 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt @@ -0,0 +1,27 @@ +/* + * 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.matrix.api.notification + +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem + +data class NotificationData( + val item: MatrixTimelineItem, + val title: String, + val subtitle: String?, + val isNoisy: Boolean, + val avatarUrl: String?, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt new file mode 100644 index 0000000000..2c1672d864 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.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.matrix.api.notification + +interface NotificationService { + fun getNotification(userId: String, roomId: String, eventId: String): NotificationData? +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 4acafa321e..0fa5a668c8 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -21,12 +21,14 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.media.MediaResolver +import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.impl.media.RustMediaResolver +import io.element.android.libraries.matrix.impl.notification.RustNotificationService import io.element.android.libraries.matrix.impl.pushers.RustPushersService import io.element.android.libraries.matrix.impl.room.RustMatrixRoom import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource @@ -63,6 +65,7 @@ class RustMatrixClient constructor( private val verificationService = RustSessionVerificationService() private val pushersService = RustPushersService(client) + private val notificationService = RustNotificationService(baseDirectory) private var slidingSyncUpdateJob: Job? = null private val clientDelegate = object : ClientDelegate { @@ -167,6 +170,8 @@ class RustMatrixClient constructor( override fun pushersService(): PushersService = pushersService + override fun notificationService(): NotificationService = notificationService + override fun startSync() { if (isSyncing.compareAndSet(false, true)) { slidingSyncObserverToken = slidingSync.sync() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt new file mode 100644 index 0000000000..ae47beb700 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.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.matrix.impl.notification + +import io.element.android.libraries.matrix.api.notification.NotificationData +import io.element.android.libraries.matrix.impl.timeline.MatrixTimelineItemMapper +import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper +import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper +import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper +import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper +import org.matrix.rustcomponents.sdk.NotificationItem +import org.matrix.rustcomponents.sdk.use +import javax.inject.Inject + +class NotificationMapper @Inject constructor() { + // TODO Inject and remove duplicate? + private val timelineItemFactory = MatrixTimelineItemMapper( + virtualTimelineItemMapper = VirtualTimelineItemMapper(), + eventTimelineItemMapper = EventTimelineItemMapper( + contentMapper = TimelineEventContentMapper( + eventMessageMapper = EventMessageMapper() + ) + ) + ) + + fun map(notificationItem: NotificationItem): NotificationData { + return notificationItem.use { + NotificationData( + item = timelineItemFactory.map(it.item), + title = it.title, + subtitle = it.subtitle, + isNoisy = it.isNoisy, + avatarUrl = it.avatarUrl, + ) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt new file mode 100644 index 0000000000..27091c17ee --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.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.matrix.impl.notification + +import io.element.android.libraries.matrix.api.notification.NotificationData +import io.element.android.libraries.matrix.api.notification.NotificationService +import java.io.File + +class RustNotificationService( + private val baseDirectory: File, +) : NotificationService { + private val notificationMapper: NotificationMapper = NotificationMapper() + + override fun getNotification(userId: String, roomId: String, eventId: String): NotificationData? { + return org.matrix.rustcomponents.sdk.NotificationService( + basePath = File(baseDirectory, "sessions").absolutePath, + userId = userId + ).use { + // TODO Not implemented yet, see https://github.com/matrix-org/matrix-rust-sdk/issues/1628 + it.getNotificationItem(roomId, eventId)?.let { notificationItem -> + notificationMapper.map(notificationItem) + } + } + } +} diff --git a/libraries/push/api/build.gradle.kts b/libraries/push/api/build.gradle.kts index 27a6827364..be1bbc13ef 100644 --- a/libraries/push/api/build.gradle.kts +++ b/libraries/push/api/build.gradle.kts @@ -25,4 +25,5 @@ android { dependencies { implementation(libs.androidx.corektx) implementation(libs.coroutines.core) + implementation(projects.libraries.matrix.api) } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt index 335ed9426c..77adb869c5 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt @@ -16,10 +16,15 @@ package io.element.android.libraries.push.api +import io.element.android.libraries.matrix.api.core.UserId + interface PushService { fun setCurrentRoom(roomId: String?) fun setCurrentThread(threadId: String?) fun notificationStyleChanged() + // Ensure pusher is registered + suspend fun registerPusher(userId: UserId) + suspend fun testPush() } 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 index bf3a252315..e9b7efcdee 100644 --- 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 @@ -18,6 +18,7 @@ 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.core.UserId import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager import javax.inject.Inject @@ -39,6 +40,10 @@ class DefaultPushService @Inject constructor( notificationDrawerManager.notificationStyleChanged() } + override suspend fun registerPusher(userId: UserId) { + pusherManager.registerPusher(userId) + } + override suspend fun testPush() { pusherManager.testPush() } 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 index 20cbd078e2..8a713585b6 100644 --- 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 @@ -18,6 +18,7 @@ package io.element.android.libraries.push.impl import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService 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.pusher.SetHttpPusherData import io.element.android.libraries.push.impl.clientsecret.PushClientSecret import io.element.android.libraries.push.impl.config.PushConfig @@ -39,6 +40,7 @@ class PushersManager @Inject constructor( private val pushClientSecret: PushClientSecret, private val sessionStore: SessionStore, private val matrixAuthenticationService: MatrixAuthenticationService, + private val fcmHelper: FcmHelper, ) { suspend fun testPush() { pushGatewayNotifyRequest.execute( @@ -55,6 +57,7 @@ class PushersManager @Inject constructor( return enqueueRegisterPusher(pushKey, PushConfig.pusher_http_url) } + // TODO Rename suspend fun enqueueRegisterPusher( pushKey: String, gateway: String @@ -68,6 +71,14 @@ class PushersManager @Inject constructor( } } + suspend fun registerPusher(userId: UserId) { + val pushKey = fcmHelper.getFcmToken() ?: return + // Register the pusher for the session + val client = matrixAuthenticationService.restoreSession(userId).getOrNull() ?: return + client.pushersService().setHttpPusher(createHttpPusher(pushKey, PushConfig.pusher_http_url, userId.value)) + // Close sessions? + } + private suspend fun createHttpPusher( pushKey: String, gateway: String, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt index 3e0d0a3d6d..f6a4b5d83d 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt @@ -27,6 +27,8 @@ 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.matrix.api.core.SessionId import io.element.android.libraries.push.api.store.PushDataStore import io.element.android.libraries.push.impl.clientsecret.PushClientSecret import io.element.android.libraries.push.impl.model.PushData @@ -34,11 +36,7 @@ import io.element.android.libraries.push.impl.notifications.NotifiableEventResol import io.element.android.libraries.push.impl.notifications.NotificationActionIds import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager import io.element.android.libraries.push.impl.store.DefaultPushDataStore -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.* import timber.log.Timber import javax.inject.Inject @@ -53,7 +51,8 @@ class VectorPushHandler @Inject constructor( private val pushClientSecret: PushClientSecret, private val actionIds: NotificationActionIds, @ApplicationContext private val context: Context, - private val buildMeta: BuildMeta + private val buildMeta: BuildMeta, + private val matrixAuthenticationService: MatrixAuthenticationService, ) { private val coroutineScope = CoroutineScope(SupervisorJob()) @@ -115,9 +114,38 @@ class VectorPushHandler @Inject constructor( 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()?.value + } + + if (userId == null) { + Timber.w("Unable to get a session") + return + } + + // Restore session + val session = matrixAuthenticationService.restoreSession(SessionId(userId)).getOrNull() ?: return + // TODO EAx, no need for a session? + val notificationData = session.notificationService().getNotification( + userId = userId, + roomId = pushData.roomId, + eventId = pushData.eventId, + ) + + Timber.w("Notification: $notificationData") + // TODO Display notification + /* TODO EAx - - Retrieve secret and use pushClientSecret - - Open matching session - get the event - display the notif diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushData.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushData.kt index 75bed1027b..06445d7ca6 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushData.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushData.kt @@ -27,6 +27,5 @@ data class PushData( val eventId: String?, val roomId: String?, val unread: Int?, - - // TODO EAx Client secret + val clientSecret: String?, ) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataFcm.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataFcm.kt index 0e37c14e12..fbde04cc36 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataFcm.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataFcm.kt @@ -25,19 +25,22 @@ import io.element.android.libraries.matrix.api.core.MatrixPatterns * "event_id":"$anEventId", * "room_id":"!aRoomId", * "unread":"1", - * "prio":"high" + * "prio":"high", + * "cs":"" * } * * . */ data class PushDataFcm( - val eventId: String?, - val roomId: String?, - var unread: Int?, + val eventId: String?, + val roomId: String?, + var unread: Int?, + val clientSecret: String? ) fun PushDataFcm.toPushData() = PushData( - eventId = eventId?.takeIf { MatrixPatterns.isEventId(it) }, - roomId = roomId?.takeIf { MatrixPatterns.isRoomId(it) }, - unread = unread + eventId = eventId?.takeIf { MatrixPatterns.isEventId(it) }, + roomId = roomId?.takeIf { MatrixPatterns.isRoomId(it) }, + unread = unread, + clientSecret = clientSecret, ) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataUnifiedPush.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataUnifiedPush.kt index c4227b3db2..fc4ed55783 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataUnifiedPush.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataUnifiedPush.kt @@ -38,7 +38,7 @@ import kotlinx.serialization.Serializable */ @Serializable data class PushDataUnifiedPush( - val notification: PushDataUnifiedPushNotification? + val notification: PushDataUnifiedPushNotification? ) @Serializable @@ -50,11 +50,12 @@ data class PushDataUnifiedPushNotification( @Serializable data class PushDataUnifiedPushCounts( - @SerialName("unread") val unread: Int? + @SerialName("unread") val unread: Int? ) fun PushDataUnifiedPush.toPushData() = PushData( - eventId = notification?.eventId?.takeIf { MatrixPatterns.isEventId(it) }, - roomId = notification?.roomId?.takeIf { MatrixPatterns.isRoomId(it) }, - unread = notification?.counts?.unread + eventId = notification?.eventId?.takeIf { MatrixPatterns.isEventId(it) }, + roomId = notification?.roomId?.takeIf { MatrixPatterns.isRoomId(it) }, + 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/parser/PushParser.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/parser/PushParser.kt index 7413264d5d..1504e6ec00 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/parser/PushParser.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/parser/PushParser.kt @@ -50,6 +50,7 @@ class PushParser @Inject constructor() { eventId = message["event_id"], roomId = message["room_id"], unread = message["unread"]?.let { tryOrNull { Integer.parseInt(it) } }, + clientSecret = message["cs"], ) return pushDataFcm.toPushData() } From afbd4672e6381680f215de1d67bb1a4e7de274c8 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 30 Mar 2023 14:18:23 +0200 Subject: [PATCH 061/107] Show basic notification when push is recieve --- .../android/x/intent/IntentProviderImpl.kt | 36 +++++++++++++++++++ .../libraries/push/impl/PushersManager.kt | 4 +-- .../libraries/push/impl/VectorPushHandler.kt | 3 ++ .../push/impl/intent/IntentProvider.kt | 26 ++++++++++++++ .../notifications/NotificationDisplayer.kt | 14 ++++++-- .../NotificationDrawerManager.kt | 10 ++++-- .../impl/notifications/NotificationFactory.kt | 4 +++ .../notifications/NotificationRenderer.kt | 9 +++++ .../impl/notifications/NotificationUtils.kt | 33 ++++++++++++----- .../impl/src/main/res/values/temporary.xml | 1 + 10 files changed, 124 insertions(+), 16 deletions(-) create mode 100644 app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt diff --git a/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt b/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt new file mode 100644 index 0000000000..f7b96f3aad --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.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.x.intent + +import android.content.Context +import android.content.Intent +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.push.impl.intent.IntentProvider +import io.element.android.x.MainActivity +import javax.inject.Inject + +// TODO EAx change to deep-link. +@ContributesBinding(AppScope::class) +class IntentProviderImpl @Inject constructor( + @ApplicationContext private val context: Context, +) : IntentProvider { + override fun getMainIntent(): Intent { + return Intent(context, MainActivity::class.java) + } +} 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 index 8a713585b6..b4952f35b4 100644 --- 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 @@ -67,7 +67,7 @@ class PushersManager @Inject constructor( val client = matrixAuthenticationService.restoreSession(SessionId(sessionData.userId)).getOrNull() client ?: return@forEach client.pushersService().setHttpPusher(createHttpPusher(pushKey, gateway, sessionData.userId)) - // Close sessions? + // TODO EAx Close sessions } } @@ -76,7 +76,7 @@ class PushersManager @Inject constructor( // Register the pusher for the session val client = matrixAuthenticationService.restoreSession(userId).getOrNull() ?: return client.pushersService().setHttpPusher(createHttpPusher(pushKey, PushConfig.pusher_http_url, userId.value)) - // Close sessions? + // TODO EAx Close sessions } private suspend fun createHttpPusher( diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt index f6a4b5d83d..0357e40a0a 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt @@ -142,9 +142,12 @@ class VectorPushHandler @Inject constructor( eventId = pushData.eventId, ) + // TODO Remove Timber.w("Notification: $notificationData") // TODO Display notification + notificationDrawerManager.displayTemporaryNotification() + /* TODO EAx - get the event - display the notif diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt new file mode 100644 index 0000000000..936ccfcbde --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.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.intent + +import android.content.Intent + +interface IntentProvider { + /** + * Provide an intent to start the application + */ + fun getMainIntent(): Intent +} 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 index 7f9ec73343..838356b370 100644 --- 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 @@ -16,20 +16,28 @@ 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 context: Context, -) { +const val TEMPORARY_ID = 101 +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) } 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 index c40680a1fd..1bf3e68f7f 100644 --- 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 @@ -41,7 +41,6 @@ import javax.inject.Inject @SingleIn(AppScope::class) class NotificationDrawerManager @Inject constructor( @ApplicationContext context: Context, - private val notificationDisplayer: NotificationDisplayer, private val pushDataStore: PushDataStore, // private val activeSessionDataSource: ActiveSessionDataSource, private val notifiableEventProcessor: NotifiableEventProcessor, @@ -154,7 +153,7 @@ class NotificationDrawerManager @Inject constructor( val newSettings = pushDataStore.useCompleteNotificationFormat() if (newSettings != useCompleteNotificationFormat) { // Settings has changed, remove all current notifications - notificationDisplayer.cancelAllNotifications() + notificationRenderer.cancelAllNotifications() useCompleteNotificationFormat = newSettings } } @@ -232,6 +231,13 @@ class NotificationDrawerManager @Inject constructor( return resolvedEvent.shouldIgnoreMessageEventInRoom(currentRoomId, currentThreadId) } + /** + * Temporary notification for EAx + */ + fun displayTemporaryNotification() { + notificationRenderer.displayTemporaryNotification() + } + companion object { const val SUMMARY_NOTIFICATION_ID = 0 const val ROOM_MESSAGES_NOTIFICATION_ID = 1 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 index f935a36366..5a3f008963 100644 --- 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 @@ -105,6 +105,10 @@ class NotificationFactory @Inject constructor( ) } } + + fun createTemporaryNotification(): Notification { + return notificationUtils.createTemporaryNotification() + } } sealed interface RoomNotification { 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 index 8b5fa70365..e0fc44cca0 100644 --- 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 @@ -104,6 +104,15 @@ class NotificationRenderer @Inject constructor( } } } + + fun cancelAllNotifications() { + notificationDisplayer.cancelAllNotifications() + } + + fun displayTemporaryNotification() { + val notification = notificationFactory.createTemporaryNotification() + notificationDisplayer.showNotificationMessage(null, TEMPORARY_ID, notification) + } } private fun List>.groupByType(): GroupedNotificationEvents { 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 index bfa80908bf..3fce6e0d84 100755 --- 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 @@ -45,6 +45,7 @@ import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SingleIn import io.element.android.libraries.push.impl.R +import io.element.android.libraries.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 @@ -61,6 +62,7 @@ class NotificationUtils @Inject constructor( private val stringProvider: StringProvider, private val clock: SystemClock, private val actionIds: NotificationActionIds, + private val intentProvider: IntentProvider, private val buildMeta: BuildMeta, ) { @@ -107,6 +109,10 @@ class NotificationUtils @Inject constructor( private val notificationManager = NotificationManagerCompat.from(context) + init { + createNotificationChannels() + } + /* ========================================================================================== * Channel names * ========================================================================================== */ @@ -114,7 +120,7 @@ class NotificationUtils @Inject constructor( /** * Create notification channels. */ - fun createNotificationChannels() { + private fun createNotificationChannels() { if (!supportNotificationChannels()) { return } @@ -650,14 +656,6 @@ class NotificationUtils @Inject constructor( ) } - fun showNotificationMessage(tag: String?, id: Int, notification: Notification) { - notificationManager.notify(tag, id, notification) - } - - fun cancelNotificationMessage(tag: String?, id: Int) { - notificationManager.cancel(tag, id) - } - /** * Cancel the foreground notification service. */ @@ -705,6 +703,23 @@ class NotificationUtils @Inject constructor( ) } + fun createTemporaryNotification(): Notification { + val contentIntent = intentProvider.getMainIntent() + val pendingIntent = PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE) + + return NotificationCompat.Builder(context, NOISY_NOTIFICATION_CHANNEL_ID) + .setContentTitle(buildMeta.applicationName) + .setContentText(stringProvider.getString(R.string.notification_new_messages_temporary)) + .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(pendingIntent) + .build() + } + private fun getBitmap(context: Context, @DrawableRes drawableRes: Int): Bitmap? { val drawable = ResourcesCompat.getDrawable(context.resources, drawableRes, null) ?: return null val canvas = Canvas() diff --git a/libraries/push/impl/src/main/res/values/temporary.xml b/libraries/push/impl/src/main/res/values/temporary.xml index b560669f57..e7b8618cc2 100644 --- a/libraries/push/impl/src/main/res/values/temporary.xml +++ b/libraries/push/impl/src/main/res/values/temporary.xml @@ -25,6 +25,7 @@ Silent notifications Call New Messages + You have new message(s) Mark as read Join Reject From 281eb617b58750988311411c4de02b6dbc4c67d8 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 30 Mar 2023 14:47:49 +0200 Subject: [PATCH 062/107] Add missing Fake classes --- .../libraries/matrix/test/FakeMatrixClient.kt | 12 ++++++++- .../notification/FakeNotificationService.kt | 26 +++++++++++++++++++ .../matrix/test/pushers/FakePushersService.kt | 24 +++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 998c7cdea4..b44bbc9af6 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -20,11 +20,15 @@ import io.element.android.libraries.matrix.api.MatrixClient 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.media.MediaResolver +import io.element.android.libraries.matrix.api.notification.NotificationService +import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.test.media.FakeMediaResolver +import io.element.android.libraries.matrix.test.notification.FakeNotificationService +import io.element.android.libraries.matrix.test.pushers.FakePushersService import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService @@ -35,7 +39,9 @@ class FakeMatrixClient( private val userDisplayName: Result = Result.success(A_USER_NAME), private val userAvatarURLString: Result = Result.success(AN_AVATAR_URL), override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(), - private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService() + private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), + private val pushersService: FakePushersService = FakePushersService(), + private val notificationService: FakeNotificationService = FakeNotificationService(), ) : MatrixClient { private var logoutFailure: Throwable? = null @@ -81,6 +87,10 @@ class FakeMatrixClient( override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService + override fun pushersService(): PushersService = pushersService + + override fun notificationService(): NotificationService = notificationService + override fun onSlidingSyncUpdate() {} override fun roomMembershipObserver(): RoomMembershipObserver { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt new file mode 100644 index 0000000000..a788e56f19 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.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.matrix.test.notification + +import io.element.android.libraries.matrix.api.notification.NotificationData +import io.element.android.libraries.matrix.api.notification.NotificationService + +class FakeNotificationService : NotificationService { + override fun getNotification(userId: String, roomId: String, eventId: String): NotificationData? { + return null + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt new file mode 100644 index 0000000000..b9f4580ee8 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt @@ -0,0 +1,24 @@ +/* + * 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.matrix.test.pushers + +import io.element.android.libraries.matrix.api.pusher.PushersService +import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData + +class FakePushersService : PushersService { + override fun setHttpPusher(setHttpPusherData: SetHttpPusherData) = Unit +} From c1f80cda80b5ca7b5e7caf6697b7db3e81c5ca0b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 30 Mar 2023 15:05:08 +0200 Subject: [PATCH 063/107] Create LoggedIn presenter --- appnav/build.gradle.kts | 4 + .../android/appnav/LoggedInFlowNode.kt | 26 ++++--- .../android/appnav/loggedin/LoggedInEvents.kt | 22 ++++++ .../appnav/loggedin/LoggedInPresenter.kt | 62 +++++++++++++++ .../android/appnav/loggedin/LoggedInState.kt | 24 ++++++ .../appnav/loggedin/LoggedInStateProvider.kt | 33 ++++++++ .../android/appnav/loggedin/LoggedInView.kt | 76 +++++++++++++++++++ features/roomlist/impl/build.gradle.kts | 2 - .../roomlist/impl/RoomListPresenter.kt | 17 ----- .../features/roomlist/impl/RoomListState.kt | 2 - .../roomlist/impl/RoomListStateProvider.kt | 2 - .../features/roomlist/impl/RoomListView.kt | 30 ++------ .../roomlist/impl/RoomListPresenterTests.kt | 8 -- .../android/samples/minimal/RoomListScreen.kt | 3 - 14 files changed, 243 insertions(+), 68 deletions(-) create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInEvents.kt create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index b2d2b391d8..8ece3e5841 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -41,12 +41,16 @@ dependencies { allFeaturesApi(rootDir) implementation(projects.libraries.core) + implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.push.api) implementation(projects.libraries.designsystem) implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) + implementation(projects.libraries.permissions.api) + implementation(projects.libraries.permissions.noop) + implementation(projects.features.verifysession.api) implementation(projects.features.roomdetails.api) implementation(projects.tests.uitests) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 42d82913b8..b20dcc8f44 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -35,6 +35,8 @@ import com.bumble.appyx.navmodel.backstack.operation.push import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode +import io.element.android.appnav.loggedin.LoggedInPresenter +import io.element.android.appnav.loggedin.LoggedInView import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.roomlist.api.RoomListEntryPoint @@ -52,7 +54,6 @@ import io.element.android.libraries.matrix.api.MatrixClient 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.ui.di.MatrixUIBindings -import io.element.android.libraries.push.api.PushService import io.element.android.services.appnavstate.api.AppNavigationStateService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -71,7 +72,7 @@ class LoggedInFlowNode @AssistedInject constructor( private val verifySessionEntryPoint: VerifySessionEntryPoint, private val coroutineScope: CoroutineScope, snackbarDispatcher: SnackbarDispatcher, - private val pushService: PushService, + private val loggedInPresenter: LoggedInPresenter, ) : BackstackNode( backstack = BackStack( initialElement = NavTarget.RoomList, @@ -114,10 +115,6 @@ class LoggedInFlowNode @AssistedInject constructor( // TODO We do not support Space yet, so directly navigate to main space appNavigationStateService.onNavigateToSpace(MAIN_SPACE) loggedInFlowProcessor.observeEvents(coroutineScope) - runBlocking { - // TODO - pushService.registerPusher(inputs.matrixClient.sessionId) - } }, onDestroy = { val imageLoaderFactory = bindings().notLoggedInImageLoaderFactory() @@ -208,11 +205,16 @@ class LoggedInFlowNode @AssistedInject constructor( @Composable override fun View(modifier: Modifier) { - Children( - navModel = backstack, - modifier = modifier, - // Animate navigation to settings and to a room - transitionHandler = rememberDefaultTransitionHandler(), - ) + val loggedInState = loggedInPresenter.present() + LoggedInView( + state = loggedInState + ) { + Children( + navModel = backstack, + modifier = modifier, + // Animate navigation to settings and to a room + transitionHandler = rememberDefaultTransitionHandler(), + ) + } } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInEvents.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInEvents.kt new file mode 100644 index 0000000000..2712b42003 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInEvents.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.appnav.loggedin + +// TODO Add your events or remove the file completely if no events +sealed interface LoggedInEvents { + object MyEvent : LoggedInEvents +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt new file mode 100644 index 0000000000..62127a5ea7 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.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.appnav.loggedin + +import android.Manifest +import android.os.Build +import androidx.compose.runtime.Composable +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter +import io.element.android.libraries.push.api.PushService +import javax.inject.Inject + +class LoggedInPresenter @Inject constructor( + private val permissionsPresenterFactory: PermissionsPresenter.Factory, + // private val matrixClient: MatrixClient, + // private val pushService: PushService, +) : Presenter { + + private val postNotificationPermissionsPresenter by lazy { + // Ask for POST_NOTIFICATION PERMISSION on Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS) + } else { + NoopPermissionsPresenter() + } + } + + @Composable + override fun present(): LoggedInState { + + // TODO EAx pushService.registerPusher(matrixClient.sessionId) + + val permissionsState = postNotificationPermissionsPresenter.present() + + fun handleEvents(event: LoggedInEvents) { + when (event) { + LoggedInEvents.MyEvent -> Unit + } + } + + return LoggedInState( + permissionsState = permissionsState, + eventSink = ::handleEvents + ) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt new file mode 100644 index 0000000000..a5c43801bd --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt @@ -0,0 +1,24 @@ +/* + * 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.appnav.loggedin + +import io.element.android.libraries.permissions.api.PermissionsState + +data class LoggedInState( + val permissionsState: PermissionsState, + val eventSink: (LoggedInEvents) -> Unit +) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt new file mode 100644 index 0000000000..90ff2136e5 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.loggedin + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.permissions.api.createDummyPostNotificationPermissionsState + +open class LoggedInStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aLoggedInState(), + // Add other state here + ) +} + +fun aLoggedInState() = LoggedInState( + permissionsState = createDummyPostNotificationPermissionsState(), + eventSink = {} +) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt new file mode 100644 index 0000000000..8071d7ca99 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.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. + */ + +package io.element.android.appnav.loggedin + +import android.app.Activity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.androidutils.system.openAppSettingsPage +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.permissions.api.PermissionsView + +@Composable +fun LoggedInView( + state: LoggedInState, + modifier: Modifier = Modifier, + children: @Composable BoxScope.() -> Unit, +) { + val activity = LocalContext.current as? Activity + + Box( + modifier = modifier + .fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + children() + + PermissionsView( + state = state.permissionsState, + openSystemSettings = { + activity?.let { openAppSettingsPage(it, "") } + } + ) + } +} + +@Preview +@Composable +fun LoggedInViewLightPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun LoggedInViewDarkPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: LoggedInState) { + LoggedInView( + state = state + ) { + Text("Children") + } +} diff --git a/features/roomlist/impl/build.gradle.kts b/features/roomlist/impl/build.gradle.kts index b679c2d823..e5c0e289e0 100644 --- a/features/roomlist/impl/build.gradle.kts +++ b/features/roomlist/impl/build.gradle.kts @@ -47,8 +47,6 @@ dependencies { implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.elementresources) - implementation(projects.libraries.permissions.api) - implementation(projects.libraries.permissions.noop) implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) implementation(projects.libraries.dateformatter.api) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index c38120161b..ac37dfe30d 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -16,8 +16,6 @@ package io.element.android.features.roomlist.impl -import android.Manifest -import android.os.Build import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -45,8 +43,6 @@ import io.element.android.libraries.matrix.api.room.RoomSummary import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.ui.model.MatrixUser -import io.element.android.libraries.permissions.api.PermissionsPresenter -import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -63,20 +59,10 @@ class RoomListPresenter @Inject constructor( private val roomLastMessageFormatter: RoomLastMessageFormatter, private val sessionVerificationService: SessionVerificationService, private val snackbarDispatcher: SnackbarDispatcher, - private val permissionsPresenterFactory: PermissionsPresenter.Factory, ) : Presenter { private val roomMembershipObserver: RoomMembershipObserver = client.roomMembershipObserver() - private val postNotificationPermissionsPresenter by lazy { - // Ask for POST_NOTIFICATION PERMISSION on Android 13+ - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS) - } else { - NoopPermissionsPresenter() - } - } - @Composable override fun present(): RoomListState { val matrixUser: MutableState = remember { @@ -119,15 +105,12 @@ class RoomListPresenter @Inject constructor( val snackbarMessage = handleSnackbarMessage(snackbarDispatcher) - val permissionsState = postNotificationPermissionsPresenter.present() - return RoomListState( matrixUser = matrixUser.value, roomList = filteredRoomSummaries.value, filter = filter, displayVerificationPrompt = displayVerificationPrompt, snackbarMessage = snackbarMessage, - permissionsState = permissionsState, eventSink = ::handleEvents ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt index 2deb13ff2b..a14ef74e94 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt @@ -20,7 +20,6 @@ import androidx.compose.runtime.Immutable import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.matrix.ui.model.MatrixUser -import io.element.android.libraries.permissions.api.PermissionsState import kotlinx.collections.immutable.ImmutableList @Immutable @@ -30,6 +29,5 @@ data class RoomListState( val filter: String, val displayVerificationPrompt: Boolean, val snackbarMessage: SnackbarMessage?, - val permissionsState: PermissionsState, val eventSink: (RoomListEvents) -> Unit ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt index 62fd918cca..07e2fcff1a 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt @@ -23,7 +23,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.ui.model.MatrixUser -import io.element.android.libraries.permissions.api.createDummyPostNotificationPermissionsState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import io.element.android.libraries.ui.strings.R as StringR @@ -43,7 +42,6 @@ internal fun aRoomListState() = RoomListState( filter = "filter", snackbarMessage = null, displayVerificationPrompt = false, - permissionsState = createDummyPostNotificationPermissionsState(), eventSink = {} ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt index 9567ef9464..b4c7676daf 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -16,7 +16,6 @@ package io.element.android.features.roomlist.impl -import android.app.Activity import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -49,7 +48,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -59,7 +57,6 @@ import androidx.compose.ui.unit.dp import io.element.android.features.roomlist.impl.components.RoomListTopBar import io.element.android.features.roomlist.impl.components.RoomSummaryRow import io.element.android.features.roomlist.impl.model.RoomListRoomSummary -import io.element.android.libraries.androidutils.system.openAppSettingsPage import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight @@ -72,7 +69,6 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.coroutines.launch -import io.element.android.libraries.permissions.api.PermissionsView import io.element.android.libraries.designsystem.R as DrawableR import io.element.android.libraries.ui.strings.R as StringR @@ -85,24 +81,14 @@ fun RoomListView( onVerifyClicked: () -> Unit = {}, onCreateRoomClicked: () -> Unit = {}, ) { - val activity = LocalContext.current as? Activity - - Box(modifier = modifier) { - RoomListContent( - state = state, - modifier = Modifier, - onRoomClicked = onRoomClicked, - onOpenSettings = onOpenSettings, - onVerifyClicked = onVerifyClicked, - onCreateRoomClicked = onCreateRoomClicked, - ) - PermissionsView( - state = state.permissionsState, - openSystemSettings = { - activity?.let { openAppSettingsPage(it, "") } - } - ) - } + RoomListContent( + state = state, + modifier = modifier, + onRoomClicked = onRoomClicked, + onOpenSettings = onOpenSettings, + onVerifyClicked = onVerifyClicked, + onCreateRoomClicked = onCreateRoomClicked, + ) } @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index 18ead8f3e6..3f3e43e2e7 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -37,7 +37,6 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService -import io.element.android.libraries.permissions.noop.NoopPermissionsPresenterFactory import kotlinx.coroutines.test.runTest import org.junit.Test @@ -51,7 +50,6 @@ class RoomListPresenterTests { FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), SnackbarDispatcher(), - NoopPermissionsPresenterFactory(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -79,7 +77,6 @@ class RoomListPresenterTests { FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), SnackbarDispatcher(), - NoopPermissionsPresenterFactory(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -101,7 +98,6 @@ class RoomListPresenterTests { FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), SnackbarDispatcher(), - NoopPermissionsPresenterFactory(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -127,7 +123,6 @@ class RoomListPresenterTests { FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), SnackbarDispatcher(), - NoopPermissionsPresenterFactory(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -158,7 +153,6 @@ class RoomListPresenterTests { FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), SnackbarDispatcher(), - NoopPermissionsPresenterFactory(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -194,7 +188,6 @@ class RoomListPresenterTests { FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), SnackbarDispatcher(), - NoopPermissionsPresenterFactory(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -244,7 +237,6 @@ class RoomListPresenterTests { givenVerifiedStatus(SessionVerifiedStatus.NotVerified) }, SnackbarDispatcher(), - NoopPermissionsPresenterFactory(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() 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 9d1c7a495d..5dce2feafe 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 @@ -29,7 +29,6 @@ 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 io.element.android.libraries.permissions.noop.NoopPermissionsPresenterFactory import kotlinx.coroutines.launch import kotlinx.datetime.Clock import kotlinx.datetime.TimeZone @@ -45,14 +44,12 @@ class RoomListScreen( private val dateTimeProvider = LocalDateTimeProvider(clock, timeZone) private val dateFormatters = DateFormatters(locale, clock, timeZone) private val sessionVerificationService = matrixClient.sessionVerificationService() - private val permissionsPresenterFactory = NoopPermissionsPresenterFactory() private val presenter = RoomListPresenter( matrixClient, DefaultLastMessageTimestampFormatter(dateTimeProvider, dateFormatters), DefaultRoomLastMessageFormatter(context, matrixClient), sessionVerificationService, SnackbarDispatcher(), - permissionsPresenterFactory, ) @Composable From eaff43de3a146f461f5dabe51adcd9bd098335eb Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 31 Mar 2023 10:15:51 +0200 Subject: [PATCH 064/107] Create a LoggedInNode, used as a PermanentNode in LoggedInFlowNode --- .../android/appnav/LoggedInFlowNode.kt | 19 ++++---- .../android/appnav/loggedin/LoggedInNode.kt | 44 +++++++++++++++++++ .../appnav/loggedin/LoggedInPresenter.kt | 11 +++-- .../android/appnav/loggedin/LoggedInView.kt | 33 ++++---------- .../android/libraries/push/api/PushService.kt | 8 +++- .../libraries/push/impl/DefaultPushService.kt | 6 +-- .../libraries/push/impl/PushersManager.kt | 10 ++--- 7 files changed, 85 insertions(+), 46 deletions(-) create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInNode.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index b20dcc8f44..caef28bb9e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -35,8 +35,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode -import io.element.android.appnav.loggedin.LoggedInPresenter -import io.element.android.appnav.loggedin.LoggedInView +import io.element.android.appnav.loggedin.LoggedInNode import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.roomlist.api.RoomListEntryPoint @@ -72,7 +71,6 @@ class LoggedInFlowNode @AssistedInject constructor( private val verifySessionEntryPoint: VerifySessionEntryPoint, private val coroutineScope: CoroutineScope, snackbarDispatcher: SnackbarDispatcher, - private val loggedInPresenter: LoggedInPresenter, ) : BackstackNode( backstack = BackStack( initialElement = NavTarget.RoomList, @@ -128,6 +126,9 @@ class LoggedInFlowNode @AssistedInject constructor( } sealed interface NavTarget : Parcelable { + @Parcelize + object Permanent : NavTarget + @Parcelize object RoomList : NavTarget @@ -146,6 +147,9 @@ class LoggedInFlowNode @AssistedInject constructor( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { + NavTarget.Permanent -> { + createNode(buildContext) + } NavTarget.RoomList -> { val callback = object : RoomListEntryPoint.Callback { override fun onRoomClicked(roomId: RoomId) { @@ -205,16 +209,15 @@ class LoggedInFlowNode @AssistedInject constructor( @Composable override fun View(modifier: Modifier) { - val loggedInState = loggedInPresenter.present() - LoggedInView( - state = loggedInState - ) { + Box(modifier = modifier) { Children( navModel = backstack, - modifier = modifier, + modifier = Modifier, // Animate navigation to settings and to a room transitionHandler = rememberDefaultTransitionHandler(), ) + + PermanentChild(navTarget = NavTarget.Permanent) } } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInNode.kt new file mode 100644 index 0000000000..6950b9b699 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInNode.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.loggedin + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +class LoggedInNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val loggedInPresenter: LoggedInPresenter, +) : Node(buildContext, plugins = plugins) { + + @Composable + override fun View(modifier: Modifier) { + val loggedInState = loggedInPresenter.present() + LoggedInView( + state = loggedInState, + modifier = modifier + ) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt index 62127a5ea7..a845ec4600 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt @@ -19,6 +19,7 @@ package io.element.android.appnav.loggedin import android.Manifest import android.os.Build import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.permissions.api.PermissionsPresenter @@ -27,9 +28,9 @@ import io.element.android.libraries.push.api.PushService import javax.inject.Inject class LoggedInPresenter @Inject constructor( + private val matrixClient: MatrixClient, private val permissionsPresenterFactory: PermissionsPresenter.Factory, - // private val matrixClient: MatrixClient, - // private val pushService: PushService, + private val pushService: PushService, ) : Presenter { private val postNotificationPermissionsPresenter by lazy { @@ -43,8 +44,10 @@ class LoggedInPresenter @Inject constructor( @Composable override fun present(): LoggedInState { - - // TODO EAx pushService.registerPusher(matrixClient.sessionId) + LaunchedEffect(Unit) { + // Ensure pusher is registered + pushService.registerPusher(matrixClient) + } val permissionsState = postNotificationPermissionsPresenter.present() diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt index 8071d7ca99..5db19ccae7 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt @@ -17,11 +17,7 @@ package io.element.android.appnav.loggedin import android.app.Activity -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview @@ -29,31 +25,22 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.libraries.androidutils.system.openAppSettingsPage import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight -import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.permissions.api.PermissionsView @Composable fun LoggedInView( state: LoggedInState, - modifier: Modifier = Modifier, - children: @Composable BoxScope.() -> Unit, + modifier: Modifier = Modifier ) { val activity = LocalContext.current as? Activity - Box( - modifier = modifier - .fillMaxSize(), - contentAlignment = Alignment.TopCenter, - ) { - children() - - PermissionsView( - state = state.permissionsState, - openSystemSettings = { - activity?.let { openAppSettingsPage(it, "") } - } - ) - } + PermissionsView( + state = state.permissionsState, + modifier = modifier, + openSystemSettings = { + activity?.let { openAppSettingsPage(it, "") } + } + ) } @Preview @@ -70,7 +57,5 @@ fun LoggedInViewDarkPreview(@PreviewParameter(LoggedInStateProvider::class) stat private fun ContentToPreview(state: LoggedInState) { LoggedInView( state = state - ) { - Text("Children") - } + ) } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt index 77adb869c5..7d0f2cf4cb 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt @@ -16,15 +16,19 @@ package io.element.android.libraries.push.api -import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.MatrixClient interface PushService { + // TODO EAx remove fun setCurrentRoom(roomId: String?) + + // TODO EAx remove fun setCurrentThread(threadId: String?) + fun notificationStyleChanged() // Ensure pusher is registered - suspend fun registerPusher(userId: UserId) + suspend fun registerPusher(matrixClient: MatrixClient) suspend fun testPush() } 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 index e9b7efcdee..82c7062959 100644 --- 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 @@ -18,7 +18,7 @@ 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.core.UserId +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager import javax.inject.Inject @@ -40,8 +40,8 @@ class DefaultPushService @Inject constructor( notificationDrawerManager.notificationStyleChanged() } - override suspend fun registerPusher(userId: UserId) { - pusherManager.registerPusher(userId) + override suspend fun registerPusher(matrixClient: MatrixClient) { + pusherManager.registerPusher(matrixClient) } override suspend fun testPush() { 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 index b4952f35b4..710688e520 100644 --- 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 @@ -16,9 +16,9 @@ package io.element.android.libraries.push.impl +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.SessionId -import io.element.android.libraries.matrix.api.core.UserId 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 @@ -71,12 +71,12 @@ class PushersManager @Inject constructor( } } - suspend fun registerPusher(userId: UserId) { + suspend fun registerPusher(matrixClient: MatrixClient) { val pushKey = fcmHelper.getFcmToken() ?: return // Register the pusher for the session - val client = matrixAuthenticationService.restoreSession(userId).getOrNull() ?: return - client.pushersService().setHttpPusher(createHttpPusher(pushKey, PushConfig.pusher_http_url, userId.value)) - // TODO EAx Close sessions + matrixClient.pushersService().setHttpPusher( + createHttpPusher(pushKey, PushConfig.pusher_http_url, matrixClient.sessionId.value) + ) } private suspend fun createHttpPusher( From 961d0ecdbc46acfdc125475654c42e58f61b3a47 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 31 Mar 2023 12:04:50 +0200 Subject: [PATCH 065/107] Add Result + Dispatcher on SDK call. --- .../matrix/api/pusher/PushersService.kt | 2 +- .../libraries/matrix/impl/RustMatrixClient.kt | 5 ++- .../matrix/impl/pushers/RustPushersService.kt | 41 +++++++++++-------- .../matrix/test/pushers/FakePushersService.kt | 2 +- 4 files changed, 30 insertions(+), 20 deletions(-) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt index a868aeab85..ef2291f8ce 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt @@ -17,5 +17,5 @@ package io.element.android.libraries.matrix.api.pusher interface PushersService { - fun setHttpPusher(setHttpPusherData: SetHttpPusherData) + suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData): Result } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 0fa5a668c8..bb4ec1c971 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -64,7 +64,10 @@ class RustMatrixClient constructor( override val sessionId: UserId = UserId(client.userId()) private val verificationService = RustSessionVerificationService() - private val pushersService = RustPushersService(client) + private val pushersService = RustPushersService( + client = client, + dispatchers = dispatchers, + ) private val notificationService = RustNotificationService(baseDirectory) private var slidingSyncUpdateJob: Job? = null diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt index 39dd5c1d11..4eaafef12d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt @@ -16,8 +16,10 @@ package io.element.android.libraries.matrix.impl.pushers +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.HttpPusherData import org.matrix.rustcomponents.sdk.PushFormat @@ -26,24 +28,29 @@ import org.matrix.rustcomponents.sdk.PusherKind class RustPushersService( private val client: Client, + private val dispatchers: CoroutineDispatchers ) : PushersService { - override fun setHttpPusher(setHttpPusherData: SetHttpPusherData) { - client.setPusher( - identifiers = PusherIdentifiers( - pushkey = setHttpPusherData.pushKey, - appId = setHttpPusherData.appId - ), - kind = PusherKind.Http( - data = HttpPusherData( - url = setHttpPusherData.url, - format = PushFormat.EVENT_ID_ONLY, - defaultPayload = setHttpPusherData.defaultPayload + override suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData): Result { + return withContext(dispatchers.io) { + runCatching { + client.setPusher( + identifiers = PusherIdentifiers( + pushkey = setHttpPusherData.pushKey, + appId = setHttpPusherData.appId + ), + kind = PusherKind.Http( + data = HttpPusherData( + url = setHttpPusherData.url, + format = PushFormat.EVENT_ID_ONLY, + defaultPayload = setHttpPusherData.defaultPayload + ) + ), + appDisplayName = setHttpPusherData.appDisplayName, + deviceDisplayName = setHttpPusherData.deviceDisplayName, + profileTag = setHttpPusherData.profileTag, + lang = setHttpPusherData.lang ) - ), - appDisplayName = setHttpPusherData.appDisplayName, - deviceDisplayName = setHttpPusherData.deviceDisplayName, - profileTag = setHttpPusherData.profileTag, - lang = setHttpPusherData.lang - ) + } + } } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt index b9f4580ee8..77087d132f 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt @@ -20,5 +20,5 @@ import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData class FakePushersService : PushersService { - override fun setHttpPusher(setHttpPusherData: SetHttpPusherData) = Unit + override suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData) = Result.success(Unit) } From b9276aa60b3bf675dfc14f36eaf327490fca64c9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 31 Mar 2023 15:55:14 +0200 Subject: [PATCH 066/107] Cleanup + Add per user store. --- .../appnav/loggedin/LoggedInPresenter.kt | 2 +- .../android/libraries/push/api/PushService.kt | 2 +- .../push/impl/src/main/AndroidManifest.xml | 6 +- .../libraries/push/impl/AutoAcceptInvites.kt | 49 --------------- .../libraries/push/impl/DefaultPushService.kt | 15 +++-- .../libraries/push/impl/PushersManager.kt | 58 ++++++++++++----- .../libraries/push/impl/UnifiedPushHelper.kt | 1 - .../EnsureFcmTokenIsRetrievedUseCase.kt | 11 ++-- .../push/impl/firebase/FirebasePushParser.kt | 33 ++++++++++ .../PushDataFirebase.kt} | 9 +-- .../VectorFirebaseMessagingService.kt | 40 +++++------- ...VectorFirebaseMessagingServiceBindings.kt} | 5 +- .../libraries/push/impl/log/LoggerTag.kt | 21 +++++++ .../notifications/NotifiableEventProcessor.kt | 10 +-- .../libraries/push/impl/parser/PushParser.kt | 57 ----------------- .../push/impl/{model => push}/PushData.kt | 2 +- .../PushHandler.kt} | 17 +++-- .../{ => unifiedpush}/GuardServiceStarter.kt | 4 +- .../KeepInternalDistributor.kt | 2 +- .../PushDataUnifiedPush.kt | 5 +- .../RegisterUnifiedPushUseCase.kt | 4 +- .../impl/unifiedpush/UnifiedPushParser.kt | 29 +++++++++ .../UnregisterUnifiedPushUseCase.kt | 7 ++- .../VectorUnifiedPushMessagingReceiver.kt | 22 +++---- ...torUnifiedPushMessagingReceiverBindings.kt | 3 +- .../push/impl/userpushstore/UserPushStore.kt | 40 ++++++++++++ .../userpushstore/UserPushStoreDataStore.kt | 63 +++++++++++++++++++ .../userpushstore/UserPushStoreFactory.kt | 32 ++++++++++ .../sessionstorage/api/SessionStore.kt | 4 ++ 29 files changed, 351 insertions(+), 202 deletions(-) delete mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/AutoAcceptInvites.kt rename libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/{ => firebase}/EnsureFcmTokenIsRetrievedUseCase.kt (78%) create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/FirebasePushParser.kt rename libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/{model/PushDataFcm.kt => firebase/PushDataFirebase.kt} (83%) rename libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/{ => firebase}/VectorFirebaseMessagingService.kt (54%) rename libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/{di/FirebaseMessagingServiceBindings.kt => firebase/VectorFirebaseMessagingServiceBindings.kt} (82%) create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/log/LoggerTag.kt delete mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/parser/PushParser.kt rename libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/{model => push}/PushData.kt (95%) rename libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/{VectorPushHandler.kt => push/PushHandler.kt} (94%) rename libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/{ => unifiedpush}/GuardServiceStarter.kt (90%) rename libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/{ => unifiedpush}/KeepInternalDistributor.kt (94%) rename libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/{model => unifiedpush}/PushDataUnifiedPush.kt (91%) rename libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/{ => unifiedpush}/RegisterUnifiedPushUseCase.kt (95%) create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnifiedPushParser.kt rename libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/{ => unifiedpush}/UnregisterUnifiedPushUseCase.kt (86%) rename libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/{ => unifiedpush}/VectorUnifiedPushMessagingReceiver.kt (88%) rename libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/{di => unifiedpush}/VectorUnifiedPushMessagingReceiverBindings.kt (86%) create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStore.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreDataStore.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreFactory.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt index a845ec4600..82f927a743 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt @@ -46,7 +46,7 @@ class LoggedInPresenter @Inject constructor( override fun present(): LoggedInState { LaunchedEffect(Unit) { // Ensure pusher is registered - pushService.registerPusher(matrixClient) + pushService.registerFirebasePusher(matrixClient) } val permissionsState = postNotificationPermissionsPresenter.present() diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt index 7d0f2cf4cb..5582e7fe92 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt @@ -28,7 +28,7 @@ interface PushService { fun notificationStyleChanged() // Ensure pusher is registered - suspend fun registerPusher(matrixClient: MatrixClient) + suspend fun registerFirebasePusher(matrixClient: MatrixClient) suspend fun testPush() } diff --git a/libraries/push/impl/src/main/AndroidManifest.xml b/libraries/push/impl/src/main/AndroidManifest.xml index 1d6f459d91..71fc629aaa 100644 --- a/libraries/push/impl/src/main/AndroidManifest.xml +++ b/libraries/push/impl/src/main/AndroidManifest.xml @@ -26,7 +26,7 @@ android:value="true" /> @@ -35,7 +35,7 @@ @@ -48,7 +48,7 @@ diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/AutoAcceptInvites.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/AutoAcceptInvites.kt deleted file mode 100644 index cc2b9100ec..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/AutoAcceptInvites.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2021 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.push.impl - -import com.squareup.anvil.annotations.ContributesBinding -import io.element.android.libraries.di.AppScope -import javax.inject.Inject - -// TODO Move away -/** - * This interface defines 2 flags so you can handle auto accept invites. - * At the moment we only have [CompileTimeAutoAcceptInvites] implementation. - */ -interface AutoAcceptInvites { - /** - * Enable auto-accept invites. It means, as soon as you got an invite from the sync, it will try to join it. - */ - val isEnabled: Boolean - - /** - * Hide invites from the UI (from notifications, notification count and room list). By default invites are hidden when [isEnabled] is true - */ - val hideInvites: Boolean - get() = isEnabled -} - -fun AutoAcceptInvites.showInvites() = !hideInvites - -/** - * Simple compile time implementation of AutoAcceptInvites flags. - */ -@ContributesBinding(AppScope::class) -class CompileTimeAutoAcceptInvites @Inject constructor() : AutoAcceptInvites { - override val isEnabled = false -} 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 index 82c7062959..327a3eea96 100644 --- 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 @@ -20,13 +20,17 @@ 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 pusherManager: PushersManager, + private val pushersManager: PushersManager, + private val fcmHelper: FcmHelper, ) : PushService { override fun setCurrentRoom(roomId: String?) { notificationDrawerManager.setCurrentRoom(roomId) @@ -40,11 +44,14 @@ class DefaultPushService @Inject constructor( notificationDrawerManager.notificationStyleChanged() } - override suspend fun registerPusher(matrixClient: MatrixClient) { - pusherManager.registerPusher(matrixClient) + 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() { - pusherManager.testPush() + pushersManager.testPush() } } 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 index 710688e520..8455624585 100644 --- 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 @@ -23,8 +23,12 @@ 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.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" @@ -40,6 +44,7 @@ class PushersManager @Inject constructor( private val pushClientSecret: PushClientSecret, private val sessionStore: SessionStore, private val matrixAuthenticationService: MatrixAuthenticationService, + private val userPushStoreFactory: UserPushStoreFactory, private val fcmHelper: FcmHelper, ) { suspend fun testPush() { @@ -54,29 +59,54 @@ class PushersManager @Inject constructor( } suspend fun enqueueRegisterPusherWithFcmKey(pushKey: String) { - return enqueueRegisterPusher(pushKey, PushConfig.pusher_http_url) + // return onNewFirebaseToken(pushKey, PushConfig.pusher_http_url) + TODO() } - // TODO Rename - suspend fun enqueueRegisterPusher( + 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().forEach { sessionData -> - val client = matrixAuthenticationService.restoreSession(SessionId(sessionData.userId)).getOrNull() - client ?: return@forEach - client.pushersService().setHttpPusher(createHttpPusher(pushKey, gateway, sessionData.userId)) - // TODO EAx Close sessions + sessionStore.getAllSessions().toUserList().forEach { userId -> + val userDataStore = userPushStoreFactory.create(userId) + if (userDataStore.isFirebase()) { + val client = matrixAuthenticationService.restoreSession(SessionId(userId)).getOrNull() + client ?: return@forEach + registerPusher(client, firebaseToken, PushConfig.pusher_http_url) + // TODO EAx Close sessions + } else { + Timber.d("This session is not using Firebase pusher") + } } } - suspend fun registerPusher(matrixClient: MatrixClient) { - val pushKey = fcmHelper.getFcmToken() ?: return - // Register the pusher for the session - matrixClient.pushersService().setHttpPusher( - createHttpPusher(pushKey, PushConfig.pusher_http_url, matrixClient.sessionId.value) - ) + /** + * 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.d("Unnecessary to register again the same pusher") + } else { + // Register the pusher to the server + matrixClient.pushersService().setHttpPusher( + createHttpPusher(pushKey, gateway, matrixClient.sessionId.value) + ).fold( + { + userDataStore.setCurrentRegisteredPushKey(pushKey) + }, + { throwable -> + Timber.e(throwable, "Unable to register the pusher") + } + ) + } } private suspend fun createHttpPusher( 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 index a6b50a58dd..f77b5b2833 100644 --- 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 @@ -27,7 +27,6 @@ import org.unifiedpush.android.connector.UnifiedPush import timber.log.Timber import java.net.URL import javax.inject.Inject -import io.element.android.libraries.ui.strings.R as StringR class UnifiedPushHelper @Inject constructor( @ApplicationContext private val context: Context, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/EnsureFcmTokenIsRetrievedUseCase.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/EnsureFcmTokenIsRetrievedUseCase.kt similarity index 78% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/EnsureFcmTokenIsRetrievedUseCase.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/EnsureFcmTokenIsRetrievedUseCase.kt index fa5e6a0e5d..9e9b28ecb8 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/EnsureFcmTokenIsRetrievedUseCase.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/EnsureFcmTokenIsRetrievedUseCase.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -14,13 +14,16 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl +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 unifiedPushHelper: UnifiedPushHelper, + private val fcmHelper: FcmHelper, // private val activeSessionHolder: ActiveSessionHolder, ) { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/FirebasePushParser.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/FirebasePushParser.kt new file mode 100644 index 0000000000..906816eb56 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/FirebasePushParser.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.firebase + +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.push.impl.push.PushData +import javax.inject.Inject + +class FirebasePushParser @Inject constructor() { + fun parse(message: Map): PushData { + val pushDataFirebase = PushDataFirebase( + eventId = message["event_id"], + roomId = message["room_id"], + unread = message["unread"]?.let { tryOrNull { Integer.parseInt(it) } }, + clientSecret = message["cs"], + ) + return pushDataFirebase.toPushData() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataFcm.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/PushDataFirebase.kt similarity index 83% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataFcm.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/PushDataFirebase.kt index fbde04cc36..af82ebce74 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataFcm.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/PushDataFirebase.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -14,9 +14,10 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.model +package io.element.android.libraries.push.impl.firebase import io.element.android.libraries.matrix.api.core.MatrixPatterns +import io.element.android.libraries.push.impl.push.PushData /** * In this case, the format is: @@ -31,14 +32,14 @@ import io.element.android.libraries.matrix.api.core.MatrixPatterns * * . */ -data class PushDataFcm( +data class PushDataFirebase( val eventId: String?, val roomId: String?, var unread: Int?, val clientSecret: String? ) -fun PushDataFcm.toPushData() = PushData( +fun PushDataFirebase.toPushData() = PushData( eventId = eventId?.takeIf { MatrixPatterns.isEventId(it) }, roomId = roomId?.takeIf { MatrixPatterns.isRoomId(it) }, unread = unread, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorFirebaseMessagingService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingService.kt similarity index 54% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorFirebaseMessagingService.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingService.kt index 81afe726f2..f0e3bc1609 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorFirebaseMessagingService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingService.kt @@ -14,58 +14,50 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl +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.api.store.PushDataStore -import io.element.android.libraries.push.impl.config.PushConfig -import io.element.android.libraries.push.impl.di.FirebaseMessagingServiceBindings -import io.element.android.libraries.push.impl.parser.PushParser +import io.element.android.libraries.push.impl.PushersManager +import io.element.android.libraries.push.impl.push.PushHandler +import io.element.android.libraries.push.impl.log.pushLoggerTag import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject -private val loggerTag = LoggerTag("Push", LoggerTag.SYNC) +private val loggerTag = LoggerTag("Firebase", pushLoggerTag) class VectorFirebaseMessagingService : FirebaseMessagingService() { - @Inject lateinit var fcmHelper: FcmHelper - @Inject lateinit var pushDataStore: PushDataStore - // @Inject lateinit var activeSessionHolder: ActiveSessionHolder @Inject lateinit var pushersManager: PushersManager - @Inject lateinit var pushParser: PushParser - @Inject lateinit var vectorPushHandler: VectorPushHandler - @Inject lateinit var unifiedPushHelper: UnifiedPushHelper + + @Inject + lateinit var pushParser: FirebasePushParser + + @Inject + lateinit var pushHandler: PushHandler private val coroutineScope = CoroutineScope(SupervisorJob()) override fun onCreate() { super.onCreate() - applicationContext.bindings().inject(this) + applicationContext.bindings().inject(this) } override fun onNewToken(token: String) { Timber.tag(loggerTag.value).d("New Firebase token") - fcmHelper.storeFcmToken(token) - if ( - // pushDataStore.areNotificationEnabledForDevice() && - // TODO EAx activeSessionHolder.hasActiveSession() && - unifiedPushHelper.isEmbeddedDistributor() - ) { - coroutineScope.launch { - pushersManager.enqueueRegisterPusher(token, PushConfig.pusher_http_url) - } + coroutineScope.launch { + pushersManager.onNewFirebaseToken(token) } } override fun onMessageReceived(message: RemoteMessage) { Timber.tag(loggerTag.value).d("New Firebase message") - pushParser.parsePushDataFcm(message.data).let { - vectorPushHandler.handle(it) + pushParser.parse(message.data).let { + pushHandler.handle(it) } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/FirebaseMessagingServiceBindings.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingServiceBindings.kt similarity index 82% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/FirebaseMessagingServiceBindings.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingServiceBindings.kt index 1de015b770..aef87e7df3 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/FirebaseMessagingServiceBindings.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingServiceBindings.kt @@ -14,13 +14,12 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.di +package io.element.android.libraries.push.impl.firebase import com.squareup.anvil.annotations.ContributesTo import io.element.android.libraries.di.AppScope -import io.element.android.libraries.push.impl.VectorFirebaseMessagingService @ContributesTo(AppScope::class) -interface FirebaseMessagingServiceBindings { +interface VectorFirebaseMessagingServiceBindings { fun inject(service: VectorFirebaseMessagingService) } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/log/LoggerTag.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/log/LoggerTag.kt new file mode 100644 index 0000000000..359779fd8a --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/log/LoggerTag.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.log + +import io.element.android.libraries.core.log.logger.LoggerTag + +internal val pushLoggerTag = LoggerTag("Push") 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 index 91b62eba0e..209c42f4ae 100644 --- 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 @@ -16,12 +16,7 @@ package io.element.android.libraries.push.impl.notifications -import io.element.android.libraries.push.impl.AutoAcceptInvites -import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent -import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom +import io.element.android.libraries.push.impl.notifications.model.* import timber.log.Timber import javax.inject.Inject @@ -29,13 +24,12 @@ private typealias ProcessedEvents = List> class NotifiableEventProcessor @Inject constructor( private val outdatedDetector: OutdatedEventDetector, - private val autoAcceptInvites: AutoAcceptInvites ) { fun process(queuedEvents: List, currentRoomId: String?, currentThreadId: String?, renderedEvents: ProcessedEvents): ProcessedEvents { val processedEvents = queuedEvents.map { val type = when (it) { - is InviteNotifiableEvent -> if (autoAcceptInvites.hideInvites) ProcessedEvent.Type.REMOVE else ProcessedEvent.Type.KEEP + is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP is NotifiableMessageEvent -> when { it.shouldIgnoreMessageEventInRoom(currentRoomId, currentThreadId) -> { ProcessedEvent.Type.REMOVE diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/parser/PushParser.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/parser/PushParser.kt deleted file mode 100644 index 1504e6ec00..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/parser/PushParser.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.push.impl.parser - -import io.element.android.libraries.core.data.tryOrNull -import io.element.android.libraries.push.impl.model.PushData -import io.element.android.libraries.push.impl.model.PushDataFcm -import io.element.android.libraries.push.impl.model.PushDataUnifiedPush -import io.element.android.libraries.push.impl.model.toPushData -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json -import javax.inject.Inject - -/** - * Parse the received data from Push. Json format are different depending on the source. - * - * Notifications received by FCM are formatted by the matrix gateway [1]. The data send to FCM is the content - * of the "notification" attribute of the json sent to the gateway [2][3]. - * On the other side, with UnifiedPush, the content of the message received is the content posted to the push - * gateway endpoint [3]. - * - * *Note*: If we want to get the same content with FCM and unifiedpush, we can do a new sygnal pusher [4]. - * - * [1] https://github.com/matrix-org/sygnal/blob/main/sygnal/gcmpushkin.py - * [2] https://github.com/matrix-org/sygnal/blob/main/sygnal/gcmpushkin.py#L366 - * [3] https://spec.matrix.org/latest/push-gateway-api/ - * [4] https://github.com/p1gp1g/sygnal/blob/unifiedpush/sygnal/upfcmpushkin.py (Not tested for a while) - */ -class PushParser @Inject constructor() { - fun parsePushDataUnifiedPush(message: ByteArray): PushData? { - return tryOrNull { Json.decodeFromString(String(message)) }?.toPushData() - } - - fun parsePushDataFcm(message: Map): PushData { - val pushDataFcm = PushDataFcm( - eventId = message["event_id"], - roomId = message["room_id"], - unread = message["unread"]?.let { tryOrNull { Integer.parseInt(it) } }, - clientSecret = message["cs"], - ) - return pushDataFcm.toPushData() - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushData.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushData.kt similarity index 95% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushData.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushData.kt index 06445d7ca6..0955c864cd 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushData.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushData.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.model +package io.element.android.libraries.push.impl.push /** * Represent parsed data that the app has received from a Push content. diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushHandler.kt similarity index 94% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushHandler.kt index 0357e40a0a..bab955b419 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorPushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushHandler.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl +package io.element.android.libraries.push.impl.push import android.content.Context import android.content.Intent @@ -30,19 +30,24 @@ import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.SessionId 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.model.PushData import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver import io.element.android.libraries.push.impl.notifications.NotificationActionIds import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager +import io.element.android.libraries.push.impl.log.pushLoggerTag import io.element.android.libraries.push.impl.store.DefaultPushDataStore -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import timber.log.Timber import javax.inject.Inject -private val loggerTag = LoggerTag("Push", LoggerTag.SYNC) +private val loggerTag = LoggerTag("Push", pushLoggerTag) -class VectorPushHandler @Inject constructor( +class PushHandler @Inject constructor( private val notificationDrawerManager: NotificationDrawerManager, private val notifiableEventResolver: NotifiableEventResolver, // private val activeSessionHolder: ActiveSessionHolder, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/GuardServiceStarter.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/GuardServiceStarter.kt similarity index 90% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/GuardServiceStarter.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/GuardServiceStarter.kt index 42993828a9..4c93b8a929 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/GuardServiceStarter.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/GuardServiceStarter.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 New Vector Ltd + * 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl +package io.element.android.libraries.push.impl.unifiedpush import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/KeepInternalDistributor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/KeepInternalDistributor.kt similarity index 94% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/KeepInternalDistributor.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/KeepInternalDistributor.kt index d351067e52..de66ed3914 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/KeepInternalDistributor.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/KeepInternalDistributor.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl +package io.element.android.libraries.push.impl.unifiedpush import android.content.BroadcastReceiver import android.content.Context diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataUnifiedPush.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/PushDataUnifiedPush.kt similarity index 91% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataUnifiedPush.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/PushDataUnifiedPush.kt index fc4ed55783..3e6a8199ec 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/model/PushDataUnifiedPush.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/PushDataUnifiedPush.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -14,9 +14,10 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.model +package io.element.android.libraries.push.impl.unifiedpush import io.element.android.libraries.matrix.api.core.MatrixPatterns +import io.element.android.libraries.push.impl.push.PushData import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/RegisterUnifiedPushUseCase.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/RegisterUnifiedPushUseCase.kt similarity index 95% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/RegisterUnifiedPushUseCase.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/RegisterUnifiedPushUseCase.kt index e9f8cb985f..50ca94f30d 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/RegisterUnifiedPushUseCase.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/RegisterUnifiedPushUseCase.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl +package io.element.android.libraries.push.impl.unifiedpush import android.content.Context import io.element.android.libraries.di.ApplicationContext diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnifiedPushParser.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnifiedPushParser.kt new file mode 100644 index 0000000000..9788ecf1a1 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnifiedPushParser.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.unifiedpush + +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.push.impl.push.PushData +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import javax.inject.Inject + +class UnifiedPushParser @Inject constructor() { + fun parse(message: ByteArray): PushData? { + return tryOrNull { Json.decodeFromString(String(message)) }?.toPushData() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnregisterUnifiedPushUseCase.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnregisterUnifiedPushUseCase.kt similarity index 86% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnregisterUnifiedPushUseCase.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnregisterUnifiedPushUseCase.kt index 34c78a237e..6cd1af1de3 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnregisterUnifiedPushUseCase.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnregisterUnifiedPushUseCase.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -14,12 +14,15 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl +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 diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorUnifiedPushMessagingReceiver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiver.kt similarity index 88% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorUnifiedPushMessagingReceiver.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiver.kt index 49a63ba4aa..db4489a489 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/VectorUnifiedPushMessagingReceiver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiver.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -14,18 +14,18 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl +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.di.VectorUnifiedPushMessagingReceiverBindings -import io.element.android.libraries.push.impl.parser.PushParser +import io.element.android.libraries.push.impl.* +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 @@ -34,15 +34,15 @@ import org.unifiedpush.android.connector.MessagingReceiver import timber.log.Timber import javax.inject.Inject -private val loggerTag = LoggerTag("Push", LoggerTag.SYNC) +private val loggerTag = LoggerTag("Unified", pushLoggerTag) class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { @Inject lateinit var pushersManager: PushersManager - @Inject lateinit var pushParser: PushParser + @Inject lateinit var pushParser: UnifiedPushParser //@Inject lateinit var activeSessionHolder: ActiveSessionHolder @Inject lateinit var pushDataStore: PushDataStore - @Inject lateinit var vectorPushHandler: VectorPushHandler + @Inject lateinit var pushHandler: PushHandler @Inject lateinit var guardServiceStarter: GuardServiceStarter @Inject lateinit var unifiedPushStore: UnifiedPushStore @Inject lateinit var unifiedPushHelper: UnifiedPushHelper @@ -64,8 +64,8 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { */ override fun onMessage(context: Context, message: ByteArray, instance: String) { Timber.tag(loggerTag.value).d("New message") - pushParser.parsePushDataUnifiedPush(message)?.let { - vectorPushHandler.handle(it) + pushParser.parse(message)?.let { + pushHandler.handle(it) } ?: run { Timber.tag(loggerTag.value).w("Invalid received data Json format") } @@ -82,7 +82,7 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { unifiedPushHelper.storeCustomOrDefaultGateway(endpoint) { unifiedPushHelper.getPushGateway()?.let { coroutineScope.launch { - pushersManager.enqueueRegisterPusher(endpoint, it) + pushersManager.onNewUnifiedPushEndpoint(endpoint, it) } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/VectorUnifiedPushMessagingReceiverBindings.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt similarity index 86% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/VectorUnifiedPushMessagingReceiverBindings.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt index 1a70d94ee4..90857d990d 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/VectorUnifiedPushMessagingReceiverBindings.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt @@ -14,11 +14,10 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.di +package io.element.android.libraries.push.impl.unifiedpush import com.squareup.anvil.annotations.ContributesTo import io.element.android.libraries.di.AppScope -import io.element.android.libraries.push.impl.VectorUnifiedPushMessagingReceiver @ContributesTo(AppScope::class) interface VectorUnifiedPushMessagingReceiverBindings { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStore.kt new file mode 100644 index 0000000000..a66b283519 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStore.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.userpushstore + +const val NOTIFICATION_METHOD_FIREBASE = "NOTIFICATION_METHOD_FIREBASE" +const val NOTIFICATION_METHOD_UNIFIEDPUSH = "NOTIFICATION_METHOD_UNIFIEDPUSH" + +/** + * Store data related to push about a user. + */ +interface UserPushStore { + /** + * NOTIFICATION_METHOD_FIREBASE or NOTIFICATION_METHOD_UNIFIEDPUSH + */ + suspend fun getNotificationMethod(): String + + suspend fun setNotificationMethod(value: String) + + suspend fun getCurrentRegisteredPushKey(): String? + + suspend fun setCurrentRegisteredPushKey(value: String) + + suspend fun reset() +} + +suspend fun UserPushStore.isFirebase(): Boolean = getNotificationMethod() == NOTIFICATION_METHOD_FIREBASE diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreDataStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreDataStore.kt new file mode 100644 index 0000000000..6f25599e54 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreDataStore.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.userpushstore + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.first + +/** + * Store data related to push about a user. + */ +class UserPushStoreDataStore( + private val context: Context, + userId: String, +) : UserPushStore { + private val Context.dataStore: DataStore by preferencesDataStore(name = "push_store_$userId") + private val notificationMethod = stringPreferencesKey("notificationMethod") + private val currentPushKey = stringPreferencesKey("currentPushKey") + + override suspend fun getNotificationMethod(): String { + return context.dataStore.data.first()[notificationMethod] ?: NOTIFICATION_METHOD_FIREBASE + } + + override suspend fun setNotificationMethod(value: String) { + context.dataStore.edit { + it[notificationMethod] = value + } + } + + override suspend fun getCurrentRegisteredPushKey(): String? { + return context.dataStore.data.first()[currentPushKey] + } + + override suspend fun setCurrentRegisteredPushKey(value: String) { + context.dataStore.edit { + it[currentPushKey] = value + } + } + + override suspend fun reset() { + context.dataStore.edit { + it.clear() + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreFactory.kt new file mode 100644 index 0000000000..4b16a21491 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreFactory.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.userpushstore + +import android.content.Context +import io.element.android.libraries.di.ApplicationContext +import javax.inject.Inject + +class UserPushStoreFactory @Inject constructor( + @ApplicationContext private val context: Context, +) { + fun create(userId: String): UserPushStore { + return UserPushStoreDataStore( + context = context, + userId = userId + ) + } +} 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 1637bd809f..223ab16aa5 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 @@ -26,3 +26,7 @@ interface SessionStore { suspend fun getLatestSession(): SessionData? suspend fun removeSession(sessionId: String) } + +fun List.toUserList(): List { + return map { it.userId } +} From dae0cae8bb55e6b82e144391120e6913ab048f72 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 31 Mar 2023 15:59:23 +0200 Subject: [PATCH 067/107] Close MatrixClient after usage --- .../android/libraries/push/impl/PushersManager.kt | 7 +++---- .../android/libraries/push/impl/push/PushHandler.kt | 12 +++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) 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 index 8455624585..8407360385 100644 --- 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 @@ -77,10 +77,9 @@ class PushersManager @Inject constructor( sessionStore.getAllSessions().toUserList().forEach { userId -> val userDataStore = userPushStoreFactory.create(userId) if (userDataStore.isFirebase()) { - val client = matrixAuthenticationService.restoreSession(SessionId(userId)).getOrNull() - client ?: return@forEach - registerPusher(client, firebaseToken, PushConfig.pusher_http_url) - // TODO EAx Close sessions + matrixAuthenticationService.restoreSession(SessionId(userId)).getOrNull()?.use { client -> + registerPusher(client, firebaseToken, PushConfig.pusher_http_url) + } } else { Timber.d("This session is not using Firebase pusher") } 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 index bab955b419..2f7a1947c5 100644 --- 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 @@ -141,11 +141,13 @@ class PushHandler @Inject constructor( // Restore session val session = matrixAuthenticationService.restoreSession(SessionId(userId)).getOrNull() ?: return // TODO EAx, no need for a session? - val notificationData = session.notificationService().getNotification( - userId = userId, - roomId = pushData.roomId, - eventId = pushData.eventId, - ) + val notificationData = session.use { + it.notificationService().getNotification( + userId = userId, + roomId = pushData.roomId, + eventId = pushData.eventId, + ) + } // TODO Remove Timber.w("Notification: $notificationData") From 62db96476d111352a2f08550a5000bdac3030f96 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 31 Mar 2023 16:02:05 +0200 Subject: [PATCH 068/107] Protect call to getNotificationItem --- .../api/notification/NotificationService.kt | 2 +- .../libraries/matrix/impl/RustMatrixClient.kt | 2 +- .../notification/RustNotificationService.kt | 23 ++++++++++++------- .../notification/FakeNotificationService.kt | 4 ++-- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt index 2c1672d864..9dec0821a3 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt @@ -17,5 +17,5 @@ package io.element.android.libraries.matrix.api.notification interface NotificationService { - fun getNotification(userId: String, roomId: String, eventId: String): NotificationData? + suspend fun getNotification(userId: String, roomId: String, eventId: String): Result } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index bb4ec1c971..cd14b35fbc 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -68,7 +68,7 @@ class RustMatrixClient constructor( client = client, dispatchers = dispatchers, ) - private val notificationService = RustNotificationService(baseDirectory) + private val notificationService = RustNotificationService(baseDirectory, dispatchers) private var slidingSyncUpdateJob: Job? = null private val clientDelegate = object : ClientDelegate { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt index 27091c17ee..1197021161 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt @@ -16,23 +16,30 @@ package io.element.android.libraries.matrix.impl.notification +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.notification.NotificationService +import kotlinx.coroutines.withContext import java.io.File class RustNotificationService( private val baseDirectory: File, + private val dispatchers: CoroutineDispatchers, ) : NotificationService { private val notificationMapper: NotificationMapper = NotificationMapper() - override fun getNotification(userId: String, roomId: String, eventId: String): NotificationData? { - return org.matrix.rustcomponents.sdk.NotificationService( - basePath = File(baseDirectory, "sessions").absolutePath, - userId = userId - ).use { - // TODO Not implemented yet, see https://github.com/matrix-org/matrix-rust-sdk/issues/1628 - it.getNotificationItem(roomId, eventId)?.let { notificationItem -> - notificationMapper.map(notificationItem) + override suspend fun getNotification(userId: String, roomId: String, eventId: String): Result { + return withContext(dispatchers.io) { + runCatching { + org.matrix.rustcomponents.sdk.NotificationService( + basePath = File(baseDirectory, "sessions").absolutePath, + userId = userId + ).use { + // TODO Not implemented yet, see https://github.com/matrix-org/matrix-rust-sdk/issues/1628 + it.getNotificationItem(roomId, eventId)?.let { notificationItem -> + notificationMapper.map(notificationItem) + } + } } } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt index a788e56f19..879a9694a3 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt @@ -20,7 +20,7 @@ import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.notification.NotificationService class FakeNotificationService : NotificationService { - override fun getNotification(userId: String, roomId: String, eventId: String): NotificationData? { - return null + override suspend fun getNotification(userId: String, roomId: String, eventId: String): Result { + return Result.success(null) } } From c52ad084e949c74192501a1ba22ff58d781b7984 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 31 Mar 2023 22:17:19 +0200 Subject: [PATCH 069/107] Observe session database to be able to detect new user and removed user. --- .../userpushstore/UserPushStoreFactory.kt | 37 +++++++- .../sessionstorage/api/SessionStore.kt | 6 ++ .../api/observer/SessionListener.kt | 22 +++++ .../api/observer/SessionObserver.kt | 22 +++++ .../impl/memory/InMemorySessionStore.kt | 4 + .../impl/DatabaseSessionStore.kt | 15 ++- .../impl/observer/DefaultSessionObserver.kt | 91 +++++++++++++++++++ 7 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionListener.kt create mode 100644 libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionObserver.kt create mode 100644 libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserver.kt 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 index 4b16a21491..0323713de7 100644 --- 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 @@ -17,16 +17,43 @@ 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 UserPushStoreDataStore( - context = context, - userId = userId - ) + 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/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 223ab16aa5..d79d700030 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt @@ -17,9 +17,11 @@ 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 @@ -30,3 +32,7 @@ interface SessionStore { fun List.toUserList(): List { return map { it.userId } } + +fun Flow>.toUserListFlow(): Flow> { + return map { it.toUserList() } +} diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionListener.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionListener.kt new file mode 100644 index 0000000000..7bcb4db792 --- /dev/null +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionListener.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.sessionstorage.api.observer + +interface SessionListener { + suspend fun onSessionCreated(userId: String) + suspend fun onSessionDeleted(userId: String) +} diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionObserver.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionObserver.kt new file mode 100644 index 0000000000..e61b4e2bba --- /dev/null +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionObserver.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.sessionstorage.api.observer + +interface SessionObserver { + fun addListener(listener: SessionListener) + fun removeListener(listener: SessionListener) +} diff --git a/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt b/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt index ce5b6e24f2..e23e34983c 100644 --- a/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt +++ b/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt @@ -30,6 +30,10 @@ class InMemorySessionStore : SessionStore { return sessionDataFlow.map { it != null } } + override fun sessionsFlow(): Flow> { + return sessionDataFlow.map { listOfNotNull(it) } + } + override suspend fun storeData(sessionData: SessionData) { sessionDataFlow.value = sessionData } 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 15c3024712..9394b66e66 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.sessionstorage.impl import com.squareup.anvil.annotations.ContributesBinding import com.squareup.sqldelight.runtime.coroutines.asFlow +import com.squareup.sqldelight.runtime.coroutines.mapToList import com.squareup.sqldelight.runtime.coroutines.mapToOneOrNull import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.SingleIn @@ -25,6 +26,7 @@ import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import timber.log.Timber import javax.inject.Inject @SingleIn(AppScope::class) @@ -34,7 +36,10 @@ class DatabaseSessionStore @Inject constructor( ) : SessionStore { override fun isLoggedIn(): Flow { - return database.sessionDataQueries.selectFirst().asFlow().mapToOneOrNull().map { it != null } + return database.sessionDataQueries.selectFirst() + .asFlow() + .mapToOneOrNull() + .map { it != null } } override suspend fun storeData(sessionData: SessionData) { @@ -59,6 +64,14 @@ class DatabaseSessionStore @Inject constructor( .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/observer/DefaultSessionObserver.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserver.kt new file mode 100644 index 0000000000..dbcfca2d4c --- /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.onSessionDeleted(addedUser) + } + } + } + + currentUsers = newUserSet + } + .collect() + } + } + } +} From 41e0249fbf7fee483d28ade321c83d6c73c9cfdd Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 3 Apr 2023 09:42:16 +0200 Subject: [PATCH 070/107] Cleanup --- .../firebase/VectorFirebaseMessagingService.kt | 16 +++++++--------- .../libraries/push/impl/push/PushHandler.kt | 14 ++++++-------- .../VectorUnifiedPushMessagingReceiver.kt | 14 +++++++++----- 3 files changed, 22 insertions(+), 22 deletions(-) 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 index f0e3bc1609..8769baa947 100644 --- 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 @@ -21,8 +21,8 @@ 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.push.PushHandler 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 @@ -33,12 +33,8 @@ private val loggerTag = LoggerTag("Firebase", pushLoggerTag) class VectorFirebaseMessagingService : FirebaseMessagingService() { @Inject lateinit var pushersManager: PushersManager - - @Inject - lateinit var pushParser: FirebasePushParser - - @Inject - lateinit var pushHandler: PushHandler + @Inject lateinit var pushParser: FirebasePushParser + @Inject lateinit var pushHandler: PushHandler private val coroutineScope = CoroutineScope(SupervisorJob()) @@ -56,8 +52,10 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { Timber.tag(loggerTag.value).d("New Firebase message") - pushParser.parse(message.data).let { - pushHandler.handle(it) + 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/push/PushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushHandler.kt index 2f7a1947c5..280fda99e5 100644 --- 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 @@ -32,20 +32,19 @@ import io.element.android.libraries.matrix.api.core.SessionId 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.log.pushLoggerTag import io.element.android.libraries.push.impl.store.DefaultPushDataStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import timber.log.Timber import javax.inject.Inject -private val loggerTag = LoggerTag("Push", pushLoggerTag) +private val loggerTag = LoggerTag("PushHandler", pushLoggerTag) class PushHandler @Inject constructor( private val notificationDrawerManager: NotificationDrawerManager, @@ -73,16 +72,14 @@ class PushHandler @Inject constructor( * * @param pushData the data received in the push. */ - fun handle(pushData: PushData) { + suspend fun handle(pushData: PushData) { Timber.tag(loggerTag.value).d("## handling pushData") if (buildMeta.lowPrivacyLoggingEnabled) { Timber.tag(loggerTag.value).d("## pushData: $pushData") } - runBlocking { - defaultPushDataStore.incrementPushCounter() - } + defaultPushDataStore.incrementPushCounter() // Diagnostic Push if (pushData.eventId == PushersManager.TEST_EVENT_ID) { @@ -91,6 +88,7 @@ class PushHandler @Inject constructor( return } + // TODO EAx Should be per user if (!pushDataStore.areNotificationEnabledForDevice()) { Timber.tag(loggerTag.value).i("Notification are disabled for this device") return @@ -141,7 +139,7 @@ class PushHandler @Inject constructor( // Restore session val session = matrixAuthenticationService.restoreSession(SessionId(userId)).getOrNull() ?: return // TODO EAx, no need for a session? - val notificationData = session.use { + val notificationData = session.let {// TODO Use make the app crashes it.notificationService().getNotification( userId = userId, roomId = pushData.roomId, 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 index db4489a489..81dd389e78 100644 --- 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 @@ -23,7 +23,9 @@ 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.* +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 @@ -64,10 +66,12 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { */ override fun onMessage(context: Context, message: ByteArray, instance: String) { Timber.tag(loggerTag.value).d("New message") - pushParser.parse(message)?.let { - pushHandler.handle(it) - } ?: run { - Timber.tag(loggerTag.value).w("Invalid received data Json format") + coroutineScope.launch { + pushParser.parse(message)?.let { + pushHandler.handle(it) + } ?: run { + Timber.tag(loggerTag.value).w("Invalid received data Json format") + } } } From a2dc4db6849b4a57e7d02214f30c5eb261062df4 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 3 Apr 2023 11:19:29 +0200 Subject: [PATCH 071/107] Bad copy/paste --- .../sessionstorage/impl/observer/DefaultSessionObserver.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index dbcfca2d4c..8fa5d9dd16 100644 --- 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 @@ -77,7 +77,7 @@ class DefaultSessionObserver @Inject constructor( val addedUsers = newUserSet - currentUserSet addedUsers.forEach { addedUser -> listeners.onEach { listener -> - listener.onSessionDeleted(addedUser) + listener.onSessionCreated(addedUser) } } } From 6f6a1e27fe97a6d1647fce59399242414e50463c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 3 Apr 2023 11:54:40 +0200 Subject: [PATCH 072/107] Fix multi Activity wen opening app from notification. --- app/src/main/AndroidManifest.xml | 6 +++--- .../kotlin/io/element/android/x/MainActivity.kt | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 31033b0500..828788ed80 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - - - No valid Google Play Services APK found. Notifications may not work properly. - Choose how to receive notifications - Google Services - Background synchronization + No valid Google Play Services found. Notifications may not work properly. + Choose how to receive notifications + Google Services + Background synchronization - Listening for events - Noisy notifications - Silent notifications - Call + Listening for events + Noisy notifications + Silent notifications + Call + Me New Messages - Mark as read - Join - Reject - You are viewing the notification! Click me! + Mark as read + Quick reply + Join + Reject + You are viewing the notification! Click me! %1$s: %2$s %1$s: %2$s %3$s ** Failed to send - please open room %1$s in %2$s and %3$s" %1$s and %2$s" %1$s in %2$s" - + %d new message %d new messages From 48044a3cc5e8f3158e890fe085e1cb18879e050f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Apr 2023 14:50:06 +0200 Subject: [PATCH 082/107] Add strings to localazy and import them --- .../libraries/push/impl/UnifiedPushHelper.kt | 10 +-- .../impl/src/main/res/values/localazy.xml | 48 ++++++++++++++ .../impl/src/main/res/values/temporary.xml | 64 ------------------- .../src/main/res/values/localazy.xml | 1 + tools/localazy/config.json | 7 ++ 5 files changed, 61 insertions(+), 69 deletions(-) create mode 100644 libraries/push/impl/src/main/res/values/localazy.xml delete mode 100644 libraries/push/impl/src/main/res/values/temporary.xml 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 index adabca5b18..12ed3f1993 100644 --- 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 @@ -44,9 +44,9 @@ class UnifiedPushHelper @Inject constructor( ) { val internalDistributorName = stringProvider.getString( if (fcmHelper.isFirebaseAvailable()) { - R.string.push_distributor_firebase + R.string.push_distributor_firebase_android } else { - R.string.push_distributor_background_sync + R.string.push_distributor_background_sync_android } ) @@ -60,7 +60,7 @@ class UnifiedPushHelper @Inject constructor( } MaterialAlertDialogBuilder(context) - .setTitle(stringProvider.getString(R.string.push_choose_distributor_dialog_title)) + .setTitle(stringProvider.getString(R.string.push_choose_distributor_dialog_title_android)) .setItems(distributorsName.toTypedArray()) { _, which -> val distributor = distributors[which] onDistributorSelected(distributor) @@ -133,8 +133,8 @@ class UnifiedPushHelper @Inject constructor( fun getCurrentDistributorName(): String { return when { - isEmbeddedDistributor() -> stringProvider.getString(R.string.push_distributor_firebase) - isBackgroundSync() -> stringProvider.getString(R.string.push_distributor_background_sync) + 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)) } } diff --git a/libraries/push/impl/src/main/res/values/localazy.xml b/libraries/push/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..3a11adb5d3 --- /dev/null +++ b/libraries/push/impl/src/main/res/values/localazy.xml @@ -0,0 +1,48 @@ + + + "Call" + "Listening for events" + "Noisy notifications" + "Silent notifications" + "** Failed to send - please open room" + "Join" + "Reject" + "New Messages" + "Mark as read" + "Quick reply" + "Me" + "You are viewing the notification! Click me!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + "%1$s and %2$s" + "%1$s in %2$s" + "%1$s in %2$s and %3$s" + + "%1$s: %2$d message" + "%1$s: %2$d messages" + + + "%d notification" + "%d notifications" + + + "%d invitation" + "%d invitations" + + + "%d new message" + "%d new messages" + + + "%d unread notified message" + "%d unread notified messages" + + + "%d room" + "%d rooms" + + "Choose how to receive notifications" + "Background synchronization" + "Google Services" + "No valid Google Play Services found. Notifications may not work properly." + \ No newline at end of file diff --git a/libraries/push/impl/src/main/res/values/temporary.xml b/libraries/push/impl/src/main/res/values/temporary.xml deleted file mode 100644 index e0833bd2dc..0000000000 --- a/libraries/push/impl/src/main/res/values/temporary.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - - No valid Google Play Services found. Notifications may not work properly. - Choose how to receive notifications - Google Services - Background synchronization - - Listening for events - Noisy notifications - Silent notifications - Call - Me - New Messages - Mark as read - Quick reply - Join - Reject - You are viewing the notification! Click me! - %1$s: %2$s - %1$s: %2$s %3$s - ** Failed to send - please open room - %1$s in %2$s and %3$s" - %1$s and %2$s" - %1$s in %2$s" - - %d new message - %d new messages - - - %d unread notified message - %d unread notified messages - - - %d room - %d rooms - - - %d invitation - %d invitations - - - %1$s: %2$d message - %1$s: %2$d messages - - - %d notification - %d notifications - - diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 28cef98c60..c37935d35b 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -38,6 +38,7 @@ "Save" "Search" "Send" + "Send message" "Share" "Share link" "Skip" diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 155a42836a..59c6bd6911 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -43,6 +43,13 @@ "rich_text_editor_.*" ] }, + { + "name": ":libraries:push:impl", + "includeRegex": [ + "push_.*", + "notification_.*" + ] + }, { "name": ":features:login:impl", "includeRegex": [ From 78b8d5cf6ef5c81398e2acded119878f95823a03 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Apr 2023 15:13:34 +0200 Subject: [PATCH 083/107] Fix lint warnings. --- .../intent/PendingIntentCompat.kt | 30 ----------------- .../impl/notifications/NotificationUtils.kt | 33 ++++++++++++------- 2 files changed, 21 insertions(+), 42 deletions(-) delete mode 100644 libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/intent/PendingIntentCompat.kt diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/intent/PendingIntentCompat.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/intent/PendingIntentCompat.kt deleted file mode 100644 index dcdb800a19..0000000000 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/intent/PendingIntentCompat.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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.androidutils.intent - -import android.app.PendingIntent -import android.os.Build - -object PendingIntentCompat { - const val FLAG_IMMUTABLE = PendingIntent.FLAG_IMMUTABLE - - val FLAG_MUTABLE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_MUTABLE - } else { - 0 - } -} 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 index 28a98f496f..eeb02ebb8b 100755 --- 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 @@ -18,6 +18,7 @@ package io.element.android.libraries.push.impl.notifications +import android.Manifest import android.annotation.SuppressLint import android.app.Activity import android.app.Notification @@ -26,18 +27,19 @@ 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.intent.PendingIntentCompat import io.element.android.libraries.androidutils.system.startNotificationChannelSettingsIntent import io.element.android.libraries.androidutils.uri.createIgnoredUri import io.element.android.libraries.core.meta.BuildMeta @@ -295,7 +297,7 @@ class NotificationUtils @Inject constructor( context, clock.epochMillis().toInt(), markRoomReadIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) NotificationCompat.Action.Builder( @@ -341,7 +343,7 @@ class NotificationUtils @Inject constructor( context.applicationContext, clock.epochMillis().toInt(), intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) setDeleteIntent(pendingIntent) } @@ -377,7 +379,7 @@ class NotificationUtils @Inject constructor( context, clock.epochMillis().toInt(), rejectIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) addAction( @@ -396,7 +398,7 @@ class NotificationUtils @Inject constructor( context, clock.epochMillis().toInt(), joinIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) addAction( R.drawable.vector_notification_accept_invitation, @@ -489,7 +491,7 @@ class NotificationUtils @Inject constructor( context, clock.epochMillis().toInt(), roomIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } @@ -505,7 +507,7 @@ class NotificationUtils @Inject constructor( context, clock.epochMillis().toInt(), threadIntentTap, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } @@ -516,7 +518,7 @@ class NotificationUtils @Inject constructor( context, clock.epochMillis().toInt(), intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } @@ -549,7 +551,11 @@ class NotificationUtils @Inject constructor( clock.epochMillis().toInt(), intent, // PendingIntents attached to actions with remote inputs must be mutable - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_MUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE + } else { + 0 + } ) } else { /* @@ -627,7 +633,7 @@ class NotificationUtils @Inject constructor( context.applicationContext, 0, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } @@ -652,15 +658,18 @@ class NotificationUtils @Inject constructor( @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 PendingIntentCompat.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - notificationManager.notify( "DIAGNOSTIC", 888, From 84e4e0f1a9cc9339916c1c4fa35e0b3f9c60bcd7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Apr 2023 15:20:56 +0200 Subject: [PATCH 084/107] Add test for NoopPermissionsPresenter and remove the unused factory. --- libraries/permissions/noop/build.gradle.kts | 6 +++ .../noop/NoopPermissionsPresenterFactory.kt | 23 ---------- .../noop/NoopPermissionsPresenterTest.kt | 45 +++++++++++++++++++ 3 files changed, 51 insertions(+), 23 deletions(-) delete mode 100644 libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterFactory.kt create mode 100644 libraries/permissions/noop/src/test/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterTest.kt diff --git a/libraries/permissions/noop/build.gradle.kts b/libraries/permissions/noop/build.gradle.kts index 7319f73104..8a1949814f 100644 --- a/libraries/permissions/noop/build.gradle.kts +++ b/libraries/permissions/noop/build.gradle.kts @@ -27,4 +27,10 @@ android { dependencies { implementation(projects.libraries.architecture) api(projects.libraries.permissions.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) } diff --git a/libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterFactory.kt b/libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterFactory.kt deleted file mode 100644 index b982969483..0000000000 --- a/libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterFactory.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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.permissions.noop - -import io.element.android.libraries.permissions.api.PermissionsPresenter - -class NoopPermissionsPresenterFactory : PermissionsPresenter.Factory { - override fun create(permission: String) = NoopPermissionsPresenter() -} diff --git a/libraries/permissions/noop/src/test/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterTest.kt b/libraries/permissions/noop/src/test/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterTest.kt new file mode 100644 index 0000000000..36b908cb0d --- /dev/null +++ b/libraries/permissions/noop/src/test/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterTest.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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.permissions.noop + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class NoopPermissionsPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = NoopPermissionsPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.permission).isEmpty() + assertThat(initialState.permissionGranted).isFalse() + assertThat(initialState.shouldShowRationale).isFalse() + assertThat(initialState.permissionAlreadyAsked).isFalse() + assertThat(initialState.permissionAlreadyDenied).isFalse() + assertThat(initialState.showDialog).isFalse() + } + } +} From 2ac74ba40facdc72c3549752dc2969e226bc1820 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Apr 2023 15:28:05 +0200 Subject: [PATCH 085/107] Comment unused code. --- .../element/android/appnav/loggedin/LoggedInEvents.kt | 7 +++---- .../android/appnav/loggedin/LoggedInPresenter.kt | 11 +++++------ .../element/android/appnav/loggedin/LoggedInState.kt | 2 +- .../android/appnav/loggedin/LoggedInStateProvider.kt | 2 +- .../permissions/impl/DefaultPermissionsPresenter.kt | 2 ++ 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInEvents.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInEvents.kt index 2712b42003..664ec1f663 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInEvents.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInEvents.kt @@ -16,7 +16,6 @@ package io.element.android.appnav.loggedin -// TODO Add your events or remove the file completely if no events -sealed interface LoggedInEvents { - object MyEvent : LoggedInEvents -} +// sealed interface LoggedInEvents { +// object MyEvent : LoggedInEvents +// } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt index 82f927a743..b628f19bd1 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt @@ -51,15 +51,14 @@ class LoggedInPresenter @Inject constructor( val permissionsState = postNotificationPermissionsPresenter.present() - fun handleEvents(event: LoggedInEvents) { - when (event) { - LoggedInEvents.MyEvent -> Unit - } - } + // fun handleEvents(event: LoggedInEvents) { + // when (event) { + // } + // } return LoggedInState( permissionsState = permissionsState, - eventSink = ::handleEvents + // eventSink = ::handleEvents ) } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt index a5c43801bd..8cf8060981 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt @@ -20,5 +20,5 @@ import io.element.android.libraries.permissions.api.PermissionsState data class LoggedInState( val permissionsState: PermissionsState, - val eventSink: (LoggedInEvents) -> Unit + // val eventSink: (LoggedInEvents) -> Unit ) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt index 90ff2136e5..b131d6d610 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt @@ -29,5 +29,5 @@ open class LoggedInStateProvider : PreviewParameterProvider { fun aLoggedInState() = LoggedInState( permissionsState = createDummyPostNotificationPermissionsState(), - eventSink = {} + // eventSink = {} ) diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt index c09a595783..c422cd1ec9 100644 --- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt @@ -127,6 +127,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor( } } + /* @Composable private fun resetStore() { LaunchedEffect(this@DefaultPermissionsPresenter) { @@ -135,4 +136,5 @@ class DefaultPermissionsPresenter @AssistedInject constructor( } } } + */ } From c014d4e12fc8cdb5bd5a33ea489e81b738147af6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Apr 2023 15:44:11 +0200 Subject: [PATCH 086/107] Add test for LoggedInPresenter --- .../appnav/loggedin/LoggedInPresenterTest.kt | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt new file mode 100644 index 0000000000..64767eaafc --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt @@ -0,0 +1,66 @@ +/* + * 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.appnav.loggedin + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter +import io.element.android.libraries.push.api.PushService +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LoggedInPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.permissionsState.permission).isEmpty() + } + } + + private fun createPresenter(): LoggedInPresenter { + return LoggedInPresenter( + matrixClient = FakeMatrixClient(), + permissionsPresenterFactory = object : PermissionsPresenter.Factory { + override fun create(permission: String): PermissionsPresenter { + return NoopPermissionsPresenter() + } + }, + pushService = object : PushService { + override fun notificationStyleChanged() { + } + + override suspend fun registerFirebasePusher(matrixClient: MatrixClient) { + } + + override suspend fun testPush() { + } + } + ) + } +} From fad849ce2150f39d3e8d45b263bc30805ea6d374 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Apr 2023 15:50:04 +0200 Subject: [PATCH 087/107] Ignore lint warning. I think it's OK. --- libraries/push/impl/src/main/AndroidManifest.xml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/libraries/push/impl/src/main/AndroidManifest.xml b/libraries/push/impl/src/main/AndroidManifest.xml index 1c380c260a..a386a89caf 100644 --- a/libraries/push/impl/src/main/AndroidManifest.xml +++ b/libraries/push/impl/src/main/AndroidManifest.xml @@ -14,7 +14,8 @@ ~ limitations under the License. --> - + @@ -37,7 +38,8 @@ + android:exported="true" + tools:ignore="ExportedReceiver"> From df492cfd0ec0165916ff016179f812cf8f30f320 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Apr 2023 15:55:50 +0200 Subject: [PATCH 088/107] Fix lint warnings. --- .../android/libraries/androidutils/system/SystemUtils.kt | 8 +++++--- .../permissions/impl/DefaultPermissionsPresenter.kt | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt index 8f01f28545..5f18f46827 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.androidutils.system +import android.annotation.SuppressLint import android.annotation.TargetApi import android.app.Activity import android.content.ActivityNotFoundException @@ -77,6 +78,7 @@ fun Context.getApplicationLabel(packageName: String): String { * Note: If the user finally does not grant the permission, PushManager.isBackgroundSyncAllowed() * will return false and the notification privacy will fallback to "LOW_DETAIL". */ +@SuppressLint("BatteryLife") fun requestDisablingBatteryOptimization(activity: Activity, activityResultLauncher: ActivityResultLauncher) { val intent = Intent() intent.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS @@ -114,9 +116,9 @@ fun startNotificationSettingsIntent(context: Context, activityResultLauncher: Ac intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) } else { - intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS - intent.putExtra("app_package", context.packageName) - intent.putExtra("app_uid", context.applicationInfo?.uid) + intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.data = Uri.fromParts("package", context.packageName, null) } activityResultLauncher.launch(intent) } diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt index c422cd1ec9..9e91869549 100644 --- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt @@ -58,7 +58,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor( override fun present(): PermissionsState { val localCoroutineScope = rememberCoroutineScope() - // To reset the store: resetStore() + // To reset the store: ResetStore() val isAlreadyDenied: Boolean by permissionsStore .isPermissionDenied(permission) @@ -129,7 +129,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor( /* @Composable - private fun resetStore() { + private fun ResetStore() { LaunchedEffect(this@DefaultPermissionsPresenter) { launch { permissionsStore.resetStore() From 73e67b638db10ff267185144cca10af05cf44772 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 5 Apr 2023 17:33:52 +0200 Subject: [PATCH 089/107] Remove RetryStartDM action --- .../features/createroom/impl/root/CreateRoomRootEvents.kt | 1 - .../createroom/impl/root/CreateRoomRootPresenter.kt | 4 ---- .../features/createroom/impl/root/CreateRoomRootView.kt | 6 +++++- .../createroom/impl/root/CreateRoomRootPresenterTests.kt | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt index de9810f34a..87f35e4b1b 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt @@ -21,6 +21,5 @@ import io.element.android.libraries.matrix.ui.model.MatrixUser sealed interface CreateRoomRootEvents { object InvitePeople : CreateRoomRootEvents data class StartDM(val matrixUser: MatrixUser) : CreateRoomRootEvents - object RetryStartDM : CreateRoomRootEvents object CancelStartDM : CreateRoomRootEvents } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt index 43ca377771..89932b0cdb 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt @@ -69,10 +69,6 @@ class CreateRoomRootPresenter @Inject constructor( fun handleEvents(event: CreateRoomRootEvents) { when (event) { is CreateRoomRootEvents.StartDM -> startDm(event.matrixUser) - CreateRoomRootEvents.RetryStartDM -> { - startDmAction.value = Async.Uninitialized - userListState.selectedUsers.firstOrNull()?.let { startDm(it) } - } CreateRoomRootEvents.CancelStartDM -> startDmAction.value = Async.Uninitialized CreateRoomRootEvents.InvitePeople -> Unit // Todo Handle invite people action } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt index 146c447996..61b8b8738e 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt @@ -112,7 +112,11 @@ fun CreateRoomRootView( RetryDialog( content = stringResource(id = StringR.string.screen_start_chat_error_starting_chat), onDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) }, - onRetry = { state.eventSink(CreateRoomRootEvents.RetryStartDM) }, + onRetry = { + state.userListState.selectedUsers.firstOrNull()?.let { + state.eventSink(CreateRoomRootEvents.StartDM(it)) + } + }, ) } else -> Unit diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt index f1467485db..cf399fdbd3 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt @@ -145,7 +145,7 @@ class CreateRoomRootPresenterTests { // Retry with success fakeMatrixClient.givenCreateDmError(null) - stateAfterSecondAttempt.eventSink(CreateRoomRootEvents.RetryStartDM) + stateAfterSecondAttempt.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) assertThat(awaitItem().startDmAction).isInstanceOf(Async.Uninitialized::class.java) assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java) val stateAfterRetryStartDM = awaitItem() From badea87d7cdf1d3393e7035ea17f998bff7a0dc4 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 5 Apr 2023 17:37:09 +0200 Subject: [PATCH 090/107] Cancel start DM if there is no more selected user --- .../features/createroom/impl/root/CreateRoomRootView.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt index 61b8b8738e..e488645b78 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt @@ -113,9 +113,10 @@ fun CreateRoomRootView( content = stringResource(id = StringR.string.screen_start_chat_error_starting_chat), onDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) }, onRetry = { - state.userListState.selectedUsers.firstOrNull()?.let { - state.eventSink(CreateRoomRootEvents.StartDM(it)) - } + state.userListState.selectedUsers.firstOrNull() + ?.let { state.eventSink(CreateRoomRootEvents.StartDM(it)) } + // Cancel start DM if there is no more selected user (should not happen) + ?: state.eventSink(CreateRoomRootEvents.CancelStartDM) }, ) } From b56ab86ef1c389020fba60d018b17930cc6f1259 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 5 Apr 2023 18:14:32 +0200 Subject: [PATCH 091/107] Fix wildcard import --- .../permissions/impl/FakePermissionStateProvider.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/FakePermissionStateProvider.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/FakePermissionStateProvider.kt index 2c67061811..c204ff5fc6 100644 --- a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/FakePermissionStateProvider.kt +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/FakePermissionStateProvider.kt @@ -18,7 +18,11 @@ package io.element.android.libraries.permissions.impl -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.PermissionStatus From 0df6e66f35d255197df1bd33869802e3ef4e033b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 5 Apr 2023 20:28:50 +0000 Subject: [PATCH 092/107] Update accompanist --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 80f67bab6b..f6966b86d2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ composecompiler = "1.4.2" coroutines = "1.6.4" # Accompanist -accompanist = "0.28.0" +accompanist = "0.30.1" # Test test_core = "1.5.0" From e7e633eb90cba9ecfef38fdd1e9f7dc6b8dda9e1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 5 Apr 2023 20:29:03 +0000 Subject: [PATCH 093/107] Update dependency androidx.core:core-ktx to v1.10.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 80f67bab6b..1c09366dec 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ molecule = "0.8.0" # AndroidX material = "1.8.0" -corektx = "1.9.0" +corektx = "1.10.0" datastore = "1.0.0" constraintlayout = "2.1.4" recyclerview = "1.3.0" From efe3d3add4a848a954b59ad9b276072d4b342d11 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 6 Apr 2023 09:12:44 +0200 Subject: [PATCH 094/107] Ignore some classes about coverage. --- build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 38a11235df..7477d4da73 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -231,6 +231,7 @@ koverMerged { overrideClassFilter { includes += "*Presenter" excludes += "*TemplatePresenter" + excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*" } bound { minValue = 90 @@ -247,6 +248,7 @@ koverMerged { excludes += "io.element.android.libraries.matrix.api.timeline.item.event.OtherState$*" excludes += "io.element.android.libraries.matrix.api.timeline.item.event.EventSendState$*" excludes += "io.element.android.libraries.matrix.api.room.RoomMembershipState*" + excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*" } bound { minValue = 90 From 46cc110ce497c4a4c493e2431cb6f94ba9a0b1d7 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Thu, 6 Apr 2023 11:30:46 +0200 Subject: [PATCH 095/107] Update compose BOM version --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f6966b86d2..184798d0cd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,7 @@ activity = "1.7.0" startup = "1.1.1" # Compose -compose_bom = "2023.01.00" +compose_bom = "2023.03.00" composecompiler = "1.4.2" # Coroutines From e4c1c12061e33df1872624b1a80bb2d65274d9f3 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Thu, 6 Apr 2023 11:34:31 +0200 Subject: [PATCH 096/107] update screenshots --- ...dalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png | 4 ++-- ...alBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 1be155bb96..a4aa94b6e4 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 38b159ccdf..317643c598 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 From ca54e11b97bb1d1c78432a0df4ffa1c20608381a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 7 Apr 2023 10:38:47 +0200 Subject: [PATCH 097/107] Fix ktlint issue. --- libraries/permissions/impl/build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/permissions/impl/build.gradle.kts b/libraries/permissions/impl/build.gradle.kts index 43608d36e8..84cd531528 100644 --- a/libraries/permissions/impl/build.gradle.kts +++ b/libraries/permissions/impl/build.gradle.kts @@ -60,7 +60,6 @@ dependencies { testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) - androidTestImplementation(libs.test.junitext) ksp(libs.showkase.processor) From 6af4057e746b7e498e248072806f108a5ba2fccb Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 7 Apr 2023 12:26:25 +0200 Subject: [PATCH 098/107] Setup localazy before running it. From https://localazy.com/docs/cli/installation#debianubuntu --- .github/workflows/sync-localazy.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml index e3c745b8f6..afb5fe9f75 100644 --- a/.github/workflows/sync-localazy.yml +++ b/.github/workflows/sync-localazy.yml @@ -15,6 +15,11 @@ jobs: uses: actions/setup-python@v4 with: python-version: 3.8 + - 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 From 32be8f29e61fc330bc444c50e215273acceadf9b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 7 Apr 2023 12:29:35 +0200 Subject: [PATCH 099/107] Run every 10 minutes to check the script. (to be reverted!) --- .github/workflows/sync-localazy.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml index afb5fe9f75..f171a506f1 100644 --- a/.github/workflows/sync-localazy.yml +++ b/.github/workflows/sync-localazy.yml @@ -2,7 +2,8 @@ name: Sync Localazy on: schedule: # At 00:00 on every Monday UTC - - cron: '0 0 * * 1' + # - cron: '0 0 * * 1' + - cron: '0/10 * * * *' jobs: sync-localazy: From 9cca2405e8dc4d420ea00423c99101217249dffc Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 7 Apr 2023 14:39:13 +0200 Subject: [PATCH 100/107] For usage of Python3 CI complain with: Traceback (most recent call last): File "./tools/localazy/generateLocalazyConfig.py", line 39, in action = baseAction | { TypeError: unsupported operand type(s) for |: 'dict' and 'dict' --- tools/localazy/downloadStrings.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/localazy/downloadStrings.sh b/tools/localazy/downloadStrings.sh index 5892a87c38..0f35f9ec59 100755 --- a/tools/localazy/downloadStrings.sh +++ b/tools/localazy/downloadStrings.sh @@ -27,7 +27,7 @@ else fi echo "Generating the configuration file for localazy..." -./tools/localazy/generateLocalazyConfig.py $allFiles +python3 ./tools/localazy/generateLocalazyConfig.py $allFiles echo "Deleting all existing localazy.xml files..." find . -name 'localazy.xml' -delete From 67955988cf6e8866499d738f4841e9e1bd7e8b43 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 7 Apr 2023 15:01:34 +0200 Subject: [PATCH 101/107] Use Python 3.9 CI complain with: Traceback (most recent call last): File "./tools/localazy/generateLocalazyConfig.py", line 39, in action = baseAction | { TypeError: unsupported operand type(s) for |: 'dict' and 'dict' --- .github/workflows/sync-localazy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml index f171a506f1..2b44c60352 100644 --- a/.github/workflows/sync-localazy.yml +++ b/.github/workflows/sync-localazy.yml @@ -12,7 +12,7 @@ jobs: if: github.repository == 'vector-im/element-x-android' steps: - uses: actions/checkout@v3 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v4 with: python-version: 3.8 From ea9f9ccdc3b66cf46484a93a3a22a912171c64e5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 7 Apr 2023 15:03:02 +0200 Subject: [PATCH 102/107] Use Python 3.9 CI complain with: Traceback (most recent call last): File "./tools/localazy/generateLocalazyConfig.py", line 39, in action = baseAction | { TypeError: unsupported operand type(s) for |: 'dict' and 'dict' --- .github/workflows/sync-localazy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml index 2b44c60352..e5c3733872 100644 --- a/.github/workflows/sync-localazy.yml +++ b/.github/workflows/sync-localazy.yml @@ -15,7 +15,7 @@ jobs: - name: Set up Python 3.9 uses: actions/setup-python@v4 with: - python-version: 3.8 + 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 From ad0886391b56088d87b509c5052a497d316e127e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 7 Apr 2023 12:29:35 +0200 Subject: [PATCH 103/107] Revert "Run every 10 minutes to check the script. (to be reverted!)" This reverts commit 32be8f29e61fc330bc444c50e215273acceadf9b. --- .github/workflows/sync-localazy.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml index e5c3733872..02f93cc3e1 100644 --- a/.github/workflows/sync-localazy.yml +++ b/.github/workflows/sync-localazy.yml @@ -2,8 +2,7 @@ name: Sync Localazy on: schedule: # At 00:00 on every Monday UTC - # - cron: '0 0 * * 1' - - cron: '0/10 * * * *' + - cron: '0 0 * * 1' jobs: sync-localazy: From 30a0da74f045d36505bbf2cf8bf3275918e06ff7 Mon Sep 17 00:00:00 2001 From: Hans Christian Schmitz Date: Mon, 10 Apr 2023 08:21:57 +0200 Subject: [PATCH 104/107] Add support for autofilling login text fields (#293) * Add support for autofilling login text fields Support autofilling login text fields via Android Autofill with the experimental AndroidX Compose API for it. Based on https://bryanherbst.com/2021/04/13/compose-autofill/ (with permission). Signed-off-by: Hans Christian Schmitz * Move autofill implementation to designsystem library Signed-off-by: Hans Christian Schmitz --------- Signed-off-by: Hans Christian Schmitz --- .../features/login/impl/root/LoginRootView.kt | 16 ++++++++-- .../theme/components/TextField.kt | 32 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootView.kt index 290aace3d5..b7491f5b3b 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootView.kt @@ -49,7 +49,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.AutofillType import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.platform.LocalFocusManager @@ -77,6 +79,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.theme.components.autofill import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.testtags.TestTags @@ -206,6 +209,7 @@ internal fun ChangeServerSection( } } +@OptIn(ExperimentalComposeUiApi::class) @Composable internal fun LoginForm( state: LoginRootState, @@ -239,7 +243,11 @@ internal fun LoginForm( modifier = Modifier .fillMaxWidth() .onTabOrEnterKeyFocusNext(focusManager) - .testTag(TestTags.loginEmailUsername), + .testTag(TestTags.loginEmailUsername) + .autofill(autofillTypes = listOf(AutofillType.Username), onFill = { + loginFieldState = it + eventSink(LoginRootEvents.SetLogin(it)) + }), label = { Text(text = stringResource(R.string.screen_login_username_hint)) }, @@ -279,7 +287,11 @@ internal fun LoginForm( modifier = Modifier .fillMaxWidth() .onTabOrEnterKeyFocusNext(focusManager) - .testTag(TestTags.loginPassword), + .testTag(TestTags.loginPassword) + .autofill(autofillTypes = listOf(AutofillType.Password), onFill = { + passwordFieldState = it + eventSink(LoginRootEvents.SetPassword(it)) + }), onValueChange = { passwordFieldState = it eventSink(LoginRootEvents.SetPassword(it)) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt index 4884217b0c..b2abb1a373 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt @@ -29,8 +29,17 @@ import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.AutofillNode +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.composed +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalAutofill +import androidx.compose.ui.platform.LocalAutofillTree import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview @@ -118,3 +127,26 @@ private fun ContentToPreview() { } } } + +@OptIn(ExperimentalComposeUiApi::class) +fun Modifier.autofill(autofillTypes: List, onFill: (String) -> Unit) = composed { + val autofillNode = AutofillNode(autofillTypes, onFill = onFill) + LocalAutofillTree.current += autofillNode + + val autofill = LocalAutofill.current + + this + .onGloballyPositioned { + // Inform autofill framework of where our composable is so it can show the popup in the right place + autofillNode.boundingBox = it.boundsInWindow() + } + .onFocusChanged { + autofill?.run { + if (it.isFocused) { + requestAutofillForNode(autofillNode) + } else { + cancelAutofillForNode(autofillNode) + } + } + } +} From f06bf83b20aab27c7a09cb7cf83c592e2bb4c5fb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 10 Apr 2023 08:33:58 +0200 Subject: [PATCH 105/107] Update dependency com.squareup:kotlinpoet to v1.13.0 (#304) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- anvilcodegen/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/anvilcodegen/build.gradle.kts b/anvilcodegen/build.gradle.kts index e8b6ab285c..d35051d2cc 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") From b9007259193f04d694377b88ba9832164109fdaa Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Mon, 10 Apr 2023 09:00:02 +0200 Subject: [PATCH 106/107] Fix lint issues that prevented CI from passing (#310) --- .../libraries/push/impl/GoogleFcmHelper.kt | 3 ++- .../libraries/push/impl/PushersManager.kt | 6 +++--- .../libraries/push/impl/UnifiedPushStore.kt | 2 +- .../PushClientSecretStoreDataStore.kt | 2 +- .../impl/notifications/FilteredEventDetector.kt | 3 ++- .../notifications/NotifiableEventProcessor.kt | 6 +++++- .../notifications/NotifiableEventResolver.kt | 5 +++-- .../impl/notifications/NotificationAction.kt | 3 ++- .../notifications/NotificationBitmapLoader.kt | 2 +- .../NotificationBroadcastReceiver.kt | 4 ++-- .../notifications/NotificationDrawerManager.kt | 3 ++- .../impl/notifications/NotificationRenderer.kt | 3 ++- .../push/impl/notifications/NotificationUtils.kt | 2 +- .../impl/notifications/OutdatedEventDetector.kt | 3 ++- .../impl/notifications/RoomEventGroupInfo.kt | 2 +- .../impl/notifications/model/NotifiableEvent.kt | 3 ++- .../android/libraries/push/impl/push/PushData.kt | 1 + .../push/impl/store/DefaultPushDataStore.kt | 16 ++++++++++++++++ .../push/impl/unifiedpush/GuardServiceStarter.kt | 4 +--- .../push/impl/userpushstore/UserPushStore.kt | 2 +- .../fake/FakeNotificationFactory.kt | 6 +++++- .../appnavstate/test/AppNavStateFixture.kt | 6 +++++- 22 files changed, 61 insertions(+), 26 deletions(-) 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 index 0ead18c75e..6c73607196 100755 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2018 New Vector Ltd + * 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. @@ -13,6 +13,7 @@ * 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 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 index f8f445a6df..f1ac346909 100644 --- 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 @@ -1,11 +1,11 @@ /* - * Copyright 2019 New Vector Ltd + * 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 + * 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, @@ -128,7 +128,7 @@ class PushersManager @Inject constructor( ) /** - * Ex: {"cs":"sfvsdv"} + * Ex: {"cs":"sfvsdv"}. */ private fun createDefaultPayload(secretForUser: String): String { return "{\"cs\":\"$secretForUser\"}" 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 index 5e2e33d7d3..226d0c5669 100644 --- 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 @@ -24,7 +24,7 @@ import io.element.android.libraries.di.DefaultPreferences import javax.inject.Inject /** - * TODO EAx Store in BDD (for multisession) + * TODO EAx Store in BDD (for multisession). */ class UnifiedPushStore @Inject constructor( @ApplicationContext val context: Context, 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 index 98b64d4e0b..055de6fc47 100644 --- 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 @@ -54,7 +54,7 @@ class PushClientSecretStoreDataStore @Inject constructor( override suspend fun getUserIdFromSecret(clientSecret: String): SessionId? { val keyValues = context.dataStore.data.first().asMap() - val matchingKey = keyValues.keys.firstOrNull { + val matchingKey = keyValues.keys.find { keyValues[it] == clientSecret } return matchingKey?.name?.asSessionId() 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 index 6e92c2ec60..a24f088998 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023 New Vector Ltd + * 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. @@ -13,6 +13,7 @@ * 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 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 index 7c083555e4..1b1fe2723e 100644 --- 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 @@ -16,7 +16,11 @@ package io.element.android.libraries.push.impl.notifications -import io.element.android.libraries.push.impl.notifications.model.* +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 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 index 6463773e7b..5ae39e1102 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2019 New Vector Ltd + * 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. @@ -13,6 +13,7 @@ * 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 @@ -104,7 +105,7 @@ private fun NotificationData.asNotifiableEvent(userId: SessionId, roomId: RoomId } /** - * TODO This is a temporary method for EAx + * TODO This is a temporary method for EAx. */ private fun NotificationData?.orDefault(roomId: RoomId, eventId: EventId): NotificationData { return this ?: NotificationData( 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 index 31ec28d023..b3f0b1e0f2 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2019 New Vector Ltd + * 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. @@ -13,6 +13,7 @@ * 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( 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 index d0ab023789..7bd76f9f42 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2019 New Vector Ltd + * 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. 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 index 078de3a8a8..a9c7036418 100644 --- 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 @@ -1,11 +1,11 @@ /* - * Copyright 2019 New Vector Ltd + * 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 + * 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, 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 index 4688a4a24c..8d3bfda4c3 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2019 New Vector Ltd + * 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. @@ -13,6 +13,7 @@ * 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 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 index 521f4f3c8c..277dc3b822 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2019 New Vector Ltd + * 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. @@ -13,6 +13,7 @@ * 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 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 index eeb02ebb8b..add8fd74eb 100755 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2018 New Vector Ltd + * 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. 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 index 1d82fc31e4..5b15dc78d2 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2019 New Vector Ltd + * 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. @@ -13,6 +13,7 @@ * 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 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 index b2a5fcbedb..734c34b051 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2018 New Vector Ltd + * 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. 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 index 40d84496a5..b1bb7cd032 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2019 New Vector Ltd + * 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. @@ -13,6 +13,7 @@ * 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 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 index 31d3eb7ea0..864155e522 100644 --- 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 @@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.core.RoomId * @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?, 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 index 8474682ab6..ffbd575aa4 100644 --- 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 @@ -1,3 +1,19 @@ +/* + * 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 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 index 4c93b8a929..08bd4a8326 100644 --- 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 @@ -26,6 +26,4 @@ interface GuardServiceStarter { } @ContributesBinding(AppScope::class) -class NoopGuardServiceStarter @Inject constructor() : GuardServiceStarter { - -} +class NoopGuardServiceStarter @Inject constructor() : GuardServiceStarter 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 index a66b283519..82c4beaf20 100644 --- 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 @@ -24,7 +24,7 @@ const val NOTIFICATION_METHOD_UNIFIEDPUSH = "NOTIFICATION_METHOD_UNIFIEDPUSH" */ interface UserPushStore { /** - * NOTIFICATION_METHOD_FIREBASE or NOTIFICATION_METHOD_UNIFIEDPUSH + * [NOTIFICATION_METHOD_FIREBASE] or [NOTIFICATION_METHOD_UNIFIEDPUSH]. */ suspend fun getNotificationMethod(): String 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 index d9ba17c28e..7d7812e6cb 100644 --- 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 @@ -17,7 +17,11 @@ package io.element.android.libraries.push.impl.notifications.fake import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.push.impl.notifications.* +import io.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 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 index 6afab893c3..20e872b803 100644 --- 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 @@ -16,7 +16,11 @@ package io.element.android.services.appnavstate.test -import io.element.android.libraries.matrix.api.core.* +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( From 1ba60543015044c699a112fedc4992f601f4403a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 10 Apr 2023 09:24:55 +0200 Subject: [PATCH 107/107] Update danger/danger-js action to v11.2.5 (#309) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/danger.yml | 2 +- .github/workflows/quality.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 4c00894a36..cb4dbe5766 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 8f6fe6112c..88ef78f9a8 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: