From cc2664c519d570c59afc981b81d1b7aa2c5f160f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 16 Oct 2023 13:31:02 +0200 Subject: [PATCH 01/13] Use local SDK if the file exist --- docs/_developer_onboarding.md | 13 ++++--------- libraries/matrix/impl/build.gradle.kts | 8 ++++++-- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/docs/_developer_onboarding.md b/docs/_developer_onboarding.md index 7af9207365..a100a2beba 100644 --- a/docs/_developer_onboarding.md +++ b/docs/_developer_onboarding.md @@ -117,6 +117,10 @@ You can also have access to the aars through the [release](https://github.com/ma #### Build the SDK locally +Easiest way: run the script [./tools/sdk/build_rust_sdk.sh](./tools/sdk/build_rust_sdk.sh) and just answer the questions. + +Legacy way: + If you need to locally build the sdk-android you can use the [build](https://github.com/matrix-org/matrix-rust-components-kotlin/blob/main/scripts/build.sh) script. @@ -147,15 +151,6 @@ Troubleshooting: - If you get the error `thread 'main' panicked at 'called `Option::unwrap()` on a `None` value', .cargo/registry/src/index.crates.io-6f17d22bba15001f/cargo-ndk-2.11.0/src/cli.rs:345:18` try updating your Cargo NDK version. In this case, 2.11.0 is too old so `cargo install cargo-ndk` to install a newer version. - If you get the error `Unsupported class file major version 64` try changing your JVM version. In this case, Java 20 is not supported in Gradle yet, so downgrade to an earlier version (Java 17 worked in this case). -Finally let the `matrix/impl` module use this aar by changing the dependencies from `libs.matrix.sdk` to `projects.libraries.rustsdk`: - -```groovy -dependencies { - api(projects.libraries.rustsdk) // <- use the local version of the sdk. Uncomment this line. - //implementation(libs.matrix.sdk) // <- use the released version. Comment this line. -} -``` - You are good to test your local rust development now! ### The Android project diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index a2b616f989..ff65a2768d 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -29,8 +29,12 @@ anvil { } dependencies { - // implementation(projects.libraries.rustsdk) - implementation(libs.matrix.sdk) + if (file("${rootDir.path}/libraries/rustsdk/matrix-rust-sdk.aar").exists()) { + println("\nNote: Using local binary of the Rust SDK.\n") + implementation(projects.libraries.rustsdk) + } else { + implementation(libs.matrix.sdk) + } implementation(projects.libraries.di) implementation(projects.libraries.androidutils) implementation(projects.libraries.network) From 9d0cfd903d1800f8becb071bb5fb0ae5d2a98d58 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 16 Oct 2023 11:11:45 +0200 Subject: [PATCH 02/13] Add script to build the rustSdk --- tools/sdk/build_rust_sdk.sh | 80 +++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100755 tools/sdk/build_rust_sdk.sh diff --git a/tools/sdk/build_rust_sdk.sh b/tools/sdk/build_rust_sdk.sh new file mode 100755 index 0000000000..2a6b7b72d0 --- /dev/null +++ b/tools/sdk/build_rust_sdk.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +# Exit on error +set -e + +# Ask to build from local source or to clone the repository +read -p "Do you want to build the Rust SDK from local source (yes/no) default to yes? " buildLocal +buildLocal=${buildLocal:-yes} + +date=$(gdate +%Y%m%d%H%M%S) + +# Ask for the Rust SDK local source path +# if folder rustSdk/ exists, use it as default +if [ ${buildLocal} == "yes" ]; then + read -p "Please enter the path to the Rust SDK local source, default to ../matrix-rust-sdk" rustSdkPath + rustSdkPath=${rustSdkPath:-../matrix-rust-sdk/} + if [ ! -d "${rustSdkPath}" ]; then + printf "\nFolder ${rustSdkPath} does not exist. Please clone the matrix-rust-sdk repository in the folder ../matrix-rust-sdk.\n\n" + exit 0 + fi +else + read -p "Please enter the Rust SDK repository url, default to https://github.com/matrix-org/matrix-rust-sdk.git " rustSdkUrl + rustSdkUrl=${rustSdkUrl:-https://github.com/matrix-org/matrix-rust-sdk.git} + read -p "Please enter the Rust SDK branch, default to main " rustSdkBranch + rustSdkBranch=${rustSdkBranch:-main} + cd .. + git clone ${rustSdkUrl} matrix-rust-sdk-$date + cd matrix-rust-sdk-$date + git checkout ${rustSdkBranch} + rustSdkPath=$(pwd) + cd ../element-x-android +fi + + +cd ${rustSdkPath} +git status + +read -p "Will build with this version of the Rust SDK ^. Is it correct (yes/no) default to yes? " sdkCorrect +sdkCorrect=${sdkCorrect:-yes} + +if [ ${sdkCorrect} != "yes" ]; then + exit 0 +fi + +# Ask if the user wants to build the app after +read -p "Do you want to build the app after (yes/no) default to yes? " buildApp +buildApp=${buildApp:-yes} + +# If folder ../matrix-rust-components-kotlin does not exist, close the repo +if [ ! -d "../matrix-rust-components-kotlin" ]; then + printf "\nFolder ../matrix-rust-components-kotlin does not exist. Cloning the repository into ../matrix-rust-components-kotlin.\n\n" + git clone https://github.com/matrix-org/matrix-rust-components-kotlin.git ../matrix-rust-components-kotlin +fi + +printf "\nResetting matrix-rust-components-kotlin to the latest main branch...\n\n" +cd ../matrix-rust-components-kotlin +git reset --hard +git checkout main +git pull + +printf "\nBuilding the SDK...\n\n" +./scripts/build.sh -p ${rustSdkPath} -m sdk -t aarch64-linux-android -o ../element-x-android/libraries/rustsdk + +cd ../element-x-android +mv ./libraries/rustsdk/sdk-android-debug.aar ./libraries/rustsdk/matrix-rust-sdk.aar +mkdir -p ./libraries/rustsdk/sdks +cp ./libraries/rustsdk/matrix-rust-sdk.aar ./libraries/rustsdk/matrix-rust-sdk-${date}.aar + + +if [ ${buildApp} != "yes" ]; then + printf "\nBuilding the application...\n\n" + ./gradlew assembleDebug +fi + +if [ ${buildLocal} == "no" ]; then + printf "\nCleaning up...\n\n" + rm -rf ../matrix-rust-sdk-$date +fi + +printf "\nDone!\n" From 21e24990c44dceab38eae537ad101fbb0e1778be Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 16 Oct 2023 18:29:25 +0200 Subject: [PATCH 03/13] Improve and fix build_rust_sdk.sh after PR review --- libraries/matrix/impl/build.gradle.kts | 5 +++-- tools/sdk/build_rust_sdk.sh | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index ff65a2768d..23a428c38f 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -29,11 +29,12 @@ anvil { } dependencies { + releaseImplementation(libs.matrix.sdk) if (file("${rootDir.path}/libraries/rustsdk/matrix-rust-sdk.aar").exists()) { println("\nNote: Using local binary of the Rust SDK.\n") - implementation(projects.libraries.rustsdk) + debugImplementation(projects.libraries.rustsdk) } else { - implementation(libs.matrix.sdk) + debugImplementation(libs.matrix.sdk) } implementation(projects.libraries.di) implementation(projects.libraries.androidutils) diff --git a/tools/sdk/build_rust_sdk.sh b/tools/sdk/build_rust_sdk.sh index 2a6b7b72d0..6853642218 100755 --- a/tools/sdk/build_rust_sdk.sh +++ b/tools/sdk/build_rust_sdk.sh @@ -46,7 +46,7 @@ fi read -p "Do you want to build the app after (yes/no) default to yes? " buildApp buildApp=${buildApp:-yes} -# If folder ../matrix-rust-components-kotlin does not exist, close the repo +# If folder ../matrix-rust-components-kotlin does not exist, clone the repo if [ ! -d "../matrix-rust-components-kotlin" ]; then printf "\nFolder ../matrix-rust-components-kotlin does not exist. Cloning the repository into ../matrix-rust-components-kotlin.\n\n" git clone https://github.com/matrix-org/matrix-rust-components-kotlin.git ../matrix-rust-components-kotlin @@ -58,7 +58,7 @@ git reset --hard git checkout main git pull -printf "\nBuilding the SDK...\n\n" +printf "\nBuilding the SDK for aarch64-linux-android...\n\n" ./scripts/build.sh -p ${rustSdkPath} -m sdk -t aarch64-linux-android -o ../element-x-android/libraries/rustsdk cd ../element-x-android @@ -67,7 +67,7 @@ mkdir -p ./libraries/rustsdk/sdks cp ./libraries/rustsdk/matrix-rust-sdk.aar ./libraries/rustsdk/matrix-rust-sdk-${date}.aar -if [ ${buildApp} != "yes" ]; then +if [ ${buildApp} == "yes" ]; then printf "\nBuilding the application...\n\n" ./gradlew assembleDebug fi From 412691f20a644015733fbb3549ab3f296a093284 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Oct 2023 08:42:33 +0200 Subject: [PATCH 04/13] Update dependency com.squareup.okhttp3:okhttp-bom to v4.12.0 (#1587) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- 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 7071a98879..947b3b2752 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -106,7 +106,7 @@ accompanist_systemui = { module = "com.google.accompanist:accompanist-systemuico squareup_seismic = "com.squareup:seismic:1.0.3" # network -network_okhttp_bom = "com.squareup.okhttp3:okhttp-bom:4.11.0" +network_okhttp_bom = "com.squareup.okhttp3:okhttp-bom:4.12.0" network_okhttp_logging = { module = "com.squareup.okhttp3:logging-interceptor" } network_okhttp = { module = "com.squareup.okhttp3:okhttp" } network_retrofit = "com.squareup.retrofit2:retrofit:2.9.0" From e5a8fd9635e067cb89df5bd6e259cca58ec04d90 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 18 Oct 2023 11:41:47 +0200 Subject: [PATCH 05/13] Make sure Konsist tests always run (#1590) * Make sure Konsist tests always run * Update tests/konsist/build.gradle.kts Co-authored-by: Benoit Marty --------- Co-authored-by: Benoit Marty --- tests/konsist/build.gradle.kts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/konsist/build.gradle.kts b/tests/konsist/build.gradle.kts index ca009bd89f..6658c0c5e0 100644 --- a/tests/konsist/build.gradle.kts +++ b/tests/konsist/build.gradle.kts @@ -32,3 +32,10 @@ dependencies { testImplementation(projects.libraries.architecture) testImplementation(projects.libraries.designsystem) } + +// Make sure Konsist tests are always run. This is needed because otherwise we'd have to either: +// - Add every single module as a dependency of this one. +// - Move the Konsist tests to the `app` module, but the `app` module does not need to know about Konsist. +tasks.withType().configureEach { + outputs.upToDateWhen { false } +} From a73378b0cec9aafd7b3abe0ea8d0bf765a805f07 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Oct 2023 08:51:21 +0200 Subject: [PATCH 06/13] Update dependency androidx.recyclerview:recyclerview to v1.3.2 (#1599) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- 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 9a0bfc3a04..dd711b6d72 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ core = "1.12.0" datastore = "1.0.0" constraintlayout = "2.1.4" constraintlayout_compose = "1.0.1" -recyclerview = "1.3.1" +recyclerview = "1.3.2" lifecycle = "2.6.2" activity = "1.8.0" startup = "1.1.1" From 6bec6235b4ce7a2eddb4a0c866463a2b507ab36d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Oct 2023 10:48:23 +0200 Subject: [PATCH 07/13] Update dependency io.sentry:sentry-android to v6.32.0 (#1602) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- 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 dd711b6d72..b2e91fce99 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -167,7 +167,7 @@ maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.1" # Analytics posthog = "com.posthog.android:posthog:2.0.3" -sentry = "io.sentry:sentry-android:6.31.0" +sentry = "io.sentry:sentry-android:6.32.0" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:e9cd9adaf18cec52ed851395eb84358b4f9b8d7f" # Emojibase From 9aa4c595165c1b1c9984aad3754d33a566861c3d Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Thu, 19 Oct 2023 10:49:11 +0200 Subject: [PATCH 08/13] Hide keyboard when exiting the room screen (#1593) --- changelog.d/1375.bugfix | 1 + .../android/features/messages/impl/MessagesView.kt | 9 +++++++++ 2 files changed, 10 insertions(+) create mode 100644 changelog.d/1375.bugfix diff --git a/changelog.d/1375.bugfix b/changelog.d/1375.bugfix new file mode 100644 index 0000000000..d80ff3543c --- /dev/null +++ b/changelog.d/1375.bugfix @@ -0,0 +1 @@ +Hide keyboard when exiting the chat room screen. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 971df06053..b79e84a2e0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -222,6 +223,14 @@ fun MessagesView( ReinviteDialog( state = state ) + + // Since the textfield is now based on an Android view, this is no longer done automatically. + // We need to hide the keyboard automatically when navigating out of this screen. + DisposableEffect(Unit) { + onDispose { + localView.hideKeyboard() + } + } } @Composable From 79d2941fe4b1617895d18dc4e935d724b621e1e4 Mon Sep 17 00:00:00 2001 From: Marco Romano Date: Thu, 19 Oct 2023 11:00:32 +0200 Subject: [PATCH 09/13] Include desugaring lib also in library modules (#1604) ## Type of change - [ ] Feature - [ ] Bugfix - [x] Technical - [ ] Other : ## Content Includes the `coreLibraryDesugaring(libs.android.desugar)` dependency in all modules which use one of our gradle plugins. ## Motivation and context Right now desugaring is enabled also in library modules but the desugar dependency is not included in those. This causes some unwanted side effects such as being unable to run compose previews in an emu. This change will also include the desugar dependency in those libraries. --- app/build.gradle.kts | 1 - libraries/pushstore/impl/build.gradle.kts | 2 -- libraries/session-storage/impl/build.gradle.kts | 2 -- plugins/src/main/kotlin/extension/CommonExtension.kt | 1 - .../kotlin/io.element.android-compose-application.gradle.kts | 4 ++++ .../main/kotlin/io.element.android-compose-library.gradle.kts | 4 ++++ plugins/src/main/kotlin/io.element.android-library.gradle.kts | 4 ++++ samples/minimal/build.gradle.kts | 1 - 8 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 839a5095dd..6ac84cfec2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -203,7 +203,6 @@ dependencies { implementation(projects.appnav) anvil(projects.anvilcodegen) - coreLibraryDesugaring(libs.android.desugar) implementation(libs.appyx.core) implementation(libs.androidx.splash) implementation(libs.androidx.core) diff --git a/libraries/pushstore/impl/build.gradle.kts b/libraries/pushstore/impl/build.gradle.kts index 5946e77694..17e0268af1 100644 --- a/libraries/pushstore/impl/build.gradle.kts +++ b/libraries/pushstore/impl/build.gradle.kts @@ -55,6 +55,4 @@ dependencies { androidTestImplementation(libs.test.truth) androidTestImplementation(libs.test.runner) androidTestImplementation(projects.libraries.sessionStorage.test) - - coreLibraryDesugaring(libs.android.desugar) } diff --git a/libraries/session-storage/impl/build.gradle.kts b/libraries/session-storage/impl/build.gradle.kts index 03de9acf86..cfbfa4c57d 100644 --- a/libraries/session-storage/impl/build.gradle.kts +++ b/libraries/session-storage/impl/build.gradle.kts @@ -45,8 +45,6 @@ dependencies { testImplementation(libs.test.turbine) testImplementation(libs.coroutines.test) testImplementation(libs.sqldelight.driver.jvm) - - coreLibraryDesugaring(libs.android.desugar) } sqldelight { diff --git a/plugins/src/main/kotlin/extension/CommonExtension.kt b/plugins/src/main/kotlin/extension/CommonExtension.kt index e3f7b3682e..97305dbc66 100644 --- a/plugins/src/main/kotlin/extension/CommonExtension.kt +++ b/plugins/src/main/kotlin/extension/CommonExtension.kt @@ -31,7 +31,6 @@ fun CommonExtension<*, *, *, *, *>.androidConfig(project: Project) { } compileOptions { - isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } diff --git a/plugins/src/main/kotlin/io.element.android-compose-application.gradle.kts b/plugins/src/main/kotlin/io.element.android-compose-application.gradle.kts index af73409888..80bc0f884e 100644 --- a/plugins/src/main/kotlin/io.element.android-compose-application.gradle.kts +++ b/plugins/src/main/kotlin/io.element.android-compose-application.gradle.kts @@ -32,9 +32,13 @@ plugins { android { androidConfig(project) composeConfig(libs) + compileOptions { + isCoreLibraryDesugaringEnabled = true + } } dependencies { commonDependencies(libs) composeDependencies(libs) + coreLibraryDesugaring(libs.android.desugar) } diff --git a/plugins/src/main/kotlin/io.element.android-compose-library.gradle.kts b/plugins/src/main/kotlin/io.element.android-compose-library.gradle.kts index e420ab3c8d..3194505e4e 100644 --- a/plugins/src/main/kotlin/io.element.android-compose-library.gradle.kts +++ b/plugins/src/main/kotlin/io.element.android-compose-library.gradle.kts @@ -32,9 +32,13 @@ plugins { android { androidConfig(project) composeConfig(libs) + compileOptions { + isCoreLibraryDesugaringEnabled = true + } } dependencies { commonDependencies(libs) composeDependencies(libs) + coreLibraryDesugaring(libs.android.desugar) } diff --git a/plugins/src/main/kotlin/io.element.android-library.gradle.kts b/plugins/src/main/kotlin/io.element.android-library.gradle.kts index 6c3c77223c..f3a84031e6 100644 --- a/plugins/src/main/kotlin/io.element.android-library.gradle.kts +++ b/plugins/src/main/kotlin/io.element.android-library.gradle.kts @@ -29,8 +29,12 @@ plugins { android { androidConfig(project) + compileOptions { + isCoreLibraryDesugaringEnabled = true + } } dependencies { commonDependencies(libs) + coreLibraryDesugaring(libs.android.desugar) } diff --git a/samples/minimal/build.gradle.kts b/samples/minimal/build.gradle.kts index 1473bd7f93..016989a2d6 100644 --- a/samples/minimal/build.gradle.kts +++ b/samples/minimal/build.gradle.kts @@ -65,5 +65,4 @@ dependencies { implementation(projects.services.toolbox.impl) implementation(projects.libraries.featureflag.impl) implementation(libs.coroutines.core) - coreLibraryDesugaring(libs.android.desugar) } From 5a7f77bc92c2179281531f55fc7be630976d88c8 Mon Sep 17 00:00:00 2001 From: Marco Romano Date: Thu, 19 Oct 2023 13:32:43 +0200 Subject: [PATCH 10/13] Extract more content from audio messages. (#1607) `TimelineItemAudioContent`: - Use `java.time.Duration` instead of milliseconds. This will ease up things in the future because currently milliseconds are sent over the wire but in the future seconds will be sent (as per the stable MSC). Using `Duration` will allow our downstream code to be independent of what's passed over the wire. - Rename `audioSource` property to `mediaSource` to better match its type. `AudioMessageType`: - Add and populate new fields `details` and `isVoiceMessage` to be used by voice messages. --- .../messages/impl/MessagesFlowNode.kt | 2 +- .../TimelineItemContentMessageFactory.kt | 7 +++-- .../model/event/TimelineItemAudioContent.kt | 5 ++-- .../event/TimelineItemAudioContentProvider.kt | 5 ++-- .../DefaultRoomLastMessageFormatterTest.kt | 2 +- .../matrix/api/media/AudioDetails.kt | 24 +++++++++++++++ .../api/timeline/item/event/MessageType.kt | 5 +++- .../matrix/impl/media/AudioDetails.kt | 30 +++++++++++++++++++ .../timeline/item/event/EventMessageMapper.kt | 8 ++++- 9 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioDetails.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioDetails.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index fcb2e7e5e8..21e384906e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -238,7 +238,7 @@ class MessagesFlowNode @AssistedInject constructor( backstack.push(navTarget) } is TimelineItemAudioContent -> { - val mediaSource = event.content.audioSource + val mediaSource = event.content.mediaSource val navTarget = NavTarget.MediaViewer( mediaInfo = MediaInfo( name = event.content.body, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index ae2ea4f350..323f110f47 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessage import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import java.time.Duration import javax.inject.Inject class TimelineItemContentMessageFactory @Inject constructor( @@ -103,11 +104,11 @@ class TimelineItemContentMessageFactory @Inject constructor( } is AudioMessageType -> TimelineItemAudioContent( body = messageType.body, - audioSource = messageType.source, - duration = messageType.info?.duration?.toMillis() ?: 0L, + mediaSource = messageType.source, + duration = messageType.info?.duration ?: Duration.ZERO, mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), - fileExtension = fileExtensionExtractor.extractFromName(messageType.body) + fileExtension = fileExtensionExtractor.extractFromName(messageType.body), ) is FileMessageType -> { val fileExtension = fileExtensionExtractor.extractFromName(messageType.body) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt index 485b863170..9d9a41e0e3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt @@ -18,11 +18,12 @@ package io.element.android.features.messages.impl.timeline.model.event import io.element.android.features.messages.impl.media.helper.formatFileExtensionAndSize import io.element.android.libraries.matrix.api.media.MediaSource +import java.time.Duration data class TimelineItemAudioContent( val body: String, - val duration: Long, - val audioSource: MediaSource, + val duration: Duration, + val mediaSource: MediaSource, val mimeType: String, val formattedFileSize: String, val fileExtension: String, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt index ed424781f8..06cb53b6fe 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt @@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.model.event import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.media.MediaSource +import java.time.Duration open class TimelineItemAudioContentProvider : PreviewParameterProvider { override val values: Sequence @@ -34,6 +35,6 @@ fun aTimelineItemAudioContent(fileName: String = "A sound.mp3") = TimelineItemAu mimeType = MimeTypes.Pdf, formattedFileSize = "100kB", fileExtension = "mp3", - duration = 100, - audioSource = MediaSource(""), + duration = Duration.ofMillis(100), + mediaSource = MediaSource(""), ) diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt index 4c26fcb3c7..50d313f132 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt @@ -161,7 +161,7 @@ class DefaultRoomLastMessageFormatterTest { val sharedContentMessagesTypes = arrayOf( TextMessageType(body, null), VideoMessageType(body, MediaSource("url"), null), - AudioMessageType(body, MediaSource("url"), null), + AudioMessageType(body, MediaSource("url"), null, null, false), ImageMessageType(body, MediaSource("url"), null), FileMessageType(body, MediaSource("url"), null), LocationMessageType(body, "geo:1,2", null), diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioDetails.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioDetails.kt new file mode 100644 index 0000000000..f8cd2d3fb4 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioDetails.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.api.media + +import java.time.Duration + +data class AudioDetails( + val duration: Duration, + val waveform: List, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt index dc06d5c94a..ba6eeca819 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.matrix.api.timeline.item.event +import io.element.android.libraries.matrix.api.media.AudioDetails import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo @@ -46,7 +47,9 @@ data class LocationMessageType( data class AudioMessageType( val body: String, val source: MediaSource, - val info: AudioInfo? + val info: AudioInfo?, + val details: AudioDetails?, + val isVoiceMessage: Boolean, ) : MessageType data class VideoMessageType( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioDetails.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioDetails.kt new file mode 100644 index 0000000000..c3fa11e40c --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioDetails.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.matrix.impl.media + +import io.element.android.libraries.matrix.api.media.AudioDetails +import org.matrix.rustcomponents.sdk.UnstableAudioDetailsContent as RustAudioDetails + +fun RustAudioDetails.map(): AudioDetails = AudioDetails( + duration = duration, + waveform = waveform.map { it.toInt() }, +) + +fun AudioDetails.map(): RustAudioDetails = RustAudioDetails( + duration = duration, + waveform = waveform.map { it.toUShort() } +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt index 0a59cfddab..18d2e1bdeb 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt @@ -75,7 +75,13 @@ class EventMessageMapper { fun mapMessageType(type: RustMessageType?) = when (type) { is RustMessageType.Audio -> { - AudioMessageType(type.content.body, type.content.source.map(), type.content.info?.map()) + AudioMessageType( + body = type.content.body, + source = type.content.source.map(), + info = type.content.info?.map(), + details = type.content.audio?.map(), + isVoiceMessage = type.content.voice != null, + ) } is RustMessageType.File -> { FileMessageType(type.content.body, type.content.source.map(), type.content.info?.map()) From 33f5c8efb8f1e495d447532b0b0ff6c17bee6099 Mon Sep 17 00:00:00 2001 From: Marco Romano Date: Thu, 19 Oct 2023 14:07:45 +0200 Subject: [PATCH 11/13] Add global `context.cacheDir` provider. (#1606) ## Type of change - [ ] Feature - [ ] Bugfix - [x] Technical - [ ] Other : ## Content Dagger now provides the app's `cacheDir` when requesting a `@CacheDirectory File` type. ## Motivation and context To support some upcoming code that needs the `cacheDir` to be changed during tests. --- .../io/element/android/x/di/AppModule.kt | 7 +++++ .../libraries/di/ApplicationContext.kt | 8 +++++- .../android/libraries/di/CacheDirectory.kt | 27 +++++++++++++++++++ .../matrix/impl/RustMatrixClientFactory.kt | 7 +++-- .../android/samples/minimal/MainActivity.kt | 2 +- 5 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 libraries/di/src/main/kotlin/io/element/android/libraries/di/CacheDirectory.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 17ba415762..037cec1e71 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 @@ -31,6 +31,7 @@ import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.CacheDirectory import io.element.android.libraries.di.DefaultPreferences import io.element.android.libraries.di.SingleIn import io.element.android.x.BuildConfig @@ -51,6 +52,12 @@ object AppModule { return File(context.filesDir, "sessions") } + @Provides + @CacheDirectory + fun providesCacheDirectory(@ApplicationContext context: Context): File { + return context.cacheDir + } + @Provides fun providesResources(@ApplicationContext context: Context): Resources { return context.resources diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/ApplicationContext.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/ApplicationContext.kt index 2108678097..421b521192 100644 --- a/libraries/di/src/main/kotlin/io/element/android/libraries/di/ApplicationContext.kt +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/ApplicationContext.kt @@ -18,4 +18,10 @@ package io.element.android.libraries.di import javax.inject.Qualifier -@Qualifier annotation class ApplicationContext +/** + * Qualifies a [Context] object that represents the application context. + */ +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +@Qualifier +annotation class ApplicationContext diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/CacheDirectory.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/CacheDirectory.kt new file mode 100644 index 0000000000..e8513bef45 --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/CacheDirectory.kt @@ -0,0 +1,27 @@ +/* + * 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 + +/** + * Qualifies a [File] object which represents the application cache directory. + */ +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +@Qualifier +annotation class CacheDirectory diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt index b37266342e..b1cb3ffac4 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -16,9 +16,8 @@ package io.element.android.libraries.matrix.impl -import android.content.Context import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.CacheDirectory import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore @@ -32,8 +31,8 @@ import java.io.File import javax.inject.Inject class RustMatrixClientFactory @Inject constructor( - @ApplicationContext private val context: Context, private val baseDirectory: File, + @CacheDirectory private val cacheDirectory: File, private val appCoroutineScope: CoroutineScope, private val coroutineDispatchers: CoroutineDispatchers, private val sessionStore: SessionStore, @@ -63,7 +62,7 @@ class RustMatrixClientFactory @Inject constructor( appCoroutineScope = appCoroutineScope, dispatchers = coroutineDispatchers, baseDirectory = baseDirectory, - baseCacheDirectory = context.cacheDir, + baseCacheDirectory = cacheDirectory, clock = clock, ) } diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt index c95a44d8ea..a405167cbf 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt @@ -49,8 +49,8 @@ class MainActivity : ComponentActivity() { sessionStore = sessionStore, userAgentProvider = userAgentProvider, rustMatrixClientFactory = RustMatrixClientFactory( - context = applicationContext, baseDirectory = baseDirectory, + cacheDirectory = applicationContext.cacheDir, appCoroutineScope = Singleton.appScope, coroutineDispatchers = Singleton.coroutineDispatchers, sessionStore = sessionStore, From a814c4a95ab3cd1148c3cb35cf83eca040ce6c90 Mon Sep 17 00:00:00 2001 From: Marco Romano Date: Thu, 19 Oct 2023 15:57:34 +0200 Subject: [PATCH 12/13] TimelineItemPresenterFactories (#1609) DI infrastructure to allow injection of presenters into the timeline. Add an `@AssistedFactory` of type `TimelineItemPresenterFactory` to a `Presenter` class and bind this factory into the TimelineItemPresenterFactory map multi binding using: ``` @Binds @IntoMap @TimelineItemEventContentKey(MyTimelineItemContent::class) ``` A map multibinding of such factories will be available in the `LocalTimelineItemPresenterFactories` composition local for further use down the UI tree. --- .../features/messages/impl/MessagesNode.kt | 32 +++++--- .../di/TimelineItemEventContentKey.kt | 29 +++++++ .../di/TimelineItemPresenterFactories.kt | 77 +++++++++++++++++++ .../di/TimelineItemPresenterFactory.kt | 33 ++++++++ tools/detekt/detekt.yml | 2 +- 5 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemEventContentKey.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactories.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactory.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 6a3cf502d7..dbf7e2fbb2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -17,6 +17,7 @@ package io.element.android.features.messages.impl import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.modality.BuildContext @@ -28,6 +29,8 @@ import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories +import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId @@ -44,6 +47,7 @@ class MessagesNode @AssistedInject constructor( private val room: MatrixRoom, private val analyticsService: AnalyticsService, private val presenterFactory: MessagesPresenter.Factory, + private val timelineItemPresenterFactories: TimelineItemPresenterFactories, ) : Node(buildContext, plugins = plugins), MessagesNavigator { private val presenter = presenterFactory.create(this) @@ -106,17 +110,21 @@ class MessagesNode @AssistedInject constructor( @Composable override fun View(modifier: Modifier) { - val state = presenter.present() - MessagesView( - state = state, - onBackPressed = this::navigateUp, - onRoomDetailsClicked = this::onRoomDetailsClicked, - onEventClicked = this::onEventClicked, - onPreviewAttachments = this::onPreviewAttachments, - onUserDataClicked = this::onUserDataClicked, - onSendLocationClicked = this::onSendLocationClicked, - onCreatePollClicked = this::onCreatePollClicked, - modifier = modifier, - ) + CompositionLocalProvider( + LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, + ) { + val state = presenter.present() + MessagesView( + state = state, + onBackPressed = this::navigateUp, + onRoomDetailsClicked = this::onRoomDetailsClicked, + onEventClicked = this::onEventClicked, + onPreviewAttachments = this::onPreviewAttachments, + onUserDataClicked = this::onUserDataClicked, + onSendLocationClicked = this::onSendLocationClicked, + onCreatePollClicked = this::onCreatePollClicked, + modifier = modifier, + ) + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemEventContentKey.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemEventContentKey.kt new file mode 100644 index 0000000000..9cb046a054 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemEventContentKey.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.features.messages.impl.timeline.di + +import dagger.MapKey +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import kotlin.reflect.KClass + +/** + * Annotation to add a factory of type [TimelineItemPresenterFactory] to a + * Dagger map multi binding keyed with a subclass of [TimelineItemEventContent]. + */ +@Retention(AnnotationRetention.RUNTIME) +@MapKey +annotation class TimelineItemEventContentKey(val value: KClass) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactories.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactories.kt new file mode 100644 index 0000000000..0574f7e903 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactories.kt @@ -0,0 +1,77 @@ +/* + * 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.messages.impl.timeline.di + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.multibindings.Multibinds +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.RoomScope +import javax.inject.Inject + +/** + * Dagger module that declares the [TimelineItemPresenterFactory] map multi binding. + * + * Its sole purpose is to support the case of an empty map multibinding. + */ +@Module +@ContributesTo(RoomScope::class) +interface TimelineItemPresenterFactoriesModule { + @Multibinds + fun multiBindTimelineItemPresenterFactories(): @JvmSuppressWildcards Map, TimelineItemPresenterFactory<*, *>> +} + +/** + * Wrapper around the [TimelineItemPresenterFactory] map multi binding. + * + * Its only purpose is to provide a nicer type name than: + * `@JvmSuppressWildcards Map, TimelineItemPresenterFactory<*, *>>`. + * + * A typealias would have been better but typealiases on Dagger types which use @JvmSuppressWildcards + * currently make Dagger crash. + * + * Request this type from Dagger to access the [TimelineItemPresenterFactory] map multibinding. + */ +data class TimelineItemPresenterFactories @Inject constructor( + val factories: @JvmSuppressWildcards Map, TimelineItemPresenterFactory<*, *>>, +) + +/** + * Provides a [TimelineItemPresenterFactories] to the composition. + */ +val LocalTimelineItemPresenterFactories = staticCompositionLocalOf { + TimelineItemPresenterFactories(emptyMap()) +} + +/** + * Creates and remembers a presenter for the given content. + * + * Will throw if the presenter is not found in the [TimelineItemPresenterFactory] map multi binding. + */ +@Composable +inline fun TimelineItemPresenterFactories.rememberPresenter( + content: C +): Presenter = remember(content) { + factories.getValue(C::class.java).let { + @Suppress("UNCHECKED_CAST") + (it as TimelineItemPresenterFactory).create(content) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactory.kt new file mode 100644 index 0000000000..f79d606f60 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactory.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.messages.impl.timeline.di + +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.libraries.architecture.Presenter + +/** + * A factory for a [Presenter] associated with a timeline item. + * + * Implementations should be annotated with [AssistedFactory] to be created by Dagger. + * + * @param C The timeline item's [TimelineItemEventContent] subtype. + * @param S The [Presenter]'s state class. + * @return A [Presenter] that produces a state of type [S] for the given content of type [C]. + */ +fun interface TimelineItemPresenterFactory { + fun create(content: C): Presenter +} diff --git a/tools/detekt/detekt.yml b/tools/detekt/detekt.yml index 1c226dd5ed..ca8370ae23 100644 --- a/tools/detekt/detekt.yml +++ b/tools/detekt/detekt.yml @@ -219,7 +219,7 @@ Compose: CompositionLocalAllowlist: active: true # You can optionally define a list of CompositionLocals that are allowed here - allowedCompositionLocals: LocalCompoundColors, LocalSnackbarDispatcher, LocalCameraPositionState + allowedCompositionLocals: LocalCompoundColors, LocalSnackbarDispatcher, LocalCameraPositionState, LocalTimelineItemPresenterFactories CompositionLocalNaming: active: true ContentEmitterReturningValues: From 46f78ef70083a0f0eeaad17401c36b09f2d7fc1b Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Thu, 19 Oct 2023 17:38:43 +0200 Subject: [PATCH 13/13] Integrate Element Call with widget API (#1581) * Integrate Element Call with widget API. - Add `appconfig` module and extract constants that can be overridden in forks there. - Add an Element Call feature flag, disabled by default. - Refactor the whole `ElementCallActivity`, move most logic out of it. - Integrate with the Rust Widget Driver API (note the Rust SDK version used in this PR lacks some needed changes to make the calls actually work). - Handle calls differently based on `CallType`. - Add UI to create/join a call. --------- Co-authored-by: ElementBot --- appconfig/build.gradle.kts | 24 +++ .../android/appconfig/AuthenticationConfig.kt | 24 +++ .../android/appconfig/ElementCallConfig.kt | 21 ++ .../android/appconfig}/MatrixConfiguration.kt | 2 +- features/call/build.gradle.kts | 26 ++- features/call/src/main/AndroidManifest.xml | 2 +- .../features/call/CallForegroundService.kt | 1 + .../element/android/features/call/CallType.kt | 34 +++ .../features/call/data/WidgetMessage.kt | 43 ++++ .../android/features/call/di/CallBindings.kt | 2 +- .../features/call/ui/CallScreeEvents.kt | 24 +++ .../features/call/ui/CallScreenPresenter.kt | 172 ++++++++++++++++ .../features/call/ui/CallScreenState.kt | 26 +++ .../features/call/{ => ui}/CallScreenView.kt | 141 +++++++------ .../call/{ => ui}/ElementCallActivity.kt | 96 ++++++--- .../call/{ => utils}/CallIntentDataParser.kt | 2 +- .../features/call/utils/CallWidgetProvider.kt | 31 +++ .../call/utils/DefaultCallWidgetProvider.kt | 50 +++++ .../utils/WebViewWidgetMessageInterceptor.kt | 100 +++++++++ .../call/utils/WidgetMessageInterceptor.kt | 24 +++ .../call/utils/WidgetMessageSerializer.kt | 33 +++ .../features/call/MapWebkitPermissionsTest.kt | 1 + .../call/ui/CallScreenPresenterTest.kt | 194 ++++++++++++++++++ .../call/ui/FakeCallScreenNavigator.kt | 26 +++ .../{ => utils}/CallIntentDataParserTest.kt | 2 +- .../utils/DefaultCallWidgetProviderTest.kt | 121 +++++++++++ .../call/utils/FakeCallWidgetProvider.kt | 42 ++++ .../utils/FakeWidgetMessageInterceptor.kt | 33 +++ features/login/impl/build.gradle.kts | 1 + .../AccountProviderProvider.kt | 4 +- .../ChangeAccountProviderPresenter.kt | 4 +- .../SearchAccountProviderStateProvider.kt | 4 +- .../SearchAccountProviderView.kt | 7 +- .../login/impl/util/LoginConstants.kt | 14 +- .../android/features/login/impl/util/Util.kt | 3 +- .../api/src/main/res/values/localazy.xml | 4 + features/messages/impl/build.gradle.kts | 1 + .../messages/impl/MessagesFlowNode.kt | 15 ++ .../features/messages/impl/MessagesNode.kt | 7 + .../messages/impl/MessagesPresenter.kt | 3 + .../features/messages/impl/MessagesState.kt | 1 + .../messages/impl/MessagesStateProvider.kt | 1 + .../features/messages/impl/MessagesView.kt | 16 ++ features/preferences/impl/build.gradle.kts | 1 + .../impl/advanced/AdvancedSettingsEvents.kt | 1 + .../advanced/AdvancedSettingsPresenter.kt | 38 ++++ .../impl/advanced/AdvancedSettingsState.kt | 9 +- .../advanced/AdvancedSettingsStateProvider.kt | 3 + .../impl/advanced/AdvancedSettingsView.kt | 27 ++- .../impl/src/main/res/values/localazy.xml | 3 + .../advanced/AdvancedSettingsPresenterTest.kt | 71 ++++++- .../libraries/architecture/Bindings.kt | 8 +- .../components/dialogs/ListDialog.kt | 6 +- .../components/list/TextFieldListItem.kt | 65 +++++- .../preferences/PreferenceTextField.kt | 141 +++++++++++++ .../theme/components/AlertDialogContent.kt | 2 + .../libraries/featureflag/api/FeatureFlags.kt | 6 + .../impl/StaticFeatureFlagProvider.kt | 1 + libraries/matrix/api/build.gradle.kts | 1 + .../matrix/api/permalink/MatrixToConverter.kt | 2 +- .../matrix/api/permalink/PermalinkBuilder.kt | 2 +- .../libraries/matrix/api/room/MatrixRoom.kt | 24 +++ .../api/widget/CallWidgetSettingsProvider.kt | 26 +++ .../matrix/api/widget/MatrixWidgetDriver.kt | 27 +++ .../matrix/api/widget/MatrixWidgetSettings.kt | 29 +++ .../matrix/impl/room/RustMatrixRoom.kt | 27 +++ .../DefaultCallWidgetSettingsProvider.kt | 46 +++++ .../impl/widget/MatrixWidgetSettings.kt | 50 +++++ .../matrix/impl/widget/RustWidgetDriver.kt | 78 +++++++ .../libraries/matrix/test/FakeMatrixClient.kt | 8 +- .../matrix/test/FakeMatrixClientProvider.kt | 27 +++ .../matrix/test/room/FakeMatrixRoom.kt | 22 ++ .../widget/FakeCallWidgetSettingsProvider.kt | 32 +++ .../matrix/test/widget/FakeWidgetDriver.kt | 52 +++++ .../preferences/api/store/PreferencesStore.kt | 3 + .../impl/store/DefaultPreferencesStore.kt | 18 ++ .../test/InMemoryPreferencesStore.kt | 10 + .../src/main/res/values-cs/translations.xml | 5 +- .../src/main/res/values-ru/translations.xml | 1 - .../src/main/res/values-sk/translations.xml | 1 - .../src/main/res/values/localazy.xml | 42 +++- settings.gradle.kts | 1 + ...lScreenView-D-0_0_null,NEXUS_5,1.0,en].png | 3 + ...lScreenView-N-0_1_null,NEXUS_5,1.0,en].png | 3 + ...lScreenView-D-0_0_null,NEXUS_5,1.0,en].png | 3 - ...lScreenView-N-0_1_null,NEXUS_5,1.0,en].png | 3 - ...sagesView-D-0_0_null_0,NEXUS_5,1.0,en].png | 4 +- ...sagesView-D-0_0_null_1,NEXUS_5,1.0,en].png | 4 +- ...sagesView-D-0_0_null_3,NEXUS_5,1.0,en].png | 4 +- ...sagesView-D-0_0_null_4,NEXUS_5,1.0,en].png | 4 +- ...sagesView-D-0_0_null_5,NEXUS_5,1.0,en].png | 4 +- ...sagesView-D-0_0_null_6,NEXUS_5,1.0,en].png | 4 +- ...sagesView-N-0_1_null_0,NEXUS_5,1.0,en].png | 4 +- ...sagesView-N-0_1_null_1,NEXUS_5,1.0,en].png | 4 +- ...sagesView-N-0_1_null_3,NEXUS_5,1.0,en].png | 4 +- ...sagesView-N-0_1_null_4,NEXUS_5,1.0,en].png | 4 +- ...sagesView-N-0_1_null_5,NEXUS_5,1.0,en].png | 4 +- ...sagesView-N-0_1_null_6,NEXUS_5,1.0,en].png | 4 +- ...tingsView-D-1_1_null_3,NEXUS_5,1.0,en].png | 3 + ...tingsView-N-1_2_null_3,NEXUS_5,1.0,en].png | 3 + ...-textfieldvalue_0_null,NEXUS_5,1.0,en].png | 3 + tools/localazy/config.json | 1 + 102 files changed, 2202 insertions(+), 166 deletions(-) create mode 100644 appconfig/build.gradle.kts create mode 100644 appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt create mode 100644 appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt rename {libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/config => appconfig/src/main/kotlin/io/element/android/appconfig}/MatrixConfiguration.kt (93%) create mode 100644 features/call/src/main/kotlin/io/element/android/features/call/CallType.kt create mode 100644 features/call/src/main/kotlin/io/element/android/features/call/data/WidgetMessage.kt create mode 100644 features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreeEvents.kt create mode 100644 features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenPresenter.kt create mode 100644 features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenState.kt rename features/call/src/main/kotlin/io/element/android/features/call/{ => ui}/CallScreenView.kt (52%) rename features/call/src/main/kotlin/io/element/android/features/call/{ => ui}/ElementCallActivity.kt (72%) rename features/call/src/main/kotlin/io/element/android/features/call/{ => utils}/CallIntentDataParser.kt (98%) create mode 100644 features/call/src/main/kotlin/io/element/android/features/call/utils/CallWidgetProvider.kt create mode 100644 features/call/src/main/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProvider.kt create mode 100644 features/call/src/main/kotlin/io/element/android/features/call/utils/WebViewWidgetMessageInterceptor.kt create mode 100644 features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageInterceptor.kt create mode 100644 features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageSerializer.kt create mode 100644 features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt create mode 100644 features/call/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt rename features/call/src/test/kotlin/io/element/android/features/call/{ => utils}/CallIntentDataParserTest.kt (99%) create mode 100644 features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt create mode 100644 features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt create mode 100644 features/call/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallWidgetSettingsProvider.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetDriver.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetSettings.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/MatrixWidgetSettings.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeCallWidgetSettingsProvider.kt create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeWidgetDriver.kt create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.call.ui_null_CallScreenView-D-0_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.call.ui_null_CallScreenView-N-0_1_null,NEXUS_5,1.0,en].png delete mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-D-0_0_null,NEXUS_5,1.0,en].png delete mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-N-0_1_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_null_AdvancedSettingsView-D-1_1_null_3,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_null_AdvancedSettingsView-N-1_2_null_3,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.list_null_Listitems_TextfieldListitem-textfieldvalue_0_null,NEXUS_5,1.0,en].png diff --git a/appconfig/build.gradle.kts b/appconfig/build.gradle.kts new file mode 100644 index 0000000000..3c03739553 --- /dev/null +++ b/appconfig/build.gradle.kts @@ -0,0 +1,24 @@ +/* + * 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. + */ +plugins { + id("java-library") + alias(libs.plugins.kotlin.jvm) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt new file mode 100644 index 0000000000..186b84f8f0 --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.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.appconfig + +object AuthenticationConfig { + const val MATRIX_ORG_URL = "https://matrix.org" + + const val DEFAULT_HOMESERVER_URL = MATRIX_ORG_URL + const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md" +} diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt new file mode 100644 index 0000000000..bbd9f62689 --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.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.appconfig + +object ElementCallConfig { + const val DEFAULT_BASE_URL = "https://call.element.io" +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/config/MatrixConfiguration.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/MatrixConfiguration.kt similarity index 93% rename from libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/config/MatrixConfiguration.kt rename to appconfig/src/main/kotlin/io/element/android/appconfig/MatrixConfiguration.kt index ddce776627..e4d6ee7ca2 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/config/MatrixConfiguration.kt +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/MatrixConfiguration.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.matrix.api.config +package io.element.android.appconfig object MatrixConfiguration { const val matrixToPermalinkBaseUrl: String = "https://matrix.to/#/" diff --git a/features/call/build.gradle.kts b/features/call/build.gradle.kts index 69046e33b4..c59f1ea855 100644 --- a/features/call/build.gradle.kts +++ b/features/call/build.gradle.kts @@ -18,20 +18,44 @@ plugins { id("io.element.android-compose-library") alias(libs.plugins.anvil) alias(libs.plugins.ksp) + id("kotlin-parcelize") + alias(libs.plugins.kotlin.serialization) } android { namespace = "io.element.android.features.call" + + buildFeatures { + buildConfig = true + } +} + +anvil { + generateDaggerFactories.set(true) } dependencies { + implementation(projects.appnav) + implementation(projects.appconfig) + implementation(projects.anvilannotations) implementation(projects.libraries.architecture) + implementation(projects.libraries.core) implementation(projects.libraries.designsystem) + implementation(projects.libraries.matrix.impl) implementation(projects.libraries.network) + implementation(projects.libraries.preferences.api) + implementation(projects.services.toolbox.api) implementation(libs.androidx.webkit) + implementation(libs.serialization.json) ksp(libs.showkase.processor) - testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) testImplementation(libs.test.robolectric) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) } diff --git a/features/call/src/main/AndroidManifest.xml b/features/call/src/main/AndroidManifest.xml index 877b7fb0a8..c7db9cc38f 100644 --- a/features/call/src/main/AndroidManifest.xml +++ b/features/call/src/main/AndroidManifest.xml @@ -27,7 +27,7 @@ { + + @AssistedFactory + interface Factory { + fun create(callType: CallType, navigator: CallScreenNavigator): CallScreenPresenter + } + + private val isInWidgetMode = callType is CallType.RoomCall + private val userAgent = userAgentProvider.provide() + + @Composable + override fun present(): CallScreenState { + val coroutineScope = rememberCoroutineScope() + val urlState = remember { mutableStateOf>(Async.Uninitialized) } + val callWidgetDriver = remember { mutableStateOf(null) } + val messageInterceptor = remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + loadUrl(callType, urlState, callWidgetDriver) + } + + callWidgetDriver.value?.let { driver -> + LaunchedEffect(Unit) { + driver.incomingMessages + .onEach { + // Relay message to the WebView + messageInterceptor.value?.sendMessage(it) + } + .launchIn(this) + + driver.run() + } + } + + messageInterceptor.value?.let { interceptor -> + LaunchedEffect(Unit) { + interceptor.interceptedMessages + .onEach { + // Relay message to Widget Driver + callWidgetDriver.value?.send(it) + + val parsedMessage = parseMessage(it) + if (parsedMessage?.direction == WidgetMessage.Direction.FromWidget && parsedMessage.action == WidgetMessage.Action.HangUp) { + close(callWidgetDriver.value, navigator) + } + } + .launchIn(this) + } + } + + fun handleEvents(event: CallScreeEvents) { + when (event) { + is CallScreeEvents.Hangup -> { + val widgetId = callWidgetDriver.value?.id + val interceptor = messageInterceptor.value + if (widgetId != null && interceptor != null) { + sendHangupMessage(widgetId, interceptor) + } + coroutineScope.launch { + close(callWidgetDriver.value, navigator) + } + } + is CallScreeEvents.SetupMessageChannels -> { + messageInterceptor.value = event.widgetMessageInterceptor + } + } + } + + return CallScreenState( + urlState = urlState.value, + userAgent = userAgent, + isInWidgetMode = isInWidgetMode, + eventSink = ::handleEvents, + ) + } + + private fun CoroutineScope.loadUrl( + inputs: CallType, + urlState: MutableState>, + callWidgetDriver: MutableState, + ) = launch { + urlState.runCatchingUpdatingState { + when (inputs) { + is CallType.ExternalUrl -> { + inputs.url + } + is CallType.RoomCall -> { + val (driver, url) = callWidgetProvider.getWidget( + sessionId = inputs.sessionId, + roomId = inputs.roomId, + clientId = UUID.randomUUID().toString(), + ).getOrThrow() + callWidgetDriver.value = driver + url + } + } + } + } + + private fun parseMessage(message: String): WidgetMessage? { + return WidgetMessageSerializer.deserialize(message).getOrNull() + } + + private fun sendHangupMessage(widgetId: String, messageInterceptor: WidgetMessageInterceptor) { + val message = WidgetMessage( + direction = WidgetMessage.Direction.ToWidget, + widgetId = widgetId, + requestId = "widgetapi-${clock.epochMillis()}", + action = WidgetMessage.Action.HangUp, + ) + messageInterceptor.sendMessage(WidgetMessageSerializer.serialize(message)) + } + + private fun CoroutineScope.close(widgetDriver: MatrixWidgetDriver?, navigator: CallScreenNavigator) = launch(dispatchers.io) { + navigator.close() + widgetDriver?.close() + } + +} + diff --git a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenState.kt b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenState.kt new file mode 100644 index 0000000000..d9716251fc --- /dev/null +++ b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenState.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.features.call.ui + +import io.element.android.libraries.architecture.Async + +data class CallScreenState( + val urlState: Async, + val userAgent: String, + val isInWidgetMode: Boolean, + val eventSink: (CallScreeEvents) -> Unit, +) diff --git a/features/call/src/main/kotlin/io/element/android/features/call/CallScreenView.kt b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt similarity index 52% rename from features/call/src/main/kotlin/io/element/android/features/call/CallScreenView.kt rename to features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt index 0f5b90cbc8..9ac06a63b7 100644 --- a/features/call/src/main/kotlin/io/element/android/features/call/CallScreenView.kt +++ b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt @@ -14,106 +14,128 @@ * limitations under the License. */ -package io.element.android.features.call +package io.element.android.features.call.ui import android.annotation.SuppressLint import android.view.ViewGroup import android.webkit.PermissionRequest import android.webkit.WebChromeClient import android.webkit.WebView +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidView +import io.element.android.features.call.R +import io.element.android.features.call.utils.WebViewWidgetMessageInterceptor +import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.designsystem.utils.CommonDrawables -import io.element.android.libraries.theme.ElementTheme typealias RequestPermissionCallback = (Array) -> Unit +interface CallScreenNavigator { + fun close() +} + @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun CallScreenView( - url: String?, - userAgent: String, + state: CallScreenState, requestPermissions: (Array, RequestPermissionCallback) -> Unit, - onClose: () -> Unit, modifier: Modifier = Modifier, ) { - ElementTheme { - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.element_call)) }, - navigationIcon = { - BackButton( - resourceId = CommonDrawables.ic_compound_close, - onClick = onClose - ) - } - ) - } - ) { padding -> - CallWebView( - modifier = Modifier - .padding(padding) - .consumeWindowInsets(padding) - .fillMaxSize(), - url = url, - userAgent = userAgent, - onPermissionsRequested = { request -> - val androidPermissions = mapWebkitPermissions(request.resources) - val callback: RequestPermissionCallback = { request.grant(it) } - requestPermissions(androidPermissions.toTypedArray(), callback) + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.element_call)) }, + navigationIcon = { + BackButton( + resourceId = CommonDrawables.ic_compound_close, + onClick = { state.eventSink(CallScreeEvents.Hangup) } + ) } ) } + ) { padding -> + BackHandler { + state.eventSink(CallScreeEvents.Hangup) + } + CallWebView( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + .fillMaxSize(), + url = state.urlState, + userAgent = state.userAgent, + onPermissionsRequested = { request -> + val androidPermissions = mapWebkitPermissions(request.resources) + val callback: RequestPermissionCallback = { request.grant(it) } + requestPermissions(androidPermissions.toTypedArray(), callback) + }, + onWebViewCreated = { webView -> + val interceptor = WebViewWidgetMessageInterceptor(webView) + state.eventSink(CallScreeEvents.SetupMessageChannels(interceptor)) + } + ) } } @Composable private fun CallWebView( - url: String?, + url: Async, userAgent: String, onPermissionsRequested: (PermissionRequest) -> Unit, + onWebViewCreated: (WebView) -> Unit, modifier: Modifier = Modifier, ) { - val isInpectionMode = LocalInspectionMode.current - AndroidView( - modifier = modifier, - factory = { context -> - WebView(context).apply { - if (!isInpectionMode) { - setup(userAgent, onPermissionsRequested) - if (url != null) { - loadUrl(url) - } - } - } - }, - update = { webView -> - if (!isInpectionMode && url != null) { - webView.loadUrl(url) - } - }, - onRelease = { webView -> - webView.destroy() + if (LocalInspectionMode.current) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Text("WebView - can't be previewed") } - ) + } else { + AndroidView( + modifier = modifier, + factory = { context -> + WebView(context).apply { + setup(userAgent, onPermissionsRequested) + if (url is Async.Success) { + loadUrl(url.data) + } + + onWebViewCreated(this) + } + }, + update = { webView -> + if (url is Async.Success && webView.url != url.data) { + webView.loadUrl(url.data) + } + }, + onRelease = { webView -> + webView.destroy() + } + ) + } } @SuppressLint("SetJavaScriptEnabled") -private fun WebView.setup(userAgent: String, onPermissionsRequested: (PermissionRequest) -> Unit) { +private fun WebView.setup( + userAgent: String, + onPermissionsRequested: (PermissionRequest) -> Unit, +) { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT @@ -140,12 +162,15 @@ private fun WebView.setup(userAgent: String, onPermissionsRequested: (Permission @PreviewsDayNight @Composable internal fun CallScreenViewPreview() { - ElementTheme { + ElementPreview { CallScreenView( - url = "https://call.element.io/some-actual-call?with=parameters", - userAgent = "", + state = CallScreenState( + urlState = Async.Success("https://call.element.io/some-actual-call?with=parameters"), + isInWidgetMode = false, + userAgent = "", + eventSink = {}, + ), requestPermissions = { _, _ -> }, - onClose = { }, ) } } diff --git a/features/call/src/main/kotlin/io/element/android/features/call/ElementCallActivity.kt b/features/call/src/main/kotlin/io/element/android/features/call/ui/ElementCallActivity.kt similarity index 72% rename from features/call/src/main/kotlin/io/element/android/features/call/ElementCallActivity.kt rename to features/call/src/main/kotlin/io/element/android/features/call/ui/ElementCallActivity.kt index 481634a4ca..651a2176f3 100644 --- a/features/call/src/main/kotlin/io/element/android/features/call/ElementCallActivity.kt +++ b/features/call/src/main/kotlin/io/element/android/features/call/ui/ElementCallActivity.kt @@ -14,10 +14,12 @@ * limitations under the License. */ -package io.element.android.features.call +package io.element.android.features.call.ui import android.Manifest +import android.content.Context import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.res.Configuration import android.media.AudioAttributes import android.media.AudioFocusRequest @@ -26,20 +28,40 @@ import android.os.Build import android.os.Bundle import android.view.WindowManager import android.webkit.PermissionRequest -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.mutableStateOf +import androidx.core.content.IntentCompat +import com.bumble.appyx.core.integrationpoint.NodeComponentActivity +import io.element.android.features.call.CallForegroundService +import io.element.android.features.call.CallType import io.element.android.features.call.di.CallBindings +import io.element.android.features.call.utils.CallIntentDataParser import io.element.android.libraries.architecture.bindings -import io.element.android.libraries.network.useragent.UserAgentProvider +import io.element.android.libraries.theme.ElementTheme import javax.inject.Inject -class ElementCallActivity : ComponentActivity() { +class ElementCallActivity : NodeComponentActivity(), CallScreenNavigator { + companion object { + private const val EXTRA_CALL_WIDGET_SETTINGS = "EXTRA_CALL_WIDGET_SETTINGS" + + fun start( + context: Context, + callInputs: CallType, + ) { + val intent = Intent(context, ElementCallActivity::class.java).apply { + putExtra(EXTRA_CALL_WIDGET_SETTINGS, callInputs) + addFlags(FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } + } - @Inject lateinit var userAgentProvider: UserAgentProvider @Inject lateinit var callIntentDataParser: CallIntentDataParser + @Inject lateinit var presenterFactory: CallScreenPresenter.Factory + + private lateinit var presenter: CallScreenPresenter private lateinit var audioManager: AudioManager @@ -51,7 +73,7 @@ class ElementCallActivity : ComponentActivity() { private val requestPermissionsLauncher = registerPermissionResultLauncher() private var isDarkMode = false - private val urlState = mutableStateOf(null) + private val webViewTarget = mutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -60,10 +82,7 @@ class ElementCallActivity : ComponentActivity() { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - urlState.value = intent?.dataString?.let(::parseUrl) ?: run { - finish() - return - } + setCallType(intent) if (savedInstanceState == null) { updateUiMode(resources.configuration) @@ -72,18 +91,17 @@ class ElementCallActivity : ComponentActivity() { audioManager = getSystemService(AUDIO_SERVICE) as AudioManager requestAudioFocus() - val userAgent = userAgentProvider.provide() - setContent { - CallScreenView( - url = urlState.value, - userAgent = userAgent, - onClose = this::finish, - requestPermissions = { permissions, callback -> - requestPermissionCallback = callback - requestPermissionsLauncher.launch(permissions) - } - ) + val state = presenter.present() + ElementTheme { + CallScreenView( + state = state, + requestPermissions = { permissions, callback -> + requestPermissionCallback = callback + requestPermissionsLauncher.launch(permissions) + } + ) + } } } @@ -96,15 +114,7 @@ class ElementCallActivity : ComponentActivity() { override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) - val intentUrl = intent?.dataString?.let(::parseUrl) - when { - // New URL, update it and reload the webview - intentUrl != null -> urlState.value = intentUrl - // Re-opened the activity but we have no url to load or a cached one, finish the activity - intent?.dataString == null && urlState.value == null -> finish() - // Coming back from notification, do nothing - else -> return - } + setCallType(intent) } override fun onStart() { @@ -130,6 +140,32 @@ class ElementCallActivity : ComponentActivity() { finishAndRemoveTask() } + override fun close() { + finish() + } + + private fun setCallType(intent: Intent?) { + val inputs = intent?.let { + IntentCompat.getParcelableExtra(it, EXTRA_CALL_WIDGET_SETTINGS, CallType::class.java) + } + val intentUrl = intent?.dataString?.let(::parseUrl) + when { + // Re-opened the activity but we have no url to load or a cached one, finish the activity + intent?.dataString == null && inputs == null && webViewTarget.value == null -> finish() + inputs != null -> { + webViewTarget.value = inputs + presenter = presenterFactory.create(inputs, this) + } + intentUrl != null -> { + val fallbackInputs = CallType.ExternalUrl(intentUrl) + webViewTarget.value = fallbackInputs + presenter = presenterFactory.create(fallbackInputs, this) + } + // Coming back from notification, do nothing + else -> return + } + } + private fun parseUrl(url: String?): String? = callIntentDataParser.parse(url) private fun registerPermissionResultLauncher(): ActivityResultLauncher> { diff --git a/features/call/src/main/kotlin/io/element/android/features/call/CallIntentDataParser.kt b/features/call/src/main/kotlin/io/element/android/features/call/utils/CallIntentDataParser.kt similarity index 98% rename from features/call/src/main/kotlin/io/element/android/features/call/CallIntentDataParser.kt rename to features/call/src/main/kotlin/io/element/android/features/call/utils/CallIntentDataParser.kt index b903b437d8..0814216745 100644 --- a/features/call/src/main/kotlin/io/element/android/features/call/CallIntentDataParser.kt +++ b/features/call/src/main/kotlin/io/element/android/features/call/utils/CallIntentDataParser.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.call +package io.element.android.features.call.utils import android.net.Uri import javax.inject.Inject diff --git a/features/call/src/main/kotlin/io/element/android/features/call/utils/CallWidgetProvider.kt b/features/call/src/main/kotlin/io/element/android/features/call/utils/CallWidgetProvider.kt new file mode 100644 index 0000000000..b65298854d --- /dev/null +++ b/features/call/src/main/kotlin/io/element/android/features/call/utils/CallWidgetProvider.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.features.call.utils + +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.widget.MatrixWidgetDriver + +interface CallWidgetProvider { + suspend fun getWidget( + sessionId: SessionId, + roomId: RoomId, + clientId: String, + languageTag: String? = null, + theme: String? = null, + ): Result> +} diff --git a/features/call/src/main/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProvider.kt b/features/call/src/main/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProvider.kt new file mode 100644 index 0000000000..f3cb9cbcd5 --- /dev/null +++ b/features/call/src/main/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProvider.kt @@ -0,0 +1,50 @@ +/* + * 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.call.utils + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.appconfig.ElementCallConfig +import io.element.android.features.preferences.api.store.PreferencesStore +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider +import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver +import kotlinx.coroutines.flow.firstOrNull +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultCallWidgetProvider @Inject constructor( + private val matrixClientsProvider: MatrixClientProvider, + private val preferencesStore: PreferencesStore, + private val callWidgetSettingsProvider: CallWidgetSettingsProvider, +) : CallWidgetProvider { + override suspend fun getWidget( + sessionId: SessionId, + roomId: RoomId, + clientId: String, + languageTag: String?, + theme: String?, + ): Result> = runCatching { + val room = matrixClientsProvider.getOrRestore(sessionId).getOrThrow().getRoom(roomId) ?: error("Room not found") + val baseUrl = preferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull() ?: ElementCallConfig.DEFAULT_BASE_URL + val widgetSettings = callWidgetSettingsProvider.provide(baseUrl) + val callUrl = room.generateWidgetWebViewUrl(widgetSettings, clientId, languageTag, theme).getOrThrow() + room.getWidgetDriver(widgetSettings).getOrThrow() to callUrl + } +} diff --git a/features/call/src/main/kotlin/io/element/android/features/call/utils/WebViewWidgetMessageInterceptor.kt b/features/call/src/main/kotlin/io/element/android/features/call/utils/WebViewWidgetMessageInterceptor.kt new file mode 100644 index 0000000000..bdb6ee48f5 --- /dev/null +++ b/features/call/src/main/kotlin/io/element/android/features/call/utils/WebViewWidgetMessageInterceptor.kt @@ -0,0 +1,100 @@ +/* + * 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.call.utils + +import android.graphics.Bitmap +import android.webkit.JavascriptInterface +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import io.element.android.features.call.BuildConfig +import kotlinx.coroutines.flow.MutableSharedFlow + +class WebViewWidgetMessageInterceptor( + private val webView: WebView, +) : WidgetMessageInterceptor { + + companion object { + // We call both the WebMessageListener and the JavascriptInterface objects in JS with this + // 'listenerName' so they can both receive the data from the WebView when + // `${LISTENER_NAME}.postMessage(...)` is called + const val LISTENER_NAME = "elementX" + } + + override val interceptedMessages = MutableSharedFlow(replay = 1, extraBufferCapacity = 2) + + init { + webView.webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + + // We inject this JS code when the page starts loading to attach a message listener to the window. + // This listener will receive both messages: + // - EC widget API -> Element X (message.data.api == "fromWidget") + // - Element X -> EC widget API (message.data.api == "toWidget"), we should ignore these + view?.evaluateJavascript( + """ + window.addEventListener('message', function(event) { + let message = {data: event.data, origin: event.origin} + if (message.data.response && message.data.api == "toWidget" + || !message.data.response && message.data.api == "fromWidget") { + let json = JSON.stringify(event.data) + ${"console.log('message sent: ' + json);".takeIf { BuildConfig.DEBUG } } + ${LISTENER_NAME}.postMessage(json); + } else { + ${"console.log('message received (ignored): ' + JSON.stringify(event.data));".takeIf { BuildConfig.DEBUG } } + } + }); + """.trimIndent(), + null + ) + } + } + + // Create a WebMessageListener, which will receive messages from the WebView and reply to them + val webMessageListener = WebViewCompat.WebMessageListener { _, message, _, _, _ -> + onMessageReceived(message.data) + } + + // Use WebMessageListener if supported, otherwise use JavascriptInterface + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + WebViewCompat.addWebMessageListener( + webView, + LISTENER_NAME, + setOf("*"), + webMessageListener + ) + } else { + webView.addJavascriptInterface(object { + @JavascriptInterface + fun postMessage(json: String?) { + onMessageReceived(json) + } + }, LISTENER_NAME) + } + } + + override fun sendMessage(message: String) { + webView.evaluateJavascript("postMessage($message, '*')", null) + } + + private fun onMessageReceived(json: String?) { + // Here is where we would handle the messages from the WebView, passing them to the Rust SDK + json?.let { interceptedMessages.tryEmit(it) } + } +} diff --git a/features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageInterceptor.kt b/features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageInterceptor.kt new file mode 100644 index 0000000000..fa5c3bea67 --- /dev/null +++ b/features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageInterceptor.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.features.call.utils + +import kotlinx.coroutines.flow.Flow + +interface WidgetMessageInterceptor { + val interceptedMessages: Flow + fun sendMessage(message: String) +} diff --git a/features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageSerializer.kt b/features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageSerializer.kt new file mode 100644 index 0000000000..5ed9db028c --- /dev/null +++ b/features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageSerializer.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.call.utils + +import io.element.android.features.call.data.WidgetMessage +import kotlinx.serialization.json.Json + +object WidgetMessageSerializer { + + private val coder = Json { ignoreUnknownKeys = true } + + fun deserialize(message: String): Result { + return runCatching { coder.decodeFromString(WidgetMessage.serializer(), message) } + } + + fun serialize(message: WidgetMessage): String { + return coder.encodeToString(WidgetMessage.serializer(), message) + } +} diff --git a/features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt index f82e31c068..55b5f16771 100644 --- a/features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt +++ b/features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt @@ -19,6 +19,7 @@ package io.element.android.features.call import android.Manifest import android.webkit.PermissionRequest import com.google.common.truth.Truth.assertThat +import io.element.android.features.call.ui.mapWebkitPermissions import org.junit.Test class MapWebkitPermissionsTest { diff --git a/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt new file mode 100644 index 0000000000..c318b1dfaa --- /dev/null +++ b/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt @@ -0,0 +1,194 @@ +/* + * 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.call.ui + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.call.CallType +import io.element.android.features.call.utils.FakeCallWidgetProvider +import io.element.android.features.call.utils.FakeWidgetMessageInterceptor +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver +import io.element.android.libraries.network.useragent.UserAgentProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class CallScreenPresenterTest { + + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - with CallType ExternalUrl just loads the URL`() = runTest { + val presenter = createCallScreenPresenter(CallType.ExternalUrl("https://call.element.io")) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Wait until the URL is loaded + skipItems(1) + + val initialState = awaitItem() + assertThat(initialState.urlState).isEqualTo(Async.Success("https://call.element.io")) + assertThat(initialState.isInWidgetMode).isFalse() + } + } + + @Test + fun `present - with CallType RoomCall loads URL and runs WidgetDriver`() = runTest { + val widgetDriver = FakeWidgetDriver() + val widgetProvider = FakeCallWidgetProvider(widgetDriver) + val presenter = createCallScreenPresenter( + callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), + widgetDriver = widgetDriver, + widgetProvider = widgetProvider, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Wait until the URL is loaded + skipItems(1) + + val initialState = awaitItem() + assertThat(initialState.urlState).isInstanceOf(Async.Success::class.java) + assertThat(initialState.isInWidgetMode).isTrue() + assertThat(widgetProvider.getWidgetCalled).isTrue() + assertThat(widgetDriver.runCalledCount).isEqualTo(1) + } + } + + @Test + fun `present - set message interceptor, send and receive messages`() = runTest { + val widgetDriver = FakeWidgetDriver() + val presenter = createCallScreenPresenter( + callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), + widgetDriver = widgetDriver, + ) + val messageInterceptor = FakeWidgetMessageInterceptor() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(CallScreeEvents.SetupMessageChannels(messageInterceptor)) + + // And incoming message from the Widget Driver is passed to the WebView + widgetDriver.givenIncomingMessage("A message") + assertThat(messageInterceptor.sentMessages).containsExactly("A message") + + // And incoming message from the WebView is passed to the Widget Driver + messageInterceptor.givenInterceptedMessage("A reply") + assertThat(widgetDriver.sentMessages).containsExactly("A reply") + + cancelAndIgnoreRemainingEvents() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - hang up event closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) { + val navigator = FakeCallScreenNavigator() + val widgetDriver = FakeWidgetDriver() + val presenter = createCallScreenPresenter( + callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), + widgetDriver = widgetDriver, + navigator = navigator, + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + ) + val messageInterceptor = FakeWidgetMessageInterceptor() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(CallScreeEvents.SetupMessageChannels(messageInterceptor)) + + initialState.eventSink(CallScreeEvents.Hangup) + + // Let background coroutines run + runCurrent() + + assertThat(navigator.closeCalled).isTrue() + assertThat(widgetDriver.closeCalledCount).isEqualTo(1) + + cancelAndIgnoreRemainingEvents() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - a received hang up message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) { + val navigator = FakeCallScreenNavigator() + val widgetDriver = FakeWidgetDriver() + val presenter = createCallScreenPresenter( + callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), + widgetDriver = widgetDriver, + navigator = navigator, + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + ) + val messageInterceptor = FakeWidgetMessageInterceptor() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(CallScreeEvents.SetupMessageChannels(messageInterceptor)) + + messageInterceptor.givenInterceptedMessage("""{"action":"im.vector.hangup","api":"fromWidget","widgetId":"1","requestId":"1"}""") + + // Let background coroutines run + runCurrent() + + assertThat(navigator.closeCalled).isTrue() + assertThat(widgetDriver.closeCalledCount).isEqualTo(1) + + cancelAndIgnoreRemainingEvents() + } + } + + private fun TestScope.createCallScreenPresenter( + callType: CallType, + navigator: CallScreenNavigator = FakeCallScreenNavigator(), + widgetDriver: FakeWidgetDriver = FakeWidgetDriver(), + widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver), + dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), + ): CallScreenPresenter { + val userAgentProvider = object : UserAgentProvider { + override fun provide(): String { + return "Test" + } + } + val clock = SystemClock { 0 } + return CallScreenPresenter( + callType, + navigator, + widgetProvider, + userAgentProvider, + clock, + dispatchers, + ) + } +} diff --git a/features/call/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt b/features/call/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt new file mode 100644 index 0000000000..498503cb15 --- /dev/null +++ b/features/call/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.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.features.call.ui + +class FakeCallScreenNavigator : CallScreenNavigator { + var closeCalled = false + private set + + override fun close() { + closeCalled = true + } + } diff --git a/features/call/src/test/kotlin/io/element/android/features/call/CallIntentDataParserTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt similarity index 99% rename from features/call/src/test/kotlin/io/element/android/features/call/CallIntentDataParserTest.kt rename to features/call/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt index ae82767f45..eb8e756182 100644 --- a/features/call/src/test/kotlin/io/element/android/features/call/CallIntentDataParserTest.kt +++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.call +package io.element.android.features.call.utils import com.google.common.truth.Truth.assertThat import org.junit.Test diff --git a/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt new file mode 100644 index 0000000000..f7f17d794d --- /dev/null +++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt @@ -0,0 +1,121 @@ +/* + * 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.call.utils + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.preferences.api.store.PreferencesStore +import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.widget.FakeCallWidgetSettingsProvider +import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultCallWidgetProviderTest { + + @Test + fun `getWidget - fails if the session does not exist`() = runTest { + val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.failure(Exception("Session not found")) }) + assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue() + } + + @Test + fun `getWidget - fails if the room does not exist`() = runTest { + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, null) + } + val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }) + assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue() + } + + @Test + fun `getWidget - fails if it can't generate the URL for the widget`() = runTest { + val room = FakeMatrixRoom().apply { + givenGenerateWidgetWebViewUrlResult(Result.failure(Exception("Can't generate URL for widget"))) + } + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }) + assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue() + } + + @Test + fun `getWidget - fails if it can't get the widget driver`() = runTest { + val room = FakeMatrixRoom().apply { + givenGenerateWidgetWebViewUrlResult(Result.success("url")) + givenGetWidgetDriverResult(Result.failure(Exception("Can't get a widget driver"))) + } + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }) + assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue() + } + + @Test + fun `getWidget - returns a widget driver when all steps are successful`() = runTest { + val room = FakeMatrixRoom().apply { + givenGenerateWidgetWebViewUrlResult(Result.success("url")) + givenGetWidgetDriverResult(Result.success(FakeWidgetDriver())) + } + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }) + assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").getOrNull()).isNotNull() + } + + @Test + fun `getWidget - will use a custom base url if it exists`() = runTest { + val room = FakeMatrixRoom().apply { + givenGenerateWidgetWebViewUrlResult(Result.success("url")) + givenGetWidgetDriverResult(Result.success(FakeWidgetDriver())) + } + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val preferencesStore = InMemoryPreferencesStore().apply { + setCustomElementCallBaseUrl("https://custom.element.io") + } + val settingsProvider = FakeCallWidgetSettingsProvider() + val provider = createProvider( + matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }, + callWidgetSettingsProvider = settingsProvider, + preferencesStore = preferencesStore, + ) + provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme") + + assertThat(settingsProvider.providedBaseUrls).containsExactly("https://custom.element.io") + } + + private fun createProvider( + matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), + preferencesStore: PreferencesStore = InMemoryPreferencesStore(), + callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider() + ) = DefaultCallWidgetProvider( + matrixClientProvider, + preferencesStore, + callWidgetSettingsProvider, + ) +} diff --git a/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt new file mode 100644 index 0000000000..69ae340648 --- /dev/null +++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.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.call.utils + +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.widget.MatrixWidgetDriver +import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver + +class FakeCallWidgetProvider( + private val widgetDriver: FakeWidgetDriver = FakeWidgetDriver(), + private val url: String = "https://call.element.io", + ) : CallWidgetProvider { + + var getWidgetCalled = false + private set + + override suspend fun getWidget( + sessionId: SessionId, + roomId: RoomId, + clientId: String, + languageTag: String?, + theme: String? + ): Result> { + getWidgetCalled = true + return Result.success(widgetDriver to url) + } + } diff --git a/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt new file mode 100644 index 0000000000..6e36dfff81 --- /dev/null +++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.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.call.utils + +import kotlinx.coroutines.flow.MutableSharedFlow + +class FakeWidgetMessageInterceptor : WidgetMessageInterceptor { + val sentMessages = mutableListOf() + + override val interceptedMessages = MutableSharedFlow(extraBufferCapacity = 1) + + override fun sendMessage(message: String) { + sentMessages += message + } + + fun givenInterceptedMessage(message: String) { + interceptedMessages.tryEmit(message) + } + } diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts index ae13197f05..6f4e959499 100644 --- a/features/login/impl/build.gradle.kts +++ b/features/login/impl/build.gradle.kts @@ -38,6 +38,7 @@ anvil { dependencies { implementation(projects.anvilannotations) + implementation(projects.appconfig) anvil(projects.anvilcodegen) implementation(projects.libraries.core) implementation(projects.libraries.androidutils) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt index 35fd7246f2..0d3b9c5dc3 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt @@ -17,7 +17,7 @@ package io.element.android.features.login.impl.accountprovider import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.login.impl.util.LoginConstants +import io.element.android.appconfig.AuthenticationConfig open class AccountProviderProvider : PreviewParameterProvider { override val values: Sequence @@ -32,7 +32,7 @@ open class AccountProviderProvider : PreviewParameterProvider { } fun anAccountProvider() = AccountProvider( - url = LoginConstants.MATRIX_ORG_URL, + url = AuthenticationConfig.MATRIX_ORG_URL, subtitle = "Matrix.org is an open network for secure, decentralized communication.", isPublic = true, isMatrixOrg = true, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt index 786d8aaeae..96fc115cfa 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt @@ -17,9 +17,9 @@ package io.element.android.features.login.impl.screens.changeaccountprovider import androidx.compose.runtime.Composable +import io.element.android.appconfig.AuthenticationConfig import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.changeserver.ChangeServerPresenter -import io.element.android.features.login.impl.util.LoginConstants import io.element.android.libraries.architecture.Presenter import javax.inject.Inject @@ -34,7 +34,7 @@ class ChangeAccountProviderPresenter @Inject constructor( // Just matrix.org by default for now accountProviders = listOf( AccountProvider( - url = LoginConstants.MATRIX_ORG_URL, + url = AuthenticationConfig.MATRIX_ORG_URL, subtitle = null, isPublic = true, isMatrixOrg = true, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt index 8dce1bd78e..50b24b3964 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt @@ -17,9 +17,9 @@ package io.element.android.features.login.impl.screens.searchaccountprovider import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.appconfig.AuthenticationConfig import io.element.android.features.login.impl.changeserver.aChangeServerState import io.element.android.features.login.impl.resolver.HomeserverData -import io.element.android.features.login.impl.util.LoginConstants import io.element.android.libraries.architecture.Async open class SearchAccountProviderStateProvider : PreviewParameterProvider { @@ -50,7 +50,7 @@ fun aHomeserverDataList(): List { } fun aHomeserverData( - homeserverUrl: String = LoginConstants.MATRIX_ORG_URL, + homeserverUrl: String = AuthenticationConfig.MATRIX_ORG_URL, isWellknownValid: Boolean = true, supportSlidingSync: Boolean = true, ): HomeserverData { diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt index 29781acff1..47dfe248b7 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt @@ -14,12 +14,11 @@ * limitations under the License. */ -@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@file:OptIn(ExperimentalMaterial3Api::class) package io.element.android.features.login.impl.screens.searchaccountprovider import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -48,13 +47,13 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import io.element.android.appconfig.AuthenticationConfig import io.element.android.features.login.impl.R import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProviderView import io.element.android.features.login.impl.changeserver.ChangeServerEvents import io.element.android.features.login.impl.changeserver.ChangeServerView import io.element.android.features.login.impl.resolver.HomeserverData -import io.element.android.features.login.impl.util.LoginConstants import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.components.button.BackButton @@ -196,7 +195,7 @@ fun SearchAccountProviderView( @Composable private fun HomeserverData.toAccountProvider(): AccountProvider { - val isMatrixOrg = homeserverUrl == LoginConstants.MATRIX_ORG_URL + val isMatrixOrg = homeserverUrl == AuthenticationConfig.MATRIX_ORG_URL return AccountProvider( url = homeserverUrl, subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt index 98fd62d7b0..91c19e4052 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt @@ -16,18 +16,12 @@ package io.element.android.features.login.impl.util +import io.element.android.appconfig.AuthenticationConfig import io.element.android.features.login.impl.accountprovider.AccountProvider -object LoginConstants { - const val MATRIX_ORG_URL = "https://matrix.org" - - const val DEFAULT_HOMESERVER_URL = "https://matrix.org" - const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md" -} - val defaultAccountProvider = AccountProvider( - url = LoginConstants.DEFAULT_HOMESERVER_URL, + url = AuthenticationConfig.DEFAULT_HOMESERVER_URL, subtitle = null, - isPublic = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL, - isMatrixOrg = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL, + isPublic = AuthenticationConfig.DEFAULT_HOMESERVER_URL == AuthenticationConfig.MATRIX_ORG_URL, + isMatrixOrg = AuthenticationConfig.DEFAULT_HOMESERVER_URL == AuthenticationConfig.MATRIX_ORG_URL, ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt index 261b02c1b8..6726105bce 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt @@ -19,9 +19,10 @@ package io.element.android.features.login.impl.util import android.content.Context import android.content.Intent import android.net.Uri +import io.element.android.appconfig.AuthenticationConfig import io.element.android.libraries.core.data.tryOrNull fun openLearnMorePage(context: Context) { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(LoginConstants.SLIDING_SYNC_READ_MORE_URL)) + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(AuthenticationConfig.SLIDING_SYNC_READ_MORE_URL)) tryOrNull { context.startActivity(intent) } } diff --git a/features/logout/api/src/main/res/values/localazy.xml b/features/logout/api/src/main/res/values/localazy.xml index 9ea4bb77fd..5a5c9c64ca 100644 --- a/features/logout/api/src/main/res/values/localazy.xml +++ b/features/logout/api/src/main/res/values/localazy.xml @@ -1,8 +1,12 @@ + "Please wait for this to complete before signing out." + "Your keys are still being backed up" "Are you sure you want to sign out?" "Sign out" "Signing out…" + "You are about to sign out of your last session. If you sign out now, you might lose access to your encrypted messages." + "Have you saved your recovery key?" "Sign out" "Sign out" diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index e886a3aeaa..956b80949a 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(projects.anvilannotations) anvil(projects.anvilcodegen) api(projects.features.messages.api) + implementation(projects.features.call) implementation(projects.features.location.api) implementation(projects.features.poll.api) implementation(projects.libraries.androidutils) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 21e384906e..128c531374 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -16,6 +16,7 @@ package io.element.android.features.messages.impl +import android.content.Context import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -29,6 +30,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.features.call.CallType +import io.element.android.features.call.ui.ElementCallActivity import io.element.android.features.location.api.Location import io.element.android.features.location.api.SendLocationEntryPoint import io.element.android.features.location.api.ShowLocationEntryPoint @@ -50,7 +53,9 @@ import io.element.android.features.poll.api.create.CreatePollEntryPoint 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.ApplicationContext import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId @@ -63,6 +68,8 @@ import kotlinx.parcelize.Parcelize class MessagesFlowNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, + @ApplicationContext private val context: Context, + private val matrixClient: MatrixClient, private val sendLocationEntryPoint: SendLocationEntryPoint, private val showLocationEntryPoint: ShowLocationEntryPoint, private val createPollEntryPoint: CreatePollEntryPoint, @@ -149,6 +156,14 @@ class MessagesFlowNode @AssistedInject constructor( override fun onCreatePollClicked() { backstack.push(NavTarget.CreatePoll) } + + override fun onJoinCallClicked(roomId: RoomId) { + val inputs = CallType.RoomCall( + sessionId = matrixClient.sessionId, + roomId = roomId, + ) + ElementCallActivity.start(context, inputs) + } } createNode(buildContext, listOf(callback)) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index dbf7e2fbb2..50b59afbcc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPr import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo @@ -63,6 +64,7 @@ class MessagesNode @AssistedInject constructor( fun onReportMessage(eventId: EventId, senderId: UserId) fun onSendLocationClicked() fun onCreatePollClicked() + fun onJoinCallClicked(roomId: RoomId) } init { @@ -108,6 +110,10 @@ class MessagesNode @AssistedInject constructor( callback?.onCreatePollClicked() } + private fun onJoinCallClicked() { + callback?.onJoinCallClicked(room.roomId) + } + @Composable override fun View(modifier: Modifier) { CompositionLocalProvider( @@ -123,6 +129,7 @@ class MessagesNode @AssistedInject constructor( onUserDataClicked = this::onUserDataClicked, onSendLocationClicked = this::onSendLocationClicked, onCreatePollClicked = this::onCreatePollClicked, + onJoinCallClicked = this::onJoinCallClicked, modifier = modifier, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 8645553f0b..6125e920b8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -152,8 +152,10 @@ class MessagesPresenter @AssistedInject constructor( val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true) var enableVoiceMessages by remember { mutableStateOf(false) } + var enableInRoomCalls by remember { mutableStateOf(false) } LaunchedEffect(featureFlagsService) { enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages) + enableInRoomCalls = featureFlagsService.isFeatureEnabled(FeatureFlags.InRoomCalls) } fun handleEvents(event: MessagesEvents) { @@ -200,6 +202,7 @@ class MessagesPresenter @AssistedInject constructor( inviteProgress = inviteProgress.value, enableTextFormatting = enableTextFormatting, enableVoiceMessages = enableVoiceMessages, + enableInRoomCalls = enableInRoomCalls, eventSink = { handleEvents(it) } ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 0a121b50a3..3a0585f390 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -49,5 +49,6 @@ data class MessagesState( val showReinvitePrompt: Boolean, val enableTextFormatting: Boolean, val enableVoiceMessages: Boolean, + val enableInRoomCalls: Boolean, val eventSink: (MessagesEvents) -> Unit ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 3b0b87ea39..4222a0889d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -85,5 +85,6 @@ fun aMessagesState() = MessagesState( showReinvitePrompt = false, enableTextFormatting = true, enableVoiceMessages = true, + enableInRoomCalls = true, eventSink = {} ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index b79e84a2e0..5a7168e7ce 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -76,9 +76,12 @@ import io.element.android.libraries.designsystem.components.dialogs.Confirmation import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton 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.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState @@ -99,6 +102,7 @@ fun MessagesView( onPreviewAttachments: (ImmutableList) -> Unit, onSendLocationClicked: () -> Unit, onCreatePollClicked: () -> Unit, + onJoinCallClicked: () -> Unit, modifier: Modifier = Modifier, ) { LogCompositions(tag = "MessagesScreen", msg = "Root") @@ -160,8 +164,10 @@ fun MessagesView( MessagesViewTopBar( roomName = state.roomName.dataOrNull(), roomAvatar = state.roomAvatar.dataOrNull(), + inRoomCallsEnabled = state.enableInRoomCalls, onBackPressed = onBackPressed, onRoomDetailsClicked = onRoomDetailsClicked, + onJoinCallClicked = onJoinCallClicked, ) } }, @@ -349,8 +355,10 @@ private fun MessagesViewContent( private fun MessagesViewTopBar( roomName: String?, roomAvatar: AvatarData?, + inRoomCallsEnabled: Boolean, modifier: Modifier = Modifier, onRoomDetailsClicked: () -> Unit = {}, + onJoinCallClicked: () -> Unit = {}, onBackPressed: () -> Unit = {}, ) { TopAppBar( @@ -373,6 +381,13 @@ private fun MessagesViewTopBar( ) } }, + actions = { + if (inRoomCallsEnabled) { + IconButton(onClick = onJoinCallClicked) { + Icon(CommonDrawables.ic_compound_video_call, contentDescription = null) // TODO add proper content description once we have the state + } + } + }, windowInsets = WindowInsets(0.dp) ) } @@ -432,5 +447,6 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class) onUserDataClicked = {}, onSendLocationClicked = {}, onCreatePollClicked = {}, + onJoinCallClicked = {}, ) } diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index a227d24b8b..4fcc69ff6b 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(projects.anvilannotations) anvil(projects.anvilcodegen) implementation(projects.libraries.androidutils) + implementation(projects.appconfig) implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt index 37641d684c..fea42baf5f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt @@ -19,4 +19,5 @@ package io.element.android.features.preferences.impl.advanced sealed interface AdvancedSettingsEvents { data class SetRichTextEditorEnabled(val enabled: Boolean) : AdvancedSettingsEvents data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents + data class SetCustomElementCallBaseUrl(val baseUrl: String?) : AdvancedSettingsEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt index 5738fe43c8..6359b34d0f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt @@ -17,16 +17,25 @@ package io.element.android.features.preferences.impl.advanced 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.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import io.element.android.appconfig.ElementCallConfig import io.element.android.features.preferences.api.store.PreferencesStore import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import kotlinx.coroutines.launch +import java.net.URL import javax.inject.Inject class AdvancedSettingsPresenter @Inject constructor( private val preferencesStore: PreferencesStore, + private val featureFlagService: FeatureFlagService, ) : Presenter { @Composable @@ -38,6 +47,14 @@ class AdvancedSettingsPresenter @Inject constructor( val isDeveloperModeEnabled by preferencesStore .isDeveloperModeEnabledFlow() .collectAsState(initial = false) + val customElementCallBaseUrl by preferencesStore + .getCustomElementCallBaseUrlFlow() + .collectAsState(initial = null) + + var canDisplayElementCallSettings by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + canDisplayElementCallSettings = featureFlagService.isFeatureEnabled(FeatureFlags.InRoomCalls) + } fun handleEvents(event: AdvancedSettingsEvents) { when (event) { @@ -47,13 +64,34 @@ class AdvancedSettingsPresenter @Inject constructor( is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch { preferencesStore.setDeveloperModeEnabled(event.enabled) } + is AdvancedSettingsEvents.SetCustomElementCallBaseUrl -> localCoroutineScope.launch { + // If the URL is either empty or the default one, we want to save 'null' to remove the custom URL + val urlToSave = event.baseUrl.takeIf { !it.isNullOrEmpty() && it != ElementCallConfig.DEFAULT_BASE_URL } + preferencesStore.setCustomElementCallBaseUrl(urlToSave) + } } } return AdvancedSettingsState( isRichTextEditorEnabled = isRichTextEditorEnabled, isDeveloperModeEnabled = isDeveloperModeEnabled, + customElementCallBaseUrlState = if (canDisplayElementCallSettings) { + CustomElementCallBaseUrlState( + baseUrl = customElementCallBaseUrl, + defaultUrl = ElementCallConfig.DEFAULT_BASE_URL, + validator = ::customElementCallUrlValidator, + ) + } else null, eventSink = ::handleEvents ) } + + private fun customElementCallUrlValidator(url: String?): Boolean { + return runCatching { + if (url.isNullOrEmpty()) return@runCatching + val parsedUrl = URL(url) + if (parsedUrl.protocol !in listOf("http", "https")) error("Incorrect protocol") + if (parsedUrl.host.isNullOrBlank()) error("Missing host") + }.isSuccess + } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt index 19625b9ebc..cd56078b27 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt @@ -16,8 +16,15 @@ package io.element.android.features.preferences.impl.advanced -data class AdvancedSettingsState constructor( +data class AdvancedSettingsState( val isRichTextEditorEnabled: Boolean, val isDeveloperModeEnabled: Boolean, + val customElementCallBaseUrlState: CustomElementCallBaseUrlState?, val eventSink: (AdvancedSettingsEvents) -> Unit ) + +data class CustomElementCallBaseUrlState( + val baseUrl: String?, + val defaultUrl: String, + val validator: (String?) -> Boolean, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt index 5ab50c8a16..d3a2dee3f4 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt @@ -24,14 +24,17 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider Unit, modifier: Modifier = Modifier, ) { + fun isUsingDefaultUrl(value: String?): Boolean { + val defaultUrl = state.customElementCallBaseUrlState?.defaultUrl ?: return false + return value.isNullOrEmpty() || value == defaultUrl + } + PreferencePage( modifier = modifier, onBackPressed = onBackPressed, @@ -50,6 +58,23 @@ fun AdvancedSettingsView( isChecked = state.isDeveloperModeEnabled, onCheckedChange = { state.eventSink(AdvancedSettingsEvents.SetDeveloperModeEnabled(it)) }, ) + state.customElementCallBaseUrlState?.let { callUrlState -> + val supportingText = if (isUsingDefaultUrl(callUrlState.baseUrl)) { + stringResource(R.string.screen_advanced_settings_element_call_base_url_description) + } else { + callUrlState.baseUrl + } + PreferenceTextField( + headline = stringResource(R.string.screen_advanced_settings_element_call_base_url), + value = callUrlState.baseUrl ?: callUrlState.defaultUrl, + supportingText = supportingText, + validation = callUrlState.validator, + onValidationErrorMessage = stringResource(R.string.screen_advanced_settings_element_call_base_url_validation_error), + displayValue = { value -> !isUsingDefaultUrl(value) }, + keyboardOptions = KeyboardOptions.Default.copy(autoCorrect = false, keyboardType = KeyboardType.Uri), + onChange = { state.eventSink(AdvancedSettingsEvents.SetCustomElementCallBaseUrl(it)) } + ) + } } } diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml index b94db7a565..1ca7071436 100644 --- a/features/preferences/impl/src/main/res/values/localazy.xml +++ b/features/preferences/impl/src/main/res/values/localazy.xml @@ -1,5 +1,8 @@ + "Custom Element Call base URL" + "Set a custom base URL for Element Call." + "Invalid URL, please make sure you include the protocol (http/https) and the correct address." "Developer mode" "Enable to have access to features and functionality for developers." "Disable the rich text editor to type Markdown manually." diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt index 76808ee5f9..11c79657ce 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt @@ -20,6 +20,8 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest @@ -34,7 +36,8 @@ class AdvancedSettingsPresenterTest { @Test fun `present - initial state`() = runTest { val store = InMemoryPreferencesStore() - val presenter = AdvancedSettingsPresenter(store) + val featureFlagService = FakeFeatureFlagService() + val presenter = AdvancedSettingsPresenter(store, featureFlagService) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -47,7 +50,8 @@ class AdvancedSettingsPresenterTest { @Test fun `present - developer mode on off`() = runTest { val store = InMemoryPreferencesStore() - val presenter = AdvancedSettingsPresenter(store) + val featureFlagService = FakeFeatureFlagService() + val presenter = AdvancedSettingsPresenter(store, featureFlagService) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -63,7 +67,8 @@ class AdvancedSettingsPresenterTest { @Test fun `present - rich text editor on off`() = runTest { val store = InMemoryPreferencesStore() - val presenter = AdvancedSettingsPresenter(store) + val featureFlagService = FakeFeatureFlagService() + val presenter = AdvancedSettingsPresenter(store, featureFlagService) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -75,4 +80,64 @@ class AdvancedSettingsPresenterTest { assertThat(awaitItem().isRichTextEditorEnabled).isFalse() } } + + @Test + fun `present - custom element call url state is null if the feature flag is disabled`() = runTest { + val store = InMemoryPreferencesStore() + val featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.InRoomCalls, false) + } + val presenter = AdvancedSettingsPresenter(store, featureFlagService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.customElementCallBaseUrlState).isNull() + } + } + + @Test + fun `present - custom element call base url`() = runTest { + val store = InMemoryPreferencesStore() + val featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.InRoomCalls, true) + } + val presenter = AdvancedSettingsPresenter(store, featureFlagService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Initial state has a default `false` feature flag value, so the state will still be null + skipItems(1) + + val initialState = awaitItem() + assertThat(initialState.customElementCallBaseUrlState).isNotNull() + assertThat(initialState.customElementCallBaseUrlState?.baseUrl).isNull() + + initialState.eventSink(AdvancedSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.dev")) + val updatedItem = awaitItem() + assertThat(updatedItem.customElementCallBaseUrlState?.baseUrl).isEqualTo("https://call.element.dev") + } + } + + @Test + fun `present - custom element call base url validator needs at least an HTTP scheme and host`() = runTest { + val store = InMemoryPreferencesStore() + val featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.InRoomCalls, true) + } + val presenter = AdvancedSettingsPresenter(store, featureFlagService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Initial state has a default `false` feature flag value, so the state will still be null + skipItems(1) + + val urlValidator = awaitItem().customElementCallBaseUrlState!!.validator + assertThat(urlValidator("")).isTrue() // We allow empty string to clear the value and use the default one + assertThat(urlValidator("test")).isFalse() + assertThat(urlValidator("http://")).isFalse() + assertThat(urlValidator("geo://test")).isFalse() + assertThat(urlValidator("https://call.element.io")).isTrue() + } + } } diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Bindings.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Bindings.kt index e4a6d7ae7d..f8b6cee0b3 100644 --- a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Bindings.kt +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Bindings.kt @@ -21,6 +21,7 @@ import android.content.ContextWrapper import com.bumble.appyx.core.node.Node import io.element.android.libraries.di.DaggerComponentOwner +inline fun Node.optionalBindings() = optionalBindings(T::class.java) inline fun Node.bindings() = bindings(T::class.java) inline fun Context.bindings() = bindings(T::class.java) @@ -36,7 +37,7 @@ fun Context.bindings(klass: Class): T { ?: error("Unable to find bindings for ${klass.name}") } -fun Node.bindings(klass: Class): T { +fun Node.optionalBindings(klass: Class): T? { // search dagger components in node hierarchy return generateSequence(this, Node::parent) .filterIsInstance() @@ -44,5 +45,8 @@ fun Node.bindings(klass: Class): T { .flatMap { if (it is Collection<*>) it else listOf(it) } .filterIsInstance(klass) .firstOrNull() - ?: error("Unable to find bindings for ${klass.name}") +} + +fun Node.bindings(klass: Class): T { + return optionalBindings(klass) ?: error("Unable to find bindings for ${klass.name}") } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt index e82ebfe532..42cd70cbbd 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt @@ -27,9 +27,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.airbnb.android.showkase.annotation.ShowkaseComposable import io.element.android.libraries.designsystem.components.list.TextFieldListItem -import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.DialogPreview import io.element.android.libraries.designsystem.theme.components.ListSupportingText import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent @@ -45,6 +45,7 @@ fun ListDialog( subtitle: String? = null, cancelText: String = stringResource(CommonStrings.action_cancel), submitText: String = stringResource(CommonStrings.action_ok), + enabled: Boolean = true, listItems: LazyListScope.() -> Unit, ) { val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let { @@ -66,6 +67,7 @@ fun ListDialog( submitText = submitText, onDismissRequest = onDismissRequest, onSubmitClicked = onSubmit, + enabled = enabled, listItems = listItems, ) } @@ -80,6 +82,7 @@ private fun ListDialogContent( submitText: String, modifier: Modifier = Modifier, title: String? = null, + enabled: Boolean = true, subtitle: @Composable (() -> Unit)? = null, ) { SimpleAlertDialogContent( @@ -90,6 +93,7 @@ private fun ListDialogContent( submitText = submitText, onCancelClicked = onDismissRequest, onSubmitClicked = onSubmitClicked, + enabled = enabled, applyPaddingToContents = false, ) { LazyColumn( diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt index 525d5e76b1..93268e25d7 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt @@ -16,10 +16,13 @@ package io.element.android.libraries.designsystem.components.list +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup @@ -29,24 +32,68 @@ import io.element.android.libraries.theme.ElementTheme @Composable fun TextFieldListItem( - placeholder: String, + placeholder: String?, text: String, onTextChanged: (String) -> Unit, modifier: Modifier = Modifier, + error: String? = null, + maxLines: Int = 1, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, ) { val textFieldStyle = ElementTheme.materialTypography.bodyLarge OutlinedTextField( value = text, - onValueChange = onTextChanged, - placeholder = { Text(placeholder) }, + onValueChange = { onTextChanged(it) }, + placeholder = placeholder?.let { @Composable { Text(it) } }, colors = OutlinedTextFieldDefaults.colors( disabledBorderColor = Color.Transparent, errorBorderColor = Color.Transparent, focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent, ), + isError = error != null, + supportingText = error?.let { @Composable { Text(it) } }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, textStyle = textFieldStyle, + maxLines = maxLines, + singleLine = maxLines == 1, + modifier = modifier, + ) +} + +@Composable +fun TextFieldListItem( + placeholder: String?, + text: TextFieldValue, + onTextChanged: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + error: String? = null, + maxLines: Int = 1, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, +) { + val textFieldStyle = ElementTheme.materialTypography.bodyLarge + + OutlinedTextField( + value = text, + onValueChange = { onTextChanged(it) }, + placeholder = placeholder?.let { @Composable { Text(it) } }, + colors = OutlinedTextFieldDefaults.colors( + disabledBorderColor = Color.Transparent, + errorBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + ), + isError = error != null, + supportingText = error?.let { @Composable { Text(it) } }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + textStyle = textFieldStyle, + maxLines = maxLines, + singleLine = maxLines == 1, modifier = modifier, ) } @@ -74,3 +121,15 @@ internal fun TextFieldListItemPreview() { ) } } + +@Preview("Text field List item - textfieldvalue", group = PreviewGroup.ListItems) +@Composable +internal fun TextFieldListItemTextFieldValuePreview() { + ElementThemedPreview { + TextFieldListItem( + placeholder = "Placeholder", + text = TextFieldValue("Text field value"), + onTextChanged = {}, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt new file mode 100644 index 0000000000..648ea97434 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.designsystem.components.preferences + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import io.element.android.libraries.designsystem.components.dialogs.ListDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.list.TextFieldListItem +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun PreferenceTextField( + headline: String, + onChange: (String?) -> Unit, + modifier: Modifier = Modifier, + placeholder: String? = null, + value: String? = null, + supportingText: String? = null, + displayValue: (String?) -> Boolean = { !it.isNullOrBlank() }, + trailingContent: ListItemContent? = null, + validation: (String?) -> Boolean = { true }, + onValidationErrorMessage: String? = null, + enabled: Boolean = true, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + style: ListItemStyle = ListItemStyle.Default, +) { + var displayTextFieldDialog by rememberSaveable { mutableStateOf(false) } + val valueToDisplay = if (displayValue(value)) { value } else supportingText + + ListItem( + modifier = modifier, + headlineContent = { Text(headline) }, + supportingContent = valueToDisplay?.let { @Composable { Text(it) } }, + trailingContent = trailingContent, + style = style, + enabled = enabled, + onClick = { displayTextFieldDialog = true } + ) + + if (displayTextFieldDialog) { + TextFieldDialog( + title = headline, + onSubmit = { + onChange(it.takeIf { it.isNotBlank() }) + displayTextFieldDialog = false + }, + onDismissRequest = { displayTextFieldDialog = false }, + placeholder = placeholder.orEmpty(), + value = value.orEmpty(), + validation = validation, + onValidationErrorMessage = onValidationErrorMessage, + keyboardOptions = keyboardOptions, + ) + } +} + +@Composable +private fun TextFieldDialog( + title: String, + onSubmit: (String) -> Unit, + onDismissRequest: () -> Unit, + value: String?, + placeholder: String?, + modifier: Modifier = Modifier, + validation: (String?) -> Boolean = { true }, + onValidationErrorMessage: String? = null, + autoSelectOnDisplay: Boolean = true, + maxLines: Int = 1, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, +) { + val focusRequester = remember { FocusRequester() } + + var textFieldContents by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(value.orEmpty(), selection = TextRange(value.orEmpty().length))) + } + var error by rememberSaveable { mutableStateOf(null) } + val canSubmit by remember { derivedStateOf { validation(textFieldContents.text) } } + ListDialog( + title = title, + onSubmit = { onSubmit(textFieldContents.text) }, + onDismissRequest = onDismissRequest, + enabled = canSubmit, + modifier = modifier, + ) { + item { + TextFieldListItem( + placeholder = placeholder.orEmpty(), + text = textFieldContents, + onTextChanged = { + error = if (!validation(it.text)) onValidationErrorMessage else null + textFieldContents = it + }, + error = error, + keyboardOptions = keyboardOptions, + keyboardActions = KeyboardActions(onAny = { + if (validation(textFieldContents.text)) { + onSubmit(textFieldContents.text) + } + }), + maxLines = maxLines, + modifier = Modifier.focusRequester(focusRequester), + ) + } + } + + if (autoSelectOnDisplay) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt index abe744bdbc..2eb290dd4e 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt @@ -96,6 +96,7 @@ internal fun SimpleAlertDialogContent( thirdButtonText: String? = null, onThirdButtonClicked: () -> Unit = {}, applyPaddingToContents: Boolean = true, + enabled: Boolean = true, icon: @Composable (() -> Unit)? = null, content: @Composable () -> Unit, ) { @@ -122,6 +123,7 @@ internal fun SimpleAlertDialogContent( if (submitText != null) { Button( text = submitText, + enabled = enabled, size = ButtonSize.Medium, onClick = onSubmitClicked, ) diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 121cf26271..e078b634d1 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -55,4 +55,10 @@ enum class FeatureFlags( description = "Allow user to lock/unlock the app with a pin code or biometrics", defaultValue = false, ), + InRoomCalls( + key = "feature.elementcall", + title = "Element call in rooms", + description = "Allow user to start or join a call in a room", + defaultValue = false, + ) } diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt index 48f159de83..87a797d13a 100644 --- a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt @@ -37,6 +37,7 @@ class StaticFeatureFlagProvider @Inject constructor() : FeatureFlags.NotificationSettings -> true FeatureFlags.VoiceMessages -> false FeatureFlags.PinUnlock -> false + FeatureFlags.InRoomCalls -> false } } else { false diff --git a/libraries/matrix/api/build.gradle.kts b/libraries/matrix/api/build.gradle.kts index 5a430f7db5..4a083eacec 100644 --- a/libraries/matrix/api/build.gradle.kts +++ b/libraries/matrix/api/build.gradle.kts @@ -34,6 +34,7 @@ anvil { } dependencies { + implementation(projects.appconfig) implementation(projects.libraries.di) implementation(libs.dagger) implementation(projects.libraries.core) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt index e352dd5cfc..19e71db332 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt @@ -17,7 +17,7 @@ package io.element.android.libraries.matrix.api.permalink import android.net.Uri -import io.element.android.libraries.matrix.api.config.MatrixConfiguration +import io.element.android.appconfig.MatrixConfiguration /** * Mapping of an input URI to a matrix.to compliant URI. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt index c79ab36a7b..2a388ae580 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt @@ -16,7 +16,7 @@ package io.element.android.libraries.matrix.api.permalink -import io.element.android.libraries.matrix.api.config.MatrixConfiguration +import io.element.android.appconfig.MatrixConfiguration import io.element.android.libraries.matrix.api.core.MatrixPatterns import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId 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 746f8cead8..4b7e4e4470 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 @@ -30,6 +30,8 @@ import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver +import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings import kotlinx.coroutines.flow.StateFlow import java.io.Closeable import java.io.File @@ -192,5 +194,27 @@ interface MatrixRoom : Closeable { progressCallback: ProgressCallback? ): Result + /** + * Generates a Widget url to display in a [android.webkit.WebView] given the provided parameters. + * @param widgetSettings The widget settings to use. + * @param clientId The client id to use. It should be unique per app install. + * @param languageTag The language tag to use. If null, the default language will be used. + * @param theme The theme to use. If null, the default theme will be used. + * @return The resulting url, or a failure. + */ + suspend fun generateWidgetWebViewUrl( + widgetSettings: MatrixWidgetSettings, + clientId: String, + languageTag: String? = null, + theme: String? = null, + ): Result + + /** + * Get a [MatrixWidgetDriver] for the provided [widgetSettings]. + * @param widgetSettings The widget settings to use. + * @return The resulting [MatrixWidgetDriver], or a failure. + */ + fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result + override fun close() = destroy() } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallWidgetSettingsProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallWidgetSettingsProvider.kt new file mode 100644 index 0000000000..f0a22d0128 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallWidgetSettingsProvider.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.api.widget + +import java.util.UUID + +interface CallWidgetSettingsProvider { + fun provide( + baseUrl: String, + widgetId: String = UUID.randomUUID().toString() + ): MatrixWidgetSettings +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetDriver.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetDriver.kt new file mode 100644 index 0000000000..675adc1ad4 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetDriver.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.widget + +import kotlinx.coroutines.flow.Flow + +interface MatrixWidgetDriver : AutoCloseable { + val id: String + val incomingMessages: Flow + + suspend fun run() + suspend fun send(message: String) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetSettings.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetSettings.kt new file mode 100644 index 0000000000..022827898f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetSettings.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.matrix.api.widget + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class MatrixWidgetSettings( + val id: String, + val initAfterContentLoad: Boolean, + val rawUrl: String, +) : Parcelable { + companion object +} 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 4dcdb6d88c..c778fa3ac5 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 @@ -40,6 +40,8 @@ import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.room.roomNotificationSettings import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver +import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings import io.element.android.libraries.matrix.impl.core.toProgressWatcher import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl import io.element.android.libraries.matrix.impl.media.map @@ -48,6 +50,8 @@ import io.element.android.libraries.matrix.impl.poll.toInner import io.element.android.libraries.matrix.impl.room.location.toInner import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline import io.element.android.libraries.matrix.impl.util.destroyAll +import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver +import io.element.android.libraries.matrix.impl.widget.generateWidgetWebViewUrl import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CancellationException @@ -65,6 +69,8 @@ import org.matrix.rustcomponents.sdk.RoomListItem import org.matrix.rustcomponents.sdk.RoomMember import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle +import org.matrix.rustcomponents.sdk.WidgetPermissions +import org.matrix.rustcomponents.sdk.WidgetPermissionsProvider import org.matrix.rustcomponents.sdk.messageEventContentFromHtml import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown import timber.log.Timber @@ -478,6 +484,27 @@ class RustMatrixRoom( ) } + override suspend fun generateWidgetWebViewUrl( + widgetSettings: MatrixWidgetSettings, + clientId: String, + languageTag: String?, + theme: String?, + ) = runCatching { + widgetSettings.generateWidgetWebViewUrl(innerRoom, clientId, languageTag, theme) + } + + override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result = runCatching { + RustWidgetDriver( + widgetSettings = widgetSettings, + room = innerRoom, + widgetPermissionsProvider = object : WidgetPermissionsProvider { + override fun acquirePermissions(permissions: WidgetPermissions): WidgetPermissions { + return permissions + } + }, + ) + } + private suspend fun sendAttachment(files: List, handle: () -> SendAttachmentJoinHandle): Result { return runCatching { MediaUploadHandlerImpl(files, handle()) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt new file mode 100644 index 0000000000..a7f208e69d --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.impl.widget + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider +import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings +import org.matrix.rustcomponents.sdk.VirtualElementCallWidgetOptions +import org.matrix.rustcomponents.sdk.newVirtualElementCallWidget +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultCallWidgetSettingsProvider @Inject constructor() : CallWidgetSettingsProvider { + override fun provide(baseUrl: String, widgetId: String): MatrixWidgetSettings { + val options = VirtualElementCallWidgetOptions( + elementCallUrl = baseUrl, + widgetId = widgetId, + parentUrl = null, + hideHeader = null, + preload = null, + fontScale = null, + appPrompt = false, + skipLobby = true, + confineToRoom = true, + fonts = null, + analyticsId = null + ) + val rustWidgetSettings = newVirtualElementCallWidget(options) + return MatrixWidgetSettings.fromRustWidgetSettings(rustWidgetSettings) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/MatrixWidgetSettings.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/MatrixWidgetSettings.kt new file mode 100644 index 0000000000..65e6c8bc84 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/MatrixWidgetSettings.kt @@ -0,0 +1,50 @@ +/* + * 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.widget + +import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings +import org.matrix.rustcomponents.sdk.ClientProperties +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.WidgetSettings +import org.matrix.rustcomponents.sdk.generateWebviewUrl + +fun MatrixWidgetSettings.toRustWidgetSettings() = WidgetSettings( + id = this.id, + initAfterContentLoad = this.initAfterContentLoad, + rawUrl = this.rawUrl, +) + +fun MatrixWidgetSettings.Companion.fromRustWidgetSettings(widgetSettings: WidgetSettings) = MatrixWidgetSettings( + id = widgetSettings.id, + initAfterContentLoad = widgetSettings.initAfterContentLoad, + rawUrl = widgetSettings.rawUrl, +) + +suspend fun MatrixWidgetSettings.generateWidgetWebViewUrl( + room: Room, + clientId: String, + languageTag: String? = null, + theme: String? = null +) = generateWebviewUrl( + widgetSettings = this.toRustWidgetSettings(), + room = room, + props = ClientProperties( + clientId = clientId, + languageTag = languageTag, + theme = theme, + ) +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt new file mode 100644 index 0000000000..e385a34e0d --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt @@ -0,0 +1,78 @@ +/* + * 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.widget + +import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver +import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.WidgetPermissionsProvider +import org.matrix.rustcomponents.sdk.makeWidgetDriver +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.coroutineContext + +class RustWidgetDriver( + widgetSettings: MatrixWidgetSettings, + private val room: Room, + private val widgetPermissionsProvider: WidgetPermissionsProvider, +): MatrixWidgetDriver { + + override val incomingMessages = MutableSharedFlow() + + private val driverAndHandle = makeWidgetDriver(widgetSettings.toRustWidgetSettings()) + private var receiveMessageJob: Job? = null + + private var isRunning = AtomicBoolean(false) + + override val id: String = widgetSettings.id + + override suspend fun run() { + // Don't run the driver if it's already running + if (!isRunning.compareAndSet(false, true)) { + return + } + + val coroutineScope = CoroutineScope(coroutineContext) + coroutineScope.launch { + // This call will suspend the coroutine while the driver is running, so it needs to be launched separately + driverAndHandle.driver.run(room, widgetPermissionsProvider) + } + receiveMessageJob = coroutineScope.launch(Dispatchers.IO) { + try { + while (isActive) { + driverAndHandle.handle.recv()?.let { incomingMessages.emit(it) } + } + } finally { + driverAndHandle.handle.close() + } + } + } + + override suspend fun send(message: String) { + driverAndHandle.handle.send(message) + } + + override fun close() { + receiveMessageJob?.cancel() + driverAndHandle.driver.close() + } +} 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 67a36f0db7..2332a37fdc 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 @@ -208,8 +208,12 @@ class FakeMatrixClient( findDmResult = result } - fun givenGetRoomResult(roomId: RoomId, result: MatrixRoom) { - getRoomResults[roomId] = result + fun givenGetRoomResult(roomId: RoomId, result: MatrixRoom?) { + if (result == null) { + getRoomResults.remove(roomId) + } else { + getRoomResults[roomId] = result + } } fun givenSearchUsersResult(searchTerm: String, result: Result) { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt new file mode 100644 index 0000000000..80cdcff7ec --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.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.test + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId + +class FakeMatrixClientProvider( + private val getClient: (SessionId) -> Result = { Result.success(FakeMatrixClient()) } +) : MatrixClientProvider { + override suspend fun getOrRestore(sessionId: SessionId): Result = getClient(sessionId) +} 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 7549522c0c..c08a742391 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 @@ -36,11 +36,14 @@ import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver +import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline +import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver import io.element.android.tests.testutils.simulateLongTask import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -92,6 +95,8 @@ class FakeMatrixRoom( private var sendPollResponseResult = Result.success(Unit) private var endPollResult = Result.success(Unit) private var progressCallbackValues = emptyList>() + private var generateWidgetWebViewUrlResult = Result.success("https://call.element.io") + private var getWidgetDriverResult: Result = Result.success(FakeWidgetDriver()) val editMessageCalls = mutableListOf>() var sendMediaCount = 0 @@ -368,6 +373,15 @@ class FakeMatrixRoom( progressCallback: ProgressCallback? ): Result = fakeSendMedia(progressCallback) + override suspend fun generateWidgetWebViewUrl( + widgetSettings: MatrixWidgetSettings, + clientId: String, + languageTag: String?, + theme: String?, + ): Result = generateWidgetWebViewUrlResult + + override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result = getWidgetDriverResult + fun givenLeaveRoomError(throwable: Throwable?) { this.leaveRoomError = throwable } @@ -475,6 +489,14 @@ class FakeMatrixRoom( fun givenProgressCallbackValues(values: List>) { progressCallbackValues = values } + + fun givenGenerateWidgetWebViewUrlResult(result: Result) { + generateWidgetWebViewUrlResult = result + } + + fun givenGetWidgetDriverResult(result: Result) { + getWidgetDriverResult = result + } } data class SendLocationInvocation( diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeCallWidgetSettingsProvider.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeCallWidgetSettingsProvider.kt new file mode 100644 index 0000000000..74cf94e4ad --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeCallWidgetSettingsProvider.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.matrix.test.widget + +import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider +import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings + +class FakeCallWidgetSettingsProvider( + private val provideFn: (String, String) -> MatrixWidgetSettings = { _, _ -> MatrixWidgetSettings("id", true, "url") } +) : CallWidgetSettingsProvider { + + val providedBaseUrls = mutableListOf() + + override fun provide(baseUrl: String, widgetId: String): MatrixWidgetSettings { + providedBaseUrls += baseUrl + return provideFn(baseUrl, widgetId) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeWidgetDriver.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeWidgetDriver.kt new file mode 100644 index 0000000000..f7fa2b494a --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeWidgetDriver.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.test.widget + +import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver +import kotlinx.coroutines.flow.MutableSharedFlow +import java.util.UUID + +class FakeWidgetDriver( + override val id: String = UUID.randomUUID().toString(), +) : MatrixWidgetDriver { + + private val _sentMessages = mutableListOf() + val sentMessages: List = _sentMessages + + var runCalledCount = 0 + private set + var closeCalledCount = 0 + private set + + override val incomingMessages = MutableSharedFlow(extraBufferCapacity = 1) + + override suspend fun run() { + runCalledCount++ + } + + override suspend fun send(message: String) { + _sentMessages.add(message) + } + + override fun close() { + closeCalledCount++ + } + + fun givenIncomingMessage(message: String) { + incomingMessages.tryEmit(message) + } +} diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/PreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/PreferencesStore.kt index 8ad2c098f6..d62fb7e6cf 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/PreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/PreferencesStore.kt @@ -25,5 +25,8 @@ interface PreferencesStore { suspend fun setDeveloperModeEnabled(enabled: Boolean) fun isDeveloperModeEnabledFlow(): Flow + suspend fun setCustomElementCallBaseUrl(string: String?) + fun getCustomElementCallBaseUrlFlow(): Flow + suspend fun reset() } diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultPreferencesStore.kt index 337301f23e..66a46d1ca3 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultPreferencesStore.kt @@ -21,6 +21,7 @@ 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.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.preferences.api.store.PreferencesStore @@ -37,6 +38,7 @@ private val Context.dataStore: DataStore by preferencesDataStore(na private val richTextEditorKey = booleanPreferencesKey("richTextEditor") private val developerModeKey = booleanPreferencesKey("developerMode") +private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl") @ContributesBinding(AppScope::class) class DefaultPreferencesStore @Inject constructor( @@ -71,6 +73,22 @@ class DefaultPreferencesStore @Inject constructor( } } + override suspend fun setCustomElementCallBaseUrl(string: String?) { + store.edit { prefs -> + if (string != null) { + prefs[customElementCallBaseUrlKey] = string + } else { + prefs.remove(customElementCallBaseUrlKey) + } + } + } + + override fun getCustomElementCallBaseUrlFlow(): Flow { + return store.data.map { prefs -> + prefs[customElementCallBaseUrlKey] + } + } + override suspend fun reset() { store.edit { it.clear() } } diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/featureflag/test/InMemoryPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/featureflag/test/InMemoryPreferencesStore.kt index a2a9fdaa3f..6dea8910ed 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/featureflag/test/InMemoryPreferencesStore.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/featureflag/test/InMemoryPreferencesStore.kt @@ -23,9 +23,11 @@ import kotlinx.coroutines.flow.MutableStateFlow class InMemoryPreferencesStore( isRichTextEditorEnabled: Boolean = false, isDeveloperModeEnabled: Boolean = false, + customElementCallBaseUrl: String? = null, ) : PreferencesStore { private var _isRichTextEditorEnabled = MutableStateFlow(isRichTextEditorEnabled) private var _isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled) + private var _customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl) override suspend fun setRichTextEditorEnabled(enabled: Boolean) { _isRichTextEditorEnabled.value = enabled @@ -43,6 +45,14 @@ class InMemoryPreferencesStore( return _isDeveloperModeEnabled } + override suspend fun setCustomElementCallBaseUrl(string: String?) { + _customElementCallBaseUrl.tryEmit(string) + } + + override fun getCustomElementCallBaseUrlFlow(): Flow { + return _customElementCallBaseUrl + } + override suspend fun reset() { // No op } diff --git a/libraries/ui-strings/src/main/res/values-cs/translations.xml b/libraries/ui-strings/src/main/res/values-cs/translations.xml index f28095763c..7ada336f28 100644 --- a/libraries/ui-strings/src/main/res/values-cs/translations.xml +++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml @@ -131,7 +131,6 @@ "např. název vašeho projektu" "Hledat někoho" "Výsledky hledání" - "Zabezpečená záloha" "Zabezpečení" "Odesílání…" "Server není podporován" @@ -208,9 +207,9 @@ "Další nastavení" "Halsové a video hovory" "Neshoda konfigurace" - "Zjednodušili jsme nastavení oznámení, abychom usnadnili hledání možností. + "Zjednodušili jsme nastavení oznámení, abychom usnadnili hledání možností. -Některá vlastní nastavení, která jste si vybrali v minulosti, se zde nezobrazují, ale jsou stále aktivní. +Některá vlastní nastavení, která jste si vybrali v minulosti, se zde nezobrazují, ale jsou stále aktivní. Pokud budete pokračovat, některá nastavení se mohou změnit." "Přímé zprávy" diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml index 7401685482..ada42316b1 100644 --- a/libraries/ui-strings/src/main/res/values-ru/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml @@ -126,7 +126,6 @@ "например, название вашего проекта" "Поиск человека" "Результаты поиска" - "Безопасное резервное копирование" "Безопасность" "Отправка…" "Сервер не поддерживается" diff --git a/libraries/ui-strings/src/main/res/values-sk/translations.xml b/libraries/ui-strings/src/main/res/values-sk/translations.xml index 536db8c501..c6cf324b75 100644 --- a/libraries/ui-strings/src/main/res/values-sk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml @@ -131,7 +131,6 @@ "napr. názov vášho projektu" "Vyhľadať niekoho" "Výsledky hľadania" - "Bezpečné zálohovanie" "Bezpečnosť" "Odosiela sa…" "Server nie je podporovaný" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index b9a6460bec..1ff0ac1bc0 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -69,6 +69,8 @@ "Share" "Share link" "Sign in again" + "Sign out" + "Sign out anyway" "Skip" "Start" "Start chat" @@ -84,6 +86,7 @@ "Analytics" "Audio" "Bubbles" + "Chat backup" "Copyright" "Creating room…" "Left room" @@ -122,6 +125,7 @@ "Privacy policy" "Reaction" "Reactions" + "Recovery key" "Refreshing…" "Replying to %1$s" "Report a bug" @@ -131,7 +135,6 @@ "e.g. your project name" "Search for someone" "Search results" - "Secure backup" "Security" "Sending…" "Server not supported" @@ -200,6 +203,22 @@ "This is the beginning of this conversation." "New" "Share analytics data" + "Turn off backup" + "Turn on backup" + "Backup ensures that you don\'t lose your message history." + "Backup" + "Change recovery key" + "Confirm recovery key" + "Your chat backup is currently out of sync." + "Set up recovery" + "Get access to your encrypted messages if you lose all your devices or are signed out of %1$@ everywhere." + "Turn off" + "You will lose your encrypted messages if you are signed out of all devices." + "Are you sure you want to turn off backup?" + "Turning off backup will remove your current encryption key backup and turn off other security features. In this case, you will:" + "Not have encrypted message history on new devices" + "Lose access to your encrypted messages if you are signed out of %1$@ everywhere" + "Are you sure you want to turn off backup?" "Failed selecting media, please try again." "Failed processing media to upload, please try again." "Failed uploading media, please try again." @@ -228,6 +247,27 @@ If you proceed, some of your settings may change." "system settings" "System notifications turned off" "Notifications" + "Get a new recovery key if you\'ve lost your existing one. After changing your recovery key, your old one will no longer work." + "Generate a new recovery key" + "Make sure you can store your recovery key somewhere safe" + "Recovery key changed" + "Change recovery key?" + "Enter your recovery key to confirm access to your chat backup." + "Enter the 48 character code." + "Enter…" + "Recovery key confirmed" + "Confirm your recovery key" + "Save recovery key" + "Write down your recovery key somewhere safe or save it in a password manager." + "Tap to copy recovery key" + "Save your recovery key" + "You will not be able to access your new recovery key after this step." + "Have you saved your recovery key?" + "Your chat backup is protected by a recovery key. If you need a new recovery key after setup you can recreate by selecting ‘Change recovery key’." + "Generate your recovery key" + "Make sure you can store your recovery key somewhere safe" + "Recovery setup successful" + "Set up recovery" "Check if you want to hide all current and future messages from this user" "Share location" "Share my location" diff --git a/settings.gradle.kts b/settings.gradle.kts index 105befcd04..9ac2c96dde 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -52,6 +52,7 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") rootProject.name = "ElementX" include(":app") include(":appnav") +include(":appconfig") include(":tests:konsist") include(":tests:uitests") include(":tests:testutils") diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.ui_null_CallScreenView-D-0_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.ui_null_CallScreenView-D-0_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f44d866ff8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.ui_null_CallScreenView-D-0_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a28b7969455f17784f060291ce58b3720324baa67e9d93c2aa59f6d979268678 +size 14429 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.ui_null_CallScreenView-N-0_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.ui_null_CallScreenView-N-0_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..232a3e752a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.ui_null_CallScreenView-N-0_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f8a811677a50035a361f65ece4a1281346423226db6a1b8b3b8611f6b2f1d23d +size 13099 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-D-0_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-D-0_0_null,NEXUS_5,1.0,en].png deleted file mode 100644 index 4e99b44510..0000000000 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-D-0_0_null,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d0264d691ec2946cda4d5860f02079dd9f3e69ddd30a2e5c2f9c701253fd659c -size 10499 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-N-0_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-N-0_1_null,NEXUS_5,1.0,en].png deleted file mode 100644 index 5301f939c3..0000000000 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-N-0_1_null,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:20ec46c4c66a68d93c45a17eafd945536c9c137b18e66a82e83eced674708d98 -size 9732 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_0,NEXUS_5,1.0,en].png index 72056b5ed9..6f1f867a7c 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:88bdef3999877e5017bfe0e0ead1514e4e6a58abcde0b0167d4b0ad9d4abd1e0 -size 54020 +oid sha256:5f219bd9b9363f237e15bb73655dd53b2ec143e18c8544c11efbfa90390e091c +size 54312 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_1,NEXUS_5,1.0,en].png index 90b4f4652f..062dfb77f8 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:35f420b550029d7f8b22d73ea0349d2794cc2e5c5f3080799f496774fad7d2ff -size 55440 +oid sha256:6065f5330e1b3638719c6743db098bf6576fcffc6ccf8215f7f600a0b981144b +size 55731 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_3,NEXUS_5,1.0,en].png index c99c22634e..1de7fbc725 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9518f3e4809856f3787bcd076f1a4f33067ea911e66cd6692790451309e6e192 -size 55769 +oid sha256:f4bdafdfa50665f05ba5cd8749252e7e5d65bea8a8c6bfc1c46eb1acd1570b52 +size 56086 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_4,NEXUS_5,1.0,en].png index 1ae57f1414..d348a6f13f 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3f62a8a4eded0b742e911970837fe1003228834dd07a216a9bdc4acef37aa468 -size 55800 +oid sha256:2bfcc4dcaa8980cfc9c724e64a93116ac1fb81a1bf4421532cb196d4e07db7c5 +size 56024 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_5,NEXUS_5,1.0,en].png index 31edf89ccf..224cef4b67 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1314aaf5394d03b5d08eccdb29783ce8d949f44d3eea28ec8ce434b830515304 -size 51662 +oid sha256:c86a6da3d45b767a41f65e0cf4e8cacefb33e0409386a6233140891deb2258f6 +size 51965 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_6,NEXUS_5,1.0,en].png index 617d8da89b..82636b3e21 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ecdc26ae1b8943734a8ec1020d7ca9ad4a8e570f5295eb568f80ed314585a9a9 -size 51981 +oid sha256:5a1fc20759ff45eeef547621853b9684a663fe9dcdf78b316454dcae26d078f9 +size 52286 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_0,NEXUS_5,1.0,en].png index 58b944edce..094ab2536a 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:32ae9c61f8a01bd54b9fb51af5f0dff222f4df0be1003a9c6ad680d20877448b -size 52275 +oid sha256:7b9bc4654b6911d7f50b4b0242b650a529cb22c13a58caf5eae50366a2d27b37 +size 52532 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_1,NEXUS_5,1.0,en].png index e28209550e..32de8d07f9 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1785f0fe49a5afd9b152f6ddb51acad7ba1700b2711cf302ef3a490d948fcd96 -size 53618 +oid sha256:0e051668beb9030ff184d6e75831a00202854f6448880f399a7ee2d5be187740 +size 53880 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_3,NEXUS_5,1.0,en].png index 5113ea50e1..009ddeadaa 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8907587eb26b29d273fcce14fe4f17acd3ab8ae2fffa83d2c5d5c2c0d8c29bc6 -size 54270 +oid sha256:c6c6603452db218811f3f5a2baa934ca640755b969e81ca8cbf6b3dcf663aee9 +size 54551 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_4,NEXUS_5,1.0,en].png index 6162f39468..d2f2f56d9b 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:455b414e5da5d5174a8d87ef37219c9754e3558b39f025d7aab226b041c51096 -size 51305 +oid sha256:e953bc91e9959f1635d12416f6afde79427aeac4edef44fb38f1512672e544bd +size 51552 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_5,NEXUS_5,1.0,en].png index 3b7c0855e3..9437874ffd 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a0b83b1d37b34cdf0769e83f625d66479638ce402d5c8e76ef40548414d34400 -size 49862 +oid sha256:0157713ae934c0771a5d3d4d064ce6dc9be11b1d8011f2360740d7f4a20f6f04 +size 50162 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_6,NEXUS_5,1.0,en].png index 500b83a53e..af4dae6214 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b09d4a60c3d9944cd6d50a8f0a06d5b3522eba7884f3b5e1e6889f93e8dd1794 -size 50026 +oid sha256:24f1d30bb62ed620671e87062a338fd53f409bce6e08fb7b116249decf74e409 +size 50295 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_null_AdvancedSettingsView-D-1_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_null_AdvancedSettingsView-D-1_1_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2c48d3e55d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_null_AdvancedSettingsView-D-1_1_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a4e1af15c571d1f087005849b627d79387f8f5557bbc4233768bb3c2d940d628 +size 48510 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_null_AdvancedSettingsView-N-1_2_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_null_AdvancedSettingsView-N-1_2_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..519d9d4d10 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_null_AdvancedSettingsView-N-1_2_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa25ebf20fe62af56a548c3e962ae2e76e6e8e1b7e685d021306b733613e49eb +size 45462 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.list_null_Listitems_TextfieldListitem-textfieldvalue_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.list_null_Listitems_TextfieldListitem-textfieldvalue_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c7b2473e20 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.list_null_Listitems_TextfieldListitem-textfieldvalue_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:17fa588278f61269982bda072f4a67f9cc6f39f6f1ebcf4da59c7c3006808e14 +size 11262 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 7e07d269c0..0525eb834d 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -145,6 +145,7 @@ "name": ":features:preferences:impl", "includeRegex": [ "screen_advanced_settings_.*", + "screen\\.advanced_settings\\..*", "screen_edit_profile_.*" ] },