diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aa9be929ea..e997e1cbdd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,7 +56,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Assemble debug APKs diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml index b1d7204168..294b94a872 100644 --- a/.github/workflows/build_enterprise.yml +++ b/.github/workflows/build_enterprise.yml @@ -61,7 +61,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Assemble debug Gplay Enterprise APK diff --git a/.github/workflows/generate_github_pages.yml b/.github/workflows/generate_github_pages.yml index 763936e3d6..a180afb07e 100644 --- a/.github/workflows/generate_github_pages.yml +++ b/.github/workflows/generate_github_pages.yml @@ -24,7 +24,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Set up Python 3.12 diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml index 1195c7b53d..89413a4d2d 100644 --- a/.github/workflows/maestro-local.yml +++ b/.github/workflows/maestro-local.yml @@ -53,7 +53,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Assemble debug APK @@ -124,6 +124,19 @@ jobs: disk-size: 3G script: | .github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh app-gplay-x86_64-debug.apk + - name: Set up Python + if: always() + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: 3.14 + - name: End all the remaining sessions of the Maestro account + if: always() + continue-on-error: true + env: + MAS_PASSWORD: ${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }} + run: | + pip install requests + python3 ./tools/mas/delete_all_sessions.py --homeserver matrix.org --username maestroelement - name: Upload test results uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index fe38c0138f..88b267acc4 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -43,7 +43,7 @@ jobs: java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: false @@ -85,7 +85,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Dependency analysis diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 1f6289a8ea..006b0101f6 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -74,7 +74,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Set up Python 3.12 @@ -113,7 +113,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Konsist tests @@ -154,7 +154,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run compose tests @@ -188,7 +188,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Build Gplay Debug @@ -233,7 +233,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Detekt @@ -274,7 +274,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Ktlint check @@ -336,7 +336,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 + - uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 upload_reports: name: Project Check Suite diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml index efac08a99f..e6580d2f7b 100644 --- a/.github/workflows/recordScreenshots.yml +++ b/.github/workflows/recordScreenshots.yml @@ -60,7 +60,7 @@ jobs: java-version: '21' # Add gradle cache, this should speed up the process - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Record screenshots diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f264aa6de..6ec125b2d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,7 +43,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - name: Create app bundle env: ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} @@ -93,7 +93,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - name: Create APKs env: ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} diff --git a/.github/workflows/scripts/recordScreenshots.sh b/.github/workflows/scripts/recordScreenshots.sh index d4122c22bf..952c552ce4 100755 --- a/.github/workflows/scripts/recordScreenshots.sh +++ b/.github/workflows/scripts/recordScreenshots.sh @@ -51,9 +51,10 @@ if [[ -z ${REPO} ]]; then exit 1 fi -echo "Deleting previous screenshots" -./gradlew removeOldSnapshots --stacktrace --warn $GRADLE_ARGS - +# Note: the previous screenshots are deliberately kept, so that Paparazzi can leave the ones that only +# differ by less than `maxPercentDifference` untouched instead of rewriting their Git LFS blob. The +# screenshots of previews that no longer exist are pruned afterwards by `pruneObsoleteSnapshots`, which +# `recordPaparazziDebug` is finalized by. echo "Record screenshots" ./gradlew recordPaparazziDebug --stacktrace $GRADLE_ARGS diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 4febf272a9..d2b57a771e 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -50,7 +50,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Build debug code and test fixtures diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml index 9065e6d4e9..8d93e958c6 100644 --- a/.github/workflows/sync-localazy.yml +++ b/.github/workflows/sync-localazy.yml @@ -25,7 +25,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Set up Python 3.12 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fe09a37ae2..b54741c566 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -68,7 +68,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index cc87bc5140..4f368d4513 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -2,6 +2,6 @@ \ No newline at end of file diff --git a/.maestro/tests/account/logout.yaml b/.maestro/tests/account/logout.yaml index 83f834da04..270020c82c 100644 --- a/.maestro/tests/account/logout.yaml +++ b/.maestro/tests/account/logout.yaml @@ -2,17 +2,25 @@ appId: ${MAESTRO_APP_ID} --- - tapOn: id: "home_screen-settings" -- tapOn: - id: "settings-user_profile" -- assertVisible: "Your account" - tapOn: "Remove this device" - takeScreenshot: build/maestro/900-SignOutScreen - back - tapOn: "Remove this device" +# Ensure pressing back cancels +- back +- tapOn: "Remove this device" # Ensure cancel cancels +- tapOn: "Cancel" +- tapOn: "Remove this device" +- tapOn: + text: "Remove this device" + index: 1 +# Cancel the dialog cancels the logout - tapOn: id: "dialog-negative" -- tapOn: "Remove this device" +- tapOn: + text: "Remove this device" + index: 1 - tapOn: id: "dialog-positive" - runFlow: ../assertions/assertInitDisplayed.yaml diff --git a/AGENTS.md b/AGENTS.md index a4a73ca965..dc34ebd571 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ PRs must meet these rules. - Create previews for **all main states** of a Composable. - Use `@PreviewsDayNight` for consistency. -- Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states. +- Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states. Always add new states to existing providers at the end of the sequence. - Wrap previews in `ElementPreview { ... }`. - When writing tests, never try to record the screenshots, the CI will do it. diff --git a/CHANGES.md b/CHANGES.md index cbf9bc0652..e303af54d7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,12 @@ +Changes in Element X v26.09.4 +============================= + +## Hotfix version: + +Fixes an issue with the room list not displaying all activity from unread messages. + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.09.3...v26.09.4 + Changes in Element X v26.09.3 ============================= diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 64bc93cb6f..706a82f0d6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -280,7 +280,6 @@ dependencies { implementation(projects.features.migration.api) implementation(projects.appnav) implementation(projects.appconfig) - implementation(projects.libraries.uiStrings) implementation(projects.services.analytics.compose) if (ModulesConfig.pushProvidersConfig.includeFirebase) { @@ -298,18 +297,13 @@ dependencies { implementation(libs.androidx.splash) implementation(libs.androidx.core) implementation(libs.androidx.corektx) - implementation(libs.androidx.lifecycle.runtime) implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.activity.compose) implementation(libs.androidx.startup) implementation(libs.androidx.preference) - implementation(libs.coil) implementation(platform(libs.network.okhttp.bom)) implementation(libs.network.okhttp.logging) - implementation(libs.serialization.json) - - implementation(libs.matrix.emojibase.bindings) testCommonDependencies(libs) testImplementation(projects.libraries.matrix.test) @@ -342,6 +336,9 @@ licensee { ignoreDependencies("com.github.matrix-org", "matrix-analytics-events") // Ignore dependency that are not third-party licenses to us. ignoreDependencies(groupId = "io.element.android") + // The rustls-platform-verifier dependency does not provide a license file in their maven repo, but there are MIT and Apache licenses in + // the source code repo: see https://github.com/rustls/rustls-platform-verifier + ignoreDependencies(groupId = "org.rustls", artifactId = "rustls-platform-verifier") } fun Project.configureLicensesTasks(reportingExtension: ReportingExtension) { diff --git a/app/proguard/foss/default-proguard-rules.keep b/app/proguard/foss/default-proguard-rules.keep index a6dce2f0ce..604e1c55fd 100644 --- a/app/proguard/foss/default-proguard-rules.keep +++ b/app/proguard/foss/default-proguard-rules.keep @@ -1,2 +1,9 @@ # Don't obfuscate anything for non-enterprise builds -dontobfuscate + +# Strip PostHog's Firebase push token integration. We don't use it and it breaks F-Droid. +# Removing the contents of this method will mark the Firebase-related classes as unused, +# which will allow R8 to remove them from the final build. +-assumevalues class com.posthog.android.PostHogAndroidConfig { + boolean getCapturePushNotificationSubscriptions() return false; +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d5f08e0795..9193c6de71 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -53,9 +53,10 @@ --> diff --git a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt index a3712ebbf6..b39dc353fc 100644 --- a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt +++ b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt @@ -9,6 +9,7 @@ package io.element.android.x import android.app.Application +import android.os.StrictMode import androidx.compose.material3.ComposeMaterial3Flags.isAnchoredDraggableComponentsStrictOffsetCheckEnabled import androidx.compose.material3.ExperimentalMaterial3Api import androidx.startup.AppInitializer @@ -38,6 +39,9 @@ class ElementXApplication : Application(), DependencyInjectionGraphOwner, Config @OptIn(ExperimentalMaterial3Api::class) override fun onCreate() { super.onCreate() + + enableStrictMode() + AppInitializer.getInstance(this).apply { initializeComponent(CrashInitializer::class.java) initializeComponent(PlatformInitializer::class.java) @@ -51,4 +55,16 @@ class ElementXApplication : Application(), DependencyInjectionGraphOwner, Config // Remove once https://issuetracker.google.com/issues/477038695 is fixed. isAnchoredDraggableComponentsStrictOffsetCheckEnabled = false } + + private fun enableStrictMode() { + // Workaround to avoid NetworkOnMainThreadException when using the network on the main thread. + // This is a temporary solution to rustls doing the certificate verification using blocking network requests. + // It should be removed once the underlying issue is fixed. + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectNetwork() + .penaltyLog() + .build() + ) + } } diff --git a/app/src/main/kotlin/io/element/android/x/MainActivity.kt b/app/src/main/kotlin/io/element/android/x/MainActivity.kt index c5551f11aa..cc058d0f8f 100644 --- a/app/src/main/kotlin/io/element/android/x/MainActivity.kt +++ b/app/src/main/kotlin/io/element/android/x/MainActivity.kt @@ -70,6 +70,19 @@ class MainActivity : NodeActivity() { } } + /** + * The pre-Android 12 way into picture-in-picture for a native call. + * + * Everything else about it is installed where the call is drawn, which knows whose call it is. + * This cannot be: an Activity override has no session in scope. From Android 12 the system is told + * up front that this Activity would like to shrink, and the call handles it without ever coming + * through here. + */ + override fun onUserLeaveHint() { + super.onUserLeaveHint() + appBindings.nativeCallPip().onUserLeaveHint(this) + } + @Composable private fun MainContent(appBindings: AppBindings) { val migrationState = appBindings.migrationEntryPoint().present() diff --git a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt index 638ba0e38c..e5411e739c 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt @@ -11,11 +11,12 @@ package io.element.android.x.di import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Multibinds -import io.element.android.features.api.MigrationEntryPoint +import io.element.android.features.callnative.api.NativeCallPip import io.element.android.features.enterprise.api.AppStartupHook import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.api.LockScreenService +import io.element.android.features.migration.api.MigrationEntryPoint import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -52,6 +53,13 @@ interface AppBindings { fun buildMeta(): BuildMeta + /** + * For picture-in-picture. Only an Activity is told it is being left, and only the call knows + * whether that is worth shrinking for, so `MainActivity` needs this much of the native call and + * no more. + */ + fun nativeCallPip(): NativeCallPip + fun sentrySdkDsn(): SentrySdkDsn? @Multibinds(allowEmpty = true) diff --git a/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt index 2a844b1331..1d2504ae67 100644 --- a/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt +++ b/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt @@ -8,11 +8,14 @@ package io.element.android.x.initializer +import android.annotation.SuppressLint import android.content.Context import android.system.Os +import android.util.Log import androidx.startup.Initializer import io.element.android.features.rageshake.api.logs.createWriteToFilesConfiguration import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.tracing.TracingConfiguration import io.element.android.x.di.AppBindings @@ -24,6 +27,15 @@ private const val ELEMENT_X_TARGET = "elementx" class PlatformInitializer : Initializer { override fun create(context: Context) { + // Init the SDK with the Android Context, so it can be used with rustls and other Android specific features + runCatchingExceptions { + org.matrix.rustsdk.Android.init(context) + }.onFailure { + // We can't log this to Timber since the logs aren't initialized yet, so we log it to Logcat instead + @SuppressLint("LogNotTimber") + Log.e("PlatformInitializer", "Failed to init the SDK with an Android Context. The app probably won't work properly.", it) + } + val appBindings = context.bindings() val tracingService = appBindings.tracingService() val platformService = appBindings.platformService() @@ -41,6 +53,8 @@ class PlatformInitializer : Initializer { sdkSentryDsn = appBindings.sentrySdkDsn()?.value?.takeIf { it.isNotBlank() }, ) bugReporter.setCurrentTracingLogLevel(logLevel.name) + + // Init the tracing service with the configuration so we have logging enabled as soon as possible platformService.init(tracingConfiguration) // Also set env variable for rust back trace Os.setenv("RUST_BACKTRACE", "1", true) diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 96add8d148..98b80d94cb 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -30,5 +30,384 @@ home lan localdomain + + + + abitabdv.crl.certum.pl + abitabev.crl.certum.pl + abitabov.crl.certum.pl + alpiro.crl.sectigo.com + anticdv.crl.certum.pl + antrustovsslg2r30ca.crl.certum.pl + autontrustprodvtlsg2r31ca.crl.certum.pl + bitcert.crl.sectigo.com + btdvtlsr35g2ca.crl.certum.pl + c.cf-b.ssl.com + c.cf-i.ssl.com + c.pki.goog + ca1.agid.gov.it + cdn.d-trust-cloudcrl.net + cdp.disig.sk + cdp.elektronicznypodpis.pl + cdp.geotrust.com + cdp.rapidssl.com + cdp.thawte.com + cdp1.disig.sk + cdp1.pca.dfn.de + cdpa.digitalcertvalidation.com + cdpb.digitalcertvalidation.com + cdpc.digitalcertvalidation.com + cdpd.digitalcertvalidation.com + cdpe.digitalcertvalidation.com + cdpf.digitalcertvalidation.com + cdpg.digitalcertvalidation.com + cdph.digitalcertvalidation.com + cdpi.digitalcertvalidation.com + certcloud.crl.trust-provider.com + certdata.crl.sectigo.com + certera.crl.sectigo.com + certificates.godaddy.com + certum-crl.wt.trustasia.com + certum.crl.sheca.com + certumdvtlsg2e39ca.crl.certum.pl + certumdvtlsg2r39ca.crl.certum.pl + certumevtlsg2e39ca.crl.certum.pl + certumevtlsg2r39ca.crl.certum.pl + certumovtlsg2e39ca.crl.certum.pl + certumovtlsg2r39ca.crl.certum.pl + cfcadveccca.crl.certum.pl + cfcadvrsaca.crl.certum.pl + cfcaeveccca.crl.certum.pl + cfcaevrsaca.crl.certum.pl + cfcaoveccca.crl.certum.pl + cfcaovrsaca.crl.certum.pl + cnssldvtlsg2r36ca.crl.certum.pl + cnsslovtlsg2r36ca.crl.certum.pl + crl-01.r1.ica.amazontrust.com + crl-01.r1.ica.amznts.eu + crl-c.emsign.com + crl-pro.litessl.com + crl.anf.es + crl.apple.com + crl.businessid.telesec.de + crl.buypass.no + crl.catrust.oemssl.cn + crl.certigna.fr + crl.certsign.ro + crl.certum.pl + crl.cfca.com.cn + crl.cntrus.oemssl.cn + crl.cnwebtrust.oemssl.cn + crl.comodoca.com + crl.crlocsp.cn + crl.cybertrust.ne.jp + crl.d-trust.net + crl.dhimyotis.com + crl.digicert.cn + crl.digicert.eu + crl.e-szigno.hu + crl.e2m01.amazontrust.com + crl.e2m01.eu.amazontrust.com + crl.e2m02.amazontrust.com + crl.e2m03.amazontrust.com + crl.e2m04.amazontrust.com + crl.e2s14.amazontrust.com + crl.e2s15.amazontrust.com + crl.e2s16.amazontrust.com + crl.e2s17.amazontrust.com + crl.e2s18.amazontrust.com + crl.e2s19.amazontrust.com + crl.e2s20.amazontrust.com + crl.e2s21.amazontrust.com + crl.e3m01.amazontrust.com + crl.e3m01.eu.amazontrust.com + crl.e3m02.amazontrust.com + crl.e3m03.amazontrust.com + crl.e3m04.amazontrust.com + crl.e3s22.amazontrust.com + crl.e3s23.amazontrust.com + crl.e3s24.amazontrust.com + crl.e3s25.amazontrust.com + crl.e3s26.amazontrust.com + crl.e3s27.amazontrust.com + crl.e3s28.amazontrust.com + crl.e3s29.amazontrust.com + crl.emsign.com + crl.ensuredca.com + crl.firmaprofesional.com + crl.gdca.com.cn + crl.global.sheca.com + crl.globalsign.com + crl.globalsign.net + crl.godaddy.com + crl.harica.gr + crl.izenpe.com + crl.litessl.com + crl.netsolssl.com + crl.oem.trustca.net + crl.pcsp.telesec.de + crl.pki.goog + crl.quovadisglobal.com + crl.r1001.amazontrust.com + crl.r2001.amazontrust.com + crl.r2m01.amazontrust.com + crl.r2m01.eu.amazontrust.com + crl.r2m02.amazontrust.com + crl.r2m03.amazontrust.com + crl.r2m04.amazontrust.com + crl.r2s18.amazontrust.com + crl.r2s19.amazontrust.com + crl.r2s20.amazontrust.com + crl.r2s21.amazontrust.com + crl.r2s22.amazontrust.com + crl.r2s23.amazontrust.com + crl.r2s24.amazontrust.com + crl.r2s25.amazontrust.com + crl.r3001.amazontrust.com + crl.r4001.amazontrust.com + crl.r4m01.amazontrust.com + crl.r4m02.amazontrust.com + crl.r4m03.amazontrust.com + crl.r4m04.amazontrust.com + crl.root-e1.certainly.com + crl.root-r1.certainly.com + crl.rootca1.amazontrust.com + crl.rootca2.amazontrust.com + crl.rootca3.amazontrust.com + crl.rootca4.amazontrust.com + crl.rztrust.oemssl.cn + crl.sbca.telesec.de + crl.sectigo.com + crl.sectigochina.com + crl.serverid.telesec.de + crl.starfieldtech.com + crl.swisssign.ch + crl.trust-provider.cn + crl.trust-provider.com + crl.tuntrust.tn + crl.usertrust.com + crl.wt.trustasia.com + crl.xinchacha.oemssl.cn + crl.zw.oemssl.cn + crl05.actalis.it + crl06.actalis.it + crl09.actalis.it + crl1.ecert.gov.hk + crl13.actalis.it + crl14.actalis.it + crl15.actalis.it + crl2.microsoft.com + crl3.digicert.com + crl4.digicert.com + crls.certainly.com + crls.ssl.com + cyberfolks2.crl.certum.pl + depo.kamusm.gov.tr + digitaltrust.crl.sectigo.com + dnencrypt.crl.sectigo.com + dvtlsca2025-crl.e-szigno.hu + dvtlsca2026-crl.e-szigno.hu + e5.c.lencr.org + e6.c.lencr.org + e7.c.lencr.org + e8.c.lencr.org + ec2ca2017-crl1.e-szigno.hu + ec2ca2017-crl2.e-szigno.hu + ec2ca2017-crl3.e-szigno.hu + ec2sslca2017-crl1.e-szigno.hu + ec2sslca2017-crl2.e-szigno.hu + ec2sslca2017-crl3.e-szigno.hu + ec3ca2017-crl1.e-szigno.hu + ec3ca2017-crl2.e-szigno.hu + ec3ca2017-crl3.e-szigno.hu + ec3sslca2017-crl1.e-szigno.hu + ec3sslca2017-crl2.e-szigno.hu + ec3sslca2017-crl3.e-szigno.hu + eca.hinet.net + edvtlsca2023-crl.e-szigno.hu + edvtlsca2025-crl.e-szigno.hu + edvtlsca2026-crl.e-szigno.hu + emudhra.crl.sectigo.com + eosslca2017-crl1.e-szigno.hu + eosslca2017-crl2.e-szigno.hu + eosslca2017-crl3.e-szigno.hu + eovtlsca2023-crl.e-szigno.hu + eovtlsca2025-crl.e-szigno.hu + eovtlsca2026-crl.e-szigno.hu + eqca2017-crl1.e-szigno.hu + eqca2017-crl2.e-szigno.hu + eqca2017-crl3.e-szigno.hu + eqcpca2017-crl1.e-szigno.hu + eqcpca2017-crl2.e-szigno.hu + eqcpca2017-crl3.e-szigno.hu + eqoca2017-crl1.e-szigno.hu + eqoca2017-crl2.e-szigno.hu + eqoca2017-crl3.e-szigno.hu + eqtlsca2018-crl1.e-szigno.hu + eqtlsca2018-crl2.e-szigno.hu + eqtlsca2018-crl3.e-szigno.hu + eqtlsca2023-crl.e-szigno.hu + eqtlsca2025-crl.e-szigno.hu + eqtlsca2026-crl.e-szigno.hu + esafer.crl.sectigo.com + eue2m1.crl.root.amznts.eu + eue3m1.crl.root.amznts.eu + eur2m1.crl.root.amznts.eu + eveccca.crl.certum.pl + evrsaca.crl.certum.pl + gdcadv.crl.certum.pl + gdcaev.crl.certum.pl + gdcaov.crl.certum.pl + gdcatrustauthdvtlsg3r31ca.crl.certum.pl + gdcatrustauthevtlsg3r31ca.crl.certum.pl + gdcatrustauthovtlsg3r31ca.crl.certum.pl + geant.crl.sectigo.com + genious.crl.sectigo.com + geossldvtlsr34g2ca.crl.certum.pl + geosslevtlsr34g2ca.crl.certum.pl + geosslovtlsr34g2ca.crl.certum.pl + globaltrust.crl.sectigo.com + globessl.crl.sectigo.com + gogetssldv.crl.certum.pl + gogetsslev.crl.certum.pl + gogetsslov.crl.certum.pl + homepldvtlsg2r35ca.crl.certum.pl + homepldvtlsg2r36ca.crl.certum.pl + homeplovtlsg2r35ca.crl.certum.pl + homeplovtlsg2r36ca.crl.certum.pl + httpcrl.trust.telia.com + ica.navercloudtrust.com + ica.navercorp.com + issauth.crl.sectigo.com + itrusdv2.crl.certum.pl + itrusdvtlsr35g2ca.crl.certum.pl + itrusov2.crl.certum.pl + itrusovtlsr35g2ca.crl.certum.pl + itso.crl.sectigo.com + joyssldvtlsg2r33ca.crl.certum.pl + joysslovtlsg2r33ca.crl.certum.pl + kingnettechdv.crl.certum.pl + kingnettechev.crl.certum.pl + kingnettechov.crl.certum.pl + kr.crl.digicert.com + lh2.crl.certum.pl + namecheap.crl.sectigo.com + nazwassldvtlsg2e29ca.crl.certum.pl + nazwassldvtlsg2r29ca.crl.certum.pl + netartcadv2.crl.certum.pl + netartssldvtlsg2e31ca.crl.certum.pl + nijimo.crl.sectigo.com + nyalabscadv.crl.certum.pl + nyatworkdv.crl.certum.pl + omitsecurity.crl.sectigo.com + onesign.crl.sectigo.com + osslca2016-crl1.e-szigno.hu + osslca2016-crl2.e-szigno.hu + osslca2016-crl3.e-szigno.hu + ovtlsca2025-crl.e-szigno.hu + ovtlsca2026-crl.e-szigno.hu + pki-crl.atos.net + pki.telesec.de + pkipro.certsign.ro + positiwise.crl.sectigo.com + psw.crl.sectigo.com + public.wisekey.com + qiduodv.crl.certum.pl + qtlsca2018-crl1.e-szigno.hu + qtlsca2018-crl2.e-szigno.hu + qtlsca2018-crl3.e-szigno.hu + qtlsca2026-crl.e-szigno.hu + r10.c.lencr.org + r11.c.lencr.org + r12.c.lencr.org + r13.c.lencr.org + rca.navercloudtrust.com + rca.navercorp.com + repo.pubcert.jprs.jp + repo1.secomtrust.net + repository.secomtrust.net + repository.tls.hinet.net + rootca.twca.com.tw + rootglobaldv.crl.certum.pl + rootnetworksdv2.crl.certum.pl + shenzhendv.crl.certum.pl + shenzhenev.crl.certum.pl + shenzhenov.crl.certum.pl + shoperdvtlsg2r34ca.crl.certum.pl + shuididv.crl.certum.pl + shuidiev.crl.certum.pl + shuidiov.crl.certum.pl + soomadv.crl.certum.pl + ssl2ca2016-crl1.e-szigno.hu + ssl2ca2016-crl2.e-szigno.hu + ssl2ca2016-crl3.e-szigno.hu + sslca2014-crl1.e-szigno.hu + sslca2014-crl2.e-szigno.hu + sslca2014-crl3.e-szigno.hu + sslcom.crl.certum.pl + ssllimiteddv.crl.certum.pl + sslserver.twca.com.tw + subca.crl.certum.pl + titrust.crl.sectigo.com + tlcdvtlsr34g2ca.crl.certum.pl + tlcevtlsr34g2ca.crl.certum.pl + tlcovtlsr34g2ca.crl.certum.pl + tlsrootca2025-crl.e-szigno.hu + trustasia.crl.certum.pl + trustasiadv.crl.certum.pl + trustasiadvtlsr35g2ca.crl.certum.pl + trustasiaev.crl.certum.pl + trustasiaevtlse35g2ca.crl.certum.pl + trustasiaevtlsr35g2ca.crl.certum.pl + trustasiaov.crl.certum.pl + trustasiaovtlse35g2ca.crl.certum.pl + trustasiaovtlsr35g2ca.crl.certum.pl + trustocean.crl.certum.pl + tstlser20.crl.telesec.de + tstlsrr23.crl.telesec.de + unitedtrustov.crl.certum.pl + validation.identrust.com + verokey.crl.sectigo.com + vtrusdvtlsg3r31ca.crl.certum.pl + vtrusovtlsg3r31ca.crl.certum.pl + webnic.crl.sectigo.com + whitessldvtlsg2e36ca.crl.certum.pl + wosign.crl.certum.pl + wotrus-dvca.crl.certum.pl + wotrus-evca.crl.certum.pl + wotrus-ovca.crl.certum.pl + wotrus.crl.sectigo.com + wtca-cafiles.itrus.com.cn + wtca-crl.itrus.com.cn + www.accv.es + www.cert.fnmt.es + www.d-trust.net + www.microsoft.com + x1.c.lencr.org + x2.c.lencr.org + xcctrustdvtlsg3r31ca.crl.certum.pl + xcctrustevtlsg3r31ca.crl.certum.pl + xcctrustovtlsg3r31ca.crl.certum.pl + xinchacha2dv.crl.certum.pl + xinchacha2ov.crl.certum.pl + xinchachatrustdvtlsg2r34ca.crl.certum.pl + xinchachatrustevtlsg2r34ca.crl.certum.pl + xinchachatrustovtlsg2r34ca.crl.certum.pl + xinnetdvtlsr34g2ca.crl.certum.pl + xinnetevtlsr34g2ca.crl.certum.pl + xinnetovtlsr34g2ca.crl.certum.pl + ye.c.lencr.org + ye1.c.lencr.org + ye2.c.lencr.org + yektadv.crl.certum.pl + yektaov.crl.certum.pl + yr.c.lencr.org + yr1.c.lencr.org + yr2.c.lencr.org + zerossl.crl.sectigo.com + ziwit.crl.sectigo.com + zycatrustdvtlsg3r31ca.crl.certum.pl + zycatrustevtlsg3r31ca.crl.certum.pl + zycatrustovtlsg3r31ca.crl.certum.pl diff --git a/app/src/main/res/xml/network_security_config_element.xml b/app/src/main/res/xml/network_security_config_element.xml new file mode 100644 index 0000000000..96add8d148 --- /dev/null +++ b/app/src/main/res/xml/network_security_config_element.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + localhost + 127.0.0.1 + + 10.0.2.2 + + onion + + + + home.arpa + local + test + + home + lan + localdomain + + diff --git a/appconfig/build.gradle.kts b/appconfig/build.gradle.kts index 64b9b76a14..e355499350 100644 --- a/appconfig/build.gradle.kts +++ b/appconfig/build.gradle.kts @@ -48,7 +48,6 @@ android { } dependencies { - implementation(libs.coroutines.core) implementation(libs.androidx.annotationjvm) implementation(libs.androidx.corektx) implementation(projects.libraries.matrix.api) diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 12b95d9a09..cc11f5c033 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -44,12 +44,12 @@ dependencies { implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiStrings) - implementation(projects.libraries.wellknown.api) implementation(projects.features.login.api) implementation(libs.coil) implementation(projects.features.announcement.api) + implementation(projects.features.callnative.api) implementation(projects.features.ftue.api) implementation(projects.features.linknewdevice.api) implementation(projects.features.share.api) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt index 0dbcb0f3f1..ee8398186d 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt @@ -33,7 +33,7 @@ import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import kotlinx.parcelize.Parcelize /** diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt index cee2296d43..ac08795f67 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt @@ -8,28 +8,51 @@ package io.element.android.appnav -import dev.zacsweers.metro.Inject +import androidx.annotation.VisibleForTesting +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.childScope import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange +import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -@Inject +@AssistedInject class LoggedInEventProcessor( - private val snackbarDispatcher: SnackbarDispatcher, + @Assisted private val snackbarDispatcher: SnackbarDispatcher, private val roomMembershipObserver: RoomMembershipObserver, + private val sessionId: SessionId, + private val roomListService: RoomListService, + private val notificationCleaner: NotificationCleaner, + private val dispatchers: CoroutineDispatchers ) { - private var observingJob: Job? = null + @AssistedFactory + interface Factory { + fun create(snackbarDispatcher: SnackbarDispatcher): LoggedInEventProcessor + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var currentChildScope: CoroutineScope? = null fun observeEvents(coroutineScope: CoroutineScope) { - observingJob = roomMembershipObserver.updates + if (currentChildScope != null) return + + val childScope = coroutineScope.childScope(dispatchers.computation, "LoggedInEventProcessor") + .also { currentChildScope = it } + + roomMembershipObserver.updates .filter { !it.isUserInRoom } .distinctUntilChanged() .onEach { roomMemberShipUpdate -> @@ -48,12 +71,20 @@ class LoggedInEventProcessor( else -> Unit } } - .launchIn(coroutineScope) + .launchIn(childScope) + + // Use the room list service state as a 'heartbeat' update to check existing notifications + roomListService.state + .filter { it == RoomListService.State.Running } + .onEach { + notificationCleaner.clearReadRoomsNotifications(sessionId) + } + .launchIn(childScope) } fun stopObserving() { - observingJob?.cancel() - observingJob = null + currentChildScope?.cancel() + currentChildScope = null } private fun displayMessage(message: Int) { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index a0bcf3b489..dce0911165 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -48,6 +48,7 @@ import io.element.android.appnav.room.RoomFlowNode import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.compound.colors.SemanticColorsLightDark +import io.element.android.features.callnative.api.NativeCallHost import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.SessionEnterpriseService @@ -92,7 +93,6 @@ import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.sync.SyncService -import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService @@ -110,14 +110,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.parcelize.Parcelize import timber.log.Timber -import java.time.Duration -import java.time.Instant import java.util.Optional import java.util.UUID import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds -import kotlin.time.toKotlinDuration import im.vector.app.features.analytics.plan.JoinedRoom as JoinedRoomAnalyticsEvent // The maximum number of room nodes that should be kept in the backstack at the same time. @@ -144,6 +140,7 @@ class LoggedInFlowNode( private val shareEntryPoint: ShareEntryPoint, private val matrixClient: MatrixClient, private val sendingQueue: SendQueues, + private val nativeCallHost: NativeCallHost, private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint, private val mediaPreviewConfigMigration: MediaPreviewConfigMigration, private val sessionEnterpriseService: SessionEnterpriseService, @@ -160,6 +157,7 @@ class LoggedInFlowNode( private val createRoomEntryPoint: CreateRoomEntryPoint, private val activeLiveLocationShareManager: ActiveLiveLocationShareManager, private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, + private val loggedInEventProcessorFactory: LoggedInEventProcessor.Factory, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Placeholder, @@ -178,41 +176,22 @@ class LoggedInFlowNode( } private val callback: Callback = callback() - private val loggedInFlowProcessor = LoggedInEventProcessor( - snackbarDispatcher = snackbarDispatcher, - roomMembershipObserver = matrixClient.roomMembershipObserver, - ) + private val loggedInFlowProcessor = loggedInEventProcessorFactory.create(snackbarDispatcher) - private val verificationListener = object : SessionVerificationServiceListener { - override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) { - // Without this launch the rendering and actual state of this Appyx node's children gets out of sync, resulting in a crash. - // This might be because this method is called back from Rust in a background thread. - lifecycleScope.launch { - val receivedAt = Instant.now() - - // Wait until the app is in foreground to display the incoming verification request - appNavigationStateService.appNavigationState.first { it.isInForeground } - - // TODO there should also be a timeout for > 10 minutes elapsed since the request was created, but the SDK doesn't expose that info yet - val now = Instant.now() - val elapsedTimeSinceReceived = Duration.between(receivedAt, now).toKotlinDuration() - - // Discard the incoming verification request if it has timed out - if (elapsedTimeSinceReceived > 2.minutes) { - Timber.w("Incoming verification request ${verificationRequest.details.flowId} discarded due to timeout.") - return@launch + /** + * Display the incoming verification request, the app is expected to be in foreground. + */ + fun onIncomingVerificationRequest(verificationRequest: VerificationRequest.Incoming) { + lifecycleScope.launch { + // Wait for the RoomList UI to be ready so the incoming verification screen can be displayed on top of it + // Otherwise, the RoomList UI may be incorrectly displayed on top + withTimeout(5.seconds) { + backstack.elements.first { elements -> + elements.any { it.key.navTarget == NavTarget.Home } } - - // Wait for the RoomList UI to be ready so the incoming verification screen can be displayed on top of it - // Otherwise, the RoomList UI may be incorrectly displayed on top - withTimeout(5.seconds) { - backstack.elements.first { elements -> - elements.any { it.key.navTarget == NavTarget.Home } - } - } - - backstack.singleTop(NavTarget.IncomingVerificationRequest(verificationRequest)) } + + backstack.singleTop(NavTarget.IncomingVerificationRequest(verificationRequest)) } } @@ -227,7 +206,6 @@ class LoggedInFlowNode( analyticsRoomListStateWatcher.start() appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId) loggedInFlowProcessor.observeEvents(sessionCoroutineScope) - matrixClient.sessionVerificationService.setListener(verificationListener) mediaPreviewConfigMigration() sessionCoroutineScope.launch { // Wait for the network to be connected before pre-fetching the max file upload size @@ -256,7 +234,6 @@ class LoggedInFlowNode( onDestroy = { appNavigationStateService.onLeavingSession(id) loggedInFlowProcessor.stopObserving() - matrixClient.sessionVerificationService.setListener(null) analyticsRoomListStateWatcher.stop() } ) @@ -382,6 +359,10 @@ class LoggedInFlowNode( override fun navigateToBugReport() { callback.navigateToBugReport() } + + override fun navigateToAddAccount() { + callback.navigateToAddAccount() + } } homeEntryPoint.createNode( parentNode = this, @@ -701,15 +682,19 @@ class LoggedInFlowNode( ) { CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) { val isOnline by syncService.isOnline.collectAsState() - ConnectivityIndicatorContainer( - isOnline = isOnline, - modifier = modifier, - ) { contentModifier -> - Box(modifier = contentModifier) { - val ftueState by ftueService.state.collectAsState() - BackstackView(transitionHandler = rememberLoggedInFlowTransitionHandler(backstack)) - if (ftueState is FtueState.Complete) { - PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent) + // Outside the connectivity indicator so the two strips stack rather than fight: an + // offline banner during a call belongs under the call bar, not over it. + nativeCallHost.Render(modifier = modifier) { callContentModifier -> + ConnectivityIndicatorContainer( + isOnline = isOnline, + modifier = callContentModifier, + ) { contentModifier -> + Box(modifier = contentModifier) { + val ftueState by ftueService.state.collectAsState() + BackstackView(transitionHandler = rememberLoggedInFlowTransitionHandler(backstack)) + if (ftueState is FtueState.Complete) { + PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent) + } } } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt index 080f3c1d77..c66ac97d83 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt @@ -13,6 +13,7 @@ package io.element.android.appnav import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope import coil3.SingletonImageLoader import coil3.annotation.DelicateCoilApi import com.bumble.appyx.core.lifecycle.subscribe @@ -24,6 +25,8 @@ import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginParams import io.element.android.libraries.architecture.BackstackView @@ -33,8 +36,11 @@ import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices import io.element.android.libraries.designsystem.utils.ScreenOrientation -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @ContributesNode(AppScope::class) @@ -45,6 +51,8 @@ class NotLoggedInFlowNode( private val loginEntryPoint: LoginEntryPoint, private val imageLoaderHolder: ImageLoaderHolder, private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, + private val featureFlagService: FeatureFlagService, + private val announcementService: AnnouncementService, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Root, @@ -68,6 +76,13 @@ class NotLoggedInFlowNode( override fun onBuilt() { super.onBuilt() analyticsColdStartWatcher.whenLoggingIn() + lifecycleScope.launch { + // Users who enter the login flow while the feature is available, either for the first time or to add + // another account, do not need to be informed about the multi-account feature. + if (featureFlagService.isFeatureEnabled(FeatureFlags.MultiAccount)) { + announcementService.onAnnouncementDismissed(Announcement.MultiAccount) + } + } lifecycle.subscribe( onResume = { SingletonImageLoader.setUnsafe(imageLoaderHolder.get()) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 5f2dc837ad..d8103c18c0 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -40,6 +40,9 @@ import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootView import io.element.android.appnav.session.MatrixSessionCache +import io.element.android.appnav.verification.IncomingVerificationRequestData +import io.element.android.appnav.verification.IncomingVerificationRequestObserver +import io.element.android.appnav.verification.OtherSessionIncomingVerificationNode import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl @@ -52,6 +55,7 @@ import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.waitForChildAttached +import io.element.android.libraries.core.coroutine.withPreviousValue import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService @@ -63,6 +67,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.asEventId import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.oauth.api.OAuthAction import io.element.android.libraries.oauth.api.OAuthActionFlow import io.element.android.libraries.sessionstorage.api.LoggedInState @@ -75,11 +80,16 @@ import io.element.android.services.appnavstate.api.ROOM_OPENED_FROM_NOTIFICATION import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.parcelize.Parcelize import timber.log.Timber +import kotlin.time.Duration.Companion.seconds @ContributesNode(AppScope::class) @AssistedInject @@ -100,6 +110,7 @@ class RootFlowNode( private val announcementService: AnnouncementService, private val analyticsService: AnalyticsService, private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, + private val incomingVerificationRequestObserver: IncomingVerificationRequestObserver, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : BaseFlowNode( backstack = BackStack( @@ -127,6 +138,8 @@ class RootFlowNode( observeNavState(false) } } + observeIncomingVerificationRequests() + observeRemovedSessions() super.onBuilt() } @@ -203,6 +216,65 @@ class RootFlowNode( private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { pendingLoginParams = null backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId)) + restoreAllSessions() + } + + /** + * Restore all the sessions with a valid token, so that they sync and can receive incoming verification requests. + */ + private fun restoreAllSessions() = lifecycleScope.launch { + sessionStore.getAllSessions() + .filter { it.isTokenValid } + .forEach { sessionData -> + matrixSessionCache.getOrRestore(SessionId(sessionData.userId)).onFailure { + Timber.e(it, "Failed to restore session ${sessionData.userId}") + } + } + } + + /** + * Remove from the cache the sessions which have been removed from the session store, for instance after a logout. + */ + private fun observeRemovedSessions() { + sessionStore.sessionsFlow() + .map { sessions -> sessions.map { SessionId(it.userId) }.toSet() } + .withPreviousValue() + .onEach { (previous, current) -> + (previous.orEmpty() - current).forEach { sessionId -> + Timber.d("Session $sessionId removed, remove it from the cache") + matrixSessionCache.remove(sessionId) + } + } + .launchIn(lifecycleScope) + } + + private fun observeIncomingVerificationRequests() { + incomingVerificationRequestObserver.incomingVerificationRequests() + .onEach(::onIncomingVerificationRequest) + .launchIn(lifecycleScope) + } + + private fun onIncomingVerificationRequest(data: IncomingVerificationRequestData) = lifecycleScope.launch { + // Wait for a logged in flow to be displayed + val currentSessionId = withTimeoutOrNull(5.seconds) { + backstack.elements.map { elements -> + elements.firstNotNullOfOrNull { (it.key.navTarget as? NavTarget.LoggedInFlow)?.sessionId } + }.filterNotNull().first() + } + if (currentSessionId == null) { + Timber.w("Incoming verification request ${data.verificationRequest.details.flowId} discarded, no logged in flow.") + return@launch + } + if (currentSessionId == data.sessionId) { + waitForChildAttached { navTarget -> + navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == currentSessionId + } + .attachSession() + .onIncomingVerificationRequest(data.verificationRequest) + } else { + // Display the request on top of the current session, without switching session + backstack.push(NavTarget.OtherAccountIncomingVerificationRequest(data.sessionId, data.verificationRequest)) + } } private fun switchToNotLoggedInFlow(params: LoginParams?) { @@ -294,6 +366,11 @@ class RootFlowNode( ) : NavTarget @Parcelize data object BugReport : NavTarget + + @Parcelize data class OtherAccountIncomingVerificationRequest( + val sessionId: SessionId, + val verificationRequest: VerificationRequest.Incoming, + ) : NavTarget } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -348,6 +425,23 @@ class RootFlowNode( ) } NavTarget.SplashScreen -> emptyNode(buildContext) + is NavTarget.OtherAccountIncomingVerificationRequest -> { + if (matrixSessionCache.getOrNull(navTarget.sessionId) == null) { + Timber.w("Couldn't find session ${navTarget.sessionId} for the incoming verification request") + lifecycleScope.launch { backstack.pop() } + return emptyNode(buildContext) + } + val inputs = OtherSessionIncomingVerificationNode.Inputs( + sessionId = navTarget.sessionId, + verificationRequest = navTarget.verificationRequest, + ) + val callback = object : OtherSessionIncomingVerificationNode.Callback { + override fun onDone() { + backstack.pop() + } + } + createNode(buildContext, plugins = listOf(inputs, callback)) + } NavTarget.BugReport -> { val callback = object : BugReportEntryPoint.Callback { override fun onDone() { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt index 7080b34f8e..201a4adab0 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt @@ -28,8 +28,8 @@ import io.element.android.appnav.room.joined.JoinedRoomFlowNode import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.appnav.room.joined.LoadingRoomNodeView import io.element.android.features.joinroom.api.JoinRoomEntryPoint -import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint -import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint.Params +import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint +import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint.Params import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt index 0c9abbc571..d74b11be0c 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt @@ -6,12 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.appnav.room.joined import android.os.Parcelable -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt index 175b662e08..2a2e3f5b91 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt @@ -12,7 +12,6 @@ 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 @@ -68,7 +67,6 @@ fun LoadingRoomNodeView( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun LoadingRoomTopBar( onBackClick: () -> Unit, diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt index 8b02752a64..17d6826a6c 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt @@ -13,7 +13,7 @@ import com.bumble.appyx.core.state.SavedStateMap import dev.zacsweers.metro.Inject import io.element.android.appnav.session.MatrixSessionCache import io.element.android.features.preferences.api.CacheService -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.Flow diff --git a/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt b/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt index ffae5f2b17..20d7bad228 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt @@ -21,6 +21,9 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analyticsproviders.api.AnalyticsUserData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber @@ -41,6 +44,12 @@ class MatrixSessionCache( private val analyticsService: AnalyticsService, ) : MatrixClientProvider { private val sessionIdsToMatrixSession = ConcurrentHashMap() + private val _matrixClients = MutableStateFlow>(emptyList()) + + /** + * The [MatrixClient]s currently in the cache. + */ + val matrixClients: StateFlow> = _matrixClients.asStateFlow() private val restoreMutex = Mutex() init { @@ -51,10 +60,12 @@ class MatrixSessionCache( fun removeAll() { sessionIdsToMatrixSession.clear() + updateMatrixClients() } fun remove(sessionId: SessionId) { sessionIdsToMatrixSession.remove(sessionId) + updateMatrixClients() } override fun getOrNull(sessionId: SessionId): MatrixClient? { @@ -122,8 +133,13 @@ class MatrixSessionCache( matrixClient = matrixClient, syncOrchestrator = syncOrchestrator, ) + updateMatrixClients() syncOrchestrator.start() } + + private fun updateMatrixClients() { + _matrixClients.value = sessionIdsToMatrixSession.values.map { it.matrixClient } + } } private data class InMemoryMatrixSession( diff --git a/appnav/src/main/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserver.kt b/appnav/src/main/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserver.kt new file mode 100644 index 0000000000..bfc952334a --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserver.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.verification + +import dev.zacsweers.metro.Inject +import io.element.android.appnav.session.MatrixSessionCache +import io.element.android.libraries.core.coroutine.withPreviousValue +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener +import io.element.android.libraries.matrix.api.verification.VerificationRequest +import io.element.android.services.appnavstate.api.AppForegroundStateService +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import timber.log.Timber +import kotlin.time.Duration.Companion.minutes + +data class IncomingVerificationRequestData( + val sessionId: SessionId, + val verificationRequest: VerificationRequest.Incoming, +) + +/** + * Listens to the incoming verification requests of all the sessions in [MatrixSessionCache], not only the current one. + */ +@Inject +class IncomingVerificationRequestObserver( + private val matrixSessionCache: MatrixSessionCache, + private val appForegroundStateService: AppForegroundStateService, +) { + /** + * Emits the incoming verification requests once the app is in foreground. + * Requests received while the app is in background for too long are discarded. + */ + fun incomingVerificationRequests(): Flow = channelFlow { + launch { + matrixSessionCache.matrixClients.withPreviousValue().collect { (previousMatrixClients, matrixClients) -> + val previousMatrixClientSet = previousMatrixClients.orEmpty().toSet() + // Remove the listener of the clients which are not in the cache anymore + (previousMatrixClientSet - matrixClients.toSet()).forEach { matrixClient -> + matrixClient.sessionVerificationService.setListener(null) + } + (matrixClients - previousMatrixClientSet).forEach { matrixClient -> + val sessionId = matrixClient.sessionId + matrixClient.sessionVerificationService.setListener( + object : SessionVerificationServiceListener { + override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) { + launch { + // Wait until the app is in foreground to display the incoming verification request + // TODO there should also be a timeout for > 10 minutes elapsed since the request was created, + // but the SDK doesn't expose that info yet + val isInForeground = withTimeoutOrNull(2.minutes) { + appForegroundStateService.isInForeground.first { it } + } + if (isInForeground == null) { + Timber.w("Incoming verification request ${verificationRequest.details.flowId} discarded due to timeout.") + } else { + send(IncomingVerificationRequestData(sessionId, verificationRequest)) + } + } + } + } + ) + } + } + } + awaitClose { + matrixSessionCache.matrixClients.value.forEach { matrixClient -> + matrixClient.sessionVerificationService.setListener(null) + } + } + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionIncomingVerificationNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionIncomingVerificationNode.kt new file mode 100644 index 0000000000..115c174bb8 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionIncomingVerificationNode.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.verification + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.appnav.di.SessionGraphFactory +import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.DependencyInjectionGraphOwner +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.verification.VerificationRequest +import kotlinx.parcelize.Parcelize + +/** + * Display an incoming verification request received by a session which is not the current one, without switching the current session. + * It sets up the Session graph of the session receiving the request. + */ +@ContributesNode(AppScope::class) +@AssistedInject +class OtherSessionIncomingVerificationNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + sessionGraphFactory: SessionGraphFactory, + matrixClientProvider: MatrixClientProvider, + private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint, +) : ParentNode( + navModel = PermanentNavModel( + navTargets = setOf(NavTarget), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +), DependencyInjectionGraphOwner { + interface Callback : Plugin { + fun onDone() + } + + @Parcelize + object NavTarget : Parcelable + + data class Inputs( + val sessionId: SessionId, + val verificationRequest: VerificationRequest.Incoming, + ) : NodeInputs + + private val callback: Callback = callback() + private val inputs: Inputs = inputs() + override val graph = sessionGraphFactory.create( + requireNotNull(matrixClientProvider.getOrNull(inputs.sessionId)) { "No MatrixClient for session ${inputs.sessionId}" } + ) + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return incomingVerificationEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = IncomingVerificationEntryPoint.Params(inputs.verificationRequest), + callback = object : IncomingVerificationEntryPoint.Callback { + override fun onDone() { + callback.onDone() + } + }, + ) + } + + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = navModel, + modifier = modifier, + ) + } +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt new file mode 100644 index 0000000000..4d5cfe06f3 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.roomlist.RoomListService +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.roomlist.FakeRoomListService +import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LoggedInEventProcessorTest { + @Test + fun `test observeEvents with left membership change`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + val roomMembershipObserver = RoomMembershipObserver() + + val loggedInEventProcessor = createLoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = roomMembershipObserver, + ) + + loggedInEventProcessor.observeEvents(backgroundScope) + + testScheduler.runCurrent() + + roomMembershipObserver.notifyUserLeftRoom( + roomId = A_ROOM_ID, + isSpace = false, + membershipBeforeLeft = CurrentUserMembership.JOINED, + ) + + testScheduler.runCurrent() + + // Verify that the snackbar message was displayed + assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull() + } + + @Test + fun `test observeEvents with left membership change for space`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + val roomMembershipObserver = RoomMembershipObserver() + + val loggedInEventProcessor = createLoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = roomMembershipObserver, + ) + + loggedInEventProcessor.observeEvents(backgroundScope) + + testScheduler.runCurrent() + + roomMembershipObserver.notifyUserLeftRoom( + roomId = A_ROOM_ID, + isSpace = true, + membershipBeforeLeft = CurrentUserMembership.JOINED, + ) + + testScheduler.runCurrent() + + // Verify that the snackbar message was displayed + assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull() + } + + @Test + fun `test observeEvents with invitation rejected membership change`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + val roomMembershipObserver = RoomMembershipObserver() + + val loggedInEventProcessor = createLoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = roomMembershipObserver, + ) + + loggedInEventProcessor.observeEvents(backgroundScope) + + testScheduler.runCurrent() + + roomMembershipObserver.notifyUserLeftRoom( + roomId = A_ROOM_ID, + isSpace = false, + membershipBeforeLeft = CurrentUserMembership.INVITED, + ) + + testScheduler.runCurrent() + + // Verify that the snackbar message was displayed + assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull() + } + + @Test + fun `test observeEvents with knock retracted membership change`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + val roomMembershipObserver = RoomMembershipObserver() + + val loggedInEventProcessor = createLoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = roomMembershipObserver, + ) + + loggedInEventProcessor.observeEvents(backgroundScope) + + testScheduler.runCurrent() + + roomMembershipObserver.notifyUserLeftRoom( + roomId = A_ROOM_ID, + isSpace = false, + membershipBeforeLeft = CurrentUserMembership.KNOCKED, + ) + + testScheduler.runCurrent() + + // Verify that the snackbar message was displayed + assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull() + } + + @Test + fun `test stopObserving cancels the child scope`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + val roomMembershipObserver = RoomMembershipObserver() + + val loggedInEventProcessor = createLoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = roomMembershipObserver, + ) + + loggedInEventProcessor.observeEvents(backgroundScope) + + assertThat(loggedInEventProcessor.currentChildScope?.isActive).isTrue() + + testScheduler.runCurrent() + + // Stop observing events + loggedInEventProcessor.stopObserving() + + // Verify that the child scope is cancelled (the scope is null) + assertThat(loggedInEventProcessor.currentChildScope).isNull() + } + + @Test + fun `test notifications are cleared when room list service state is running`() = runTest { + val clearReadNotificationsLambda = lambdaRecorder { _: SessionId -> } + val snackbarDispatcher = SnackbarDispatcher() + val notificationCleaner = FakeNotificationCleaner(clearReadRoomsNotificationsLambda = clearReadNotificationsLambda) + val roomListService = FakeRoomListService() + + val loggedInEventProcessor = createLoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + notificationCleaner = notificationCleaner, + roomListService = roomListService, + ) + + loggedInEventProcessor.observeEvents(backgroundScope) + + testScheduler.runCurrent() + + // Simulate the room list service state changing to Running + roomListService.postState(RoomListService.State.Running) + + testScheduler.runCurrent() + + // Verify that the notifications were cleared + clearReadNotificationsLambda.assertions().isCalledOnce().with(value(A_SESSION_ID)) + } + + private fun TestScope.createLoggedInEventProcessor( + snackbarDispatcher: SnackbarDispatcher, + roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), + sessionId: SessionId = A_SESSION_ID, + roomListService: FakeRoomListService = FakeRoomListService(), + notificationCleaner: FakeNotificationCleaner = FakeNotificationCleaner(), + ) = LoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = roomMembershipObserver, + sessionId = sessionId, + roomListService = roomListService, + notificationCleaner = notificationCleaner, + dispatchers = testCoroutineDispatchers(), + ) +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index fea30c8019..4a20341212 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -25,6 +25,8 @@ import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.session.FakeSyncOrchestratorFactory import io.element.android.appnav.session.MatrixSessionCache +import io.element.android.appnav.verification.IncomingVerificationRequestObserver +import io.element.android.features.announcement.test.FakeAnnouncementService import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginParams import io.element.android.features.login.test.FakeLoginEntryPoint @@ -32,7 +34,6 @@ import io.element.android.features.login.test.FakeLoginIntentResolver import io.element.android.features.login.test.accesscontrol.FakeAccountProviderAccessControl import io.element.android.features.preferences.test.FakeCacheService import io.element.android.features.rageshake.test.FakeBugReportEntryPoint -import io.element.android.features.rageshake.test.logs.FakeAnnouncementService import io.element.android.features.share.test.FakeShareIntentHandler import io.element.android.features.signedout.test.FakeSignedOutEntryPoint import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint @@ -41,7 +42,7 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.FakeSdkMetadata import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder import io.element.android.libraries.oauth.test.FakeOAuthActionFlow import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory @@ -50,6 +51,7 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher import io.element.android.services.apperror.test.FakeAppErrorStateService +import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.tests.testutils.node.FakeNodeFactoriesBindings import io.element.android.tests.testutils.node.FakeParentNode import io.element.android.tests.testutils.presenter.NotUsedPresenter @@ -139,6 +141,8 @@ class RootFlowNodeTest : RobolectricTest() { loginEntryPoint = loginEntryPoint, imageLoaderHolder = FakeImageLoaderHolder(), analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(), + featureFlagService = FakeFeatureFlagService(), + announcementService = FakeAnnouncementService(), ) } ) @@ -183,6 +187,10 @@ class RootFlowNodeTest : RobolectricTest() { announcementService = FakeAnnouncementService(), analyticsService = FakeAnalyticsService(), analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(), + incomingVerificationRequestObserver = IncomingVerificationRequestObserver( + matrixSessionCache = matrixSessionCache, + appForegroundStateService = FakeAppForegroundStateService(), + ), appCoroutineScope = backgroundScope, ) } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt index bf315ddb01..8d4a162d4f 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt @@ -70,9 +70,11 @@ class MatrixSessionCacheTest { fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient) assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient) + assertThat(matrixSessionCache.matrixClients.value).containsExactly(fakeMatrixClient) // Remove matrixSessionCache.remove(A_SESSION_ID) assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() + assertThat(matrixSessionCache.matrixClients.value).isEmpty() } @Test @@ -83,9 +85,11 @@ class MatrixSessionCacheTest { fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient) assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient) + assertThat(matrixSessionCache.matrixClients.value).containsExactly(fakeMatrixClient) // Remove all matrixSessionCache.removeAll() assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() + assertThat(matrixSessionCache.matrixClients.value).isEmpty() } @Test diff --git a/appnav/src/test/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserverTest.kt new file mode 100644 index 0000000000..35946ea82d --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserverTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.verification + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.appnav.session.MatrixSessionCache +import io.element.android.appnav.session.SyncOrchestrator +import io.element.android.features.networkmonitor.test.FakeNetworkMonitor +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.core.FlowId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails +import io.element.android.libraries.matrix.api.verification.VerificationRequest +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID_3 +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.appnavstate.test.FakeAppForegroundStateService +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class IncomingVerificationRequestObserverTest { + private val sessionVerificationService1 = FakeSessionVerificationService() + private val sessionVerificationService2 = FakeSessionVerificationService() + + @Test + fun `incoming verification requests of all the sessions are emitted with their session id`() = runTest { + val matrixSessionCache = createMatrixSessionCache() + val observer = IncomingVerificationRequestObserver( + matrixSessionCache = matrixSessionCache, + appForegroundStateService = FakeAppForegroundStateService(), + ) + observer.incomingVerificationRequests().test { + matrixSessionCache.getOrRestore(A_SESSION_ID) + matrixSessionCache.getOrRestore(A_SESSION_ID_2) + runCurrent() + val request = anIncomingVerificationRequest() + sessionVerificationService2.listener!!.onIncomingSessionRequest(request) + assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID_2, request)) + sessionVerificationService1.listener!!.onIncomingSessionRequest(request) + assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID, request)) + } + // The listeners are removed when the flow is not collected anymore + assertThat(sessionVerificationService1.listener).isNull() + assertThat(sessionVerificationService2.listener).isNull() + } + + @Test + fun `listener is removed when the session is removed from the cache`() = runTest { + val matrixSessionCache = createMatrixSessionCache() + val observer = IncomingVerificationRequestObserver( + matrixSessionCache = matrixSessionCache, + appForegroundStateService = FakeAppForegroundStateService(), + ) + observer.incomingVerificationRequests().test { + matrixSessionCache.getOrRestore(A_SESSION_ID) + matrixSessionCache.getOrRestore(A_SESSION_ID_2) + runCurrent() + assertThat(sessionVerificationService1.listener).isNotNull() + assertThat(sessionVerificationService2.listener).isNotNull() + matrixSessionCache.remove(A_SESSION_ID) + runCurrent() + assertThat(sessionVerificationService1.listener).isNull() + assertThat(sessionVerificationService2.listener).isNotNull() + } + } + + @Test + fun `incoming user verification request of another session is emitted with its session id`() = runTest { + val matrixSessionCache = createMatrixSessionCache() + val observer = IncomingVerificationRequestObserver( + matrixSessionCache = matrixSessionCache, + appForegroundStateService = FakeAppForegroundStateService(), + ) + observer.incomingVerificationRequests().test { + matrixSessionCache.getOrRestore(A_SESSION_ID) + matrixSessionCache.getOrRestore(A_SESSION_ID_2) + runCurrent() + val request = anIncomingUserVerificationRequest() + sessionVerificationService2.listener!!.onIncomingSessionRequest(request) + assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID_2, request)) + } + } + + @Test + fun `incoming verification request is emitted when the app goes to foreground`() = runTest { + val matrixSessionCache = createMatrixSessionCache() + val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = false) + val observer = IncomingVerificationRequestObserver( + matrixSessionCache = matrixSessionCache, + appForegroundStateService = appForegroundStateService, + ) + observer.incomingVerificationRequests().test { + matrixSessionCache.getOrRestore(A_SESSION_ID) + runCurrent() + val request = anIncomingVerificationRequest() + sessionVerificationService1.listener!!.onIncomingSessionRequest(request) + advanceTimeBy(30.seconds) + expectNoEvents() + appForegroundStateService.givenIsInForeground(true) + assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID, request)) + } + } + + @Test + fun `incoming verification request is discarded if the app does not go to foreground in time`() = runTest { + val matrixSessionCache = createMatrixSessionCache() + val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = false) + val observer = IncomingVerificationRequestObserver( + matrixSessionCache = matrixSessionCache, + appForegroundStateService = appForegroundStateService, + ) + observer.incomingVerificationRequests().test { + matrixSessionCache.getOrRestore(A_SESSION_ID) + runCurrent() + sessionVerificationService1.listener!!.onIncomingSessionRequest(anIncomingVerificationRequest()) + advanceTimeBy(3.minutes) + appForegroundStateService.givenIsInForeground(true) + runCurrent() + expectNoEvents() + } + } + + private fun TestScope.createMatrixSessionCache(): MatrixSessionCache { + val matrixClient1 = FakeMatrixClient( + sessionId = A_SESSION_ID, + sessionCoroutineScope = backgroundScope, + sessionVerificationService = sessionVerificationService1, + userIdServerNameLambda = { "server" }, + ) + val matrixClient2 = FakeMatrixClient( + sessionId = A_SESSION_ID_2, + sessionCoroutineScope = backgroundScope, + sessionVerificationService = sessionVerificationService2, + userIdServerNameLambda = { "server" }, + ) + return MatrixSessionCache( + authenticationService = FakeMatrixAuthenticationService( + matrixClientResult = { sessionId -> + Result.success(if (sessionId == A_SESSION_ID) matrixClient1 else matrixClient2) + }, + ), + syncOrchestratorFactory = object : SyncOrchestrator.Factory { + override fun create(matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope) = SyncOrchestrator( + matrixClient = matrixClient, + sessionCoroutineScope = sessionCoroutineScope, + appForegroundStateService = FakeAppForegroundStateService(), + networkMonitor = FakeNetworkMonitor(), + dispatchers = testCoroutineDispatchers(), + analyticsService = FakeAnalyticsService(), + ) + }, + analyticsService = FakeAnalyticsService(), + ) + } + + private fun anIncomingVerificationRequest() = VerificationRequest.Incoming.OtherSession( + details = aSessionVerificationRequestDetails(senderProfile = MatrixUser(A_SESSION_ID)), + ) + + private fun anIncomingUserVerificationRequest() = VerificationRequest.Incoming.User( + details = aSessionVerificationRequestDetails(senderProfile = MatrixUser(A_USER_ID_3)), + ) + + private fun aSessionVerificationRequestDetails(senderProfile: MatrixUser) = SessionVerificationRequestDetails( + senderProfile = senderProfile, + flowId = FlowId("flowId"), + deviceId = DeviceId("deviceId"), + deviceDisplayName = "a device name", + firstSeenTimestamp = 0, + ) +} diff --git a/build.gradle.kts b/build.gradle.kts index e7a03b403d..a5af43c743 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -101,10 +101,6 @@ allprojects { // Uncomment to suppress Compose Kotlin compiler compatibility warning // freeCompilerArgs.addAll(listOf("-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true")) - - // Fix compilation warning for annotations - // See https://youtrack.jetbrains.com/issue/KT-73255/Change-defaulting-rule-for-annotations for more details - freeCompilerArgs.add("-Xannotation-default-target=first-only") } } } @@ -149,7 +145,13 @@ sonar { allprojects { tasks.withType { - maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) + // Robolectric 4.17 sets up shared memory during application setup, which goes through its + // FileDescriptor interceptor. That interceptor reaches into `java.io.FileDescriptor` and + // `jdk.internal.access.SharedSecrets`, both of which are encapsulated since JDK 17. + jvmArgs( + "--add-opens=java.base/java.io=ALL-UNNAMED", + "--add-exports=java.base/jdk.internal.access=ALL-UNNAMED", + ) val isScreenshotTest = project.gradle.startParameter.taskNames.any { it.contains("paparazzi", ignoreCase = true) } if (isScreenshotTest) { @@ -173,6 +175,9 @@ allprojects { // Gradle parallelise by running many modules' test tasks concurrently instead. maxParallelForks = 1 + // Increase heap size for screenshot tests + maxHeapSize = "2g" + // Disable screenshot tests by default exclude("ui/*.class") exclude("translations/*.class") @@ -211,19 +216,93 @@ tasks.register("generateDocsToc", Exec::class.java) { commandLine("python3", "tools/docs/generate_toc.py", *inputs.files.map { it.path }.toTypedArray()) } -// Make sure to delete old screenshots before recording new ones +// Delete all the screenshots. Not hooked to the record tasks on purpose, see `pruneObsoleteSnapshots` +// below. Run it by hand (or use Paparazzi's own `cleanRecordPaparazziDebug`) to record from scratch. subprojects { val snapshotsDir = File("${project.projectDir}/src/test/snapshots") - val removeOldScreenshotsTask = tasks.register("removeOldSnapshots") { + tasks.register("removeOldSnapshots") { onlyIf { snapshotsDir.exists() } doFirst { println("Delete previous screenshots located at $snapshotsDir\n") snapshotsDir.deleteRecursively() } } - tasks.findByName("recordPaparazzi")?.dependsOn(removeOldScreenshotsTask) - tasks.findByName("recordPaparazziDebug")?.dependsOn(removeOldScreenshotsTask) - tasks.findByName("recordPaparazziRelease")?.dependsOn(removeOldScreenshotsTask) +} + +// Delete the screenshots of previews that no longer exist, *after* recording. +// +// Wiping the whole snapshots directory before recording used to be how stale screenshots were removed, +// but it also removes the reference image Paparazzi needs to honour +// `app.cash.paparazzi.overwriteOnMaxPercentDifference` (see gradle.properties): with no existing file to +// compare against, every screenshot is rewritten unconditionally, so renderer noise far below the +// threshold `verifyPaparazziDebug` accepts still rewrites the Git LFS blob of every affected screenshot. +// +// Instead, let the record task keep the existing screenshots, then prune the ones belonging to a +// preview that Paparazzi did not render at all. +// +// Pruning per preview, and not per screenshot, is deliberate: a preview that fails to render is absent +// from the run report in exactly the same way as a preview that was deleted, and LayoutLib does fail to +// render a preview now and then. Matching whole previews means such +// a failure leaves the other screenshots of that preview in place, so it cannot silently delete a +// screenshot that is still in use. The cost is that dropping one value from a PreviewParameterProvider +// leaves its last screenshot behind, since the preview itself still renders. Run `removeOldSnapshots` +// and record again to clean those up. +subprojects { + val projectDir = project.projectDir + val pruneObsoleteSnapshots = tasks.register("pruneObsoleteSnapshots") { + description = "Delete the recorded screenshots of previews that no longer exist" + val imagesDir = File(projectDir, "src/test/snapshots/images") + val reportsDir = File(projectDir, "build/reports/paparazzi") + onlyIf { imagesDir.exists() } + doLast { + // One `runs/.js` per forked test JVM, each holding `window.runs[...] = [ ...snapshots... ];`. + val runFiles = reportsDir.walkTopDown() + .filter { it.isFile && it.extension == "js" && it.parentFile.name == "runs" } + .toList() + check(runFiles.isNotEmpty()) { + "No Paparazzi run report found under $reportsDir, so there is no way to tell which " + + "screenshots are still in use. Refusing to delete anything." + } + // `testName` is serialised as ".#" and the screenshot file is named + // "__.png". Only the package and the class are used here, they identify + // the preview. Do not be tempted to rebuild the whole file name from the method: it has to be + // normalised exactly as `Snapshot.toFileName` does, which replaces whitespace with the + // delimiter, so a preview named "List item - Simple" does not read as its own file name. + val testNameRegex = """"testName"\s*:\s*"(.*)\.([^.]*)#""".toRegex() + val renderedPreviews = runFiles.flatMap { runFile -> + testNameRegex.findAll(runFile.readText()).map { match -> + val (packageName, className) = match.destructured + // Keep the trailing delimiter, so that `Foo_` does not also match `FooBar_…`. + "${packageName}_${className}_" + } + }.toSet() + check(renderedPreviews.isNotEmpty()) { + "Could not read any snapshot name out of the Paparazzi run reports in $reportsDir. " + + "The report format has probably changed. Refusing to delete anything." + } + + val existing = imagesDir.listFiles { file -> file.extension == "png" }.orEmpty() + val obsolete = existing.filter { file -> renderedPreviews.none { file.name.startsWith(it) } } + // Deleting a screenshot is a tracked file deletion, and a bad `renderedPreviews` set would wipe + // thousands of them. Anything beyond a trickle means something is wrong rather than a few + // deleted previews. + check(obsolete.size <= existing.size / 10) { + "pruneObsoleteSnapshots would delete ${obsolete.size} of ${existing.size} screenshots in " + + "$imagesDir, which looks like a bug rather than deleted previews. Refusing to delete " + + "anything. If the deletions are expected, run `removeOldSnapshots` and record again." + } + obsolete.forEach { + println("Delete obsolete screenshot ${it.name}") + it.delete() + } + println("Pruned ${obsolete.size} obsolete screenshot(s), kept ${existing.size - obsolete.size}.\n") + } + } + // `configureEach` on a live filtered collection, and not `findByName`, because the Paparazzi plugin + // registers its record tasks per variant, long after this block runs. + tasks.matching { it.name.startsWith("recordPaparazzi") }.configureEach { + finalizedBy(pruneObsoleteSnapshots) + } } subprojects { diff --git a/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt b/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt index 3e6c05ad01..854f327665 100644 --- a/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt +++ b/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt @@ -53,7 +53,7 @@ class ContributesNodeProcessor( val annotatedSymbols = resolver.getSymbolsWithAnnotation(ContributesNode::class.qualifiedName!!) .filterIsInstance() - val (validSymbols, invalidSymbols) = annotatedSymbols.partition { it.validate() } + val (validSymbols, invalidSymbols) = annotatedSymbols.partition { it.validate(enableNewFeatures = false) } if (validSymbols.isEmpty()) return invalidSymbols diff --git a/enterprise b/enterprise index 6b422f4c8e..819746a734 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 6b422f4c8e9e9e5557056e8a861fe3bfd9338f24 +Subproject commit 819746a734fbbeb4ffcbf3e842f7cc694cccc8a4 diff --git a/fastlane/metadata/android/en-US/changelogs/202609040.txt b/fastlane/metadata/android/en-US/changelogs/202609040.txt new file mode 100644 index 0000000000..f89659a6f0 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202609040.txt @@ -0,0 +1,2 @@ +Main changes in this version: fix an issue with the room list not displaying all activity from unread messages. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/features/analytics/api/build.gradle.kts b/features/analytics/api/build.gradle.kts index 5c7f0b223a..f861a375b3 100644 --- a/features/analytics/api/build.gradle.kts +++ b/features/analytics/api/build.gradle.kts @@ -16,6 +16,4 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.designsystem) - implementation(projects.libraries.androidutils) - implementation(projects.libraries.uiStrings) } diff --git a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt index d743ae4cd6..69def559a1 100644 --- a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt +++ b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt @@ -17,4 +17,6 @@ sealed interface Announcement { } data object NewNotificationSound : Announcement + + data object MultiAccount : Announcement } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt index adb81db61a..3c36989571 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt @@ -29,10 +29,11 @@ class DefaultAnnouncementService( ) : AnnouncementService { override suspend fun showAnnouncement(announcement: Announcement) { when (announcement) { - is Announcement.Fullscreen -> showFullscreenAnnouncement(announcement) + is Announcement.Fullscreen -> showAnnouncementIfNeverShown(announcement) Announcement.NewNotificationSound -> { announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) } + Announcement.MultiAccount -> showAnnouncementIfNeverShown(announcement) } } @@ -44,16 +45,22 @@ class DefaultAnnouncementService( return combine( flowOf(Unit), announcementStore.announcementStatusFlow(Announcement.NewNotificationSound), - ) { _, newNotificationSoundStatus -> + announcementStore.announcementStatusFlow(Announcement.MultiAccount), + ) { _, newNotificationSoundStatus, multiAccountStatus -> buildList { if (newNotificationSoundStatus == AnnouncementStatus.Show) { add(Announcement.NewNotificationSound) } + // The multi-account announcement is pending until it is dismissed. Users who were already + // logged in when the feature became available still have the default NeverShown status. + if (multiAccountStatus != AnnouncementStatus.Shown) { + add(Announcement.MultiAccount) + } } } } - private suspend fun showFullscreenAnnouncement(announcement: Announcement.Fullscreen) { + private suspend fun showAnnouncementIfNeverShown(announcement: Announcement) { val currentValue = announcementStore.announcementStatusFlow(announcement).first() if (currentValue == AnnouncementStatus.NeverShown) { announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Show) diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementView.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementView.kt index cb962f8968..b724bfacc7 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementView.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementView.kt @@ -121,7 +121,6 @@ private fun FullscreenAnnouncementHeader( subTitle = announcement.subtitle(), iconStyle = BigIcon.Style.Default( vectorIcon = announcement.icon(), - usePrimaryTint = true, ), ) } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt index 2093baa8b3..2235285bcf 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.map private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement") private val newNotificationSoundKey = intPreferencesKey("newNotificationSound") +private val multiAccountKey = intPreferencesKey("multiAccountAnnouncement") @ContributesBinding(AppScope::class) class DefaultAnnouncementStore( @@ -36,11 +37,15 @@ class DefaultAnnouncementStore( override fun announcementStatusFlow(announcement: Announcement): Flow { val key = announcement.toKey() // Default-status convention: Shown for one-shot intro/feature announcements that should - // NOT replay on first launch (Fullscreen.Space is disabled; NewNotificationSound is opted - // in by AppMigration08). + // NOT replay on first launch. + // - Fullscreen.Space is disabled + // - NewNotificationSound is opted in by AppMigration08 + // - MultiAccount is NeverShown by default, which means pending. It is set to Shown when the user enters + // the login flow while the feature is enabled (see NotLoggedInFlowNode), or dismisses the announcement. val defaultStatus = when (announcement) { Announcement.Fullscreen.Space -> AnnouncementStatus.Shown Announcement.NewNotificationSound -> AnnouncementStatus.Shown + Announcement.MultiAccount -> AnnouncementStatus.NeverShown } return store.data.map { prefs -> val ordinal = prefs[key] ?: defaultStatus.ordinal @@ -56,4 +61,5 @@ class DefaultAnnouncementStore( private fun Announcement.toKey() = when (this) { Announcement.Fullscreen.Space -> spaceAnnouncementKey Announcement.NewNotificationSound -> newNotificationSoundKey + Announcement.MultiAccount -> multiAccountKey } diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt index c72d147c1d..18442b362d 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt @@ -51,6 +51,22 @@ class DefaultAnnouncementServiceTest { assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.Show) } + @Test + fun `when showing MultiAccount announcement, announcement is set to show only if it was never shown`() = runTest { + val announcementStore = InMemoryAnnouncementStore() + val sut = createDefaultAnnouncementService( + announcementStore = announcementStore, + ) + assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.NeverShown) + sut.showAnnouncement(Announcement.MultiAccount) + assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Show) + // Simulate user close the announcement + sut.onAnnouncementDismissed(Announcement.MultiAccount) + // Calling again showAnnouncement should not change the value + sut.showAnnouncement(Announcement.MultiAccount) + assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Shown) + } + @Test fun `test announcementsToShowFlow`() = runTest { val announcementStore = InMemoryAnnouncementStore() @@ -58,10 +74,16 @@ class DefaultAnnouncementServiceTest { announcementStore = announcementStore, ) sut.announcementsToShowFlow().test { - assertThat(awaitItem()).isEmpty() + // MultiAccount is pending by default + assertThat(awaitItem()).containsExactly(Announcement.MultiAccount) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) - assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound) + assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount) + announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Show) + // Still pending + assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown) + assertThat(awaitItem()).containsExactly(Announcement.MultiAccount) + announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Shown) assertThat(awaitItem()).isEmpty() } } diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt index ed6dfec850..9b5e762eb6 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt @@ -16,9 +16,11 @@ import kotlinx.coroutines.flow.asStateFlow class InMemoryAnnouncementStore( initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, + initialMultiAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, ) : AnnouncementStore { private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus) private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus) + private val multiAccountAnnouncement = MutableStateFlow(initialMultiAccountAnnouncementStatus) override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) { announcement.toMutableStateFlow().value = status @@ -31,10 +33,12 @@ class InMemoryAnnouncementStore( override suspend fun reset() { fullScreenAnnouncement.value = AnnouncementStatus.NeverShown newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown + multiAccountAnnouncement.value = AnnouncementStatus.NeverShown } private fun Announcement.toMutableStateFlow() = when (this) { is Announcement.Fullscreen -> fullScreenAnnouncement Announcement.NewNotificationSound -> newNotificationSoundAnnouncement + Announcement.MultiAccount -> multiAccountAnnouncement } } diff --git a/features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt b/features/announcement/test/src/main/kotlin/io/element/android/features/announcement/test/FakeAnnouncementService.kt similarity index 96% rename from features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt rename to features/announcement/test/src/main/kotlin/io/element/android/features/announcement/test/FakeAnnouncementService.kt index fefb61d722..f59ac9bf6c 100644 --- a/features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt +++ b/features/announcement/test/src/main/kotlin/io/element/android/features/announcement/test/FakeAnnouncementService.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.rageshake.test.logs +package io.element.android.features.announcement.test import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/features/call/api/build.gradle.kts b/features/call/api/build.gradle.kts index 1480a31fd3..572e3fdee4 100644 --- a/features/call/api/build.gradle.kts +++ b/features/call/api/build.gradle.kts @@ -17,6 +17,5 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) } diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallTracker.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallTracker.kt new file mode 100644 index 0000000000..42190316c3 --- /dev/null +++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallTracker.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.api + +/** + * The write side of [CurrentCallService], for whoever is actually running a call. + * + * Separate from [CurrentCallService] because the two have opposite audiences: everything in the app + * that wants to know whether we are in a call reads the service, while only a call implementation + * writes. The WebView path reaches the implementation directly through `ActiveCallManager`, both + * being in `features/call/impl`; the native path is in another module and would otherwise have to + * depend on that impl to report a call it is running itself. + */ +interface CurrentCallTracker { + fun onCallStarted(call: CurrentCall) + + fun onCallEnded() +} diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/RingingCallTracker.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/RingingCallTracker.kt new file mode 100644 index 0000000000..aaec286072 --- /dev/null +++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/RingingCallTracker.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.api + +/** + * Tells the ringing machinery what became of the call it is ringing for. + * + * There is one ring per incoming call, and it is owned by `ActiveCallManager` in + * `features/call/impl` - but *whichever* call implementation answers has to stop it, cancel the + * missed-call timeout and release the wake lock. The WebView path reaches the manager directly, + * being in the same module; the native path is in another one, and this is the narrow slice of it + * that anyone answering a call needs. + */ +interface RingingCallTracker { + /** + * The user is now in the call: stop ringing, take down the incoming-call UI, and cancel the + * timeout that would otherwise post a missed call. + */ + suspend fun onCallJoined(callData: CallData) + + /** The call is over. Clears the active call, and stops a ring that is still in progress. */ + suspend fun onCallEnded(callData: CallData) +} diff --git a/features/call/impl/build.gradle.kts b/features/call/impl/build.gradle.kts index d01e31451e..e80622fa74 100644 --- a/features/call/impl/build.gradle.kts +++ b/features/call/impl/build.gradle.kts @@ -86,10 +86,10 @@ dependencies { implementation(libs.coil.compose) implementation(libs.serialization.json) implementation(libs.element.call.embedded) + implementation(projects.features.callnative.api) api(projects.features.call.api) testCommonDependencies(libs, true) - testImplementation(projects.features.call.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.matrix.test) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt index 532db3305d..074ea87551 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt @@ -16,14 +16,24 @@ import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.utils.ActiveCallManager import io.element.android.features.call.impl.utils.IntentProvider +import io.element.android.features.callnative.api.NativeCallEntryPoint +import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber @ContributesBinding(AppScope::class) class DefaultElementCallEntryPoint( @ApplicationContext private val context: Context, private val activeCallManager: ActiveCallManager, + private val nativeCallEntryPoint: NativeCallEntryPoint, + private val featureFlagService: FeatureFlagService, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : ElementCallEntryPoint { companion object { const val EXTRA_CALL_TYPE = "EXTRA_CALL_TYPE" @@ -31,7 +41,18 @@ class DefaultElementCallEntryPoint( } override fun startCall(callData: CallData) { - context.startActivity(IntentProvider.createIntent(context, callData)) + // The flag is backed by storage, so it has to be read asynchronously. Caching it in an + // eagerly started StateFlow would race: a call placed just after app start could be routed + // on the default value before the real one arrived. + appCoroutineScope.launch { + val useNativeCall = featureFlagService.isFeatureEnabled(FeatureFlags.NativeCall) + Timber.i("startCall: roomId=${callData.roomId}, nativeCall=$useNativeCall") + if (useNativeCall) { + nativeCallEntryPoint.startCall(callData) + } else { + context.startActivity(IntentProvider.createIntent(context, callData)) + } + } } override suspend fun handleIncomingCall( diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt index 647f5ebcfc..82a4ec0bb5 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt @@ -18,10 +18,8 @@ import androidx.core.app.PendingIntentCompat import androidx.core.app.Person import dev.zacsweers.metro.Inject import io.element.android.appconfig.ElementCallConfig -import io.element.android.features.call.api.CallData import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver import io.element.android.features.call.impl.ui.IncomingCallActivity -import io.element.android.features.call.impl.utils.IntentProvider import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.utils.CommonDrawables @@ -31,7 +29,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader import kotlin.time.Duration.Companion.seconds @@ -55,6 +53,15 @@ class RingingCallNotificationCreator( * Request code for the full screen intent. */ const val FULL_SCREEN_INTENT_REQUEST_CODE = 2 + + /** + * Request code for the answer action. + * + * Distinct from [FULL_SCREEN_INTENT_REQUEST_CODE] even though both now target + * `IncomingCallActivity`: they carry different extras, and a shared request code would let + * `FLAG_CANCEL_CURRENT` on one replace the other. + */ + const val ANSWER_REQUEST_CODE = 3 } suspend fun createNotification( @@ -89,14 +96,6 @@ class RingingCallNotificationCreator( .setImportant(true) .build() - val answerIntent = IntentProvider.getPendingIntent( - context, - CallData( - sessionId = sessionId, - roomId = roomId, - isAudioCall = audioOnly, - ), - ) val notificationData = CallNotificationData( sessionId = sessionId, roomId = roomId, @@ -112,6 +111,21 @@ class RingingCallNotificationCreator( audioOnly = audioOnly, ) + // Through IncomingCallActivity rather than straight to ElementCallActivity, so that + // answering goes through ElementCallEntryPoint and lands in whichever call stack the + // NativeCall flag selects. Naming the WebView activity here meant the notification's Answer + // button always opened the WebView, even with native calls turned on. + val answerIntent = PendingIntentCompat.getActivity( + context, + ANSWER_REQUEST_CODE, + Intent(context, IncomingCallActivity::class.java).apply { + putExtra(IncomingCallActivity.EXTRA_NOTIFICATION_DATA, notificationData) + putExtra(IncomingCallActivity.EXTRA_ANSWER_IMMEDIATELY, true) + }, + PendingIntent.FLAG_CANCEL_CURRENT, + false, + )!! + val declineIntent = PendingIntentCompat.getBroadcast( context, DECLINE_REQUEST_CODE, diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt index 1d6989fb3c..a4bb9eef31 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt @@ -8,6 +8,7 @@ package io.element.android.features.call.impl.ui +import android.content.Intent import android.os.Bundle import android.view.WindowManager import androidx.activity.compose.setContent @@ -47,6 +48,15 @@ class IncomingCallActivity : AppCompatActivity() { * Extra key for the notification data. */ const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA" + + /** + * Answer straight away rather than showing the incoming-call screen. + * + * Set by the notification's Answer action, which has already asked the user - showing them + * the same question again would be the bug. The screen is for the full-screen intent, where + * the user has not answered anything yet. + */ + const val EXTRA_ANSWER_IMMEDIATELY = "EXTRA_ANSWER_IMMEDIATELY" } @Inject @@ -85,6 +95,7 @@ class IncomingCallActivity : AppCompatActivity() { ) val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) } + if (answerImmediatelyIfAsked(intent)) return if (notificationData != null) { setContent { val colors by remember { @@ -116,6 +127,37 @@ class IncomingCallActivity : AppCompatActivity() { .launchIn(lifecycleScope) } + /** + * The Answer action can arrive while this activity is already showing the ringing screen, because + * the full screen intent put it there and it is `singleTask`. That delivers a new intent instead + * of recreating the activity, so the answer has to be handled here as well as in [onCreate] - and + * [setIntent] has to run first, or everything reading `intent` afterwards would still see the one + * the activity was created with. + */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + answerImmediatelyIfAsked(intent) + } + + /** + * Answers and finishes when the intent is the notification's Answer action, and reports whether + * it did. + * + * Answering goes through this activity rather than straight to a call screen so that it goes + * through `ElementCallEntryPoint`, which is what decides between the WebView and the native + * stack. The notification action used to name `ElementCallActivity` outright, so answering from + * the notification always opened the WebView however the flag was set. + */ + private fun answerImmediatelyIfAsked(intent: Intent?): Boolean { + if (intent == null || !intent.getBooleanExtra(EXTRA_ANSWER_IMMEDIATELY, false)) return false + val notificationData = IntentCompat.getParcelableExtra(intent, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) + ?: return false + onAnswer(notificationData) + finish() + return true + } + private fun onAnswer(notificationData: CallNotificationData) { elementCallEntryPoint.startCall( CallData( diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt index 685fc932fe..74a6b4873b 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt @@ -19,9 +19,11 @@ import coil3.annotation.DelicateCoilApi import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn +import dev.zacsweers.metro.binding import io.element.android.appconfig.ElementCallConfig import io.element.android.features.call.api.CallData import io.element.android.features.call.api.CurrentCall +import io.element.android.features.call.api.RingingCallTracker import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.libraries.core.extensions.runCatchingExceptions @@ -29,7 +31,7 @@ import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.push.api.notifications.ForegroundServiceType import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler @@ -90,7 +92,8 @@ interface ActiveCallManager { } @SingleIn(AppScope::class) -@ContributesBinding(AppScope::class) +@ContributesBinding(AppScope::class, binding = binding()) +@ContributesBinding(AppScope::class, binding = binding()) class DefaultActiveCallManager( @ApplicationContext context: Context, @AppCoroutineScope @@ -103,7 +106,16 @@ class DefaultActiveCallManager( private val appForegroundStateService: AppForegroundStateService, private val imageLoaderHolder: ImageLoaderHolder, private val systemClock: SystemClock, -) : ActiveCallManager { +) : ActiveCallManager, RingingCallTracker { + /** + * The two [RingingCallTracker] entry points are the existing methods under the names a caller + * outside this module can reach. Deliberately not new behaviour: answering has always had to do + * exactly this, and the native path simply could not say so from another module. + */ + override suspend fun onCallJoined(callData: CallData) = joinedCall(callData) + + override suspend fun onCallEnded(callData: CallData) = hangUpCall(callData) + private val tag = "ActiveCallManager" private var timedOutCallJob: Job? = null diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCurrentCallService.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCurrentCallService.kt index 1c8c0b342b..bf29ccd404 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCurrentCallService.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCurrentCallService.kt @@ -11,20 +11,23 @@ package io.element.android.features.call.impl.utils import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn +import dev.zacsweers.metro.binding import io.element.android.features.call.api.CurrentCall import io.element.android.features.call.api.CurrentCallService +import io.element.android.features.call.api.CurrentCallTracker import kotlinx.coroutines.flow.MutableStateFlow @SingleIn(AppScope::class) -@ContributesBinding(AppScope::class) -class DefaultCurrentCallService : CurrentCallService { +@ContributesBinding(AppScope::class, binding = binding()) +@ContributesBinding(AppScope::class, binding = binding()) +class DefaultCurrentCallService : CurrentCallService, CurrentCallTracker { override val currentCall = MutableStateFlow(CurrentCall.None) - fun onCallStarted(call: CurrentCall) { + override fun onCallStarted(call: CurrentCall) { currentCall.value = call } - fun onCallEnded() { + override fun onCallEnded() { currentCall.value = CurrentCall.None } } diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPointTest.kt similarity index 64% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPointTest.kt index 9b2ee3d923..76527b1165 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPointTest.kt @@ -6,25 +6,30 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call +package io.element.android.features.call.impl import android.content.Intent import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat import io.element.android.features.call.api.CallData -import io.element.android.features.call.impl.DefaultElementCallEntryPoint import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.ui.ElementCallActivity -import io.element.android.features.call.utils.FakeActiveCallManager +import io.element.android.features.call.impl.utils.FakeActiveCallManager +import io.element.android.features.call.impl.utils.FakeNativeCallEntryPoint +import io.element.android.features.callnative.api.NativeCallEntryPoint +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Test import org.robolectric.RuntimeEnvironment @@ -32,10 +37,13 @@ import org.robolectric.Shadows.shadowOf import kotlin.time.Duration.Companion.seconds class DefaultElementCallEntryPointTest : RobolectricTest() { + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest { val entryPoint = createEntryPoint() entryPoint.startCall(CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = false)) + // The flag is read asynchronously before routing. + advanceUntilIdle() val expectedIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ElementCallActivity::class.java) val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity @@ -43,6 +51,24 @@ class DefaultElementCallEntryPointTest : RobolectricTest() { assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue() } + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `startCall - routes to the native call stack when the feature flag is on`() = runTest { + val startNativeCallLambda = lambdaRecorder {} + val entryPoint = createEntryPoint( + nativeCallEntryPoint = FakeNativeCallEntryPoint(startNativeCallLambda), + isNativeCallEnabled = true, + ) + val callData = CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = true) + + entryPoint.startCall(callData) + advanceUntilIdle() + + startNativeCallLambda.assertions().isCalledOnce().with(value(callData)) + // The WebView Activity must not also be launched. + assertThat(shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity).isNull() + } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() = runTest { @@ -70,8 +96,15 @@ class DefaultElementCallEntryPointTest : RobolectricTest() { private fun TestScope.createEntryPoint( activeCallManager: FakeActiveCallManager = FakeActiveCallManager(), + nativeCallEntryPoint: NativeCallEntryPoint = FakeNativeCallEntryPoint(), + isNativeCallEnabled: Boolean = false, ) = DefaultElementCallEntryPoint( context = InstrumentationRegistry.getInstrumentation().targetContext, activeCallManager = activeCallManager, + nativeCallEntryPoint = nativeCallEntryPoint, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.NativeCall.key to isNativeCallEnabled), + ), + appCoroutineScope = this, ) } diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/MapWebkitPermissionsTest.kt similarity index 96% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/MapWebkitPermissionsTest.kt index e356358437..da76f088ef 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/MapWebkitPermissionsTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call +package io.element.android.features.call.impl import android.Manifest import android.webkit.PermissionRequest diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreatorTest.kt similarity index 72% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreatorTest.kt index 3f5c1c4e50..7b37c5dcd6 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreatorTest.kt @@ -6,13 +6,13 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.notifications +package io.element.android.features.call.impl.notifications import androidx.core.graphics.drawable.IconCompat import androidx.test.platform.app.InstrumentationRegistry import coil3.ImageLoader import com.google.common.truth.Truth.assertThat -import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator +import io.element.android.features.call.impl.ui.IncomingCallActivity import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -20,12 +20,13 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.test.runTest import org.junit.Test +import org.robolectric.Shadows.shadowOf class RingingCallNotificationCreatorTest : RobolectricTest() { @Test @@ -89,6 +90,35 @@ class RingingCallNotificationCreatorTest : RobolectricTest() { assertThat(acceptAction?.title?.toString()).isEqualTo("Answer") } + @Test + fun `createNotification - the answer action goes through IncomingCallActivity, asking it to answer straight away`() = runTest { + val notificationCreator = createRingingCallNotificationCreator( + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }), + ) + + val notification = notificationCreator.createTestNotification() + + // Not ElementCallActivity: naming the WebView activity here made the Answer button open the + // WebView call whatever FeatureFlags.NativeCall was set to. + val answerIntent = shadowOf(notification?.actions?.get(1)?.actionIntent).savedIntent + assertThat(answerIntent.component?.className).isEqualTo(IncomingCallActivity::class.java.name) + assertThat(answerIntent.getBooleanExtra(IncomingCallActivity.EXTRA_ANSWER_IMMEDIATELY, false)).isTrue() + assertThat(answerIntent.hasExtra(IncomingCallActivity.EXTRA_NOTIFICATION_DATA)).isTrue() + } + + @Test + fun `createNotification - tapping the notification body answers too`() = runTest { + val notificationCreator = createRingingCallNotificationCreator( + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }), + ) + + val notification = notificationCreator.createTestNotification() + + val contentIntent = shadowOf(notification?.contentIntent).savedIntent + assertThat(contentIntent.component?.className).isEqualTo(IncomingCallActivity::class.java.name) + assertThat(contentIntent.getBooleanExtra(IncomingCallActivity.EXTRA_ANSWER_IMMEDIATELY, false)).isTrue() + } + private suspend fun RingingCallNotificationCreator.createTestNotification(audioOnly: Boolean = false) = createNotification( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallDataTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallDataTest.kt similarity index 93% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallDataTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallDataTest.kt index f0cdd44082..3193a73343 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallDataTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallDataTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.ui +package io.element.android.features.call.impl.ui import com.google.common.truth.Truth.assertThat import io.element.android.features.call.api.CallData diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenBackPressPolicyTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenBackPressPolicyTest.kt similarity index 93% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenBackPressPolicyTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenBackPressPolicyTest.kt index f07f7039d3..c75d14a599 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenBackPressPolicyTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenBackPressPolicyTest.kt @@ -5,11 +5,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.ui +package io.element.android.features.call.impl.ui import com.google.common.truth.Truth.assertThat -import io.element.android.features.call.impl.ui.CallScreenBackPressAction -import io.element.android.features.call.impl.ui.CallScreenBackPressPolicy import org.junit.Test class CallScreenBackPressPolicyTest { diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenterTest.kt similarity index 96% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenterTest.kt index 276e6670f1..2889869565 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenterTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.ui +package io.element.android.features.call.impl.ui import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow @@ -14,13 +14,10 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.MobileScreen import io.element.android.features.call.api.CallData -import io.element.android.features.call.impl.ui.CallScreenEvent -import io.element.android.features.call.impl.ui.CallScreenNavigator -import io.element.android.features.call.impl.ui.CallScreenPresenter +import io.element.android.features.call.impl.utils.FakeActiveCallManager +import io.element.android.features.call.impl.utils.FakeCallWidgetProvider +import io.element.android.features.call.impl.utils.FakeWidgetMessageInterceptor import io.element.android.features.call.impl.utils.WidgetMessageSerializer -import io.element.android.features.call.utils.FakeActiveCallManager -import io.element.android.features.call.utils.FakeCallWidgetProvider -import io.element.android.features.call.utils.FakeWidgetMessageInterceptor import io.element.android.libraries.androidutils.json.DefaultJsonProvider import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.coroutine.CoroutineDispatchers diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenViewTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt similarity index 93% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenViewTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt index 76ce8e8ede..4107a0826a 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenViewTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.ui +package io.element.android.features.call.impl.ui import android.view.KeyEvent import android.webkit.WebView @@ -18,11 +18,6 @@ import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.features.call.impl.pip.PictureInPictureEvent import io.element.android.features.call.impl.pip.PictureInPictureState import io.element.android.features.call.impl.pip.aPictureInPictureState -import io.element.android.features.call.impl.ui.CallScreenEvent -import io.element.android.features.call.impl.ui.CallScreenState -import io.element.android.features.call.impl.ui.CallScreenView -import io.element.android.features.call.impl.ui.JavascriptBackHandlerBridge -import io.element.android.features.call.impl.ui.aCallScreenState import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.robolectric.RobolectricTest diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/FakeCallScreenNavigator.kt similarity index 78% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/FakeCallScreenNavigator.kt index 431b746362..f4a0b7e16a 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/FakeCallScreenNavigator.kt @@ -6,9 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.ui - -import io.element.android.features.call.impl.ui.CallScreenNavigator +package io.element.android.features.call.impl.ui class FakeCallScreenNavigator : CallScreenNavigator { var closeCalled = false diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeLanguageTagProvider.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/FakeLanguageTagProvider.kt similarity index 79% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeLanguageTagProvider.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/FakeLanguageTagProvider.kt index f68842813b..4329174636 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeLanguageTagProvider.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/FakeLanguageTagProvider.kt @@ -6,10 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.ui +package io.element.android.features.call.impl.ui import androidx.compose.runtime.Composable -import io.element.android.features.call.impl.ui.LanguageTagProvider class FakeLanguageTagProvider(private val languageTag: String?) : LanguageTagProvider { @Composable diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/DefaultActiveCallManagerTest.kt similarity index 98% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/DefaultActiveCallManagerTest.kt index c95f8c5088..6820985825 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/DefaultActiveCallManagerTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.utils +package io.element.android.features.call.impl.utils import android.os.PowerManager import androidx.core.app.NotificationManagerCompat @@ -16,10 +16,6 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.call.api.CallData import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.features.call.impl.notifications.aCallNotificationData -import io.element.android.features.call.impl.utils.ActiveCall -import io.element.android.features.call.impl.utils.CallState -import io.element.android.features.call.impl.utils.DefaultActiveCallManager -import io.element.android.features.call.impl.utils.DefaultCurrentCallService import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId @@ -34,7 +30,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.aRoomInfo -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder import io.element.android.libraries.push.api.notifications.ForegroundServiceType import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProviderTest.kt similarity index 98% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProviderTest.kt index 093abfcd32..69d95ac12c 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProviderTest.kt @@ -6,10 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.utils +package io.element.android.features.call.impl.utils import com.google.common.truth.Truth.assertThat -import io.element.android.features.call.impl.utils.DefaultCallWidgetProvider 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 diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeActiveCallManager.kt similarity index 88% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeActiveCallManager.kt index c2c38284a9..96fb0cb5cb 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeActiveCallManager.kt @@ -6,12 +6,10 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.utils +package io.element.android.features.call.impl.utils import io.element.android.features.call.api.CallData import io.element.android.features.call.impl.notifications.CallNotificationData -import io.element.android.features.call.impl.utils.ActiveCall -import io.element.android.features.call.impl.utils.ActiveCallManager import io.element.android.tests.testutils.simulateLongTask import kotlinx.coroutines.flow.MutableStateFlow diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeCallWidgetProvider.kt similarity index 90% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeCallWidgetProvider.kt index ebc00b0e8f..98789c1d71 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeCallWidgetProvider.kt @@ -6,9 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.utils +package io.element.android.features.call.impl.utils -import io.element.android.features.call.impl.utils.CallWidgetProvider import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeNativeCallEntryPoint.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeNativeCallEntryPoint.kt new file mode 100644 index 0000000000..dcabb72507 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeNativeCallEntryPoint.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.utils + +import io.element.android.features.call.api.CallData +import io.element.android.features.callnative.api.NativeCallEntryPoint + +class FakeNativeCallEntryPoint( + private val startCallLambda: (CallData) -> Unit = {}, +) : NativeCallEntryPoint { + override fun startCall(callData: CallData) = startCallLambda(callData) +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeWidgetMessageInterceptor.kt similarity index 84% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeWidgetMessageInterceptor.kt index 6b68b1519c..8fff92faf3 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeWidgetMessageInterceptor.kt @@ -6,9 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.utils +package io.element.android.features.call.impl.utils -import io.element.android.features.call.impl.utils.WidgetMessageInterceptor import kotlinx.coroutines.flow.MutableSharedFlow class FakeWidgetMessageInterceptor : WidgetMessageInterceptor { diff --git a/features/call/test/build.gradle.kts b/features/call/test/build.gradle.kts index f06c5ed16e..920670287e 100644 --- a/features/call/test/build.gradle.kts +++ b/features/call/test/build.gradle.kts @@ -17,7 +17,6 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.core) api(projects.features.call.api) implementation(projects.libraries.matrix.api) diff --git a/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeCurrentCallTracker.kt b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeCurrentCallTracker.kt new file mode 100644 index 0000000000..b6e31f8882 --- /dev/null +++ b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeCurrentCallTracker.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.test + +import io.element.android.features.call.api.CurrentCall +import io.element.android.features.call.api.CurrentCallTracker + +class FakeCurrentCallTracker : CurrentCallTracker { + /** Every transition in order, so a test can assert the call was reported before it was cleared. */ + val calls = mutableListOf() + + override fun onCallStarted(call: CurrentCall) { + calls += call + } + + override fun onCallEnded() { + calls += CurrentCall.None + } +} diff --git a/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeRingingCallTracker.kt b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeRingingCallTracker.kt new file mode 100644 index 0000000000..915641a0a4 --- /dev/null +++ b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeRingingCallTracker.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.test + +import io.element.android.features.call.api.CallData +import io.element.android.features.call.api.RingingCallTracker + +class FakeRingingCallTracker : RingingCallTracker { + val joined = mutableListOf() + val ended = mutableListOf() + + override suspend fun onCallJoined(callData: CallData) { + joined += callData + } + + override suspend fun onCallEnded(callData: CallData) { + ended += callData + } +} diff --git a/features/callnative/api/build.gradle.kts b/features/callnative/api/build.gradle.kts new file mode 100644 index 0000000000..f5f3c25f4b --- /dev/null +++ b/features/callnative/api/build.gradle.kts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + // NativeCallHost renders the call, so this module carries a composable. + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.callnative.api" +} + +// Temporary: `-PelementCallLocalVersion` points this at a locally published build of +// element-call-android. Goes with the repositories in settings.gradle.kts. +val elementCallVersion: String = providers.gradleProperty("elementCallLocalVersion") + .getOrElse(libs.versions.element.call.get()) + +dependencies { + // api, not implementation: ElementCallMatrixTransport is part of ElementCallTransportFactory's + // signature, and CallData of NativeCallEntryPoint's. + // api, so that consumers of this module inherit the versions the BOM pins - the artifact + // below is declared without one. + api(platform("io.element.android:element-call-bom:$elementCallVersion")) + api(libs.element.call.api) + api(projects.features.call.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/ElementCallTransportFactory.kt b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/ElementCallTransportFactory.kt new file mode 100644 index 0000000000..bda31738f0 --- /dev/null +++ b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/ElementCallTransportFactory.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.callnative.api + +import io.element.android.call.api.matrix.ElementCallMatrixTransport +import io.element.android.libraries.matrix.api.MatrixClient + +/** + * Makes the native call's view of Matrix for a session. + * + * The component's turnkey transport is built over a raw Rust SDK client, and only + * `libraries/matrix/impl` is compiled against the SDK. This is the seam that keeps it that way: the + * implementation lives there, everything else takes a [MatrixClient] and asks. + * + * A factory rather than a binding for the transport itself, because the call is started from app scope + * - a call can be placed for an account that is not the one on screen - and the session it belongs to + * is known only as an id until a client is fetched for it. + */ +fun interface ElementCallTransportFactory { + fun create(client: MatrixClient): ElementCallMatrixTransport +} diff --git a/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallEntryPoint.kt b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallEntryPoint.kt new file mode 100644 index 0000000000..486a0b14c1 --- /dev/null +++ b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallEntryPoint.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.callnative.api + +import io.element.android.features.call.api.CallData + +/** + * Starts a call on the native MatrixRTC stack instead of the Element Call WebView. + * + * Experimental: gated behind `FeatureFlags.NativeCall` and reached only through + * [io.element.android.features.call.api.ElementCallEntryPoint], so nothing else in the app has to know + * it exists. + * + * There is nothing behind this seam yet - the default implementation does nothing and says so. The + * native stack that implements it is a separate library, added in a follow-up. + */ +fun interface NativeCallEntryPoint { + fun startCall(callData: CallData) +} diff --git a/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallHost.kt b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallHost.kt new file mode 100644 index 0000000000..c90ffe1e29 --- /dev/null +++ b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallHost.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.callnative.api + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Where a native call is drawn, wrapped around the logged-in content. + * + * A wrapper rather than an overlay, because the two states of a call need different things from the + * layout: minimized, the call takes a strip of height above the content; maximized, it covers it. The + * [Modifier] handed back to [content] has consumed whatever window insets the call has already taken, + * so the screens underneath do not pad for the status bar a second time. + * + * With no call running this renders [content] and nothing else. + */ +interface NativeCallHost { + @Composable + fun Render(modifier: Modifier, content: @Composable (Modifier) -> Unit) +} diff --git a/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallPip.kt b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallPip.kt new file mode 100644 index 0000000000..0cd8ea9110 --- /dev/null +++ b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallPip.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.callnative.api + +import android.app.Activity + +/** + * The one part of picture-in-picture that an Activity has to do itself. + * + * Everything else is installed where the call is drawn, which is inside the logged-in content and so + * knows which session's call it is. This does not: `onUserLeaveHint` is an Activity override, there is + * no session in scope at that point, and it applies to whichever call happens to be running. + * + * Only Android 11 and below reach this. From Android 12 the system is told up front that the Activity + * would like to shrink, which covers every way out of the app including the home gesture - the one + * people actually use, and the one this hook never sees. + */ +interface NativeCallPip { + /** Call from the host Activity's `onUserLeaveHint()` override. */ + fun onUserLeaveHint(activity: Activity) +} diff --git a/features/callnative/impl/build.gradle.kts b/features/callnative/impl/build.gradle.kts new file mode 100644 index 0000000000..a7fdd18252 --- /dev/null +++ b/features/callnative/impl/build.gradle.kts @@ -0,0 +1,52 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.callnative.impl" +} + +setupDependencyInjection() + +// Temporary: `-PelementCallLocalVersion` points this at a locally published build of +// element-call-android, because the library has no remote repository yet. Goes with the mavenLocal +// block in settings.gradle.kts. Only the BOM carries a version; every other artifact follows it. +val elementCallVersion: String = providers.gradleProperty("elementCallLocalVersion") + .getOrElse(libs.versions.element.call.get()) + +dependencies { + api(projects.features.callnative.api) + + implementation(platform("io.element.android:element-call-bom:$elementCallVersion")) + implementation(libs.element.call.api) + implementation(libs.element.call.impl) + implementation(libs.element.call.ui) + + implementation(projects.features.call.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.architecture) + implementation(projects.libraries.di) + implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.preferences.api) + // For NotificationIdProvider: the ongoing-call notification id is shared with the WebView call's + // foreground service, so that the two paths cannot leave two call notifications in the shade. + implementation(projects.libraries.push.api) + implementation(projects.services.appnavstate.api) + implementation(libs.androidx.corektx) + + testCommonDependencies(libs) + testImplementation(libs.element.call.test) + testImplementation(projects.features.call.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.services.appnavstate.test) +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPoint.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPoint.kt new file mode 100644 index 0000000000..2210663376 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPoint.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.callnative.impl + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.call.api.ElementCallData +import io.element.android.call.api.rtc.id.RoomId +import io.element.android.features.call.api.CallData +import io.element.android.features.callnative.api.NativeCallEntryPoint +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.di.annotations.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Starts a call on the native MatrixRTC stack. + * + * App-scoped, while the stack it drives is per session: the session comes from the call rather than + * from whatever is on screen, so answering a notification for a second account reaches that account's + * stack. + */ +@ContributesBinding(AppScope::class) +class DefaultNativeCallEntryPoint( + @ApplicationContext private val context: Context, + private val controllers: ElementCallControllers, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : NativeCallEntryPoint { + override fun startCall(callData: CallData) { + appCoroutineScope.launch { + val controller = controllers.getOrBuild(callData.sessionId) ?: return@launch + controller.startCall( + ElementCallData( + roomId = RoomId(callData.roomId.value), + isAudioCall = callData.isAudioCall, + ) + ) + // The call screen asks for the microphone itself, and is where the answer normally comes + // from. This covers the case it cannot reach: a call answered from a notification while + // the app is in the background has no call screen composed yet, and a call waiting on a + // permission nobody is there to grant would sit unanswered. Reporting a permission we + // already hold is idempotent, so the screen asking again later costs nothing. + if (hasMicrophonePermission()) { + controller.setMicrophonePermissionGranted(true) + } + } + } + + private fun hasMicrophonePermission(): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt new file mode 100644 index 0000000000..332f28f7d8 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.callnative.impl + +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import dev.zacsweers.metro.ContributesBinding +import io.element.android.call.impl.ElementCallPictureInPicture +import io.element.android.call.ui.ElementCallOverlay +import io.element.android.features.callnative.api.NativeCallHost +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import timber.log.Timber + +/** + * Draws the session's native call around the logged-in content. + * + * Session-scoped, unlike the entry point: this renders inside the logged-in navigation, so the + * session is already in scope and there is no need to go looking for one. The stack it draws is not, + * so this asks the app-scoped registry for its own session's controller. + * + * That controller only exists once a call has been placed or answered. Until then the overlay draws + * the content alone. + */ +@ContributesBinding(SessionScope::class) +class DefaultNativeCallHost( + private val client: MatrixClient, + private val controllers: ElementCallControllers, +) : NativeCallHost { + @Composable + override fun Render(modifier: Modifier, content: @Composable (Modifier) -> Unit) { + val controller by remember(client.sessionId) { controllers.controller(client.sessionId) } + .collectAsState(initial = null) + val current = controller + + // Installed here rather than in the Activity because this is the only place that has both the + // Activity and the session whose call it is. The Activity keeps `onUserLeaveHint`, which it + // cannot delegate: see NativeCallPip. + val activity = LocalActivity.current as? ComponentActivity + DisposableEffect(activity, current) { + if (activity != null && current != null) { + runCatchingExceptions { ElementCallPictureInPicture.attach(activity, current) } + .onFailure { Timber.w(it, "NativeCall: cannot attach picture-in-picture") } + } + onDispose { } + } + + // Composed even with no controller yet, so the content never moves in the tree and keeps its state. + ElementCallOverlay( + controller = current, + modifier = modifier, + style = rememberElementXCallStyle(), + content = content, + ) + } +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallPip.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallPip.kt new file mode 100644 index 0000000000..b94785a931 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallPip.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.callnative.impl + +import android.app.Activity +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.call.impl.ElementCallPictureInPicture +import io.element.android.features.callnative.api.NativeCallPip + +/** + * Forwards the Activity's leave hint to whichever session has a call. + * + * App-scoped because `onUserLeaveHint` is, and it asks for the running call rather than a particular + * session's: only one call runs at a time, and the Activity has no session in scope to name. Sessions + * whose stack was never built are skipped, so asking cannot start one. + */ +@ContributesBinding(AppScope::class) +class DefaultNativeCallPip( + private val controllers: ElementCallControllers, +) : NativeCallPip { + override fun onUserLeaveHint(activity: Activity) { + val controller = controllers.withRunningCall() ?: return + ElementCallPictureInPicture.onUserLeaveHint(activity, controller) + } +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementCallControllers.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementCallControllers.kt new file mode 100644 index 0000000000..5792da9a93 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementCallControllers.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.callnative.impl + +import android.content.Context +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.call.api.ElementCallController +import io.element.android.call.api.ElementCallNotificationConfig +import io.element.android.call.api.ElementCallOptions +import io.element.android.call.api.rtc.MatrixRtcLogLevel +import io.element.android.call.api.rtc.MatrixRtcLoggingConfiguration +import io.element.android.call.impl.ElementCallStack +import io.element.android.features.call.api.CurrentCallTracker +import io.element.android.features.call.api.RingingCallTracker +import io.element.android.features.callnative.api.ElementCallTransportFactory +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +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 +import io.element.android.libraries.matrix.api.tracing.LogLevel +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.push.api.notifications.ForegroundServiceType +import io.element.android.libraries.push.api.notifications.NotificationIdProvider +import io.element.android.services.appnavstate.api.AppForegroundStateService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.job +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber + +/** + * The native call controller of a session, built on demand. + * + * An interface so that what starts calls can be tested without the real stack, which loads the RTC + * native library. + */ +interface ElementCallControllers { + /** + * The session's controller, building its call stack on first use. Null when there is no such + * session - logged out, or a session id that cannot be restored. + */ + suspend fun getOrBuild(sessionId: SessionId): ElementCallController? + + /** + * The session's controller once its stack exists, and null until then. Never builds one. + * + * A flow rather than a getter because the stack appears part-way through the session's life, when + * a call is first placed or answered, and whatever draws the call has to notice when it does. + */ + fun controller(sessionId: SessionId): Flow + + /** + * The controller of whichever session currently has a call, or null when none does. + * + * For the Activity-level seams, which are app-scoped and have no session of their own to ask. Only + * sessions whose stack is already built are considered, so asking cannot start one. + */ + fun withRunningCall(): ElementCallController? +} + +/** + * Builds one call stack per session, from a Matrix client, and keeps it for the session's life. + * + * App-scoped and keyed by session id rather than session-scoped, because everything that starts a call + * is app-scoped: the push path that resolves a ringing call, the notification it posts, and the Activity + * that answers it all run on a [MatrixClient] fetched by id, and none of them has a session graph. A + * session-scoped stack could only be reached after the user had navigated into the session, so answering + * a call notification after a cold start would find nothing to answer with. + * + * One stack serves every call in a session; each call opens and closes its own room underneath, which + * the component does itself. + */ +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultElementCallControllers( + @ApplicationContext private val context: Context, + private val matrixClientProvider: MatrixClientProvider, + private val transportFactory: ElementCallTransportFactory, + private val currentCallTracker: CurrentCallTracker, + private val ringingCallTracker: RingingCallTracker, + private val appForegroundStateService: AppForegroundStateService, + private val appPreferencesStore: AppPreferencesStore, + private val featureFlagService: FeatureFlagService, +) : ElementCallControllers { + // A flow rather than a plain map because what draws the call has to see the stack appear. + private val stacks = MutableStateFlow>(emptyMap()) + + // One at a time across every session: two calls placed at once must not race into two stacks for + // the same session, because only one of them would ever be closed. + private val mutex = Mutex() + + override suspend fun getOrBuild(sessionId: SessionId): ElementCallController? = mutex.withLock { + stacks.value[sessionId]?.let { return@withLock it.controller } + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() + if (client == null) { + Timber.w("NativeCall: no client for $sessionId, cannot start a call") + return@withLock null + } + build(client).also { stack -> + stacks.update { it + (sessionId to stack) } + // The client's scope is the stack's lifetime: the component has no shutdown of its own, so + // cancelling this at logout is what stops the RTC core. Closing here only releases the + // component's own reference, so a dead stack is not left behind it. + client.sessionCoroutineScope.coroutineContext.job.invokeOnCompletion { + stacks.update { if (it[sessionId] === stack) it - sessionId else it } + stack.close() + } + }.controller + } + + override fun controller(sessionId: SessionId): Flow = + stacks.map { it[sessionId]?.controller }.distinctUntilChanged() + + override fun withRunningCall(): ElementCallController? = + stacks.value.values.firstNotNullOfOrNull { stack -> + stack.controller.takeIf { it.state.value != null } + } + + private suspend fun build(client: MatrixClient): ElementCallStack = + ElementCallStack.Builder(context, transportFactory.create(client)) + .lifecycleListener( + ElementXCallLifecycleListener( + sessionId = client.sessionId, + currentCallTracker = currentCallTracker, + ringingCallTracker = ringingCallTracker, + sessionCoroutineScope = client.sessionCoroutineScope, + appForegroundStateService = appForegroundStateService, + ) + ) + .roomContext(ElementXRoomContextProvider(client)) + .options(options()) + .build(scope = client.sessionCoroutineScope) + + private suspend fun options() = ElementCallOptions( + logging = loggingConfiguration(), + notification = ElementCallNotificationConfig( + // The same id the WebView call's foreground service uses, so that the two paths cannot + // leave two ongoing-call notifications in the shade. + notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.ONGOING_CALL), + ), + ) + + /** + * Read here rather than in `PlatformInitializer`, where the Rust SDK's tracing is configured: the + * core applies this when it first loads, which is when a call is first placed, and this is the first + * place where both the preference and the flag are readable. + */ + private suspend fun loggingConfiguration(): MatrixRtcLoggingConfiguration { + val logLevel = appPreferencesStore.getTracingLogLevelFlow().first() + return MatrixRtcLoggingConfiguration( + // Floored at debug: turning the SDK level down to quiet the timeline and event-cache + // traces is exactly what you do while working on calls, and it must not also silence the + // RTC core. The core's own filter holds down the parts that log per frame. + logLevel = maxOf(logLevel, LogLevel.DEBUG).toMatrixRtcLogLevel(), + writesToLogcat = featureFlagService.isFeatureEnabled(FeatureFlags.PrintLogsToLogcat), + ) + } + + private fun LogLevel.toMatrixRtcLogLevel(): MatrixRtcLogLevel = when (this) { + LogLevel.ERROR -> MatrixRtcLogLevel.ERROR + LogLevel.WARN -> MatrixRtcLogLevel.WARN + LogLevel.INFO -> MatrixRtcLogLevel.INFO + LogLevel.DEBUG -> MatrixRtcLogLevel.DEBUG + LogLevel.TRACE -> MatrixRtcLogLevel.TRACE + } +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListener.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListener.kt new file mode 100644 index 0000000000..83ec22b392 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListener.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.callnative.impl + +import io.element.android.call.api.ElementCallData +import io.element.android.call.api.ElementCallLifecycleListener +import io.element.android.features.call.api.CallData +import io.element.android.features.call.api.CurrentCall +import io.element.android.features.call.api.CurrentCallTracker +import io.element.android.features.call.api.RingingCallTracker +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.services.appnavstate.api.AppForegroundStateService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +/** + * Reports a native call back to the parts of Element X that already track calls. + * + * The library knows it is in a call; Element X's room list, timeline and ringing machinery each + * already know how to react to one. This joins the two, so that a native call looks like any other + * call to the rest of the app rather than needing every reader to learn about it. + */ +class ElementXCallLifecycleListener( + private val sessionId: SessionId, + private val currentCallTracker: CurrentCallTracker, + private val ringingCallTracker: RingingCallTracker, + private val sessionCoroutineScope: CoroutineScope, + appForegroundStateService: AppForegroundStateService, +) : ElementCallLifecycleListener { + override val isAppInForeground: Flow = appForegroundStateService.isInForeground + + override fun onCallStarted(callData: ElementCallData) { + currentCallTracker.onCallStarted(CurrentCall.RoomCall(callData.roomId())) + } + + override fun onCallJoined(callData: ElementCallData) { + // The ringing side is suspending and these callbacks are not, so the work is handed to the + // session scope rather than blocking the controller's. It outlives the call on purpose: a + // ring still has to be taken down even if the call ends while this is in flight. + sessionCoroutineScope.launch { ringingCallTracker.onCallJoined(callData.toCallData()) } + } + + override fun onCallEnded(callData: ElementCallData?) { + currentCallTracker.onCallEnded() + callData?.let { sessionCoroutineScope.launch { ringingCallTracker.onCallEnded(it.toCallData()) } } + } + + private fun ElementCallData.roomId() = RoomId(roomId.value) + + private fun ElementCallData.toCallData() = CallData( + sessionId = sessionId, + roomId = roomId(), + isAudioCall = isAudioCall, + ) +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallStyle.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallStyle.kt new file mode 100644 index 0000000000..751679b298 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallStyle.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.callnative.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import io.element.android.call.ui.theme.ElementCallColors +import io.element.android.call.ui.theme.ElementCallIcons +import io.element.android.call.ui.theme.ElementCallStyle +import io.element.android.call.ui.theme.ElementCallTypography +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType + +/** + * How Element X dresses the native call. + * + * Deliberately almost nothing. The component's own defaults are already Compound - its colours are + * Compound's dark palette and its glyphs are Compound vectors - so re-deriving them from + * `ElementTheme` would buy no visual change and a lot of mapping to keep in step. The call also stays + * dark in a light-themed app on purpose: it is a video surface, as it is in Element Call on the web. + * + * What the component genuinely cannot do is turn an `mxc://` URL into a picture, because that needs a + * Matrix media loader. So the avatar is the one thing overridden here, and it is what makes faces + * appear on the tiles rather than initials. + */ +@Composable +fun rememberElementXCallStyle(): ElementCallStyle { + val icons = ElementCallIcons.default() + return remember(icons) { + ElementCallStyle( + colors = ElementCallColors.Dark, + typography = ElementCallTypography.Default, + icons = icons, + avatar = { data, size -> + Avatar( + avatarData = AvatarData( + id = data.id, + name = data.name, + url = data.url, + // Unused: forcedAvatarSize below is what decides the size, and the component + // asks in dp because it lays the tiles out itself. + size = AvatarSize.ActiveCallItem, + ), + avatarType = AvatarType.User, + forcedAvatarSize = size, + ) + }, + ) + } +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXRoomContextProvider.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXRoomContextProvider.kt new file mode 100644 index 0000000000..aed2f97725 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXRoomContextProvider.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.callnative.impl + +import io.element.android.call.api.ElementCallRoomContext +import io.element.android.call.api.ElementCallRoomContextProvider +import io.element.android.call.api.ElementCallRoomMember +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.roomMembers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import timber.log.Timber +import io.element.android.call.api.rtc.id.RoomId as ElementCallRoomId +import io.element.android.call.api.rtc.id.UserId as ElementCallUserId +import io.element.android.libraries.matrix.api.core.RoomId as MatrixRoomId + +/** + * Tells the call what the room is called and who is in it. + * + * The RTC layer knows a participant only as a member id and a user id - it has no reason to know + * what anyone is called or what they look like. Element X does, so the join happens here rather than + * in the call UI, and it goes through Element X's room cache so that names and avatars are the same + * ones the rest of the app shows. The library ships a provider of its own that reads the SDK + * directly; it is not used, because it would miss exactly that cache. + */ +class ElementXRoomContextProvider( + private val matrixClient: MatrixClient, +) : ElementCallRoomContextProvider { + override fun roomContext(roomId: ElementCallRoomId): Flow = flow { + val room = matrixClient.getRoom(MatrixRoomId(roomId.value)) + if (room == null) { + // Emits nothing rather than guessing: the call then shows user ids, which is better than + // a name we made up. + Timber.w("NativeCall: no room for ${roomId.value}, the call will show user ids") + return@flow + } + room.use { + coroutineScope { + // The member list is loaded lazily, and a call is exactly the moment it is needed: + // without this the tiles would show user ids until something else happened to ask. + // Launched rather than awaited so the room name still arrives immediately. + launch { room.updateMembers() } + emitAll( + combine(room.roomInfoFlow, room.membersStateFlow) { info, membersState -> + ElementCallRoomContext( + displayName = info.name, + isDm = info.isDm, + members = membersState.toElementCallMembers(), + ) + } + ) + } + } + } + + private fun RoomMembersState.toElementCallMembers(): Map = + roomMembers().orEmpty().associate { member -> + ElementCallUserId(member.userId.value) to ElementCallRoomMember( + userId = ElementCallUserId(member.userId.value), + displayName = member.displayName, + avatarUrl = member.avatarUrl, + ) + } +} diff --git a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt new file mode 100644 index 0000000000..cd209069e7 --- /dev/null +++ b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.callnative.impl + +import android.Manifest +import android.app.Application +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.call.test.FakeElementCallController +import io.element.android.features.call.api.CallData +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultNativeCallEntryPointTest : RobolectricTest() { + @Test + fun `starting a call answers the microphone permission the app already holds`() = runTest { + grantMicrophonePermission() + val controller = FakeElementCallController() + val entryPoint = createEntryPoint(controller) + + entryPoint.startCall(A_CALL_DATA) + runCurrent() + + assertThat(controller.startedCalls.map { it.roomId.value }).containsExactly(A_ROOM_ID.value) + // The call screen normally answers this, but it is not composed when a call is answered from + // a notification with the app in the background - and a call waiting on a permission nobody + // is there to grant sits unanswered and refuses every later attempt. + assertThat(controller.microphonePermissionAnswers).containsExactly(true) + } + + @Test + fun `a call the user placed opens on the call screen, not in the minimized bar`() = runTest { + grantMicrophonePermission() + val controller = FakeElementCallController() + val entryPoint = createEntryPoint(controller) + + entryPoint.startCall(A_CALL_DATA) + runCurrent() + + // A snapshot starts maximized, which is what the user asked for by pressing call. Saying + // otherwise here is what a UI-less build had to do, and it would now dock a freshly placed + // call into the bar. + assertThat(controller.maximizedCalls).isEmpty() + } + + @Test + fun `the call still starts without the microphone permission, so the call screen can ask`() = runTest { + shadowOf(RuntimeEnvironment.getApplication()).denyPermissions(Manifest.permission.RECORD_AUDIO) + val controller = FakeElementCallController() + val entryPoint = createEntryPoint(controller) + + entryPoint.startCall(A_CALL_DATA) + runCurrent() + + assertThat(controller.startedCalls).hasSize(1) + // Nothing to report, and nothing to report it from: only an Activity can ask, and the call + // screen does exactly that once it appears. + assertThat(controller.microphonePermissionAnswers).isEmpty() + } + + @Test + fun `nothing happens for a session that cannot be restored`() = runTest { + grantMicrophonePermission() + val controller = FakeElementCallController() + val entryPoint = createEntryPoint(controller, sessionCanBeRestored = false) + + entryPoint.startCall(A_CALL_DATA) + runCurrent() + + assertThat(controller.startedCalls).isEmpty() + } + + private fun grantMicrophonePermission() { + shadowOf(RuntimeEnvironment.getApplication()).grantPermissions(Manifest.permission.RECORD_AUDIO) + } + + private fun TestScope.createEntryPoint( + controller: FakeElementCallController, + sessionCanBeRestored: Boolean = true, + ) = DefaultNativeCallEntryPoint( + context = InstrumentationRegistry.getInstrumentation().targetContext as Application, + controllers = FakeElementCallControllers(controller.takeIf { sessionCanBeRestored }), + appCoroutineScope = this, + ) + + private companion object { + val A_CALL_DATA = CallData(sessionId = A_SESSION_ID, roomId = A_ROOM_ID, isAudioCall = true) + } +} diff --git a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListenerTest.kt b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListenerTest.kt new file mode 100644 index 0000000000..6ef057e512 --- /dev/null +++ b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListenerTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.callnative.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.call.api.ElementCallData +import io.element.android.call.api.rtc.id.RoomId +import io.element.android.features.call.api.CurrentCall +import io.element.android.features.call.test.FakeCurrentCallTracker +import io.element.android.features.call.test.FakeRingingCallTracker +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.services.appnavstate.test.FakeAppForegroundStateService +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ElementXCallLifecycleListenerTest { + @Test + fun `a started call is reported as the current call, and cleared when it ends`() = runTest { + val currentCallTracker = FakeCurrentCallTracker() + val listener = createListener(currentCallTracker = currentCallTracker) + + listener.onCallStarted(A_CALL) + listener.onCallEnded(A_CALL) + + assertThat(currentCallTracker.calls).containsExactly( + CurrentCall.RoomCall(A_ROOM_ID), + CurrentCall.None, + ).inOrder() + } + + @Test + fun `joining a call stops the ring`() = runTest { + val ringingCallTracker = FakeRingingCallTracker() + val listener = createListener(ringingCallTracker = ringingCallTracker) + + listener.onCallJoined(A_CALL) + runCurrent() + + assertThat(ringingCallTracker.joined.map { it.roomId }).containsExactly(A_ROOM_ID) + assertThat(ringingCallTracker.ended).isEmpty() + } + + @Test + fun `ending a call tells the ringing side, and survives a call that never had data`() = runTest { + val ringingCallTracker = FakeRingingCallTracker() + val currentCallTracker = FakeCurrentCallTracker() + val listener = createListener( + currentCallTracker = currentCallTracker, + ringingCallTracker = ringingCallTracker, + ) + + listener.onCallEnded(null) + runCurrent() + + // The current call is always cleared; only the ring needs to know which call it was. + assertThat(currentCallTracker.calls).containsExactly(CurrentCall.None) + assertThat(ringingCallTracker.ended).isEmpty() + } + + private fun TestScope.createListener( + currentCallTracker: FakeCurrentCallTracker = FakeCurrentCallTracker(), + ringingCallTracker: FakeRingingCallTracker = FakeRingingCallTracker(), + ) = ElementXCallLifecycleListener( + sessionId = A_SESSION_ID, + currentCallTracker = currentCallTracker, + ringingCallTracker = ringingCallTracker, + sessionCoroutineScope = this, + appForegroundStateService = FakeAppForegroundStateService(), + ) + + private companion object { + val A_CALL = ElementCallData(roomId = RoomId(A_ROOM_ID.value), isAudioCall = true) + } +} diff --git a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt new file mode 100644 index 0000000000..fb87f494d5 --- /dev/null +++ b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.callnative.impl + +import io.element.android.call.api.ElementCallController +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +/** + * @param controller what [getOrBuild] answers with, or null to stand for a session that cannot be + * restored - logged out, or an id from a stale notification. + */ +class FakeElementCallControllers( + private val controller: ElementCallController? = null, +) : ElementCallControllers { + val requestedSessions = mutableListOf() + + override suspend fun getOrBuild(sessionId: SessionId): ElementCallController? { + requestedSessions += sessionId + return controller + } + + override fun controller(sessionId: SessionId): Flow = flowOf(controller) + + override fun withRunningCall(): ElementCallController? = + controller?.takeIf { it.state.value != null } +} diff --git a/features/contentscanner/api/build.gradle.kts b/features/contentscanner/api/build.gradle.kts index 0e3d592878..b90b0c277f 100644 --- a/features/contentscanner/api/build.gradle.kts +++ b/features/contentscanner/api/build.gradle.kts @@ -8,16 +8,8 @@ android { } dependencies { - implementation(projects.libraries.core) - implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) - implementation(projects.libraries.designsystem) - testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) - testImplementation(libs.molecule.runtime) - testImplementation(libs.test.truth) - testImplementation(libs.test.turbine) - testImplementation(projects.libraries.matrix.test) } diff --git a/features/contentscanner/impl/build.gradle.kts b/features/contentscanner/impl/build.gradle.kts index 34558a182e..1f17a0d991 100644 --- a/features/contentscanner/impl/build.gradle.kts +++ b/features/contentscanner/impl/build.gradle.kts @@ -24,7 +24,6 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) - implementation(projects.libraries.designsystem) testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) diff --git a/features/createroom/impl/build.gradle.kts b/features/createroom/impl/build.gradle.kts index 2cd3961ead..ce0584828b 100644 --- a/features/createroom/impl/build.gradle.kts +++ b/features/createroom/impl/build.gradle.kts @@ -34,7 +34,6 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) - implementation(projects.libraries.deeplink.api) implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.permissions.api) @@ -52,7 +51,6 @@ dependencies { testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.permissions.test) - testImplementation(projects.features.startchat.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.features.enterprise.test) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt index b23226b9c0..f95af65fd8 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt @@ -11,7 +11,6 @@ package io.element.android.features.createroom.impl.addpeople import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -68,7 +67,6 @@ fun AddPeopleView( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AddPeopleTopBar( onSkipClick: () -> Unit, diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt index fec820ffbb..053149ccfe 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -179,7 +178,6 @@ fun ConfigureRoomView( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ConfigureRoomToolbar( isSpace: Boolean, diff --git a/features/createroom/impl/src/main/res/values-zh/translations.xml b/features/createroom/impl/src/main/res/values-zh/translations.xml index b9934dfb63..8b0f251bdb 100644 --- a/features/createroom/impl/src/main/res/values-zh/translations.xml +++ b/features/createroom/impl/src/main/res/values-zh/translations.xml @@ -8,7 +8,7 @@ "新房间" "新空间" "仅限受邀人员加入。" - "私密" + "私有" "任何人都能找到此房间。 你可以随时在房间设置中更改。" "任何人都可以加入,包括你的组织之外的人员。" @@ -18,7 +18,7 @@ "%1$s 中的任何人都可以加入,但其他人必须申请访问。" "申请加入" "仅限受邀人员加入。" - "私密" + "私有" "任何人都可以加入,包括你的组织之外的人员。" "公共" "%1$s 中的任何人都可以加入。" diff --git a/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt b/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/test/FakeCreateRoomEntryPoint.kt similarity index 88% rename from features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt rename to features/createroom/test/src/main/kotlin/io/element/android/features/createroom/test/FakeCreateRoomEntryPoint.kt index a28e47e5a0..430386f0f4 100644 --- a/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt +++ b/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/test/FakeCreateRoomEntryPoint.kt @@ -6,10 +6,11 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.createroom.api +package io.element.android.features.createroom.test import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node +import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.tests.testutils.lambda.lambdaError diff --git a/features/deactivation/impl/build.gradle.kts b/features/deactivation/impl/build.gradle.kts index bca144071b..c0a38cabab 100644 --- a/features/deactivation/impl/build.gradle.kts +++ b/features/deactivation/impl/build.gradle.kts @@ -28,7 +28,6 @@ setupDependencyInjection() dependencies { implementation(projects.libraries.androidutils) - implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.designsystem) diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationEvent.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationEvent.kt similarity index 91% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationEvent.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationEvent.kt index cc1f5093fd..16bed4f04a 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationEvent.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationEvent.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl sealed interface AccountDeactivationEvent { data class SetEraseData(val eraseData: Boolean) : AccountDeactivationEvent diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationNode.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationNode.kt similarity index 95% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationNode.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationNode.kt index 44be7b5d53..a3b648a03d 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationNode.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationNode.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenter.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationPresenter.kt similarity index 98% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenter.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationPresenter.kt index 11e658aa7f..3dd527bc9e 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenter.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationPresenter.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationState.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationState.kt similarity index 94% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationState.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationState.kt index ebf9fec3c4..73ddf6857e 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationState.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationState.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import android.os.Parcelable import io.element.android.libraries.architecture.AsyncAction diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStatePreviewParam.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationStatePreviewParam.kt similarity index 97% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStatePreviewParam.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationStatePreviewParam.kt index b5ab948b05..49bb01f71e 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStatePreviewParam.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationStatePreviewParam.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncAction diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationView.kt similarity index 97% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationView.kt index d86eae41ff..4c256bf3b4 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationView.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -23,7 +23,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -45,8 +44,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.deactivation.impl.R -import io.element.android.features.logout.impl.ui.AccountDeactivationActionDialog +import io.element.android.features.deactivation.impl.ui.AccountDeactivationActionDialog import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.form.textFieldState @@ -69,7 +67,6 @@ import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.persistentListOf -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AccountDeactivationView( state: AccountDeactivationState, diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPoint.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/DefaultAccountDeactivationEntryPoint.kt similarity index 93% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPoint.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/DefaultAccountDeactivationEntryPoint.kt index c93d519e67..c0c62cf6cc 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPoint.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/DefaultAccountDeactivationEntryPoint.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationActionDialog.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/ui/AccountDeactivationActionDialog.kt similarity index 96% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationActionDialog.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/ui/AccountDeactivationActionDialog.kt index 84037d7eb1..f4862d0df7 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationActionDialog.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/ui/AccountDeactivationActionDialog.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl.ui +package io.element.android.features.deactivation.impl.ui import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/ui/AccountDeactivationConfirmationDialog.kt similarity index 94% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/ui/AccountDeactivationConfirmationDialog.kt index ab9d87c543..7e255b0b5b 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/ui/AccountDeactivationConfirmationDialog.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl.ui +package io.element.android.features.deactivation.impl.ui import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource diff --git a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenterTest.kt b/features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationPresenterTest.kt similarity index 99% rename from features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenterTest.kt rename to features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationPresenterTest.kt index d6402c2f59..8a9aac510b 100644 --- a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenterTest.kt +++ b/features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationPresenterTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow diff --git a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt b/features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationViewTest.kt similarity index 98% rename from features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt rename to features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationViewTest.kt index 5c650fea9d..4da3f236a9 100644 --- a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt +++ b/features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationViewTest.kt @@ -8,7 +8,7 @@ @file:OptIn(ExperimentalTestApi::class) -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import androidx.activity.ComponentActivity import androidx.compose.ui.test.AndroidComposeUiTest @@ -16,7 +16,6 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.v2.runAndroidComposeUiTest -import io.element.android.features.deactivation.impl.R import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_PASSWORD diff --git a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPointTest.kt b/features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/DefaultAccountDeactivationEntryPointTest.kt similarity index 95% rename from features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPointTest.kt rename to features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/DefaultAccountDeactivationEntryPointTest.kt index a9fdf3ff36..0a6fbb801c 100644 --- a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPointTest.kt +++ b/features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/DefaultAccountDeactivationEntryPointTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext diff --git a/features/enterprise/api/build.gradle.kts b/features/enterprise/api/build.gradle.kts index 5e7f81e4b4..1c541c5080 100644 --- a/features/enterprise/api/build.gradle.kts +++ b/features/enterprise/api/build.gradle.kts @@ -17,5 +17,4 @@ dependencies { implementation(projects.libraries.compound) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.wellknown.api) } diff --git a/features/enterprise/impl-foss/build.gradle.kts b/features/enterprise/impl-foss/build.gradle.kts index 2585d00d23..a600920586 100644 --- a/features/enterprise/impl-foss/build.gradle.kts +++ b/features/enterprise/impl-foss/build.gradle.kts @@ -25,7 +25,6 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.androidutils) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.wellknown.api) implementation(libs.serialization.json) testCommonDependencies(libs) diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt index 02fa9b282c..e4e2611bc7 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt @@ -15,6 +15,7 @@ import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.libraries.androidutils.json.JsonProvider +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.mapCatchingExceptions import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.matrix.api.ClientUrlContentFetcher @@ -23,6 +24,7 @@ import io.element.android.libraries.matrix.api.accountprovider.AccountProvider import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import timber.log.Timber @@ -31,15 +33,16 @@ import timber.log.Timber class DefaultEnterpriseService( private val temporaryMatrixClientFactory: TemporaryMatrixClientFactory, private val jsonProvider: JsonProvider, + private val dispatchers: CoroutineDispatchers, ) : EnterpriseService { override suspend fun isEnterpriseUser(sessionId: SessionId) = false override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url override fun accountProviderAllowList(): List = emptyList() override fun canConnectToAnyAccountProvider(): Boolean = true override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider) = true - override suspend fun isElementProEnforced(serverName: String): Boolean { - val temporaryMatrixClient = temporaryMatrixClientFactory.create(serverName).getOrElse { return false } - return temporaryMatrixClient.use { client -> + override suspend fun isElementProEnforced(serverName: String): Boolean = withContext(dispatchers.io) { + val temporaryMatrixClient = temporaryMatrixClientFactory.create(serverName).getOrElse { return@withContext false } + temporaryMatrixClient.use { client -> val baseUrl = serverName.ensureProtocol().removeSuffix("/") // We'll always perform a network request here since we're not interested in any cached value. client.getUrl("$baseUrl/.well-known/element/element.json") diff --git a/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt index f43284e0bd..8119b8f53e 100644 --- a/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt +++ b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt @@ -21,19 +21,21 @@ import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory import io.element.android.libraries.matrix.test.accountprovider.anAccountProviderGeneric import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import org.junit.Test class DefaultEnterpriseServiceTest { @Test - fun accountProviderAllowList() { + fun accountProviderAllowList() = runTest { val defaultEnterpriseService = createDefaultEnterpriseService() assertThat(defaultEnterpriseService.accountProviderAllowList()).isEmpty() } @Test - fun `canConnectToAnyAccountProvider is always true`() { + fun `canConnectToAnyAccountProvider is always true`() = runTest { val defaultEnterpriseService = createDefaultEnterpriseService() assertThat(defaultEnterpriseService.canConnectToAnyAccountProvider()).isTrue() } @@ -132,11 +134,12 @@ class DefaultEnterpriseServiceTest { closeLambda.assertions().isCalledOnce() } - private fun createDefaultEnterpriseService( + private fun TestScope.createDefaultEnterpriseService( client: FakeTemporaryMatrixClient = FakeTemporaryMatrixClient(), jsonProvider: JsonProvider = { Json } ) = DefaultEnterpriseService( temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(createResult = { Result.success(client) }), jsonProvider = jsonProvider, + dispatchers = testCoroutineDispatchers(), ) } diff --git a/features/enterprise/test/build.gradle.kts b/features/enterprise/test/build.gradle.kts index 99dbe733dd..c37fc53de3 100644 --- a/features/enterprise/test/build.gradle.kts +++ b/features/enterprise/test/build.gradle.kts @@ -18,6 +18,5 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.compound) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.wellknown.api) implementation(projects.tests.testutils) } diff --git a/features/forward/test/build.gradle.kts b/features/forward/test/build.gradle.kts index bf9db05e34..a3d8428be2 100644 --- a/features/forward/test/build.gradle.kts +++ b/features/forward/test/build.gradle.kts @@ -15,7 +15,6 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(projects.features.forward.api) implementation(projects.tests.testutils) } diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts index 49b3baa71b..f6d1970a5a 100644 --- a/features/ftue/impl/build.gradle.kts +++ b/features/ftue/impl/build.gradle.kts @@ -32,12 +32,10 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.preferences.api) implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiStrings) - implementation(projects.libraries.testtags) implementation(projects.features.analytics.api) implementation(projects.features.logout.api) implementation(projects.features.securebackup.api) diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt index 96d6b82d47..f6f1094d1b 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -42,7 +41,6 @@ import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChooseSelfVerificationModeView( state: ChooseSelfVerificationModeState, diff --git a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt index 1c6a0a906e..486a8bafce 100644 --- a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt +++ b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt @@ -32,5 +32,6 @@ interface HomeEntryPoint : FeatureEntryPoint { fun navigateToEnterRecoveryKey() fun navigateToRoomSettings(roomId: RoomId) fun navigateToBugReport() + fun navigateToAddAccount() } } diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index c66a4fd0e6..7fcc2dde32 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -45,15 +45,11 @@ dependencies { implementation(projects.libraries.indicator.api) implementation(projects.libraries.deeplink.api) implementation(projects.libraries.fullscreenintent.api) - implementation(projects.libraries.permissions.api) - implementation(projects.libraries.permissions.noop) implementation(projects.libraries.preferences.api) implementation(projects.libraries.push.api) implementation(projects.libraries.sessionStorage.api) implementation(projects.features.announcement.api) implementation(projects.features.invite.api) - implementation(projects.features.networkmonitor.api) - implementation(projects.features.logout.api) implementation(projects.features.leaveroom.api) implementation(projects.features.rageshake.api) implementation(projects.services.analytics.api) @@ -67,18 +63,14 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.features.announcement.test) testImplementation(projects.features.invite.test) - testImplementation(projects.features.logout.test) testImplementation(projects.features.networkmonitor.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.eventformatter.test) testImplementation(projects.libraries.indicator.test) - testImplementation(projects.libraries.permissions.noop) - testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.push.test) testImplementation(projects.services.analytics.test) - testImplementation(projects.services.toolbox.test) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt index 7d2bb09d30..6d1eec3dff 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt @@ -13,4 +13,5 @@ import io.element.android.libraries.matrix.api.core.SessionId sealed interface HomeEvent { data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent data class SwitchToAccount(val sessionId: SessionId) : HomeEvent + data object DismissMultiAccountAnnouncement : HomeEvent } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt index 20f27f67bd..a481171e97 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt @@ -40,7 +40,6 @@ import io.element.android.features.invite.api.InviteData import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint import io.element.android.features.leaveroom.api.LeaveRoomRenderer -import io.element.android.features.logout.api.direct.DirectLogoutView import io.element.android.features.reportroom.api.ReportRoomEntryPoint import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType @@ -83,7 +82,6 @@ class HomeFlowNode( private val inviteFriendsUseCase: InviteFriendsUseCase, private val analyticsService: AnalyticsService, private val acceptDeclineInviteView: AcceptDeclineInviteView, - private val directLogoutView: DirectLogoutView, private val reportRoomEntryPoint: ReportRoomEntryPoint, private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint, private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint, @@ -223,6 +221,7 @@ class HomeFlowNode( homeState = state, onRoomClick = ::navigateToRoom, onSettingsClick = callback::navigateToSettings, + onAddAccountClick = callback::navigateToAddAccount, onStartChatClick = callback::navigateToCreateRoom, onCreateSpaceClick = callback::navigateToCreateSpace, onSetUpRecoveryClick = callback::navigateToSetUpRecovery, @@ -248,7 +247,6 @@ class HomeFlowNode( ) } ) - directLogoutView.Render(state.directLogoutState) } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index 1e49b1aa70..95c98a4aec 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -19,19 +19,23 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.spaces.HomeSpacesState -import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @Inject @@ -42,9 +46,10 @@ class HomePresenter( private val indicatorService: IndicatorService, private val roomListPresenter: Presenter, private val homeSpacesPresenter: Presenter, - private val logoutPresenter: Presenter, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val sessionStore: SessionStore, + private val announcementService: AnnouncementService, + private val featureFlagService: FeatureFlagService, ) : Presenter { private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() @@ -61,6 +66,30 @@ class HomePresenter( }.collectAsState(initial = persistentListOf(matrixUser)) val isOnline by syncService.isOnline.collectAsState() val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) + val showMultiAccountAnnouncement by remember { + combine( + announcementService.announcementsToShowFlow(), + featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount), + sessionStore.sessionsFlow(), + ) { announcements, isMultiAccountEnabled, sessions -> + // Do not announce the feature to users who already have several accounts + isMultiAccountEnabled && sessions.size == 1 && announcements.contains(Announcement.MultiAccount) + } + }.collectAsState(false) + LaunchedEffect(Unit) { + // The user already uses several accounts, so the feature does not need to be announced anymore, + // even if they later remove accounts. + combine( + announcementService.announcementsToShowFlow(), + sessionStore.sessionsFlow(), + ) { announcements, sessions -> + announcements.contains(Announcement.MultiAccount) && sessions.size > 1 + } + .filter { it } + .collect { + announcementService.onAnnouncementDismissed(Announcement.MultiAccount) + } + } val roomListState = roomListPresenter.present() val homeSpacesState = homeSpacesPresenter.present() var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) } @@ -75,7 +104,6 @@ class HomePresenter( } // Avatar indicator val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator() - val directLogoutState = logoutPresenter.present() fun handleEvent(event: HomeEvent) { when (event) { @@ -85,6 +113,9 @@ class HomePresenter( is HomeEvent.SwitchToAccount -> coroutineState.launch { sessionStore.setLatestSession(event.sessionId.value) } + HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch { + announcementService.onAnnouncementDismissed(Announcement.MultiAccount) + } } } @@ -98,7 +129,7 @@ class HomePresenter( homeSpacesState = homeSpacesState, snackbarMessage = snackbarMessage, canReportBug = canReportBug, - directLogoutState = directLogoutState, + showMultiAccountAnnouncement = showMultiAccountAnnouncement, eventSink = ::handleEvent, ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt index ae59ef8eb9..a49d3876e9 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt @@ -11,7 +11,6 @@ package io.element.android.features.home.impl import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.home.impl.spaces.HomeSpacesState -import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.collections.immutable.ImmutableList @@ -29,7 +28,7 @@ data class HomeState( val homeSpacesState: HomeSpacesState, val snackbarMessage: SnackbarMessage?, val canReportBug: Boolean, - val directLogoutState: DirectLogoutState, + val showMultiAccountAnnouncement: Boolean, val eventSink: (HomeEvent) -> Unit, ) { val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt index 14ab6dc7f6..f86c32c532 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt @@ -16,8 +16,6 @@ import io.element.android.features.home.impl.roomlist.aRoomsContentState import io.element.android.features.home.impl.roomlist.generateRoomListRoomSummaryList import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.home.impl.spaces.aHomeSpacesState -import io.element.android.features.logout.api.direct.DirectLogoutState -import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser @@ -50,6 +48,8 @@ open class HomeStatePreviewParam : PreviewParameterProvider { homeSpacesState = aHomeSpacesState( spaceRooms = emptyList(), ), + ) + aHomeState( + showMultiAccountAnnouncement = true, ) } @@ -63,7 +63,7 @@ internal fun aHomeState( roomListState: RoomListState = aRoomListState(), homeSpacesState: HomeSpacesState = aHomeSpacesState(), canReportBug: Boolean = true, - directLogoutState: DirectLogoutState = aDirectLogoutState(), + showMultiAccountAnnouncement: Boolean = false, eventSink: (HomeEvent) -> Unit = {} ) = HomeState( currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(), @@ -71,7 +71,7 @@ internal fun aHomeState( hasNetworkConnection = hasNetworkConnection, snackbarMessage = snackbarMessage, canReportBug = canReportBug, - directLogoutState = directLogoutState, + showMultiAccountAnnouncement = showMultiAccountAnnouncement, currentHomeNavigationBarItem = currentHomeNavigationBarItem, roomListState = roomListState, homeSpacesState = homeSpacesState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index 1df5496ef5..6ff31a7c12 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -22,8 +22,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.plus import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FabPosition import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState @@ -41,6 +39,7 @@ import androidx.compose.ui.zIndex import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.home.impl.components.HomeTopBar +import io.element.android.features.home.impl.components.MultiAccountAnnouncementBottomSheet import io.element.android.features.home.impl.components.RoomListContentView import io.element.android.features.home.impl.components.RoomListMenuAction import io.element.android.features.home.impl.model.RoomListRoomSummary @@ -78,6 +77,7 @@ fun HomeView( homeState: HomeState, onRoomClick: (RoomId, EventId?) -> Unit, onSettingsClick: () -> Unit, + onAddAccountClick: () -> Unit, onSetUpRecoveryClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit, onStartChatClick: () -> Unit, @@ -112,6 +112,13 @@ fun HomeView( ) } + if (homeState.showMultiAccountAnnouncement) { + MultiAccountAnnouncementBottomSheet( + onDismiss = { homeState.eventSink(HomeEvent.DismissMultiAccountAnnouncement) }, + onAddAccountClick = { if (firstThrottler.canHandle()) onAddAccountClick() }, + ) + } + leaveRoomView() HomeScaffold( @@ -150,7 +157,6 @@ fun HomeView( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun HomeScaffold( state: HomeState, @@ -187,7 +193,7 @@ private fun HomeScaffold( val spacesLazyListState = rememberLazyListState() Scaffold( - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + modifier = modifier, topBar = { HomeTopBar( selectedNavigationItem = state.currentHomeNavigationBarItem, @@ -268,42 +274,50 @@ private fun HomeScaffold( val contentPadding = PaddingValues( bottom = 96.dp, ) - when (state.currentHomeNavigationBarItem) { - HomeNavigationBarItem.Chats -> { - RoomListContentView( - contentState = roomListState.contentState, - filtersState = roomListState.filtersState, - spaceFiltersState = roomListState.spaceFiltersState, - lazyListState = roomsLazyListState, - hideInvitesAvatars = roomListState.hideInvitesAvatars, - eventSink = roomListState.eventSink, - onSetUpRecoveryClick = onSetUpRecoveryClick, - onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, - onRoomClick = ::onRoomClick, - onCreateRoomClick = onStartChatClick, - contentPadding = lazyColumnContentPadding + contentPadding, - modifier = Modifier - .padding(outerPadding) - .consumeWindowInsets(outerPadding) - ) - SpaceFiltersView(roomListState.spaceFiltersState) - } - HomeNavigationBarItem.Spaces -> { - HomeSpacesView( - modifier = Modifier - .fillMaxSize() - .padding(outerPadding) - .consumeWindowInsets(outerPadding), - contentPadding = lazyColumnContentPadding + contentPadding, - state = state.homeSpacesState, - lazyListState = spacesLazyListState, - onSpaceClick = { spaceId -> - onRoomClick(spaceId) - }, - onCreateSpaceClick = onCreateSpaceClick, - // TODO use actual callbacks for this - onExploreClick = {}, - ) + // The nested scroll connection is set on the content and not on the Scaffold, else the top bar would + // consume the scroll events of its own content. + Box( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + when (state.currentHomeNavigationBarItem) { + HomeNavigationBarItem.Chats -> { + RoomListContentView( + contentState = roomListState.contentState, + filtersState = roomListState.filtersState, + spaceFiltersState = roomListState.spaceFiltersState, + lazyListState = roomsLazyListState, + hideInvitesAvatars = roomListState.hideInvitesAvatars, + eventSink = roomListState.eventSink, + onSetUpRecoveryClick = onSetUpRecoveryClick, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + onRoomClick = ::onRoomClick, + onCreateRoomClick = onStartChatClick, + contentPadding = lazyColumnContentPadding + contentPadding, + modifier = Modifier + .padding(outerPadding) + .consumeWindowInsets(outerPadding) + ) + SpaceFiltersView(roomListState.spaceFiltersState) + } + HomeNavigationBarItem.Spaces -> { + HomeSpacesView( + modifier = Modifier + .fillMaxSize() + .padding(outerPadding) + .consumeWindowInsets(outerPadding), + contentPadding = lazyColumnContentPadding + contentPadding, + state = state.homeSpacesState, + lazyListState = spacesLazyListState, + onSpaceClick = { spaceId -> + onRoomClick(spaceId) + }, + onCreateSpaceClick = onCreateSpaceClick, + // TODO use actual callbacks for this + onExploreClick = {}, + ) + } } } }, @@ -325,7 +339,6 @@ private fun HomeFloatingActionButton( } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun HomeBottomBar( currentHomeNavigationBarItem: HomeNavigationBarItem, @@ -362,6 +375,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) sta homeState = state, onRoomClick = { _, _ -> }, onSettingsClick = {}, + onAddAccountClick = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, onStartChatClick = {}, @@ -382,6 +396,7 @@ internal fun HomeViewA11yPreview() = ElementPreview { homeState = aHomeState(), onRoomClick = { _, _ -> }, onSettingsClick = {}, + onAddAccountClick = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, onStartChatClick = {}, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt index 9f4a2403f7..e799f44e25 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt @@ -9,9 +9,11 @@ package io.element.android.features.home.impl.components import androidx.compose.foundation.clickable +import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides @@ -25,7 +27,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior @@ -44,18 +45,10 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.CompositingStrategy -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import io.element.android.appconfig.RoomListConfig import io.element.android.compound.theme.ElementTheme @@ -100,7 +93,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeTopBar( selectedNavigationItem: HomeNavigationBarItem, @@ -298,11 +290,15 @@ private fun NavigationIcon( onAccountSwitch: (SessionId) -> Unit, onClick: () -> Unit, ) { + // Reserve the status emoji slot for all the accounts, else the width of the navigation icon, + // and so the position of the title, would change while scrolling the pager. + val reserveStatusEmojiSlot = currentUserAndNeighbors.any { it.displayedStatus != null } if (currentUserAndNeighbors.size == 1) { AccountIcon( matrixUser = currentUserAndNeighbors.single(), isCurrentAccount = true, showAvatarIndicator = showAvatarIndicator, + reserveStatusEmojiSlot = reserveStatusEmojiSlot, onClick = onClick, ) } else { @@ -323,6 +319,7 @@ private fun NavigationIcon( matrixUser = currentUserAndNeighbors[page], isCurrentAccount = page == 1, showAvatarIndicator = page == 1 && showAvatarIndicator, + reserveStatusEmojiSlot = reserveStatusEmojiSlot, onClick = if (page == 1) { onClick } else { @@ -338,95 +335,74 @@ private fun AccountIcon( matrixUser: MatrixUser, isCurrentAccount: Boolean, showAvatarIndicator: Boolean, + reserveStatusEmojiSlot: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier val interactionSource = remember { MutableInteractionSource() } - Box( + Row( modifier = modifier .then(testTag) - .minimumInteractiveComponentSize() .clickable( interactionSource = interactionSource, onClick = onClick, - indication = ripple(bounded = false), + // The ripple is rendered on the avatar only, see below. + indication = null, ), - contentAlignment = Alignment.Center, + verticalAlignment = Alignment.CenterVertically, ) { - val avatarData by remember(matrixUser) { - derivedStateOf { - matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar) + Box( + modifier = Modifier + .minimumInteractiveComponentSize() + .indication( + interactionSource = interactionSource, + indication = ripple(bounded = false), + ), + contentAlignment = Alignment.Center, + ) { + val avatarData by remember(matrixUser) { + derivedStateOf { + matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar) + } + } + Avatar( + avatarData = avatarData, + avatarType = AvatarType.User, + contentDescription = if (isCurrentAccount) { + if (showAvatarIndicator) { + stringResource(CommonStrings.a11y_settings_with_required_action) + } else { + stringResource(CommonStrings.common_settings) + } + } else { + null + }, + ) + if (showAvatarIndicator) { + RedIndicatorAtom( + modifier = Modifier.align(Alignment.TopEnd) + ) } } - val statusEmoji = matrixUser.displayedStatus?.toEmojiText() - val avatarModifier = if (statusEmoji != null) { - Modifier.eraseStatusEmojiBackground( - parentSize = AvatarSize.CurrentUserTopBar.dp, - layoutDirection = LocalLayoutDirection.current, - ) - } else { - Modifier - } - Avatar( - avatarData = avatarData, - avatarType = AvatarType.User, - modifier = avatarModifier, - contentDescription = if (isCurrentAccount) { - if (showAvatarIndicator) { - stringResource(CommonStrings.a11y_settings_with_required_action) - } else { - stringResource(CommonStrings.common_settings) - } - } else { - null - }, - ) - if (statusEmoji != null) { - StatusEmojiBadge( - emoji = statusEmoji, - modifier = Modifier.align(Alignment.BottomEnd), - ) - } - if (showAvatarIndicator) { - RedIndicatorAtom( - modifier = Modifier.align(Alignment.TopEnd) + if (reserveStatusEmojiSlot) { + StatusEmoji( + emoji = matrixUser.displayedStatus?.toEmojiText(), + modifier = Modifier.offset(x = statusEmojiStartOffset), ) } } } -private val statusEmojiBadgeSize = 20.dp -private val statusEmojiBadgeRadius = statusEmojiBadgeSize / 2 -private val statusEmojiBadgeOffset = 8.dp +private val statusEmojiSize = 20.dp -private fun Modifier.eraseStatusEmojiBackground( - parentSize: Dp, - layoutDirection: LayoutDirection, -): Modifier = this - .graphicsLayer { - compositingStrategy = CompositingStrategy.Offscreen - } - .drawWithContent { - drawContent() - drawCircle( - color = Color.Black, - center = Offset( - x = if (layoutDirection == LayoutDirection.Ltr) { - (parentSize - statusEmojiBadgeRadius + statusEmojiBadgeOffset).toPx() - } else { - (statusEmojiBadgeRadius - statusEmojiBadgeOffset).toPx() - }, - y = size.height - statusEmojiBadgeRadius.toPx(), - ), - radius = statusEmojiBadgeRadius.toPx(), - blendMode = BlendMode.Clear, - ) - } +// The avatar is centered inside a 48dp touch target, so there is already 8dp of free space +// on its trailing side. Compensate it to end up with a 4dp gap after the avatar itself. +private val statusEmojiStartOffset = 4.dp - (48.dp - AvatarSize.CurrentUserTopBar.dp) / 2 @Composable -private fun StatusEmojiBadge( - emoji: String, +private fun StatusEmoji( + emoji: String?, modifier: Modifier = Modifier, ) { val density = LocalDensity.current @@ -435,21 +411,20 @@ private fun StatusEmojiBadge( Density(density = density.density, fontScale = 1f) } Box( - modifier = modifier - .offset(x = statusEmojiBadgeOffset) - .size(statusEmojiBadgeSize), + modifier = modifier.size(statusEmojiSize), contentAlignment = Alignment.Center, ) { - CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) { - Text( - text = emoji, - style = ElementTheme.typography.fontBodyMdRegular, - ) + if (emoji != null) { + CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) { + Text( + text = emoji, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } } } } -@OptIn(ExperimentalMaterial3Api::class) @PreviewsDayNight @Composable internal fun HomeTopBarPreview() = ElementPreview { @@ -470,7 +445,6 @@ internal fun HomeTopBarPreview() = ElementPreview { ) } -@OptIn(ExperimentalMaterial3Api::class) @PreviewsDayNight @Composable internal fun HomeTopBarSpaceFiltersSelectedPreview() = ElementPreview { @@ -491,7 +465,6 @@ internal fun HomeTopBarSpaceFiltersSelectedPreview() = ElementPreview { ) } -@OptIn(ExperimentalMaterial3Api::class) @PreviewsDayNight @Composable internal fun HomeTopBarSpacesPreview() = ElementPreview { @@ -512,7 +485,6 @@ internal fun HomeTopBarSpacesPreview() = ElementPreview { ) } -@OptIn(ExperimentalMaterial3Api::class) @PreviewsDayNight @Composable internal fun HomeTopBarWithIndicatorPreview() = ElementPreview { @@ -533,7 +505,6 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview { ) } -@OptIn(ExperimentalMaterial3Api::class) @PreviewsDayNight @Composable internal fun HomeTopBarWithStatusPreview() = ElementPreview { @@ -560,7 +531,6 @@ internal fun HomeTopBarWithStatusPreview() = ElementPreview { ) } -@OptIn(ExperimentalMaterial3Api::class) @PreviewsDayNight @Composable internal fun HomeTopBarMultiAccountPreview() = ElementPreview { diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt new file mode 100644 index 0000000000..cec1dbf3a0 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.home.impl.R +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.annotatedTextWithBold +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.launch + +/** + * Bottom sheet announcing that several accounts can now be added. + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=195-43073 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun MultiAccountAnnouncementBottomSheet( + onDismiss: () -> Unit, + onAddAccountClick: () -> Unit, +) { + val sheetState = rememberBottomSheetState( + initialValue = SheetValue.Hidden, + ) + val coroutineScope = rememberCoroutineScope() + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = onDismiss, + scrollable = false, + ) { + MultiAccountAnnouncementContent( + onAddAccountClick = { + coroutineScope.launch { + sheetState.hide() + onDismiss() + onAddAccountClick() + } + }, + ) + } +} + +@Composable +private fun MultiAccountAnnouncementContent( + onAddAccountClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(all = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BigIcon( + style = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), + ) + Spacer(Modifier.height(16.dp)) + Text( + modifier = Modifier + .padding(horizontal = 8.dp) + .semantics { heading() }, + text = stringResource(R.string.screen_multi_account_announcement_title), + style = ElementTheme.typography.fontHeadingMdBold, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + val path = stringResource(CommonStrings.common_settings) + " > " + stringResource(CommonStrings.common_add_another_account) + Text( + modifier = Modifier.padding(horizontal = 8.dp), + text = annotatedTextWithBold( + text = stringResource(R.string.screen_multi_account_announcement_description, path), + boldText = path, + ), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(40.dp)) + Button( + text = stringResource(R.string.screen_multi_account_announcement_action), + leadingIcon = IconSource.Vector(CompoundIcons.Plus()), + modifier = Modifier.fillMaxWidth(), + onClick = onAddAccountClick, + ) + Spacer(Modifier.height(16.dp)) + } +} + +@PreviewsDayNight +@Composable +internal fun MultiAccountAnnouncementBottomSheetPreview() = ElementPreview(fillMaxSize = true) { + MultiAccountAnnouncementBottomSheet( + onDismiss = {}, + onAddAccountClick = {}, + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt index e06fe7c46d..5fc6d4c85e 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt @@ -8,7 +8,6 @@ package io.element.android.features.home.impl.components -import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,7 +22,6 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -129,6 +127,9 @@ private fun SkeletonView( } } +/** + * Ref: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=8-4656 + */ @Composable private fun EmptyView( state: RoomListContentState.Empty, @@ -139,13 +140,19 @@ private fun EmptyView( modifier: Modifier = Modifier, ) { Box(modifier.fillMaxSize()) { + val text = buildString { + append(stringResource(R.string.screen_roomlist_empty_title)) + append("\n") + append(stringResource(R.string.screen_roomlist_empty_message)) + } EmptyScaffold( - title = R.string.screen_roomlist_empty_title, - subtitle = R.string.screen_roomlist_empty_message, - action = { + title = null, + subtitle = text, + bottomPart = { + Spacer(modifier = Modifier.height(16.dp)) Button( text = stringResource(CommonStrings.action_start_chat), - leadingIcon = IconSource.Vector(CompoundIcons.Compose()), + leadingIcon = IconSource.Vector(CompoundIcons.Chat()), onClick = onCreateRoomClick, ) }, @@ -280,6 +287,7 @@ private fun RoomsViewList( isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE && state.seenRoomInvites.contains(room.roomId), showUnreadCount = state.showUnreadCount, + showAllActivity = state.showAllActivity, onClick = onRoomClick, eventSink = eventSink, ) @@ -298,39 +306,44 @@ private fun EmptyViewForFilterStates( ) { val emptyStateResources = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected) ?: return EmptyScaffold( - title = emptyStateResources.title, - subtitle = emptyStateResources.subtitle, + title = stringResource(emptyStateResources.title), + subtitle = stringResource(emptyStateResources.subtitle), modifier = modifier, + bottomPart = { + // Keep a spacer so that the content is not too low on the screen + Spacer(modifier = Modifier.height(32.dp)) + } ) } @Composable private fun EmptyScaffold( - @StringRes title: Int, - @StringRes subtitle: Int, + title: String?, + subtitle: String, modifier: Modifier = Modifier, - action: @Composable (ColumnScope.() -> Unit)? = null, + bottomPart: @Composable ColumnScope.() -> Unit, ) { Column( modifier = modifier.padding(horizontal = 60.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { + title?.let { + Text( + text = title, + style = ElementTheme.typography.fontHeadingMdBold, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(16.dp)) + } Text( - text = stringResource(title), - style = ElementTheme.typography.fontHeadingMdBold, - color = ElementTheme.colors.textPrimary, - textAlign = TextAlign.Center, - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(subtitle), + text = subtitle, style = ElementTheme.typography.fontBodyLgRegular, color = ElementTheme.colors.textSecondary, textAlign = TextAlign.Center, ) - Spacer(modifier = Modifier.height(32.dp)) - action?.invoke(this) + bottomPart() } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt index 4b60e9e684..2ab529ed43 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt @@ -80,6 +80,7 @@ internal fun RoomSummaryRow( onClick: (RoomListRoomSummary) -> Unit, modifier: Modifier = Modifier, showUnreadCount: Boolean = false, + showAllActivity: Boolean = false, eventSink: (RoomListEvent) -> Unit, ) { Box(modifier = modifier) { @@ -131,7 +132,7 @@ internal fun RoomSummaryRow( isHighlighted = room.isHighlighted, dmUserStatus = room.dmUserStatus, ) - MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount) + MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount, showAllActivity = showAllActivity) } } RoomSummaryDisplayType.KNOCKED -> { @@ -278,6 +279,7 @@ private fun InviteSubtitle( private fun MessagePreviewAndIndicatorRow( room: RoomListRoomSummary, showUnreadCount: Boolean, + showAllActivity: Boolean, modifier: Modifier = Modifier, ) { Row( @@ -345,10 +347,10 @@ private fun MessagePreviewAndIndicatorRow( // Call and unread Row( modifier = Modifier - .heightIn(min = 16.dp) + .height(16.dp) // Used to force this line to be read aloud earlier than the latest event when using Talkback .zIndex(-1f), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary @@ -366,14 +368,9 @@ private fun MessagePreviewAndIndicatorRow( } if (room.hasNewContent) { val contentDescription = stringResource(CommonStrings.a11y_notifications_new_messages) - val count = when { - showUnreadCount && !isMuted && room.numberOfUnreadNotifications > 0 -> room.numberOfUnreadNotifications - !showUnreadCount -> 0 - else -> null - } UnreadIndicatorAtom( color = tint, - count = count, + count = room.unreadIndicatorCount(showUnreadCount = showUnreadCount, showAllActivity = showAllActivity), contentDescription = contentDescription, ) } @@ -381,6 +378,27 @@ private fun MessagePreviewAndIndicatorRow( } } +/** + * Computes the value to pass to [UnreadIndicatorAtom] as `count`. + * - `null`: nothing is displayed. + * - `0`: dot indicator without a count. + * - `> 0`: the number to display. + */ +internal fun RoomListRoomSummary.unreadIndicatorCount( + showUnreadCount: Boolean, + showAllActivity: Boolean, +): Long? { + val isMuted = userDefinedNotificationMode == RoomNotificationMode.MUTE + return when { + // Show only unread notifications when the room is not muted and there are unread notifications + !isMuted && numberOfUnreadNotifications > 0 -> if (showUnreadCount) numberOfUnreadNotifications else 0 + // Otherwise, show a dot for unread messages when showAllActivity is true and there are unread messages + showAllActivity -> if (numberOfUnreadMessages > 0) 0 else null + // Or don't display anything + else -> null + } +} + @Composable private fun InviteNameAndIndicatorRow( name: String?, @@ -405,7 +423,8 @@ private fun InviteNameAndIndicatorRow( ) if (!isInviteSeen) { UnreadIndicatorAtom( - color = ElementTheme.colors.unreadIndicator + color = ElementTheme.colors.unreadIndicator, + count = 0, ) } } @@ -455,5 +474,6 @@ internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryPreviewP onClick = {}, eventSink = {}, showUnreadCount = data.numberOfUnreadNotifications > 0, + showAllActivity = true, ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStatePreviewParam.kt index 4ca87992f9..e24074e042 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStatePreviewParam.kt @@ -40,6 +40,7 @@ internal fun aRoomsContentState( summaries: ImmutableList = aRoomListRoomSummaryList(), fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(), batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(), + showAllActivity: Boolean = false, seenRoomInvites: Set = emptySet(), ) = RoomListContentState.Rooms( securityBannerState = securityBannerState, @@ -48,6 +49,7 @@ internal fun aRoomsContentState( fullScreenIntentPermissionsState = fullScreenIntentPermissionsState, batteryOptimizationState = batteryOptimizationState, summaries = summaries, + showAllActivity = showAllActivity, seenRoomInvites = seenRoomInvites.toImmutableSet(), ) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index 546092ede6..228ca4d754 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -118,6 +118,10 @@ class RoomListPresenter( val contextMenu = remember { mutableStateOf(RoomListState.ContextMenu.Hidden) } val declineInviteMenu = remember { mutableStateOf(RoomListState.DeclineInviteMenu.Hidden) } + val showAllActivity by produceState(initialValue = false) { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowAllActivityInRoomList).collect { value = it } + } + fun handleEvent(event: RoomListEvent) { when (event) { is RoomListEvent.UpdateVisibleRange -> coroutineScope.launch { @@ -165,13 +169,14 @@ class RoomListPresenter( val canReportRoom by produceState(false) { value = client.canReportRoom() } val showUnreadCount by produceState(false) { - value = featureFlagService.isFeatureEnabled(FeatureFlags.UnreadIndicatorCount) + featureFlagService.isFeatureEnabledFlow(FeatureFlags.UnreadIndicatorCount).collect { value = it } } val contentState = roomListContentState( securityBannerDismissed, showNewNotificationSoundBanner, showUnreadCount, + showAllActivity, ) return RoomListState( @@ -230,6 +235,7 @@ class RoomListPresenter( securityBannerDismissed: Boolean, showNewNotificationSoundBanner: Boolean, showUnreadCount: Boolean, + showAllActivity: Boolean, ): RoomListContentState { val roomSummaries by produceState(initialValue = AsyncData.Loading()) { roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) } @@ -262,6 +268,7 @@ class RoomListPresenter( fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(), batteryOptimizationState = batteryOptimizationPresenter.present(), summaries = roomSummaries.dataOrNull().orEmpty().toImmutableList(), + showAllActivity = showAllActivity, seenRoomInvites = seenRoomInvites.toImmutableSet(), ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt index 4164c56e9d..bb1ee631a1 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt @@ -76,5 +76,6 @@ sealed interface RoomListContentState { val showUnreadCount: Boolean, val summaries: ImmutableList, val seenRoomInvites: ImmutableSet, + val showAllActivity: Boolean, ) : RoomListContentState } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt index 1e051bd807..ef4d031540 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt @@ -29,5 +29,11 @@ sealed interface GlobalSearchEvent { data class SaveRoomToHistory(val roomId: RoomId) : GlobalSearchEvent /** The user selected a search result from the search history. */ - data class SearchHistoryResultSelected(val resultItem: SearchHistoryResultItem) : GlobalSearchEvent + data class SearchHistoryResultSelected(val resultItem: SearchHistoryListItem) : GlobalSearchEvent + + /** The user removed a search history entry. */ + data class RemoveSearchHistoryResult(val result: SearchHistoryListItem) : GlobalSearchEvent + + /** The user cleared the search history. */ + data object ClearSearchHistory : GlobalSearchEvent } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index 7b8b21be38..f07a169589 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -57,6 +57,7 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import timber.log.Timber import kotlin.time.Duration.Companion.milliseconds @@ -87,14 +88,14 @@ class GlobalSearchPresenter( var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) } val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) } - val searchHistory by produceState>>(initialValue = AsyncData.Uninitialized) { + val searchHistory by produceState>>(initialValue = AsyncData.Uninitialized) { searchHistoryStore.history.collectLatest { history -> val mappedHistoryItems = history.mapNotNull { result -> when (result) { - is SearchHistoryResult.Query -> SearchHistoryResultItem.Query(term = result.term) + is SearchHistoryResult.Query -> SearchHistoryListItem.Query(term = result.term) is SearchHistoryResult.Room -> { val roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@mapNotNull null - SearchHistoryResultItem.Room(roomId = result.roomId, roomInfo = roomInfo) + SearchHistoryListItem.Room(roomId = result.roomId, roomInfo = roomInfo) } } } @@ -236,7 +237,7 @@ class GlobalSearchPresenter( } is GlobalSearchEvent.SearchHistoryResultSelected -> { when (event.resultItem) { - is SearchHistoryResultItem.Query -> coroutineScope.launch { + is SearchHistoryListItem.Query -> coroutineScope.launch { delay(300.milliseconds) queryState.edit { replace(0, length, event.resultItem.term) @@ -244,9 +245,21 @@ class GlobalSearchPresenter( searchResults = AsyncData.Loading() currentTarget = GlobalSearchTarget.ROOMS } - is SearchHistoryResultItem.Room -> Unit + is SearchHistoryListItem.Room -> Unit } } + is GlobalSearchEvent.RemoveSearchHistoryResult -> coroutineScope.launch { + val currentHistory = searchHistoryStore.history.first() + val id = when (event.result) { + is SearchHistoryListItem.Query -> event.result.term + is SearchHistoryListItem.Room -> event.result.roomId.value + } + val result = currentHistory.find { it.id == id } ?: return@launch + searchHistoryStore.remove(result) + } + GlobalSearchEvent.ClearSearchHistory -> coroutineScope.launch { + searchHistoryStore.clear() + } } } @@ -265,7 +278,7 @@ class GlobalSearchPresenter( result: MessageSearchResult, roomInfo: RoomInfo, formattedTimestamp: String, - ): MessageSearchResultItem.Message? { + ): MessageSearchListItem.Message? { val body = latestEventFormatter.format( latestEvent = LatestEventValue.Remote( timestamp = result.timestamp, @@ -276,7 +289,7 @@ class GlobalSearchPresenter( ), isDmRoom = false, ) - return MessageSearchResultItem.Message( + return MessageSearchListItem.Message( messageSearchResult = result, body = body?.toString() ?: "", formattedTimestamp = formattedTimestamp, @@ -288,7 +301,7 @@ class GlobalSearchPresenter( result: MessageSearchResult, roomInfo: RoomInfo, formattedTimestamp: String, - ): MessageSearchResultItem.Media? { + ): MessageSearchListItem.Media? { val messageType = when (val content = result.content) { is MessageContent if content.type is MessageTypeWithAttachment -> content.type as MessageTypeWithAttachment else -> return null @@ -342,7 +355,7 @@ class GlobalSearchPresenter( blurhash = blurhash ) - return MessageSearchResultItem.Media( + return MessageSearchListItem.Media( messageSearchResult = result, mediaContent = mediaContent, formattedTimestamp = formattedTimestamp, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt index 44b94bd512..d60290b62e 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt @@ -28,20 +28,26 @@ data class GlobalSearchState( val queryState: TextFieldState, val currentTarget: GlobalSearchTarget, val results: AsyncData, - val history: AsyncData>, + val history: AsyncData>, val eventSink: (GlobalSearchEvent) -> Unit ) +/** + * The target of the global search: either rooms or messages. + */ @Immutable enum class GlobalSearchTarget { ROOMS, MESSAGES, } +/** + * The results of a global search, either a list of rooms or a list of messages. + */ @Immutable sealed interface GlobalSearchResults { data class RoomListResults(val results: ImmutableList) : GlobalSearchResults - data class MessageSearchResults(val results: ImmutableList) : GlobalSearchResults + data class MessageSearchResults(val results: ImmutableList) : GlobalSearchResults fun isEmpty(): Boolean = when (this) { is RoomListResults -> results.isEmpty() @@ -49,7 +55,10 @@ sealed interface GlobalSearchResults { } } -sealed interface MessageSearchResultItem { +/** + * A single item in the message search results. It's the UI representation of [MessageSearchResult], which is the mapped model returned by the Matrix SDK. + */ +sealed interface MessageSearchListItem { val roomId: RoomId val eventId: EventId val senderId: UserId @@ -57,24 +66,30 @@ sealed interface MessageSearchResultItem { val roomInfo: RoomInfo val formattedTimestamp: String + /** + * A message item in the message search results. + */ data class Message( val messageSearchResult: MessageSearchResult, val body: String, override val roomInfo: RoomInfo, override val formattedTimestamp: String, - ) : MessageSearchResultItem { + ) : MessageSearchListItem { override val roomId: RoomId = messageSearchResult.roomId override val eventId: EventId = messageSearchResult.eventId override val senderId: UserId = messageSearchResult.senderId override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId) } + /** + * A message with media item in the message search results. + */ data class Media( val messageSearchResult: MessageSearchResult, val mediaContent: MediaSearchResultContent, override val roomInfo: RoomInfo, override val formattedTimestamp: String, - ) : MessageSearchResultItem { + ) : MessageSearchListItem { override val roomId: RoomId = messageSearchResult.roomId override val eventId: EventId = messageSearchResult.eventId override val senderId: UserId = messageSearchResult.senderId @@ -82,20 +97,28 @@ sealed interface MessageSearchResultItem { } } -sealed interface SearchHistoryResultItem { +/** + * A single entry in the global search history, displayed in the list of recent searches. + * + * It's the UI representation of [io.element.android.features.home.impl.search.history.SearchHistoryResult], which is the persisted model. + */ +sealed interface SearchHistoryListItem { val id: String - data class Query(val term: String) : SearchHistoryResultItem { + data class Query(val term: String) : SearchHistoryListItem { override val id: String = "query:$term" } data class Room( val roomId: RoomId, val roomInfo: RoomInfo, - ) : SearchHistoryResultItem { + ) : SearchHistoryListItem { override val id: String = "room:${roomId.value}" } } +/** + * Represents the media content of a message search result. + */ data class MediaSearchResultContent( val filename: String, val extension: String?, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStatePreviewParam.kt index f9cc105a77..ab1efca019 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStatePreviewParam.kt @@ -79,13 +79,13 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider = AsyncData.Uninitialized, - history: AsyncData> = AsyncData.Uninitialized, + history: AsyncData> = AsyncData.Uninitialized, eventSink: (GlobalSearchEvent) -> Unit = {}, ) = GlobalSearchState( isEnabled = isEnabled, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt index cce0620f3f..5154289770 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt @@ -12,12 +12,14 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -31,9 +33,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.KeyboardActionHandler import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -67,6 +71,8 @@ import io.element.android.libraries.designsystem.modifiers.niceClickable import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.roundToPx +import io.element.android.libraries.designsystem.text.toDp +import io.element.android.libraries.designsystem.theme.components.ButtonSize import io.element.android.libraries.designsystem.theme.components.FilledTextField import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon @@ -75,6 +81,7 @@ import io.element.android.libraries.designsystem.theme.components.LinearProgress import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.SegmentedButton import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding @@ -155,10 +162,14 @@ private fun GlobalSearchContent( val historyResults = state.history.dataOrNull()?.toImmutableList() ?: persistentListOf() when { state.results.isUninitialized() -> if (state.queryState.text.isBlank() && historyResults.isNotEmpty()) { - searchHistory(historyResults) { result -> + searchHistory( + historyResults = historyResults, + onRemoveResult = { item -> state.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(item)) }, + onClearAllResults = { state.eventSink(GlobalSearchEvent.ClearSearchHistory) }, + ) { result -> when (result) { - is SearchHistoryResultItem.Query -> Unit - is SearchHistoryResultItem.Room -> onSelectSearchResult(result.roomInfo.id, null) + is SearchHistoryListItem.Query -> Unit + is SearchHistoryListItem.Room -> onSelectSearchResult(result.roomInfo.id, null) } state.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(result)) @@ -214,7 +225,7 @@ private fun SearchTopAppBarContent(state: GlobalSearchState) { modifier = Modifier.clip(CircleShape).size(20.dp), onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }, colors = IconButtonDefaults.iconButtonColors( - containerColor = ElementTheme.colors.borderInteractivePrimary, + containerColor = ElementTheme.colors.iconSecondary, contentColor = ElementTheme.colors.iconOnSolidPrimary ) ) { @@ -279,31 +290,52 @@ private fun LazyListScope.startSearching() { } private fun LazyListScope.searchHistory( - historyResults: ImmutableList, - onSelectSearchHistoryResult: (SearchHistoryResultItem) -> Unit, + historyResults: ImmutableList, + onRemoveResult: (SearchHistoryListItem) -> Unit, + onClearAllResults: () -> Unit, + onSelectSearchHistoryResult: (SearchHistoryListItem) -> Unit, ) { item { - Text( - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), - text = stringResource(R.string.screen_search_recent_searches_section_title), - style = ElementTheme.typography.fontBodyLgMedium, - color = ElementTheme.colors.textPrimary, - ) + Row( + modifier = Modifier.padding(top = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.padding(start = 16.dp).weight(1f), + text = stringResource(R.string.screen_search_recent_searches_section_title), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + ) + + TextButton( + text = stringResource(CommonStrings.action_clear_all), + size = ButtonSize.Small, + colors = ButtonDefaults.textButtonColors(contentColor = ElementTheme.colors.textActionAccent), + onClick = onClearAllResults, + ) + } } items( items = historyResults, key = { it.id }, ) { result -> + val twoLineHeight = + // Vertical padding + 12.dp * 2 + + // Text line heights + ElementTheme.typography.fontBodyMdRegular.lineHeight.toDp() + + ElementTheme.typography.fontBodyLgRegular.lineHeight.toDp() Row( modifier = Modifier - .niceClickable(onClick = { onSelectSearchHistoryResult(result) }) + .clickable(onClick = { onSelectSearchHistoryResult(result) }) .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(start = 16.dp, end = 2.dp) + .height(twoLineHeight), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { when (result) { - is SearchHistoryResultItem.Query -> { + is SearchHistoryListItem.Query -> { Icon( modifier = Modifier.size(32.dp) .clip(RoundedCornerShape(8.dp)) @@ -315,13 +347,14 @@ private fun LazyListScope.searchHistory( ) Text( + modifier = Modifier.weight(1f, fill = true), text = result.term, maxLines = 1, overflow = TextOverflow.Ellipsis, style = ElementTheme.typography.fontBodyLgRegular, ) } - is SearchHistoryResultItem.Room -> { + is SearchHistoryListItem.Room -> { val heroAvatars = remember(result.roomInfo.heroes) { result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.SearchRoomListItem) }.toImmutableList() } @@ -333,7 +366,7 @@ private fun LazyListScope.searchHistory( ), ) - Column(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f, fill = true)) { Text( text = result.roomInfo.name.orEmpty(), maxLines = 1, @@ -365,6 +398,16 @@ private fun LazyListScope.searchHistory( } } } + + Icon( + modifier = Modifier.minimumInteractiveComponentSize() + .niceClickable { + onRemoveResult(result) + }, + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_remove), + tint = ElementTheme.colors.iconSecondary, + ) } } } @@ -451,7 +494,7 @@ private fun LazyListScope.roomListResults( } private fun LazyListScope.messageSearchResults( - results: ImmutableList, + results: ImmutableList, onSearchResultSelected: (RoomId, EventId?) -> Unit, ) { if (results.isNotEmpty()) { @@ -471,7 +514,7 @@ private fun LazyListScope.messageSearchResults( ) { index, result -> val clickableModifier = Modifier.niceClickable(onClick = { onSearchResultSelected(result.roomId, result.eventId) }) when (result) { - is MessageSearchResultItem.Message -> TextMessageSearchResultItemView( + is MessageSearchListItem.Message -> TextMessageSearchResultItemView( modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex), avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem), avatarType = AvatarType.Room( @@ -482,7 +525,7 @@ private fun LazyListScope.messageSearchResults( body = result.body, formattedTimestamp = result.formattedTimestamp, ) - is MessageSearchResultItem.Media -> { + is MessageSearchListItem.Media -> { MediaMessageSearchResultItemView( modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex), avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem), diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt index ff354cf717..6bc3c94fdd 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable @@ -88,7 +87,6 @@ internal fun RoomListSearchView( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomListSearchContent( state: RoomListSearchState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStore.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStore.kt index 4da3456043..b8ab5a385c 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStore.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStore.kt @@ -54,6 +54,17 @@ class DefaultSearchHistoryStore( } } + override suspend fun remove(result: SearchHistoryResult) { + withContext(dispatchers.io) { + mutex.withLock { + ensureLoadedLocked() + val updated = cache.value.filterNot { it == result } + cache.value = updated + persist(updated) + } + } + } + override suspend fun clear() = withContext(dispatchers.io) { mutex.withLock { isLoaded = true diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryResult.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryResult.kt index 1b883fa24e..2c37c1038d 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryResult.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryResult.kt @@ -18,9 +18,15 @@ import kotlinx.serialization.Serializable */ @Serializable sealed interface SearchHistoryResult { - @Serializable - data class Query(val term: String) : SearchHistoryResult + val id: String @Serializable - data class Room(val roomId: RoomId) : SearchHistoryResult + data class Query(val term: String) : SearchHistoryResult { + override val id: String = term + } + + @Serializable + data class Room(val roomId: RoomId) : SearchHistoryResult { + override val id: String = roomId.value + } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryStore.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryStore.kt index 7718d32f77..f2c41f26f6 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryStore.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryStore.kt @@ -30,6 +30,11 @@ interface SearchHistoryStore { */ suspend fun add(result: SearchHistoryResult) + /** + * Remove a [result] from the history. + */ + suspend fun remove(result: SearchHistoryResult) + /** * Remove every entry from the history. */ diff --git a/features/home/impl/src/main/res/values-in/translations.xml b/features/home/impl/src/main/res/values-in/translations.xml index 36512038ba..3b293502b1 100644 --- a/features/home/impl/src/main/res/values-in/translations.xml +++ b/features/home/impl/src/main/res/values-in/translations.xml @@ -14,7 +14,7 @@ "Penyimpanan kunci Anda tidak sinkron" "Untuk memastikan Anda tidak melewatkan panggilan penting, silakan ubah pengaturan Anda untuk memperbolehkan notifikasi layar penuh ketika ponsel Anda terkunci." "Tingkatkan pengalaman panggilan Anda" - "Semua Obrolan" + "Obrolan" "Space" "Apakah Anda yakin ingin menolak undangan untuk bergabung ke %1$s?" "Tolak undangan" @@ -46,7 +46,7 @@ Untuk sementara, Anda dapat membatalkan pilihan saringan untuk melihat percakapa "Selamat! Anda tidak memiliki pesan yang belum dibaca!" "Permintaan untuk bergabung dikirim" - "Semua Obrolan" + "Obrolan" "Tandai sebagai dibaca" "Tandai sebagai belum dibaca" "Ruangan ini telah ditingkatkan" diff --git a/features/home/impl/src/main/res/values/localazy.xml b/features/home/impl/src/main/res/values/localazy.xml index b3c14354f5..4f3420fa23 100644 --- a/features/home/impl/src/main/res/values/localazy.xml +++ b/features/home/impl/src/main/res/values/localazy.xml @@ -24,6 +24,9 @@ "%1$s (%2$s) invited you" "This is a one time process, thanks for waiting." "Setting up your account." + "Add account" + "Go to %1$s to sign into another account. Then you can easily switch between them." + "You can now add multiple accounts" "Create a new conversation or room" "Clear filters" "Get started by messaging someone." diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt index c0cb5f7c01..643de32ecb 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt @@ -36,7 +36,6 @@ class DefaultHomeEntryPointTest : RobolectricTest() { inviteFriendsUseCase = { lambdaError() }, analyticsService = FakeAnalyticsService(), acceptDeclineInviteView = { _, _, _, _ -> lambdaError() }, - directLogoutView = { _ -> lambdaError() }, reportRoomEntryPoint = { _, _, _ -> lambdaError() }, declineInviteAndBlockUserEntryPoint = FakeDeclineInviteAndBlockEntryPoint(), changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() }, @@ -53,6 +52,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() { override fun navigateToEnterRecoveryKey() = lambdaError() override fun navigateToRoomSettings(roomId: RoomId) = lambdaError() override fun navigateToBugReport() = lambdaError() + override fun navigateToAddAccount() = lambdaError() } val result = entryPoint.createNode( parentNode = parentNode, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index 58ad4eb100..cf592d9e7e 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -9,13 +9,18 @@ package io.element.android.features.home.impl import com.google.common.truth.Truth.assertThat +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService +import io.element.android.features.announcement.test.FakeAnnouncementService import io.element.android.features.home.impl.roomlist.aRoomListState import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.home.impl.spaces.aHomeSpacesState -import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.indicator.test.FakeIndicatorService import io.element.android.libraries.matrix.api.MatrixClient @@ -31,6 +36,8 @@ import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -145,6 +152,99 @@ class HomePresenterTest { assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces) } } + + @Test + fun `present - multi account announcement is shown when announced, feature enabled and a single account`() = runTest { + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData()), + updateUserProfileResult = { _, _, _, _ -> }, + ), + announcementService = FakeAnnouncementService( + initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount), + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MultiAccount.key to true), + ), + ) + presenter.test { + assertThat(awaitItem().showMultiAccountAnnouncement).isFalse() + assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isTrue() + } + } + + @Test + fun `present - multi account announcement is not shown when the feature is disabled`() = runTest { + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData()), + updateUserProfileResult = { _, _, _, _ -> }, + ), + announcementService = FakeAnnouncementService( + initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount), + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MultiAccount.key to false), + ), + ) + presenter.test { + assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isFalse() + } + } + + @Test + fun `present - multi account announcement is not shown and is dismissed when there are several accounts`() = runTest { + val onAnnouncementDismissedResult = lambdaRecorder { } + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = "@alice:server.org"), + aSessionData(sessionId = "@bob:server.org"), + ), + updateUserProfileResult = { _, _, _, _ -> }, + ), + announcementService = FakeAnnouncementService( + initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount), + onAnnouncementDismissedResult = onAnnouncementDismissedResult, + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MultiAccount.key to true), + ), + ) + presenter.test { + assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isFalse() + onAnnouncementDismissedResult.assertions().isCalledOnce() + .with(value(Announcement.MultiAccount)) + } + } + + @Test + fun `present - dismiss multi account announcement`() = runTest { + val onAnnouncementDismissedResult = lambdaRecorder { } + val announcementService = FakeAnnouncementService( + initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount), + onAnnouncementDismissedResult = onAnnouncementDismissedResult, + ) + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData()), + updateUserProfileResult = { _, _, _, _ -> }, + ), + announcementService = announcementService, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MultiAccount.key to true), + ), + ) + presenter.test { + val state = expectMostRecentItem() + assertThat(state.showMultiAccountAnnouncement).isTrue() + state.eventSink(HomeEvent.DismissMultiAccountAnnouncement) + announcementService.emitAnnouncementsToShow(emptyList()) + assertThat(awaitItem().showMultiAccountAnnouncement).isFalse() + onAnnouncementDismissedResult.assertions().isCalledOnce() + .with(value(Announcement.MultiAccount)) + } + } } internal fun createHomePresenter( @@ -155,6 +255,8 @@ internal fun createHomePresenter( indicatorService: IndicatorService = FakeIndicatorService(), homeSpacesPresenter: Presenter = Presenter { aHomeSpacesState() }, sessionStore: SessionStore = InMemorySessionStore(), + announcementService: AnnouncementService = FakeAnnouncementService(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), ) = HomePresenter( client = client, syncService = syncService, @@ -162,7 +264,8 @@ internal fun createHomePresenter( indicatorService = indicatorService, roomListPresenter = { aRoomListState() }, homeSpacesPresenter = homeSpacesPresenter, - logoutPresenter = { aDirectLogoutState() }, rageshakeFeatureAvailability = rageshakeFeatureAvailability, sessionStore = sessionStore, + announcementService = announcementService, + featureFlagService = featureFlagService, ) diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/components/RoomSummaryRowUnreadIndicatorCountTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/components/RoomSummaryRowUnreadIndicatorCountTest.kt new file mode 100644 index 0000000000..5b4d918ee9 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/components/RoomSummaryRowUnreadIndicatorCountTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.home.impl.model.createRoomListRoomSummary +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import org.junit.Test + +class RoomSummaryRowUnreadIndicatorCountTest { + @Test + fun `unread notifications are used when the room is not muted`() { + val room = createRoomListRoomSummary( + numberOfUnreadNotifications = 3, + numberOfUnreadMessages = 5, + ) + assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = false)).isEqualTo(3L) + assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isEqualTo(3L) + } + + @Test + fun `unread notifications only show a dot when showUnreadCount is false`() { + val room = createRoomListRoomSummary( + numberOfUnreadNotifications = 3, + numberOfUnreadMessages = 5, + ) + assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = false)).isEqualTo(0L) + assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = true)).isEqualTo(0L) + } + + @Test + fun `unread messages are used when there are no unread notifications and showAllActivity is true, but always display a dot`() { + val room = createRoomListRoomSummary( + numberOfUnreadNotifications = 0, + numberOfUnreadMessages = 5, + ) + assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isEqualTo(0L) + assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = true)).isEqualTo(0L) + } + + @Test + fun `unread messages are ignored when showAllActivity is false`() { + val room = createRoomListRoomSummary( + numberOfUnreadNotifications = 0, + numberOfUnreadMessages = 5, + ) + assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = false)).isNull() + assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = false)).isNull() + } + + @Test + fun `muted room ignores unread notifications and falls back to unread messages displaying a dot when showAllActivity is true`() { + val room = createRoomListRoomSummary( + numberOfUnreadNotifications = 3, + numberOfUnreadMessages = 5, + userDefinedNotificationMode = RoomNotificationMode.MUTE, + ) + assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isEqualTo(0L) + assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = true)).isEqualTo(0L) + assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = false)).isNull() + } + + @Test + fun `no count when there are no unread notifications nor unread messages`() { + val room = createRoomListRoomSummary( + numberOfUnreadNotifications = 0, + numberOfUnreadMessages = 0, + isMarkedUnread = true, + ) + assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isNull() + assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = false)).isNull() + } +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index 5245a164d1..dc96df2739 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -12,6 +12,7 @@ import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Interaction import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.api.AnnouncementService +import io.element.android.features.announcement.test.FakeAnnouncementService import io.element.android.features.home.impl.FakeDateTimeObserver import io.element.android.features.home.impl.datasource.RoomListDataSource import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory @@ -32,13 +33,13 @@ import io.element.android.features.invite.test.InMemorySeenInvitesStore import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead -import io.element.android.features.rageshake.test.logs.FakeAnnouncementService import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState import io.element.android.libraries.matrix.api.MatrixClient @@ -217,6 +218,10 @@ class RoomListPresenterTest { val presenter = createRoomListPresenter(client = client) presenter.test { val initialState = awaitItem() + + // Skip intermediate event for loading the value of the feature flags + skipItems(1) + val summary = createRoomListRoomSummary() initialState.eventSink(RoomListEvent.ShowContextMenu(summary)) @@ -260,6 +265,10 @@ class RoomListPresenterTest { val presenter = createRoomListPresenter(client = client) presenter.test { val initialState = awaitItem() + + // Skip intermediate event for loading the value of the feature flags + skipItems(1) + val summary = createRoomListRoomSummary() initialState.eventSink(RoomListEvent.ShowContextMenu(summary)) @@ -309,6 +318,10 @@ class RoomListPresenterTest { ) presenter.test { val initialState = awaitItem() + + // Skip intermediate event for loading the value of the feature flags + skipItems(1) + eventRecorder.assertEmpty() initialState.eventSink(RoomListEvent.ToggleSearchResults) eventRecorder.assertSingle( @@ -398,6 +411,7 @@ class RoomListPresenterTest { client = matrixClient, ) presenter.test { + skipItems(1) // Skip initial state assertThat(awaitItem().contentState).isInstanceOf(RoomListContentState.Empty::class.java) } } @@ -616,7 +630,7 @@ class RoomListPresenterTest { ) presenter.test { assertThat(announcementService.announcementsToShowFlow().first()).isEmpty() - skipItems(1) + skipItems(2) // Skip initial state and intermediate event for loading the value of the feature flags val state = awaitItem() assertThat(state.contentAsRooms().showNewNotificationSoundBanner).isFalse() announcementService.emitAnnouncementsToShow(listOf(Announcement.NewNotificationSound)) @@ -630,6 +644,51 @@ class RoomListPresenterTest { } } + @Test + fun `present - showAllActivity and showUnreadCount reflect the feature flag values`() = runTest { + val roomList = FakeDynamicRoomList( + summaries = MutableStateFlow(listOf(aRoomSummary())), + loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1)), + ) + val matrixClient = FakeMatrixClient( + roomListService = FakeRoomListService(createRoomListLambda = { roomList }), + ) + val featureFlagService = FakeFeatureFlagService( + initialState = mapOf( + FeatureFlags.ShowAllActivityInRoomList.key to true, + FeatureFlags.UnreadIndicatorCount.key to true, + ) + ) + val presenter = createRoomListPresenter( + client = matrixClient, + featureFlagService = featureFlagService, + ) + presenter.test { + val enabledState = consumeItemsUntilPredicate { state -> + state.contentState is RoomListContentState.Rooms && + state.contentAsRooms().showAllActivity && + state.contentAsRooms().showUnreadCount + }.last() + assertThat(enabledState.contentAsRooms().showAllActivity).isTrue() + assertThat(enabledState.contentAsRooms().showUnreadCount).isTrue() + + featureFlagService.setFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList, false) + val allActivityDisabledState = consumeItemsUntilPredicate { state -> + state.contentState is RoomListContentState.Rooms && !state.contentAsRooms().showAllActivity + }.last() + assertThat(allActivityDisabledState.contentAsRooms().showAllActivity).isFalse() + assertThat(allActivityDisabledState.contentAsRooms().showUnreadCount).isTrue() + + featureFlagService.setFeatureEnabled(FeatureFlags.UnreadIndicatorCount, false) + val unreadCountDisabledState = consumeItemsUntilPredicate { state -> + state.contentState is RoomListContentState.Rooms && !state.contentAsRooms().showUnreadCount + }.last() + assertThat(unreadCountDisabledState.contentAsRooms().showAllActivity).isFalse() + assertThat(unreadCountDisabledState.contentAsRooms().showUnreadCount).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + private fun createTestMarkRoomAsRead( client: MatrixClient, notificationCleaner: NotificationCleaner, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt index bd18cd903e..3cff7ca28b 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.features.home.impl.HomeEvent import io.element.android.features.home.impl.HomeView import io.element.android.features.home.impl.R import io.element.android.features.home.impl.aHomeState @@ -259,6 +260,23 @@ class RoomListViewTest : RobolectricTest() { ) ) } + + @Test + fun `clicking on add account in the multi-account announcement emits the expected Event and invokes the callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + ensureCalledOnce { callback -> + setRoomListView( + state = aRoomListState(), + showMultiAccountAnnouncement = true, + homeEventSink = eventsRecorder, + onAddAccountClick = callback, + ) + clickOn(R.string.screen_multi_account_announcement_action) + // Wait for the bottom sheet to be hidden + waitForIdle() + } + eventsRecorder.assertSingle(HomeEvent.DismissMultiAccountAnnouncement) + } } private fun AndroidComposeUiTest.setRoomListView( @@ -273,12 +291,20 @@ private fun AndroidComposeUiTest.setRoomListView( onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(), onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), onDeclineInviteAndBlockUser: (RoomListRoomSummary) -> Unit = EnsureNeverCalledWithParam(), + onAddAccountClick: () -> Unit = EnsureNeverCalled(), + showMultiAccountAnnouncement: Boolean = false, + homeEventSink: (HomeEvent) -> Unit = {}, ) { setSafeContent { HomeView( - homeState = aHomeState(roomListState = state), + homeState = aHomeState( + roomListState = state, + showMultiAccountAnnouncement = showMultiAccountAnnouncement, + eventSink = homeEventSink, + ), onRoomClick = { roomId, _ -> onRoomClick(roomId) }, onSettingsClick = onSettingsClick, + onAddAccountClick = onAddAccountClick, onSetUpRecoveryClick = onSetUpRecoveryClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, onStartChatClick = onCreateRoomClick, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt index 6d54c59912..0dda72b0fb 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt @@ -224,7 +224,7 @@ class GlobalSearchPresenterTest { val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results assertThat(results).hasSize(1) - assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java) + assertThat(results.first()).isInstanceOf(MessageSearchListItem.Message::class.java) cancelAndIgnoreRemainingEvents() } } @@ -251,7 +251,7 @@ class GlobalSearchPresenterTest { val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results assertThat(results).hasSize(1) - assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java) + assertThat(results.first()).isInstanceOf(MessageSearchListItem.Message::class.java) // Remove the query successState.queryState.edit { replace(0, length, "") } @@ -337,6 +337,71 @@ class GlobalSearchPresenterTest { } } + @Test + fun `present - RemoveSearchHistoryResult removes a query entry`() = runTest { + val searchHistoryStore = FakeSearchHistoryStore().apply { + add(SearchHistoryResult.Query("keep")) + add(SearchHistoryResult.Query("drop")) + } + val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(SearchHistoryListItem.Query("drop"))) + testScheduler.advanceUntilIdle() + + assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("keep")) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - RemoveSearchHistoryResult removes a room entry`() = runTest { + val searchHistoryStore = FakeSearchHistoryStore().apply { + add(SearchHistoryResult.Query("keep")) + add(SearchHistoryResult.Room(A_ROOM_ID)) + } + val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(SearchHistoryListItem.Room(A_ROOM_ID, aRoomInfo()))) + testScheduler.advanceUntilIdle() + + assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("keep")) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - RemoveSearchHistoryResult with an unknown id does nothing`() = runTest { + val searchHistoryStore = FakeSearchHistoryStore().apply { add(SearchHistoryResult.Query("keep")) } + val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(SearchHistoryListItem.Query("query:unknown"))) + testScheduler.advanceUntilIdle() + + assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("keep")) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - ClearSearchHistory removes every entry`() = runTest { + val searchHistoryStore = FakeSearchHistoryStore().apply { + add(SearchHistoryResult.Query("a")) + add(SearchHistoryResult.Room(A_ROOM_ID)) + } + val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.ClearSearchHistory) + testScheduler.advanceUntilIdle() + + assertThat(searchHistoryStore.history.first()).isEmpty() + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - SearchHistoryResultSelected does nothing for a room result`() = runTest { val searchHistoryStore = FakeSearchHistoryStore() @@ -345,7 +410,7 @@ class GlobalSearchPresenterTest { val initialState = awaitItem() skipItems(1) - initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Room(A_ROOM_ID, aRoomInfo()))) + initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryListItem.Room(A_ROOM_ID, aRoomInfo()))) testScheduler.advanceUntilIdle() ensureAllEventsConsumed() @@ -361,7 +426,7 @@ class GlobalSearchPresenterTest { // Skip loading search results state skipItems(1) - initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Query("Test"))) + initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryListItem.Query("Test"))) assertThat(awaitItem().queryState.text.toString()).isEqualTo("Test") assertThat(awaitItem().results.isLoading()).isTrue() diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchViewTest.kt index f63e21496e..45b3e52acf 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchViewTest.kt @@ -162,7 +162,7 @@ class GlobalSearchViewTest : RobolectricTest() { results = AsyncData.Success( GlobalSearchResults.MessageSearchResults( persistentListOf( - MessageSearchResultItem.Message( + MessageSearchListItem.Message( messageSearchResult = aMessageSearchResult(roomId = roomId, eventId = eventId), body = "A message body", roomInfo = aRoomInfo(id = roomId, name = "A message room"), @@ -184,7 +184,7 @@ class GlobalSearchViewTest : RobolectricTest() { @Test fun `clicking on a history query result - emits the expected Event`() = runAndroidComposeUiTest { - val queryResult = SearchHistoryResultItem.Query("Recent term") + val queryResult = SearchHistoryListItem.Query("Recent term") val eventsRecorder = EventsRecorder() setGlobalSearchView( state = aGlobalSearchState( @@ -203,10 +203,47 @@ class GlobalSearchViewTest : RobolectricTest() { eventsRecorder.assertSingle(GlobalSearchEvent.SearchHistoryResultSelected(queryResult)) } + @Test + fun `clicking on the remove button of a history result - emits the expected Event`() = runAndroidComposeUiTest { + val queryResult = SearchHistoryListItem.Query("Recent term") + val eventsRecorder = EventsRecorder() + setGlobalSearchView( + state = aGlobalSearchState( + isSearchActive = true, + queryState = TextFieldState(), + results = AsyncData.Uninitialized, + history = AsyncData.Success(persistentListOf(queryResult)), + eventSink = eventsRecorder, + ), + ) + // Remove automatic initial events + eventsRecorder.clear() + onNodeWithContentDescription(activity!!.getString(CommonStrings.action_remove)).performClick() + eventsRecorder.assertSingle(GlobalSearchEvent.RemoveSearchHistoryResult(queryResult)) + } + + @Test + fun `clicking on clear all in the history - emits the expected Event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setGlobalSearchView( + state = aGlobalSearchState( + isSearchActive = true, + queryState = TextFieldState(), + results = AsyncData.Uninitialized, + history = AsyncData.Success(persistentListOf(SearchHistoryListItem.Query("Recent term"))), + eventSink = eventsRecorder, + ), + ) + // Remove automatic initial events + eventsRecorder.clear() + onNodeWithText("Clear all").performClick() + eventsRecorder.assertSingle(GlobalSearchEvent.ClearSearchHistory) + } + @Test fun `clicking on a history room result - invokes the callback and emits the expected Events`() = runAndroidComposeUiTest { val roomId = RoomId("!aHistoryRoom:server.org") - val roomResult = SearchHistoryResultItem.Room( + val roomResult = SearchHistoryListItem.Room( roomId = roomId, roomInfo = aRoomInfo(id = roomId, name = "A history room"), ) diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStoreTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStoreTest.kt index a0a63755dc..fccd1bda93 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStoreTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStoreTest.kt @@ -89,6 +89,31 @@ class DefaultSearchHistoryStoreTest { ).inOrder() } + @Test + fun `remove deletes only the given entry and persists the change`() = runTest { + val fileStore = FakeSearchHistoryFileStore() + val store = createStore(fileStore) + store.add(SearchHistoryResult.Query("a")) + store.add(SearchHistoryResult.Room(A_ROOM_ID)) + store.add(SearchHistoryResult.Query("b")) + + store.remove(SearchHistoryResult.Room(A_ROOM_ID)) + + val expected = listOf(SearchHistoryResult.Query("b"), SearchHistoryResult.Query("a")) + assertThat(store.history.first()).containsExactlyElementsIn(expected).inOrder() + assertThat(createStore(fileStore).history.first()).containsExactlyElementsIn(expected).inOrder() + } + + @Test + fun `remove of an unknown entry leaves the history unchanged`() = runTest { + val store = createStore() + store.add(SearchHistoryResult.Query("a")) + + store.remove(SearchHistoryResult.Query("unknown")) + + assertThat(store.history.first()).containsExactly(SearchHistoryResult.Query("a")) + } + @Test fun `clear removes every entry and deletes the persisted content`() = runTest { val fileStore = FakeSearchHistoryFileStore() diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/FakeSearchHistoryStore.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/FakeSearchHistoryStore.kt index 14d6a7ce65..d79f9002c6 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/FakeSearchHistoryStore.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/FakeSearchHistoryStore.kt @@ -22,6 +22,10 @@ class FakeSearchHistoryStore : SearchHistoryStore { }.take(MAX_SEARCH_HISTORY_SIZE) } + override suspend fun remove(result: SearchHistoryResult) { + state.value = state.value.filterNot { it == result } + } + override suspend fun clear() { state.value = emptyList() } diff --git a/features/invite/api/build.gradle.kts b/features/invite/api/build.gradle.kts index 084ad1088d..390fc6f3eb 100644 --- a/features/invite/api/build.gradle.kts +++ b/features/invite/api/build.gradle.kts @@ -18,7 +18,4 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) - implementation(projects.libraries.designsystem) - implementation(projects.services.analytics.api) } diff --git a/features/invite/impl/build.gradle.kts b/features/invite/impl/build.gradle.kts index e033f2740c..0ea9923f5a 100644 --- a/features/invite/impl/build.gradle.kts +++ b/features/invite/impl/build.gradle.kts @@ -32,7 +32,6 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt index eaace62309..36a90e088e 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager @@ -41,7 +40,6 @@ import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun DeclineAndBlockView( state: DeclineAndBlockState, diff --git a/features/invitepeople/impl/build.gradle.kts b/features/invitepeople/impl/build.gradle.kts index 2ab2fcb4a3..7d1dd2ac59 100644 --- a/features/invitepeople/impl/build.gradle.kts +++ b/features/invitepeople/impl/build.gradle.kts @@ -39,12 +39,10 @@ dependencies { implementation(projects.libraries.testtags) implementation(libs.coil.compose) implementation(projects.services.apperror.api) - implementation(projects.libraries.featureflag.api) api(projects.features.invitepeople.api) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.usersearch.test) testImplementation(projects.services.apperror.test) - testImplementation(projects.libraries.featureflag.test) } diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt index 4da2ec7cea..79d28547b5 100644 --- a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt +++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt @@ -171,7 +171,6 @@ private fun InvitePeopleContentView( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun InvitePeopleSearchBar( queryState: TextFieldState, diff --git a/features/joinroom/impl/build.gradle.kts b/features/joinroom/impl/build.gradle.kts index 71acdad128..c18a4b8a81 100644 --- a/features/joinroom/impl/build.gradle.kts +++ b/features/joinroom/impl/build.gradle.kts @@ -37,12 +37,9 @@ dependencies { implementation(projects.features.invite.api) implementation(projects.features.roomdirectory.api) implementation(projects.services.analytics.api) - implementation(projects.libraries.preferences.api) - implementation(projects.appconfig) testCommonDependencies(libs, true) testImplementation(projects.features.invite.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.previewutils) } diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt index 663b4e440f..9bab09bdda 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -593,7 +592,6 @@ private fun DefaultLoadedContent( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun JoinRoomTopBar( contentState: ContentState, diff --git a/features/knockrequests/impl/build.gradle.kts b/features/knockrequests/impl/build.gradle.kts index e6a1a30167..2e60838b41 100644 --- a/features/knockrequests/impl/build.gradle.kts +++ b/features/knockrequests/impl/build.gradle.kts @@ -30,7 +30,6 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) implementation(projects.libraries.designsystem) diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt index 52b11bc8c4..2124354600 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -479,7 +478,6 @@ private fun KnockRequestsEmptyList( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun KnockRequestsListTopBar(onBackClick: () -> Unit) { TopAppBar( diff --git a/features/knockrequests/test/build.gradle.kts b/features/knockrequests/test/build.gradle.kts index dc3407da01..726ea6969f 100644 --- a/features/knockrequests/test/build.gradle.kts +++ b/features/knockrequests/test/build.gradle.kts @@ -17,6 +17,5 @@ android { dependencies { implementation(projects.features.knockrequests.api) implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(projects.tests.testutils) } diff --git a/features/licenses/api/build.gradle.kts b/features/licenses/api/build.gradle.kts index fcbf81f210..86070f65f9 100644 --- a/features/licenses/api/build.gradle.kts +++ b/features/licenses/api/build.gradle.kts @@ -15,6 +15,4 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) } diff --git a/features/licenses/impl/build.gradle.kts b/features/licenses/impl/build.gradle.kts index 52f21ac0bd..85023488cd 100644 --- a/features/licenses/impl/build.gradle.kts +++ b/features/licenses/impl/build.gradle.kts @@ -31,5 +31,4 @@ dependencies { testCommonDependencies(libs) testImplementation(libs.coroutines.core) - testImplementation(projects.libraries.matrix.test) } diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/details/DependenciesDetailsView.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/details/DependenciesDetailsView.kt index 14b195a0b5..da3b7d04cb 100644 --- a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/details/DependenciesDetailsView.kt +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/details/DependenciesDetailsView.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.plus import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -28,7 +27,6 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets -@OptIn(ExperimentalMaterial3Api::class) @Composable fun DependenciesDetailsView( licenseItem: DependencyLicenseItem, diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt index 17f82cd14e..76845f434d 100644 --- a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.plus import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -39,7 +38,6 @@ import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun DependencyLicensesListView( state: DependencyLicensesListState, diff --git a/features/linknewdevice/impl/build.gradle.kts b/features/linknewdevice/impl/build.gradle.kts index 4659a2d2b8..7c86e1d057 100644 --- a/features/linknewdevice/impl/build.gradle.kts +++ b/features/linknewdevice/impl/build.gradle.kts @@ -28,38 +28,24 @@ setupDependencyInjection() dependencies { // TODO Cleanup - implementation(projects.appconfig) implementation(projects.features.enterprise.api) implementation(projects.features.lockscreen.api) - implementation(projects.features.rageshake.api) implementation(projects.libraries.core) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api) implementation(projects.libraries.designsystem) - implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) implementation(projects.libraries.permissions.api) - implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.qrcode) - implementation(projects.libraries.oauth.api) - implementation(projects.libraries.uiUtils) - implementation(projects.libraries.wellknown.api) implementation(libs.androidx.browser) implementation(libs.androidx.webkit) - implementation(libs.serialization.json) api(projects.features.linknewdevice.api) testCommonDependencies(libs, true) - testImplementation(projects.features.linknewdevice.test) testImplementation(projects.features.lockscreen.test) testImplementation(projects.features.enterprise.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.oauth.test) testImplementation(projects.libraries.permissions.test) - testImplementation(projects.libraries.sessionStorage.test) - testImplementation(projects.libraries.wellknown.test) } diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt index 935c848e42..c43de805b0 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt @@ -5,8 +5,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.features.linknewdevice.impl.screens.desktop import androidx.compose.foundation.background @@ -16,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt index b60fd285af..b9f583ec2a 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt @@ -5,8 +5,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.features.linknewdevice.impl.screens.number import androidx.compose.foundation.layout.Arrangement @@ -16,7 +14,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt index 4cd22e37d2..cecee623d0 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt @@ -5,8 +5,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.features.linknewdevice.impl.screens.qrcode import androidx.compose.animation.AnimatedContent @@ -21,7 +19,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt index b27cce17d9..2dbca3940c 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt @@ -5,12 +5,9 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.features.linknewdevice.impl.screens.root import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource diff --git a/features/linknewdevice/test/build.gradle.kts b/features/linknewdevice/test/build.gradle.kts index 388612f920..39a99e7d67 100644 --- a/features/linknewdevice/test/build.gradle.kts +++ b/features/linknewdevice/test/build.gradle.kts @@ -14,6 +14,5 @@ android { } dependencies { - implementation(projects.features.linknewdevice.api) implementation(projects.tests.testutils) } diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts index 8830b39a22..36dbfdb139 100644 --- a/features/location/api/build.gradle.kts +++ b/features/location/api/build.gradle.kts @@ -68,12 +68,9 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) - implementation(projects.libraries.wellknown.api) implementation(projects.features.enterprise.api) implementation(libs.coil.compose) - implementation(libs.datetime) testCommonDependencies(libs) } diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts index 1fcb2e1fc4..9733f3c768 100644 --- a/features/location/impl/build.gradle.kts +++ b/features/location/impl/build.gradle.kts @@ -42,14 +42,11 @@ dependencies { implementation(projects.services.appnavstate.api) implementation(libs.accompanist.permission) implementation(projects.libraries.uiStrings) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.preferences.api) implementation(projects.libraries.push.api) implementation(projects.libraries.sessionStorage.api) - implementation(projects.libraries.wellknown.api) implementation(libs.androidx.datastore.preferences) - implementation(libs.datetime) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) @@ -58,10 +55,8 @@ dependencies { testImplementation(projects.libraries.testtags) testImplementation(projects.services.analytics.test) testImplementation(projects.features.messages.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.sessionStorage.test) - testImplementation(projects.libraries.wellknown.test) testImplementation(projects.features.location.test) testImplementation(projects.features.enterprise.test) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt index 60e53b4f2b..993e5c9871 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt @@ -10,20 +10,12 @@ package io.element.android.features.location.impl.common import android.Manifest import org.maplibre.compose.camera.CameraPosition -import org.maplibre.compose.map.GestureOptions -import org.maplibre.compose.map.MapOptions -import org.maplibre.compose.map.RenderOptions import org.maplibre.spatialk.geojson.Position /** * Common configuration values for the map. */ object MapDefaults { - val options = MapOptions( - renderOptions = RenderOptions.Standard, - gestureOptions = GestureOptions.Standard, - ) - val defaultCameraPosition = CameraPosition( target = Position(0.0, 0.0), zoom = 0.0, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt index e30dfd53de..dd380dca83 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt @@ -24,13 +24,13 @@ import org.maplibre.compose.expressions.dsl.feature import org.maplibre.compose.expressions.dsl.image import org.maplibre.compose.expressions.dsl.not import org.maplibre.compose.expressions.value.SymbolAnchor +import org.maplibre.compose.interaction.ClickResult import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.layers.SymbolLayer import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.GeoJsonOptions import org.maplibre.compose.sources.GeoJsonSource import org.maplibre.compose.sources.rememberGeoJsonSource -import org.maplibre.compose.util.ClickResult import org.maplibre.spatialk.geojson.Feature import org.maplibre.spatialk.geojson.FeatureCollection import org.maplibre.spatialk.geojson.Point diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt index 983e9fc029..d90888da5c 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt @@ -7,6 +7,7 @@ package io.element.android.features.location.impl.common.ui +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -23,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsPadding @@ -41,27 +43,22 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max -import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig -import io.element.android.features.location.api.internal.rememberTileStyleUrl -import io.element.android.features.location.impl.common.MapDefaults -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator -import org.maplibre.compose.camera.CameraState -import org.maplibre.compose.camera.rememberCameraState -import org.maplibre.compose.map.MapOptions +import org.maplibre.compose.interaction.MapInteractions +import org.maplibre.compose.map.MapState +import org.maplibre.compose.map.MapUiOptions import org.maplibre.compose.map.MaplibreMap +import org.maplibre.compose.map.RenderOptions import org.maplibre.compose.overlay.ExpandingAttributionButton -import org.maplibre.compose.overlay.MapOverlay import org.maplibre.compose.overlay.MaplibreLogo -import org.maplibre.compose.style.BaseStyle -import org.maplibre.compose.util.MaplibreComposable import kotlin.math.roundToInt /** @@ -72,43 +69,45 @@ import kotlin.math.roundToInt * - Updating camera position padding based on sheet height * - Rendering the MaplibreMap with proper ornament positioning * - * @param customMapTilerConfig Optional custom style URL for the map + * @param mapState The map state (camera, style and layer content) for the map. `null` in + * inspection/preview mode, where the native map cannot initialize and a placeholder is shown. + * @param isReady Used to gate rendering until the map should be displayed * @param modifier Modifier for the root layout * @param scaffoldState State for the bottom sheet scaffold - * @param cameraState The camera state for the map - * @param mapOptions The options to configure the map + * @param renderOptions The options to configure the map rendering + * @param interactions The gestures and camera interactions allowed on the map + * @param uiOptions The map UI options (gestures, scrolling, keys, first-frame behaviour) * @param sheetPeekHeight The height of the sheet when collapsed * @param sheetDragHandle Optional drag handle for the sheet * @param sheetSwipeEnabled Whether the sheet can be swiped * @param topBar The top app bar content * @param snackbarHost The snackbar host content * @param sheetContent The content to display in the bottom sheet - * @param mapContent The content inside the MaplibreMap (layers, location pucks, etc.) - * @param overlayContent Content to overlay on top of the map (FAB, pin icons, etc.) + * @param overlay Content to overlay on top of the map (FAB, pin icons, etc.) */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun MapBottomSheetScaffold( - customMapTilerConfig: AsyncData, + mapState: MapState?, + isReady: Boolean, modifier: Modifier = Modifier, scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = rememberBottomSheetState( initialValue = SheetValue.PartiallyExpanded, ) ), - cameraState: CameraState = rememberCameraState(), - mapOptions: MapOptions = MapDefaults.options, + renderOptions: RenderOptions = RenderOptions.Standard, + interactions: MapInteractions = MapInteractions.Standard, + uiOptions: MapUiOptions = MapUiOptions.Standard, sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight, sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, sheetSwipeEnabled: Boolean = true, topBar: (@Composable () -> Unit)? = null, snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, - sheetContent: @Composable ColumnScope.(PaddingValues) -> Unit = {}, - mapContent: @Composable @MaplibreComposable () -> Unit = {}, - overlayContent: @Composable BoxScope.(sheetPadding: PaddingValues) -> Unit = {}, + sheetContent: @Composable ColumnScope.() -> Unit = {}, + overlay: @Composable BoxScope.() -> Unit = {}, ) { val density = LocalDensity.current - val windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) BoxWithConstraints(modifier = modifier.windowInsetsPadding(windowInsets)) { val layoutHeightPx by rememberUpdatedState(constraints.maxHeight) @@ -126,7 +125,7 @@ fun MapBottomSheetScaffold( sheetContent = { val maxContentHeight = (layoutHeightPx * 0.5f).roundToInt().toDp() Column(modifier = Modifier.heightIn(max = maxContentHeight)) { - sheetContent(sheetPadding) + sheetContent() Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) } }, @@ -136,39 +135,57 @@ fun MapBottomSheetScaffold( snackbarHost = snackbarHost, topBar = topBar, ) { - Box { - when (customMapTilerConfig) { - is AsyncData.Success -> { + Box(modifier = Modifier.fillMaxSize()) { + when { + isReady && mapState != null -> { MaplibreMap( - options = mapOptions, - cameraPadding = sheetPadding, - contentWindowInsets = WindowInsets(bottom = sheetPadding.calculateBottomPadding()), - baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapTilerConfig.data)), - overlay = MapOverlay { - val style = styleState - Row( - Modifier - .align(Alignment.BottomStart) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + state = mapState, + viewportInsets = sheetPadding, + renderOptions = renderOptions, + interactions = interactions, + uiOptions = uiOptions, + overlay = { + Box( + modifier = Modifier + .fillMaxSize() + .padding(sheetPadding) ) { - MaplibreLogo() - ExpandingAttributionButton(cameraState = cameraState, styleState = style) + Row( + Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .padding(all = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + MaplibreLogo() + ExpandingAttributionButton() + } } }, modifier = Modifier.fillMaxSize(), - cameraState = cameraState, - content = mapContent, ) } + mapState == null -> { + // Inspection/preview mode: the map cannot initialize, render a placeholder + // matching MaplibreMap's own inspection rendering. + Box(modifier = Modifier.fillMaxSize().background(Color.Gray)) + } else -> { Box(modifier = Modifier.fillMaxSize()) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } } - overlayContent(sheetPadding) + // App overlay (FABs, pins, live-location indicators). Layered above the map so it is + // present even in inspection/preview mode, where the map is a placeholder. + Box( + modifier = Modifier + .fillMaxSize() + .padding(sheetPadding) + ) { + overlay() + } } } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt index 074404a4cc..bfa94f8c61 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt @@ -8,34 +8,28 @@ package io.element.android.features.location.impl.common.ui import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import org.maplibre.compose.camera.CameraState -import org.maplibre.compose.location.Location -import org.maplibre.compose.location.LocationPuck -import org.maplibre.compose.location.LocationPuckColors -import org.maplibre.compose.location.LocationPuckSizes +import org.maplibre.compose.layers.LocationIndicatorDefaults +import org.maplibre.compose.layers.LocationIndicatorLayer +import org.maplibre.compose.location.LocationMeasurement @Composable fun UserLocationPuck( - cameraState: CameraState, - location: Location?, + location: LocationMeasurement?, ) { - LocationPuck( - idPrefix = "user-location", - location = location, - cameraState = cameraState, - accuracyThreshold = Float.POSITIVE_INFINITY, - showBearingAccuracy = false, - showBearing = false, - sizes = LocationPuckSizes( - dotRadius = 8.dp, - dotStrokeWidth = 2.dp, + LocationIndicatorLayer( + id = "user-location", + location = location?.position, + // Hide the accuracy circle and the bearing accuracy sector: leaving `accuracyRadius` + // and `bearingAccuracy` null is what hides them. + accuracyRadius = null, + bearingAccuracy = null, + // Hide the bearing indicator. + bearing = null, + bearingImage = null, + topImage = LocationIndicatorDefaults.topImage( + color = ElementTheme.colors.iconAccentPrimary, + borderColor = ElementTheme.colors.bgCanvasDefault, ), - colors = LocationPuckColors( - dotFillColorCurrentLocation = ElementTheme.colors.iconAccentPrimary, - dotFillColorOldLocation = ElementTheme.colors.iconAccentTertiary, - dotStrokeColor = ElementTheme.colors.bgCanvasDefault, - ) ) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt index 083cd9dd45..ded16bc79b 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt @@ -39,7 +39,7 @@ class DefaultUserLocationStateFactory : UserLocationState.Factory { .updates(locationRequest) .mapNotNull { event -> when (event) { - is LocationEvent.Fix -> event.location + is LocationEvent.Update -> event.measurement is LocationEvent.Unavailable -> null } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/Location.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/Location.kt index 9777e14af5..7b4ed5d914 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/Location.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/Location.kt @@ -8,19 +8,18 @@ package io.element.android.features.location.impl.common.userlocation import io.element.android.features.location.api.Location -import org.maplibre.compose.location.PositionWithAccuracy +import org.maplibre.compose.location.LocationMeasurement import org.maplibre.spatialk.geojson.Position import org.maplibre.spatialk.units.extensions.meters -import kotlin.time.TimeSource -import org.maplibre.compose.location.Location as MapLibreLocation +import kotlin.time.Clock -fun Location.asMapLibreLocation(): MapLibreLocation { - return MapLibreLocation( - position = PositionWithAccuracy( - value = Position(latitude = lat, longitude = lon), - accuracy = accuracy?.toDouble()?.meters +fun Location.toLocationMeasurement(): LocationMeasurement { + return LocationMeasurement( + position = Position( + latitude = lat, + longitude = lon, ), - // Not relevant as not used - timestamp = TimeSource.Monotonic.markNow(), + horizontalAccuracy = accuracy?.toDouble()?.meters, + measuredAt = Clock.System.now(), ) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/LocationProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/LocationProvider.kt index 0251f03238..bd300ea82c 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/LocationProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/LocationProvider.kt @@ -37,7 +37,7 @@ import org.maplibre.compose.location.LocationEvent import org.maplibre.compose.location.LocationProvider import org.maplibre.compose.location.LocationRequest import org.maplibre.compose.location.LocationUnavailableReason -import org.maplibre.compose.location.asMapLibreLocation +import org.maplibre.compose.location.asMapLibreLocationUpdate import org.maplibre.spatialk.units.extensions.inMeters import java.util.ServiceLoader @@ -75,7 +75,7 @@ class PlatformLocationProvider( val locationManager = context.getSystemService(LocationManager::class.java) val listener = object : LocationListenerCompat { override fun onLocationChanged(location: Location) { - trySend(LocationEvent.Fix(location.asMapLibreLocation())) + trySend(location.asMapLibreLocationUpdate()) } override fun onProviderDisabled(provider: String) { @@ -100,7 +100,7 @@ class PlatformLocationProvider( return } locationManager.getLastKnownLocation(provider)?.let { location -> - trySend(LocationEvent.Fix(location.asMapLibreLocation())) + trySend(location.asMapLibreLocationUpdate()) } val locationRequestCompat = LocationRequestCompat.Builder(request.minimumInterval.inWholeMilliseconds) .setQuality(request.accuracy.toLocationRequestQuality()) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationState.kt index 250ad536b7..938570efd4 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationState.kt @@ -9,10 +9,10 @@ package io.element.android.features.location.impl.common.userlocation import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import org.maplibre.compose.location.Location +import org.maplibre.compose.location.LocationMeasurement @Immutable -data class UserLocationState(val location: Location?) { +data class UserLocationState(val location: LocationMeasurement?) { fun interface Factory { @Composable fun create(hasLocationPermission: Boolean): UserLocationState diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt index ca6c10b7fa..08cff42c4a 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt @@ -10,12 +10,16 @@ package io.element.android.features.location.impl.common.userlocation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import io.element.android.features.location.impl.common.MapDefaults import kotlinx.coroutines.flow.distinctUntilChanged -import org.maplibre.compose.camera.CameraState -import org.maplibre.compose.location.Location +import org.maplibre.compose.camera.CameraAnimation +import org.maplibre.compose.location.LocationMeasurement +import org.maplibre.compose.map.MapState import kotlin.math.abs /** @@ -27,7 +31,7 @@ internal fun UserLocationTrackingEffect( locationState: UserLocationState, enabled: Boolean = true, precision: Double = 0.00001, - onLocationChange: suspend (Location?) -> Unit, + onLocationChange: suspend (LocationMeasurement?) -> Unit, ) { val latestOnLocationChange by rememberUpdatedState(onLocationChange) val latestLocationState by rememberUpdatedState(locationState) @@ -38,8 +42,8 @@ internal fun UserLocationTrackingEffect( .distinctUntilChanged { oldLocation, newLocation -> if (oldLocation != null && newLocation != null) { when { - abs(oldLocation.position.value.latitude - newLocation.position.value.latitude) >= precision -> false - abs(oldLocation.position.value.longitude - newLocation.position.value.longitude) >= precision -> false + abs(oldLocation.position.latitude - newLocation.position.latitude) >= precision -> false + abs(oldLocation.position.longitude - newLocation.position.longitude) >= precision -> false else -> true } } else { @@ -54,24 +58,30 @@ internal fun UserLocationTrackingEffect( @Composable internal fun UserLocationTrackingEffect( - cameraState: CameraState, + mapState: MapState, locationState: UserLocationState, enabled: Boolean = true, precision: Double = 0.00001, ) { + // Tracks whether the camera has already been placed on the user location once. + var hasPositioned by remember(mapState) { mutableStateOf(false) } UserLocationTrackingEffect( locationState = locationState, enabled = enabled, precision = precision ) { location -> - val target = location?.position?.value ?: cameraState.position.target - cameraState.animateTo( - cameraState.position.copy( - target = target, - // Force pointing to NORTH - bearing = 0.0, - zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM) - ) + val target = location?.position ?: return@UserLocationTrackingEffect + val position = mapState.cameraPosition.copy( + target = target, + // Force pointing to NORTH + bearing = 0.0, + zoom = mapState.cameraPosition.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM) ) + if (hasPositioned) { + mapState.animateCamera(position.toCameraUpdate(), CameraAnimation.Fly()) + } else { + mapState.setCameraPosition(position) + hasPositioned = true + } } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt index d8868021dc..ff09cca6e8 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt @@ -102,11 +102,11 @@ class LiveLocationSharingService : Service() { } .onEach { locationEvent -> when (locationEvent) { - is LocationEvent.Fix -> { + is LocationEvent.Update -> { val location = ApiLocation( - lat = locationEvent.location.position.value.latitude, - lon = locationEvent.location.position.value.longitude, - accuracy = locationEvent.location.position.accuracy?.inMeters?.toFloat(), + lat = locationEvent.measurement.position.latitude, + lon = locationEvent.measurement.position.longitude, + accuracy = locationEvent.measurement.horizontalAccuracy?.inMeters?.toFloat(), ) coordinator.dispatch(location) } @@ -115,7 +115,6 @@ class LiveLocationSharingService : Service() { LocationUnavailableReason.ServicesDisabled, LocationUnavailableReason.TemporarilyUnavailable -> Unit LocationUnavailableReason.Unsupported, - LocationUnavailableReason.Misconfigured, LocationUnavailableReason.PermissionDenied, LocationUnavailableReason.UnexpectedFailure -> coordinator.dispatchUnrecoverableError() } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index 4151b5fce3..6baa7bf3d2 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -13,7 +13,6 @@ package io.element.android.features.location.impl.share import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.itemsIndexed @@ -30,6 +29,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue 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.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -37,6 +37,7 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.location.api.Location import io.element.android.features.location.api.internal.centerBottomEdge +import io.element.android.features.location.api.internal.rememberTileStyleUrl import io.element.android.features.location.impl.R import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog @@ -67,11 +68,11 @@ import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import org.maplibre.compose.camera.CameraMoveReason -import org.maplibre.compose.camera.CameraState -import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.map.MapState +import org.maplibre.compose.map.rememberMapState +import org.maplibre.compose.style.BaseStyle import kotlin.time.Duration -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ShareLocationView( state: ShareLocationState, @@ -104,23 +105,84 @@ fun ShareLocationView( ) } + if (LocalInspectionMode.current) { + // Inspection/preview mode: the native map runtime is unavailable (previews, screenshot + // tests, Robolectric), so no MapState is created and the scaffold renders a placeholder map. + ShareLocationScaffold( + state = state, + mapState = null, + navigateUp = navigateUp, + onSharePinLocation = {}, + modifier = modifier, + ) + } else { + ShareLocationViewContent( + state = state, + navigateUp = navigateUp, + modifier = modifier, + ) + } +} + +@Composable +private fun ShareLocationViewContent( + state: ShareLocationState, + navigateUp: () -> Unit, + modifier: Modifier = Modifier, +) { + val tileStyleUrl = rememberTileStyleUrl(state.customMapTilerConfig.dataOrNull()) + val mapState = rememberMapState( + baseStyle = BaseStyle.Uri(tileStyleUrl), + initialCameraPosition = MapDefaults.defaultCameraPosition, + ) { + UserLocationPuck(location = state.userLocationState.location) + } + UserLocationTrackingEffect( + mapState = mapState, + locationState = state.userLocationState, + enabled = state.trackUserLocation, + ) + LaunchedEffect(mapState.isCameraMoving) { + if (mapState.cameraMoveReason == CameraMoveReason.GESTURE) { + state.eventSink(ShareLocationEvent.StopTrackingUserLocation) + } + } + ShareLocationScaffold( + state = state, + mapState = mapState, + navigateUp = navigateUp, + onSharePinLocation = { + val positionTarget = mapState.cameraPosition.target + state.eventSink( + ShareLocationEvent.ShareStaticLocation( + location = Location(lat = positionTarget.latitude, lon = positionTarget.longitude), + isPinned = true + ) + ) + navigateUp() + }, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ShareLocationScaffold( + mapState: MapState?, + state: ShareLocationState, + navigateUp: () -> Unit, + onSharePinLocation: () -> Unit, + modifier: Modifier = Modifier, +) { val scaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = rememberBottomSheetState( initialValue = SheetValue.Expanded, enabledValues = setOf(SheetValue.PartiallyExpanded, SheetValue.Expanded), ) ) - val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition) - - LaunchedEffect(cameraState.isCameraMoving) { - if (cameraState.moveReason == CameraMoveReason.GESTURE) { - state.eventSink(ShareLocationEvent.StopTrackingUserLocation) - } - } - MapBottomSheetScaffold( - customMapTilerConfig = state.customMapTilerConfig, - cameraState = cameraState, + isReady = state.customMapTilerConfig.isReady(), + mapState = mapState, modifier = modifier, scaffoldState = scaffoldState, sheetDragHandle = null, @@ -135,38 +197,21 @@ fun ShareLocationView( }, sheetContent = { BottomSheetContent( - cameraState = cameraState, state = state, - navigateUp = navigateUp + navigateUp = navigateUp, + onSharePinLocation = onSharePinLocation, ) }, - mapContent = { - UserLocationTrackingEffect( - cameraState = cameraState, - locationState = state.userLocationState, - enabled = state.trackUserLocation, - ) - UserLocationPuck( - cameraState = cameraState, - location = state.userLocationState.location, - ) - }, - overlayContent = { sheetPadding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(sheetPadding) - ) { - val variant = if (state.trackUserLocation) { - PinVariant.UserLocation(isLive = false, avatarData = state.currentUser.getAvatarData(AvatarSize.LocationPin)) - } else { - PinVariant.PinnedLocation - } - LocationPin( - variant = variant, - modifier = Modifier.centerBottomEdge(this), - ) + overlay = { + val variant = if (state.trackUserLocation) { + PinVariant.UserLocation(isLive = false, avatarData = state.currentUser.getAvatarData(AvatarSize.LocationPin)) + } else { + PinVariant.PinnedLocation } + LocationPin( + variant = variant, + modifier = Modifier.centerBottomEdge(this), + ) LocationFloatingActionButton( isMapCenteredOnUser = state.trackUserLocation, onClick = { state.eventSink(ShareLocationEvent.StartTrackingUserLocation) }, @@ -217,9 +262,9 @@ private fun StartLiveLocationActionView( @Composable private fun BottomSheetContent( - cameraState: CameraState, state: ShareLocationState, navigateUp: () -> Unit, + onSharePinLocation: () -> Unit, ) { Spacer(Modifier.height(20.dp)) val userLocation = state.userLocationState.location @@ -228,8 +273,8 @@ private fun BottomSheetContent( state.eventSink( ShareLocationEvent.ShareStaticLocation( location = Location( - lat = userLocation.position.value.latitude, - lon = userLocation.position.value.longitude + lat = userLocation.position.latitude, + lon = userLocation.position.longitude ), isPinned = false ) @@ -237,18 +282,7 @@ private fun BottomSheetContent( navigateUp() } } else { - SharePinLocationItem( - onClick = { - val positionTarget = cameraState.position.target - state.eventSink( - ShareLocationEvent.ShareStaticLocation( - location = Location(lat = positionTarget.latitude, lon = positionTarget.longitude), - isPinned = true - ) - ) - navigateUp() - } - ) + SharePinLocationItem(onClick = onSharePinLocation) } if (state.canShareLiveLocation) { ShareLiveLocationItem { diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index 412e96eb9c..51ed1d6c68 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -38,7 +38,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS import io.element.android.features.location.impl.common.toDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState -import io.element.android.features.location.impl.common.userlocation.asMapLibreLocation +import io.element.android.features.location.impl.common.userlocation.toLocationMeasurement import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.mapState @@ -215,7 +215,7 @@ class ShowLocationPresenter( val hideUserLocationPuck = mode is ShowLocationMode.Live && isCurrentlySharing val userLocationState = if (hideUserLocationPuck) { // When sharing with this device, use the user LocationShareItem as source of data instead of the device. - val ownLocationShare by remember { derivedStateOf { updatedLocationShares.find { it.isOwnUser }?.location?.asMapLibreLocation() } } + val ownLocationShare by remember { derivedStateOf { updatedLocationShares.find { it.isOwnUser }?.location?.toLocationMeasurement() } } UserLocationState(ownLocationShare) } else { userLocationStateFactory.create(hasLocationPermission = permissionsState.isAnyGranted) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index 1e4200c7d7..8a797ad1a6 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -31,11 +31,13 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue 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.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme +import io.element.android.features.location.api.internal.rememberTileStyleUrl import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton @@ -52,12 +54,14 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch +import org.maplibre.compose.camera.CameraAnimation import org.maplibre.compose.camera.CameraMoveReason import org.maplibre.compose.camera.CameraPosition -import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.map.MapState +import org.maplibre.compose.map.rememberMapState +import org.maplibre.compose.style.BaseStyle import org.maplibre.spatialk.geojson.Position -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ShowLocationView( state: ShowLocationState, @@ -73,7 +77,49 @@ fun ShowLocationView( onDismiss = { state.eventSink(ShowLocationEvent.DismissDialog) }, ) - val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition) + if (LocalInspectionMode.current) { + // Inspection/preview mode: the native map runtime is unavailable (previews, screenshot + // tests, Robolectric), so no MapState is created and the scaffold renders a placeholder map. + ShowLocationScaffold( + state = state, + mapState = null, + onBackClick = onBackClick, + onLocationShareClick = {}, + modifier = modifier, + ) + } else { + ShowLocationViewContent( + state = state, + onBackClick = onBackClick, + modifier = modifier, + ) + } +} + +@Composable +private fun ShowLocationViewContent( + state: ShowLocationState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val tileStyleUrl = rememberTileStyleUrl(state.customMapTilerConfig.dataOrNull()) + val mapState = rememberMapState( + baseStyle = BaseStyle.Uri(tileStyleUrl), + initialCameraPosition = MapDefaults.defaultCameraPosition, + ) { + if (!state.hideUserLocationPuck) { + UserLocationPuck(location = state.userLocationState.location) + } + val markers = remember(state.locationShares) { + state.locationShares.map { it.toMarkerData() }.toImmutableList() + } + LocationPinMarkers(markers) + } + UserLocationTrackingEffect( + mapState = mapState, + locationState = state.userLocationState, + enabled = state.isTrackMyLocation, + ) var hasAnimatedToFocusedLocation by remember { mutableStateOf(false) } LaunchedEffect(state.focusedLocation) { if (state.focusedLocation != null && !hasAnimatedToFocusedLocation) { @@ -82,15 +128,44 @@ fun ShowLocationView( target = Position(latitude = state.focusedLocation.location.lat, longitude = state.focusedLocation.location.lon), zoom = MapDefaults.DEFAULT_ZOOM ) - cameraState.position = position + mapState.setCameraPosition(position) } } - LaunchedEffect(cameraState.isCameraMoving) { - if (cameraState.moveReason == CameraMoveReason.GESTURE) { + LaunchedEffect(mapState.isCameraMoving) { + if (mapState.cameraMoveReason == CameraMoveReason.GESTURE) { state.eventSink(ShowLocationEvent.TrackMyLocation(false)) } } + val coroutineScope = rememberCoroutineScope() + ShowLocationScaffold( + state = state, + mapState = mapState, + onBackClick = onBackClick, + onLocationShareClick = { locationShare -> + state.eventSink(ShowLocationEvent.TrackMyLocation(false)) + val position = CameraPosition( + target = Position(locationShare.location.lon, locationShare.location.lat), + // Force pointing to NORTH + bearing = 0.0, + zoom = mapState.cameraPosition.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM), + ) + coroutineScope.launch { + mapState.animateCamera(update = position.toCameraUpdate(), animation = CameraAnimation.Fly()) + } + }, + modifier = modifier, + ) +} +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ShowLocationScaffold( + state: ShowLocationState, + mapState: MapState?, + onBackClick: () -> Unit, + onLocationShareClick: (LocationShareItem) -> Unit, + modifier: Modifier = Modifier, +) { val scaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = rememberBottomSheetState( initialValue = SheetValue.Expanded, @@ -103,7 +178,7 @@ fun ShowLocationView( } } MapBottomSheetScaffold( - customMapTilerConfig = state.customMapTilerConfig, + isReady = state.customMapTilerConfig.isReady(), sheetDragHandle = if (state.isSheetDraggable) { { BottomSheetDefaults.DragHandle() } } else { @@ -111,7 +186,7 @@ fun ShowLocationView( }, sheetSwipeEnabled = state.isSheetDraggable, scaffoldState = scaffoldState, - cameraState = cameraState, + mapState = mapState, modifier = modifier, topBar = { TopAppBar( @@ -123,8 +198,7 @@ fun ShowLocationView( }, ) }, - sheetContent = { sheetPaddings -> - val coroutineScope = rememberCoroutineScope() + sheetContent = { if (!state.isSheetDraggable) { // If sheet is draggable the DragHandle has already some padding Spacer(Modifier.height(20.dp)) @@ -152,41 +226,13 @@ fun ShowLocationView( item = locationShare, onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) }, onStopClick = { state.eventSink(ShowLocationEvent.StopLocationSharing) }, - modifier = Modifier.clickable { - state.eventSink(ShowLocationEvent.TrackMyLocation(false)) - val position = CameraPosition( - target = Position(locationShare.location.lon, locationShare.location.lat), - // Force pointing to NORTH - bearing = 0.0, - zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM), - ) - coroutineScope.launch { - cameraState.animateTo(finalPosition = position) - } - } + modifier = Modifier.clickable { onLocationShareClick(locationShare) } ) } } } }, - mapContent = { - UserLocationTrackingEffect( - cameraState = cameraState, - locationState = state.userLocationState, - enabled = state.isTrackMyLocation, - ) - if (!state.hideUserLocationPuck) { - UserLocationPuck( - cameraState = cameraState, - location = state.userLocationState.location, - ) - } - val markers = remember(state.locationShares) { - state.locationShares.map { it.toMarkerData() }.toImmutableList() - } - LocationPinMarkers(markers) - }, - overlayContent = { + overlay = { LocationFloatingActionButton( isMapCenteredOnUser = state.isTrackMyLocation, onClick = { state.eventSink(ShowLocationEvent.TrackMyLocation(true)) }, diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index 1fab0d98af..b2a84d24e1 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -510,8 +510,8 @@ class ShowLocationPresenterTest { val state = awaitItem() assertThat(state.hideUserLocationPuck).isTrue() val location = requireNotNull(state.userLocationState.location) - assertThat(location.position.value.latitude).isEqualTo(ownLocation?.lat) - assertThat(location.position.value.longitude).isEqualTo(ownLocation?.lon) + assertThat(location.position.latitude).isEqualTo(ownLocation?.lat) + assertThat(location.position.longitude).isEqualTo(ownLocation?.lon) } } } diff --git a/features/lockscreen/impl/build.gradle.kts b/features/lockscreen/impl/build.gradle.kts index 414b4b5909..d287cbea78 100644 --- a/features/lockscreen/impl/build.gradle.kts +++ b/features/lockscreen/impl/build.gradle.kts @@ -32,12 +32,10 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.featureflag.api) implementation(projects.libraries.cryptography.api) implementation(projects.libraries.preferences.api) - implementation(projects.libraries.testtags) implementation(projects.libraries.uiUtils) implementation(projects.features.logout.api) implementation(projects.libraries.uiCommon) @@ -52,7 +50,6 @@ dependencies { testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.cryptography.test) testImplementation(projects.libraries.cryptography.impl) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.services.appnavstate.test) testImplementation(projects.features.logout.test) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt index 7b0f86e386..96ce6d8e2d 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt @@ -6,8 +6,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.features.lockscreen.impl.setup.pin import androidx.compose.foundation.layout.Column @@ -17,7 +15,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ReadOnlyComposable diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt index fb7d3aa372..0b37498a8d 100644 --- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt @@ -12,7 +12,10 @@ package io.element.android.features.lockscreen.impl.unlock.keypad import android.view.KeyEvent import androidx.activity.ComponentActivity +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.input.InputModeManager import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.hasContentDescription @@ -52,7 +55,9 @@ class PinKeypadTest : RobolectricTest() { @Test fun `typing using the hardware keyboard emits the expected events`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() - setPinKeyPad(onClick = eventsRecorder) + val inputModeManager = setPinKeyPad(onClick = eventsRecorder) + // Like a hardware keyboard would do, leave the touch mode, else the keys are not focusable. + runOnIdle { inputModeManager.requestInputMode(InputMode.Keyboard) } onNodeWithText("1").requestFocus() onAllNodes(isRoot())[0].performKeyInput { val keys = listOf( @@ -115,13 +120,16 @@ class PinKeypadTest : RobolectricTest() { private fun AndroidComposeUiTest.setPinKeyPad( onClick: (PinKeypadModel) -> Unit = EnsureNeverCalledWithParam(), - ) { + ): InputModeManager { + lateinit var inputModeManager: InputModeManager setContent { + inputModeManager = LocalInputModeManager.current PinKeypad( onClick = onClick, maxWidth = 1000.dp, maxHeight = 1000.dp, ) } + return inputModeManager } } diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts index 6cf8945e05..8cbfa9575d 100644 --- a/features/login/impl/build.gradle.kts +++ b/features/login/impl/build.gradle.kts @@ -71,14 +71,11 @@ dependencies { implementation(projects.libraries.oauth.api) implementation(projects.libraries.preferences.api) implementation(projects.libraries.uiUtils) - implementation(projects.libraries.wellknown.api) implementation(libs.androidx.browser) implementation(libs.androidx.webkit) - implementation(libs.serialization.json) api(projects.features.login.api) testCommonDependencies(libs, true) - testImplementation(projects.features.login.test) testImplementation(projects.features.enterprise.test) testImplementation(projects.features.preferences.test) testImplementation(projects.libraries.preferences.test) @@ -86,7 +83,6 @@ dependencies { testImplementation(projects.libraries.oauth.test) testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.sessionStorage.test) - testImplementation(projects.libraries.wellknown.test) testImplementation(libs.androidx.camera.camera2) testImplementation(libs.androidx.camera.lifecycle) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt index dfddd1d496..64158f0e41 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt @@ -25,6 +25,7 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn import io.element.android.features.login.impl.BuildConfig import io.element.android.libraries.androidutils.service.ServiceBinder +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.di.annotations.AppCoroutineScope @@ -69,6 +70,7 @@ class DefaultElementClassicConnection( private val coroutineScope: CoroutineScope, private val matrixAuthenticationService: MatrixAuthenticationService, private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker, + private val dispatchers: CoroutineDispatchers, ) : ElementClassicConnection { // Messenger for communicating with the service. private var messenger: Messenger? = null @@ -221,7 +223,7 @@ class DefaultElementClassicConnection( fun onSessionReceived(data: Bundle) { // The data must be extracted from the bundle before we launch the coroutine, else the bundle will be emptied val state = data.toElementClassicConnectionState() - coroutineScope.launch { + coroutineScope.launch(dispatchers.io) { val updatedState = ensureHomeserverIsSupported(state) emitState(updatedState) val userId = (updatedState as? ElementClassicConnectionState.ElementClassicReady)?.elementClassicSession?.userId diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt index c2d0bd05b2..6b86e95ea6 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt @@ -6,8 +6,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.features.login.impl.screens.chooseaccountprovider import androidx.compose.foundation.layout.Box @@ -21,7 +19,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt index 845c8ec8ee..00012fa03a 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt @@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -56,7 +55,6 @@ import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun LoginWithClassicView( state: LoginWithClassicState, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt index c2a983a692..06e01f1d9d 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -70,7 +69,6 @@ import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun LoginPasswordView( state: LoginPasswordState, diff --git a/features/login/impl/src/main/res/values-in/translations.xml b/features/login/impl/src/main/res/values-in/translations.xml index 34ba1bd0ed..7a071a285b 100644 --- a/features/login/impl/src/main/res/values-in/translations.xml +++ b/features/login/impl/src/main/res/values-in/translations.xml @@ -89,7 +89,7 @@ Coba masuk secara manual, atau pindai kode QR dengan perangkat lain." "%1$s tidak didukung" "Siap untuk memindai" "Buka %1$s di perangkat desktop" - "Klik pada avatar Anda" + "Klik avatar Anda" "Pilih %1$s" "“Tautkan perangkat baru”" "Pindai kode QR dengan perangkat ini" diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt index 702adaaa6d..4949524813 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt @@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationSer import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope @@ -499,5 +500,6 @@ class DefaultElementClassicConnectionTest : RobolectricTest() { coroutineScope = coroutineScope, matrixAuthenticationService = matrixAuthenticationService, homeServerLoginCompatibilityChecker = homeServerLoginCompatibilityChecker, + dispatchers = testCoroutineDispatchers(), ) } diff --git a/features/logout/api/build.gradle.kts b/features/logout/api/build.gradle.kts index 4453d37931..9a0d858dba 100644 --- a/features/logout/api/build.gradle.kts +++ b/features/logout/api/build.gradle.kts @@ -14,8 +14,5 @@ android { } dependencies { - implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) - implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) } diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutState.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutState.kt index 75c3392ac1..62b4d36bd1 100644 --- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutState.kt +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutState.kt @@ -11,7 +11,6 @@ package io.element.android.features.logout.api.direct import io.element.android.libraries.architecture.AsyncAction data class DirectLogoutState( - val canDoDirectSignOut: Boolean, val logoutAction: AsyncAction, val eventSink: (DirectLogoutEvent) -> Unit, ) diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutStatePreviewParam.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutStatePreviewParam.kt index 936f7d2a58..ca1d9fbd0b 100644 --- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutStatePreviewParam.kt +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutStatePreviewParam.kt @@ -23,11 +23,9 @@ open class DirectLogoutStatePreviewParam : PreviewParameterProvider = AsyncAction.Uninitialized, eventSink: (DirectLogoutEvent) -> Unit = {}, ) = DirectLogoutState( - canDoDirectSignOut = canDoDirectSignOut, logoutAction = logoutAction, eventSink = eventSink, ) diff --git a/features/logout/impl/build.gradle.kts b/features/logout/impl/build.gradle.kts index d5356ced63..612037a618 100644 --- a/features/logout/impl/build.gradle.kts +++ b/features/logout/impl/build.gradle.kts @@ -29,19 +29,17 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.architecture) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) - implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.workmanager.api) api(projects.features.logout.api) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.workmanager.test) } diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt index 4ad8a6c78e..a3cd2f6429 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt @@ -41,6 +41,7 @@ class LogoutPresenter( @Composable override fun present(): LogoutState { val localCoroutineScope = rememberCoroutineScope() + val currentUser by matrixClient.userProfile.collectAsState() val logoutAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } @@ -90,6 +91,7 @@ class LogoutPresenter( } return LogoutState( + currentUser = currentUser, isLastDevice = isLastDevice, backupState = backupState, doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(), diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt index 6233c1cb23..dfab1906fb 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt @@ -12,8 +12,10 @@ import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.user.MatrixUser data class LogoutState( + val currentUser: MatrixUser, val isLastDevice: Boolean, val backupState: BackupState, val doesBackupExistOnServer: Boolean, diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStatePreviewParam.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStatePreviewParam.kt index e2d948a7f3..9db884330b 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStatePreviewParam.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStatePreviewParam.kt @@ -14,6 +14,8 @@ import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.SteadyStateException +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUser open class LogoutStatePreviewParam : PreviewParameterProvider { override val values: Sequence @@ -39,10 +41,12 @@ open class LogoutStatePreviewParam : PreviewParameterProvider { backupUploadState = BackupUploadState.Waiting, waitingForALongTime = true, ), + aLogoutState(currentUser = aMatrixUser(id = "@alice:server.org", displayName = null)), ) } fun aLogoutState( + currentUser: MatrixUser = aMatrixUser(), isLastDevice: Boolean = false, backupState: BackupState = BackupState.ENABLED, doesBackupExistOnServer: Boolean = true, @@ -52,6 +56,7 @@ fun aLogoutState( logoutAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (LogoutEvent) -> Unit = {}, ) = LogoutState( + currentUser = currentUser, isLastDevice = isLastDevice, backupState = backupState, doesBackupExistOnServer = doesBackupExistOnServer, diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt index 90a502fc16..4093be223d 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt @@ -11,7 +11,9 @@ package io.element.android.features.logout.impl import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -32,15 +34,22 @@ import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.SteadyStateException +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCard +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardMode +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardState import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings +/** + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=442-34581 + */ @Composable fun LogoutView( state: LogoutState, @@ -54,15 +63,17 @@ fun LogoutView( onBackClick = onBackClick, title = title(state), subTitle = subtitle(state), - iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()), + iconStyle = BigIcon.Style.Default(CompoundIcons.Mobile()), modifier = modifier, + isScrollable = true, buttons = { Buttons( state = state, onChangeRecoveryKeyClick = onChangeRecoveryKeyClick, onLogoutClick = { eventSink(LogoutEvent.Logout(ignoreSdkError = false)) - } + }, + onCancelClick = onBackClick, ) }, ) { @@ -71,6 +82,8 @@ fun LogoutView( LogoutActionDialog( state.logoutAction, + // Do not show the progress dialog, the progress is rendered in the button. + showProgressDialog = false, onConfirmClick = { eventSink(LogoutEvent.Logout(ignoreSdkError = false)) }, @@ -116,6 +129,7 @@ private fun ColumnScope.Buttons( state: LogoutState, onLogoutClick: () -> Unit, onChangeRecoveryKeyClick: () -> Unit, + onCancelClick: () -> Unit, ) { val logoutAction = state.logoutAction if (state.isLastDevice) { @@ -139,6 +153,12 @@ private fun ColumnScope.Buttons( .testTag(TestTags.signOut), onClick = onLogoutClick, ) + TextButton( + text = stringResource(id = CommonStrings.action_cancel), + enabled = logoutAction !is AsyncAction.Loading, + modifier = Modifier.fillMaxWidth(), + onClick = onCancelClick, + ) } @Composable @@ -149,9 +169,16 @@ private fun Content( Column( modifier = modifier .fillMaxWidth() - .padding(top = 60.dp, start = 20.dp, end = 20.dp), + .padding(top = 8.dp, start = 16.dp, end = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { + AccountInfoCard( + AccountInfoCardState( + matrixUser = state.currentUser, + mode = AccountInfoCardMode.Simple, + ) + ) + Spacer(modifier = Modifier.height(16.dp)) when (state.backupUploadState) { is BackupUploadState.Uploading -> { LinearProgressIndicator( diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt index 901e4e9654..454a7a25fb 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt @@ -27,6 +27,7 @@ class DefaultDirectLogoutView : DirectLogoutView { val eventSink = state.eventSink LogoutActionDialog( state.logoutAction, + showProgressDialog = true, onConfirmClick = { eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false)) }, diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenter.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenter.kt index 79798dd2ab..0b18856ff4 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenter.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenter.kt @@ -10,28 +10,22 @@ package io.element.android.features.logout.impl.direct import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -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 dev.zacsweers.metro.Inject import io.element.android.features.logout.api.direct.DirectLogoutEvent import io.element.android.features.logout.api.direct.DirectLogoutState -import io.element.android.features.logout.impl.tools.isBackingUp import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.encryption.BackupUploadState -import io.element.android.libraries.matrix.api.encryption.EncryptionService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @Inject class DirectLogoutPresenter( private val matrixClient: MatrixClient, - private val encryptionService: EncryptionService, ) : Presenter { @Composable override fun present(): DirectLogoutState { @@ -41,13 +35,6 @@ class DirectLogoutPresenter( mutableStateOf(AsyncAction.Uninitialized) } - val backupUploadState: BackupUploadState by remember { - encryptionService.waitForBackupUploadSteadyState() - } - .collectAsState(initial = BackupUploadState.Unknown) - - val isLastDevice by encryptionService.isLastDevice.collectAsState() - fun handleEvent(event: DirectLogoutEvent) { when (event) { is DirectLogoutEvent.Logout -> { @@ -64,8 +51,6 @@ class DirectLogoutPresenter( } return DirectLogoutState( - canDoDirectSignOut = !isLastDevice && - !backupUploadState.isBackingUp(), logoutAction = logoutAction.value, eventSink = ::handleEvent, ) diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt index 0222a991db..ca9e6d9a16 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt @@ -19,6 +19,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun LogoutActionDialog( state: AsyncAction, + showProgressDialog: Boolean, onConfirmClick: () -> Unit, onForceLogoutClick: () -> Unit, onDismissDialog: () -> Unit, @@ -31,8 +32,11 @@ fun LogoutActionDialog( onSubmitClick = onConfirmClick, onDismiss = onDismissDialog ) - is AsyncAction.Loading -> - ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content)) + is AsyncAction.Loading -> { + if (showProgressDialog) { + ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content)) + } + } is AsyncAction.Failure -> RetryDialog( title = stringResource(id = CommonStrings.dialog_title_error), diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt index 5b14bc5cc3..f22eef1bc3 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt @@ -20,7 +20,9 @@ import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_EXCEPTION +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.encryption.FakeEncryptionService import io.element.android.libraries.workmanager.api.WorkManagerRequestType @@ -39,11 +41,18 @@ class LogoutPresenterTest { @Test fun `present - initial state`() = runTest { - val presenter = createLogoutPresenter() + val presenter = createLogoutPresenter( + matrixClient = FakeMatrixClient( + sessionId = A_SESSION_ID, + userDisplayName = "Alice", + userAvatarUrl = "mxc://avatar", + ), + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitFirstItem() + assertThat(initialState.currentUser).isEqualTo(MatrixUser(A_SESSION_ID, "Alice", "mxc://avatar")) assertThat(initialState.isLastDevice).isFalse() assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN) assertThat(initialState.doesBackupExistOnServer).isTrue() diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt index f69ec8f0ca..4ec9bb4811 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt @@ -13,8 +13,11 @@ package io.element.android.features.logout.impl import androidx.activity.ComponentActivity import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled @@ -25,8 +28,22 @@ import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressTag import io.element.android.tests.testutils.robolectric.RobolectricTest import org.junit.Test +import org.robolectric.annotation.Config class LogoutViewTest : RobolectricTest() { + @Test + @Config(qualifiers = "h1024dp") + fun `the current user is displayed`() = runAndroidComposeUiTest { + setLogoutView( + aLogoutState( + currentUser = aMatrixUser(id = "@alice:server.org", displayName = "Alice"), + eventSink = EventsRecorder(expectEvents = false), + ), + ) + onNodeWithText("Alice").assertIsDisplayed() + onNodeWithText("@alice:server.org").assertIsDisplayed() + } + @Test fun `clicking on logout sends a LogoutEvent`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() @@ -88,10 +105,24 @@ class LogoutViewTest : RobolectricTest() { eventSink = eventsRecorder ), ) - clickOn(CommonStrings.action_cancel) + clickOn(CommonStrings.action_cancel, inDialog = true) eventsRecorder.assertSingle(LogoutEvent.CloseDialogs) } + @Test + fun `clicking on cancel invoke back callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + setLogoutView( + aLogoutState( + eventSink = eventsRecorder + ), + onBackClick = callback, + ) + clickOn(CommonStrings.action_cancel) + } + } + @Test fun `last session setting button invoke onChangeRecoveryKeyClicked`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenterTest.kt index d11c31cf1b..ff0420e04d 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenterTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenterTest.kt @@ -16,13 +16,9 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.logout.api.direct.DirectLogoutEvent import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.encryption.BackupUploadState -import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.FakeMatrixClient -import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.tests.testutils.WarmUpRule -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -38,44 +34,6 @@ class DirectLogoutPresenterTest { presenter.present() }.test { val initialState = awaitFirstItem() - assertThat(initialState.canDoDirectSignOut).isTrue() - assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) - } - } - - @Test - fun `present - initial state - last session`() = runTest { - val presenter = createDirectLogoutPresenter( - encryptionService = FakeEncryptionService().apply { - emitIsLastDevice(true) - } - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val initialState = awaitFirstItem() - assertThat(initialState.canDoDirectSignOut).isFalse() - assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) - } - } - - @Test - fun `present - initial state - backing up`() = runTest { - val encryptionService = FakeEncryptionService() - encryptionService.givenWaitForBackupUploadSteadyStateFlow( - flow { - emit(BackupUploadState.Waiting) - } - ) - val presenter = createDirectLogoutPresenter( - encryptionService = encryptionService - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - skipItems(1) - val initialState = awaitFirstItem() - assertThat(initialState.canDoDirectSignOut).isFalse() assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) } } @@ -180,9 +138,7 @@ class DirectLogoutPresenterTest { private fun createDirectLogoutPresenter( matrixClient: MatrixClient = FakeMatrixClient(), - encryptionService: EncryptionService = FakeEncryptionService(), ): DirectLogoutPresenter = DirectLogoutPresenter( matrixClient = matrixClient, - encryptionService = encryptionService, ) } diff --git a/features/messages/api/build.gradle.kts b/features/messages/api/build.gradle.kts index 029ef624e0..20e05a1798 100644 --- a/features/messages/api/build.gradle.kts +++ b/features/messages/api/build.gradle.kts @@ -19,7 +19,5 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.designsystem) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.mediaviewer.api) - implementation(projects.libraries.preferences.api) api(projects.libraries.textcomposer.impl) } diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 882d9f1f97..cd7ce3703a 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -53,7 +53,6 @@ dependencies { implementation(projects.libraries.permissions.api) implementation(projects.libraries.preferences.api) implementation(projects.libraries.emoji.api) - implementation(projects.libraries.roomselect.api) implementation(projects.libraries.slashcommands.api) implementation(projects.libraries.audio.api) implementation(projects.libraries.voiceplayer.api) @@ -63,22 +62,17 @@ dependencies { implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiUtils) implementation(projects.libraries.testtags) - implementation(projects.libraries.wellknown.api) - implementation(projects.features.networkmonitor.api) implementation(projects.features.contentscanner.api) implementation(projects.services.analytics.compose) implementation(projects.services.appnavstate.api) implementation(projects.services.toolbox.api) implementation(libs.coil.compose) - implementation(libs.datetime) implementation(libs.jsoup) - implementation(libs.androidx.constraintlayout) implementation(libs.androidx.constraintlayout.compose) implementation(libs.androidx.exifinterface) implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.ui) implementation(libs.sigpwned.emoji4j) - implementation(libs.vanniktech.blurhash) implementation(libs.telephoto.zoomableimage) implementation(libs.matrix.emojibase.bindings) implementation(projects.features.knockrequests.api) @@ -106,7 +100,6 @@ dependencies { testImplementation(projects.libraries.voicerecorder.test) testImplementation(projects.libraries.mediaplayer.test) testImplementation(projects.libraries.mediaviewer.test) - testImplementation(projects.libraries.testtags) testImplementation(projects.features.poll.test) testImplementation(projects.libraries.eventformatter.test) testImplementation(projects.libraries.emoji.impl) 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 98e595c110..89556bb1e1 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 @@ -8,7 +8,6 @@ package io.element.android.features.messages.impl -import android.os.Build import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -634,8 +633,9 @@ class MessagesPresenter( event.eventId ?: return room.getPermalinkFor(event.eventId).fold( onSuccess = { permalink -> - clipboardHelper.copyPlainText(permalink) - snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_link_copied_to_clipboard)) + clipboardHelper.copyPlainText(permalink) { + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_link_copied_to_clipboard)) + } }, onFailure = { Timber.e(it, "Failed to get permalink for event ${event.eventId}") @@ -650,16 +650,14 @@ class MessagesPresenter( is TimelineItemStateContent -> event.content.body else -> return } - clipboardHelper.copyPlainText(content) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + clipboardHelper.copyPlainText(content) { snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_message_copied)) } } private fun handleCopyCaption(event: TimelineItem.Event) { val content = event.content.captionOrNull() ?: return - clipboardHelper.copyPlainText(content) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + clipboardHelper.copyPlainText(content) { snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt index 86b8a85557..0f9ca3d7c1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -92,7 +91,6 @@ import kotlin.time.Duration.Companion.milliseconds /** * Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3514 */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AttachmentsPreviewView( state: AttachmentsPreviewState, @@ -263,7 +261,7 @@ private fun AttachmentSendStateView( } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable private fun AttachmentPreviewContent( state: AttachmentsPreviewState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt index 70a3375fa8..a14f2804b2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -82,7 +81,6 @@ private val maxHandleTouchRadius = 56.dp /** * Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3539 */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AttachmentImageEditorView( state: AttachmentImageEditorState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt index 5c6980c8bb..2391766203 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt @@ -15,7 +15,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.stringResource import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin @@ -26,8 +25,9 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.model.TimelineItem -import io.element.android.libraries.androidutils.system.copyToClipboard +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.androidutils.system.openUrlInExternalApp +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.callback import io.element.android.libraries.di.RoomScope import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer @@ -53,6 +53,8 @@ class PinnedMessagesListNode( private val timelineItemPresenterFactories: TimelineItemPresenterFactories, private val permalinkParser: PermalinkParser, private val emojiPickerRenderer: EmojiPickerRenderer, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator { interface Callback : Plugin { fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean) @@ -114,7 +116,6 @@ class PinnedMessagesListNode( LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, ) { val context = LocalContext.current - val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) val view = LocalView.current val state = presenter.present() PinnedMessagesListView( @@ -132,10 +133,9 @@ class PinnedMessagesListNode( view.performHapticFeedback( HapticFeedbackConstants.LONG_PRESS ) - context.copyToClipboard( - text = it.url, - toastMessage = toastMessage, - ) + clipboardHelper.copyPlainText(it.url) { + toastHelper.show(CommonStrings.common_copied_to_clipboard) + } }, emojiPickerRenderer = emojiPickerRenderer, modifier = modifier diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index 298a783e97..0a8b23d845 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.plus import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -106,7 +105,6 @@ fun PinnedMessagesListView( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun PinnedMessagesListTopBar( state: PinnedMessagesListState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt index a3cf4f09da..33575ddf66 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Switch import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -43,7 +42,6 @@ import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReportMessageView( state: ReportMessageState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt index c38da19a67..d72d3d4b10 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -74,7 +73,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ThreadsListView( state: ThreadsListState, @@ -269,6 +267,7 @@ private fun ThreadListItemRow( UnreadIndicatorAtom( size = 14.dp, isVisible = hasUnreadNotifications, + count = 0, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index 66807b74f2..82f13a6dae 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt @@ -29,6 +29,8 @@ sealed interface TimelineEvent { data object MarkAllAsRead : TimelineEvent + data class CopyToClipboard(val text: String) : TimelineEvent + /** * Events coming from a timeline item. */ diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 4580f77d59..516abcdfd8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -44,6 +44,8 @@ import io.element.android.features.messages.impl.voicemessages.timeline.Redacted import io.element.android.features.poll.api.actions.EndPollAction import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.roomcall.api.RoomCallState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.annotations.SessionCoroutineScope @@ -60,6 +62,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationToMessage import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom @@ -104,6 +107,8 @@ class TimelinePresenter( private val liveLocationShareManager: ActiveLiveLocationShareManager, private val markAsFullyRead: MarkAsFullyRead, private val timelineProtectionPresenter: Presenter, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Presenter { private val tag = "TimelinePresenter" @@ -315,6 +320,11 @@ class TimelinePresenter( is TimelineEvent.ValidateMedia -> { timelineProtectionState.eventSink(TimelineProtectionEvent.ValidateContent(event.mediaSources, event.validationState)) } + is TimelineEvent.CopyToClipboard -> { + clipboardHelper.copyPlainText(event.text) { + toastHelper.show(CommonStrings.common_copied_to_clipboard) + } + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index ed53a9f885..0dbe6c315f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -63,7 +63,6 @@ import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.rememberNestedScrollInteropConnection @@ -93,7 +92,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentPreviewParam import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState -import io.element.android.libraries.androidutils.system.copyToClipboard import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom import io.element.android.libraries.designsystem.components.dialogs.AlertDialog import io.element.android.libraries.designsystem.preview.ElementPreview @@ -167,8 +165,6 @@ fun TimelineView( state.eventSink(TimelineEvent.FocusOnEvent(eventId)) } - val context = LocalContext.current - val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) val view = LocalView.current fun inReplyToClick(eventId: EventId) { state.eventSink(TimelineEvent.FocusOnEvent(eventId)) @@ -178,10 +174,7 @@ fun TimelineView( view.performHapticFeedback( HapticFeedbackConstants.LONG_PRESS ) - context.copyToClipboard( - text = link.url, - toastMessage = toastMessage, - ) + state.eventSink(TimelineEvent.CopyToClipboard(link.url)) } fun prefetchMoreItems() { @@ -580,6 +573,7 @@ private fun JumpToPositionButton( .offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) }, color = ElementTheme.colors.iconSuccessPrimary, border = BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault), + count = 0, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt index 931e0de801..7b667c0d6a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt @@ -52,7 +52,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.ui.strings.CommonStrings @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt index 232c107698..2e71af8447 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt @@ -48,13 +48,13 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.ui.strings.CommonStrings import kotlin.math.max diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt index c7878c00f5..aef5952689 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt @@ -37,10 +37,10 @@ import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenu import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator -import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.ui.strings.CommonStrings private const val STICKER_SIZE_IN_DP = 128 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index 5b3803713e..a4dede5d37 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -47,12 +47,12 @@ import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenu import io.element.android.libraries.designsystem.modifiers.roundedBackground import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT -import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH -import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState +import io.element.android.libraries.matrixmedia.api.MAX_THUMBNAIL_HEIGHT +import io.element.android.libraries.matrixmedia.api.MAX_THUMBNAIL_WIDTH +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt index 5b6c7fda37..17e650a9e9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt @@ -71,8 +71,8 @@ import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.matrixmedia.api.MediaRequestData import kotlinx.coroutines.launch internal val REACTION_SUMMARY_LINE_HEIGHT = 25.sp diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt index 6288f3f159..3c80cea7f7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt @@ -16,17 +16,22 @@ import com.bumble.appyx.core.plugin.Plugin import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.ui.strings.CommonStrings @ContributesNode(RoomScope::class) @AssistedInject class EventDebugInfoNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Node(buildContext, plugins = plugins) { data class Inputs( val eventId: EventId?, @@ -39,6 +44,12 @@ class EventDebugInfoNode( navigateUp() } + private fun onCopyText(text: String) { + clipboardHelper.copyPlainText(text) { + toastHelper.show(CommonStrings.common_copied_to_clipboard) + } + } + @Composable override fun View(modifier: Modifier) = with(inputs) { EventDebugInfoView( @@ -46,7 +57,8 @@ class EventDebugInfoNode( model = timelineItemDebugInfo.model, originalJson = timelineItemDebugInfo.originalJson, latestEditedJson = timelineItemDebugInfo.latestEditedJson, - onBackClick = ::onBackClick + onBackClick = ::onBackClick, + onCopyText = ::onCopyText, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt index 3b0448ddb3..f836bc5d68 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt @@ -8,8 +8,6 @@ package io.element.android.features.messages.impl.timeline.debug -import android.content.ClipData -import android.content.ClipboardManager import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically @@ -24,7 +22,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -35,11 +32,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp -import androidx.core.content.getSystemService import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.components.button.BackButton @@ -57,7 +52,6 @@ import org.json.JSONObject * Screen used to display debug info for events. * It will only be available in debug builds. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun EventDebugInfoView( eventId: EventId?, @@ -65,6 +59,7 @@ fun EventDebugInfoView( originalJson: String?, latestEditedJson: String?, onBackClick: () -> Unit, + onCopyText: (String) -> Unit, modifier: Modifier = Modifier, isTest: Boolean = false, ) { @@ -89,20 +84,30 @@ fun EventDebugInfoView( item { Column(Modifier.padding(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text(text = "Event ID:") - CopyableText(text = eventId?.value ?: "-", modifier = Modifier.fillMaxWidth()) + CopyableText(text = eventId?.value ?: "-", onClick = onCopyText, modifier = Modifier.fillMaxWidth()) } } item { - CollapsibleSection(title = "Model:", text = model, initiallyExpanded = sectionsInitiallyExpanded) + CollapsibleSection(title = "Model:", text = model, onCopyText = onCopyText, initiallyExpanded = sectionsInitiallyExpanded) } if (originalJson != null) { item { - CollapsibleSection(title = "Original JSON:", text = prettyJSON(originalJson), initiallyExpanded = sectionsInitiallyExpanded) + CollapsibleSection( + title = "Original JSON:", + text = prettyJSON(originalJson), + onCopyText = onCopyText, + initiallyExpanded = sectionsInitiallyExpanded, + ) } } if (latestEditedJson != null) { item { - CollapsibleSection(title = "Latest edited JSON:", text = prettyJSON(latestEditedJson), initiallyExpanded = sectionsInitiallyExpanded) + CollapsibleSection( + title = "Latest edited JSON:", + text = prettyJSON(latestEditedJson), + onCopyText = onCopyText, + initiallyExpanded = sectionsInitiallyExpanded, + ) } } } @@ -122,6 +127,7 @@ private fun prettyJSON(maybeJSON: String): String { private fun CollapsibleSection( title: String, text: String, + onCopyText: (String) -> Unit, initiallyExpanded: Boolean = false, ) { var isExpanded by remember { mutableStateOf(initiallyExpanded) } @@ -141,7 +147,7 @@ private fun CollapsibleSection( ) } AnimatedVisibility(visible = isExpanded, enter = expandVertically(), exit = shrinkVertically()) { - CopyableText(text = text, modifier = Modifier.fillMaxWidth()) + CopyableText(text = text, onClick = onCopyText, modifier = Modifier.fillMaxWidth()) } } } @@ -149,16 +155,15 @@ private fun CollapsibleSection( @Composable private fun CopyableText( text: String, + onClick: (String) -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current - val clipboardManager = remember { requireNotNull(context.getSystemService()) } Box( modifier .clip(RoundedCornerShape(4.dp)) .background(MaterialTheme.colorScheme.surfaceVariant) .padding(6.dp) - .clickable { clipboardManager.setPrimaryClip(ClipData.newPlainText("JSON", text)) } + .clickable { onClick(text) } ) { Text( text = text, @@ -176,6 +181,7 @@ internal fun EventDebugInfoViewPreview() = ElementPreview { model = "Rust(\n\tModel()\n)", originalJson = "{\"name\": \"original\"}", latestEditedJson = "{\"name\": \"edited\"}", - onBackClick = { } + onBackClick = { }, + onCopyText = { }, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt index 840f19488d..cfb702678c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt @@ -8,7 +8,7 @@ package io.element.android.features.messages.impl.timeline.model.event import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrixmedia.api.MediaRequestData import kotlinx.collections.immutable.ImmutableList import kotlin.time.Duration @@ -51,8 +51,8 @@ data class GalleryItem( MediaRequestData( source = thumbnailSource ?: mediaSource, kind = MediaRequestData.Kind.Thumbnail( - width = thumbnailWidth?.toLong() ?: io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH, - height = thumbnailHeight?.toLong() ?: io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT, + width = thumbnailWidth?.toLong() ?: io.element.android.libraries.matrixmedia.api.MAX_THUMBNAIL_WIDTH, + height = thumbnailHeight?.toLong() ?: io.element.android.libraries.matrixmedia.api.MAX_THUMBNAIL_HEIGHT, ), ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt index 5e78adc945..e67738b0fd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt @@ -10,9 +10,9 @@ package io.element.android.features.messages.impl.timeline.model.event import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT -import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH -import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrixmedia.api.MAX_THUMBNAIL_HEIGHT +import io.element.android.libraries.matrixmedia.api.MAX_THUMBNAIL_WIDTH +import io.element.android.libraries.matrixmedia.api.MediaRequestData data class TimelineItemImageContent( override val filename: String, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt index d6a55c0563..5eed146e26 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -61,7 +60,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun MessagesViewTopBar( roomName: String?, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/ThreadTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/ThreadTopBar.kt index 5ef4541f06..c2d08c8c1f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/ThreadTopBar.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/ThreadTopBar.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth 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 @@ -43,7 +42,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ThreadTopBar( roomName: String?, diff --git a/features/messages/impl/src/main/res/values-hr/translations.xml b/features/messages/impl/src/main/res/values-hr/translations.xml index 33daf3b00f..8b8a43cde7 100644 --- a/features/messages/impl/src/main/res/values-hr/translations.xml +++ b/features/messages/impl/src/main/res/values-hr/translations.xml @@ -48,9 +48,9 @@ "Anketa" "Oblikovanje teksta" "Razlog (neobavezno)" - "Unesite…" + "Unesite razlog…" "(Neobavezno)" - "Potvrdite uklanjanje" + "Ukloniti poruku?" "Povijest poruka trenutačno nije dostupna." "Povijest poruka nije dostupna u ovoj sobi. Potvrdite ovaj uređaj kako biste vidjeli povijest poruka." "Želite li ih pozvati natrag?" diff --git a/features/messages/impl/src/main/res/values-in/translations.xml b/features/messages/impl/src/main/res/values-in/translations.xml index bae097e818..bed52fe550 100644 --- a/features/messages/impl/src/main/res/values-in/translations.xml +++ b/features/messages/impl/src/main/res/values-in/translations.xml @@ -36,7 +36,7 @@ "Tambahkan media" "Blokir pengguna" "Centang jika Anda ingin menyembunyikan semua pesan saat ini dan yang akan datang dari pengguna ini" - "Pesan ini akan dilaporkan ke administrator homeserver Anda. Mereka tidak akan dapat membaca pesan terenkripsi apa pun." + "Pesan ini akan dilaporkan ke penyedia akun Anda. Mereka tidak akan dapat membaca pesan terenkripsi apa pun." "Alasan melaporkan konten ini" "Kamera" "Ambil foto" diff --git a/features/messages/impl/src/main/res/values-ru/translations.xml b/features/messages/impl/src/main/res/values-ru/translations.xml index 1fab6ed5b3..d23ba8febc 100644 --- a/features/messages/impl/src/main/res/values-ru/translations.xml +++ b/features/messages/impl/src/main/res/values-ru/translations.xml @@ -49,9 +49,9 @@ "Опрос" "Форматирование текста" "Причина (необязательно)" - "Введите…" + "Укажите причину…" "(Необязательно)" - "Подтвердите удаление" + "Удалить сообщение?" "В настоящее время история сообщений недоступна в этой комнате." "История сообщений в этой комнате недоступна. Проверьте это устройство, чтобы увидеть историю сообщений." "Хотите пригласить их снова?" diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index aa4198d156..f09e595e7c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -33,6 +33,10 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.poll.test.actions.FakeEndPollAction import io.element.android.features.poll.test.actions.FakeSendPollResponseAction import io.element.android.features.roomcall.api.aStandByCallState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +import io.element.android.libraries.androidutils.toast.FakeToastHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService @@ -73,6 +77,7 @@ import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.awaitLastSequentialItem @@ -1541,6 +1546,38 @@ class TimelinePresenterTest { } } + @Test + fun `present - copy to clipboard on old device shows a toast`() = runTest { + val clipboardHelper = FakeClipboardHelper(isOldDevice = true) + val toastHelper = FakeToastHelper() + val presenter = createTimelinePresenter( + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + presenter.test { + awaitFirstItem().eventSink(TimelineEvent.CopyToClipboard("https://element.io")) + assertThat(clipboardHelper.clipboardContents).isEqualTo("https://element.io") + assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - copy to clipboard on new device does not show a toast`() = runTest { + val clipboardHelper = FakeClipboardHelper(isOldDevice = false) + val toastHelper = FakeToastHelper() + val presenter = createTimelinePresenter( + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + presenter.test { + awaitFirstItem().eventSink(TimelineEvent.CopyToClipboard("https://element.io")) + assertThat(clipboardHelper.clipboardContents).isEqualTo("https://element.io") + assertThat(toastHelper.shownToasts).isEmpty() + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - show shield hide shield`() = runTest { val presenter = createTimelinePresenter() @@ -1897,6 +1934,8 @@ class TimelinePresenterTest { markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> }, timelineProtectionPresenter: Presenter = { aTimelineProtectionState() }, resolveVerifiedUserSendFailurePresenter: Presenter = { aResolveVerifiedUserSendFailureState() }, + clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + toastHelper: ToastHelper = FakeToastHelper(), ): TimelinePresenter { return TimelinePresenter( timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), @@ -1923,6 +1962,8 @@ class TimelinePresenterTest { liveLocationShareManager = liveLocationShareManager, markAsFullyRead = markAsFullyRead, timelineProtectionPresenter = timelineProtectionPresenter, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt index e4c3310089..8fac783581 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt @@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.messagecomposer.aReplyMode import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.audio.api.AudioFocusRequester +import io.element.android.libraries.audio.test.FakeAudioFocus import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.timeline.Timeline @@ -28,7 +29,6 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.timeline.FakeTimeline -import io.element.android.libraries.mediaplayer.test.FakeAudioFocus import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.impl.DefaultMediaSender diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt index 466bfba4fd..7f608fe283 100644 --- a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt @@ -11,8 +11,8 @@ package io.element.android.features.messages.test.timeline.voicemessages.compose import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer import io.element.android.features.messages.test.FakeMessageComposerContext +import io.element.android.libraries.audio.test.FakeAudioFocus import io.element.android.libraries.matrix.api.timeline.Timeline -import io.element.android.libraries.mediaplayer.test.FakeAudioFocus import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.test.FakeMediaSender diff --git a/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationEntryPoint.kt b/features/migration/api/src/main/kotlin/io/element/android/features/migration/api/MigrationEntryPoint.kt similarity index 90% rename from features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationEntryPoint.kt rename to features/migration/api/src/main/kotlin/io/element/android/features/migration/api/MigrationEntryPoint.kt index 471ee30d22..57faede46e 100644 --- a/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationEntryPoint.kt +++ b/features/migration/api/src/main/kotlin/io/element/android/features/migration/api/MigrationEntryPoint.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.api +package io.element.android.features.migration.api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationState.kt b/features/migration/api/src/main/kotlin/io/element/android/features/migration/api/MigrationState.kt similarity index 88% rename from features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationState.kt rename to features/migration/api/src/main/kotlin/io/element/android/features/migration/api/MigrationState.kt index 000d898a6a..ca5b9a4882 100644 --- a/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationState.kt +++ b/features/migration/api/src/main/kotlin/io/element/android/features/migration/api/MigrationState.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.api +package io.element.android.features.migration.api import io.element.android.libraries.architecture.AsyncData diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationEntryPoint.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationEntryPoint.kt index edefa7ce75..df5f38a797 100644 --- a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationEntryPoint.kt +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationEntryPoint.kt @@ -12,8 +12,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.features.api.MigrationEntryPoint -import io.element.android.features.api.MigrationState +import io.element.android.features.migration.api.MigrationEntryPoint +import io.element.android.features.migration.api.MigrationState @ContributesBinding(AppScope::class) class DefaultMigrationEntryPoint( diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt index 2d78e5f706..1301c0e309 100644 --- a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt @@ -18,7 +18,7 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn -import io.element.android.features.api.MigrationState +import io.element.android.features.migration.api.MigrationState import io.element.android.features.migration.impl.migrations.AppMigration import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStatePreviewParam.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStatePreviewParam.kt index de25284867..b43681427e 100644 --- a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStatePreviewParam.kt +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStatePreviewParam.kt @@ -9,7 +9,7 @@ package io.element.android.features.migration.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.api.MigrationState +import io.element.android.features.migration.api.MigrationState import io.element.android.libraries.architecture.AsyncData internal class MigrationStatePreviewParam : PreviewParameterProvider { diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationView.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationView.kt index c7f4dd678a..c3f32eb06b 100644 --- a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationView.kt +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationView.kt @@ -18,7 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import io.element.android.features.api.MigrationState +import io.element.android.features.migration.api.MigrationState import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt index 46bde271c4..8451d1b07f 100644 --- a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt @@ -10,7 +10,7 @@ package io.element.android.features.migration.impl.migrations import com.google.common.truth.Truth.assertThat import io.element.android.features.announcement.api.Announcement -import io.element.android.features.rageshake.test.logs.FakeAnnouncementService +import io.element.android.features.announcement.test.FakeAnnouncementService import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value diff --git a/features/networkmonitor/impl/build.gradle.kts b/features/networkmonitor/impl/build.gradle.kts index 5adc383d64..e08e7e0f78 100644 --- a/features/networkmonitor/impl/build.gradle.kts +++ b/features/networkmonitor/impl/build.gradle.kts @@ -21,11 +21,8 @@ android { dependencies { implementation(libs.coroutines.core) - implementation(projects.libraries.core) implementation(projects.libraries.di) api(projects.features.networkmonitor.api) testCommonDependencies(libs) - testImplementation(projects.libraries.matrix.test) - testImplementation(projects.features.networkmonitor.test) } diff --git a/features/poll/impl/build.gradle.kts b/features/poll/impl/build.gradle.kts index 175f7b4471..0d5790639e 100644 --- a/features/poll/impl/build.gradle.kts +++ b/features/poll/impl/build.gradle.kts @@ -30,7 +30,6 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.services.analytics.api) implementation(projects.features.messages.api) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt index 602466b582..28a8179131 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -216,7 +215,6 @@ fun CreatePollView( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun CreatePollTopAppBar( mode: CreatePollState.Mode, diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt index 40bc2f893a..e9298e9c27 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -53,7 +52,6 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PollHistoryView( state: PollHistoryState, @@ -125,7 +123,6 @@ fun PollHistoryView( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun PollHistoryFilterButtons( activeFilter: PollHistoryFilter, diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index 11f863e26d..c137d6be06 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -48,15 +48,12 @@ setupDependencyInjection() dependencies { implementation(projects.libraries.androidutils) - implementation(projects.appconfig) implementation(projects.libraries.core) implementation(projects.libraries.architecture) - implementation(projects.libraries.cachestore.api) implementation(projects.libraries.matrix.api) implementation(projects.libraries.designsystem) implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.ui) - implementation(projects.libraries.network) implementation(projects.libraries.pushstore.api) implementation(projects.libraries.indicator.api) implementation(projects.libraries.preferences.api) @@ -80,14 +77,12 @@ dependencies { implementation(projects.features.licenses.api) implementation(projects.features.logout.api) implementation(projects.features.deactivation.api) - implementation(projects.features.home.api) implementation(projects.features.invite.api) implementation(projects.libraries.emoji.api) implementation(projects.services.analytics.api) implementation(projects.services.analytics.compose) implementation(projects.services.appnavstate.api) implementation(projects.services.toolbox.api) - implementation(libs.datetime) implementation(libs.coil.compose) implementation(libs.color.picker) implementation(libs.androidx.browser) @@ -108,7 +103,6 @@ dependencies { testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushstore.test) - testImplementation(projects.libraries.roomselect.test) testImplementation(projects.libraries.mediaviewer.test) testImplementation(projects.libraries.troubleshoot.test) testImplementation(projects.features.deactivation.test) @@ -116,11 +110,9 @@ dependencies { testImplementation(projects.features.invite.test) testImplementation(projects.features.licenses.test) testImplementation(projects.features.lockscreen.test) - testImplementation(projects.features.rageshake.test) testImplementation(projects.features.logout.test) testImplementation(projects.libraries.indicator.test) testImplementation(projects.libraries.pushproviders.test) - testImplementation(projects.libraries.cachestore.test) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.services.appnavstate.impl) testImplementation(projects.services.analytics.test) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt index ad9686be9c..4e04cd0ab6 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt @@ -17,7 +17,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject import io.element.android.features.enterprise.api.SessionEnterpriseService -import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -34,7 +33,6 @@ class PreferencesAccountPresenter( private val sessionVerificationService: SessionVerificationService, private val snackbarDispatcher: SnackbarDispatcher, private val indicatorService: IndicatorService, - private val directLogoutPresenter: Presenter, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val featureFlagService: FeatureFlagService, private val sessionEnterpriseService: SessionEnterpriseService, @@ -65,8 +63,6 @@ class PreferencesAccountPresenter( canDeactivateAccount = matrixClient.canDeactivateAccount() } - val directLogoutState = directLogoutPresenter.present() - val accountManagementUrl by produceState(initialValue = null) { value = matrixClient.getAccountManagementUrl(null) .getOrNull() @@ -83,7 +79,6 @@ class PreferencesAccountPresenter( canReportBug = canReportBug, showLinkNewDevice = showLinkNewDevice, canDeactivateAccount = canDeactivateAccount, - directLogoutState = directLogoutState, snackbarMessage = snackbarMessage, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountState.kt index 00abbf2ad4..eaf65328f0 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountState.kt @@ -7,7 +7,6 @@ package io.element.android.features.preferences.impl.account -import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.user.MatrixUser @@ -19,6 +18,5 @@ data class PreferencesAccountState( val canReportBug: Boolean, val showLinkNewDevice: Boolean, val canDeactivateAccount: Boolean, - val directLogoutState: DirectLogoutState, val snackbarMessage: SnackbarMessage?, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountStatePreviewParam.kt index 0cf42104ef..2308b75d43 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountStatePreviewParam.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountStatePreviewParam.kt @@ -8,8 +8,6 @@ package io.element.android.features.preferences.impl.account import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.logout.api.direct.DirectLogoutState -import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser @@ -56,7 +54,6 @@ fun aPreferencesAccountState( canReportBug: Boolean = false, showLinkNewDevice: Boolean = false, canDeactivateAccount: Boolean = false, - directLogoutState: DirectLogoutState = aDirectLogoutState(), snackbarMessage: SnackbarMessage? = null, ) = PreferencesAccountState( myUser = myUser, @@ -66,6 +63,5 @@ fun aPreferencesAccountState( canReportBug = canReportBug, showLinkNewDevice = showLinkNewDevice, canDeactivateAccount = canDeactivateAccount, - directLogoutState = directLogoutState, snackbarMessage = snackbarMessage, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt index 8f20694d81..ebb671e6d0 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt @@ -81,8 +81,9 @@ class BlockedUsersPresenter( unblockUserAction.value = AsyncAction.ConfirmingNoParams } is BlockedUsersEvent.CopyToClipboard -> { - clipboardHelper.copyPlainText(event.userId.value) - snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + clipboardHelper.copyPlainText(event.userId.value) { + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + } } BlockedUsersEvent.ConfirmUnblock -> { pendingUserToUnblock?.let { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt index be6c0ef170..5a1df1d7d6 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier @@ -38,7 +37,6 @@ import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun BlockedUsersView( state: BlockedUsersState, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvent.kt index 34ccba2abe..a4ca35e50d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvent.kt @@ -19,4 +19,5 @@ sealed interface DeveloperSettingsEvent { data object DismissMarkAllRoomsAsReadConfirmation : DeveloperSettingsEvent data object OpenPushRules : DeveloperSettingsEvent data object DismissPushRulesError : DeveloperSettingsEvent + data class CopyToClipboard(val text: String) : DeveloperSettingsEvent } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt index 74bf595ce4..d531bb1cb1 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -26,7 +26,9 @@ import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase import io.element.android.features.preferences.impl.tasks.MarkAllRoomsAsRead import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter @@ -38,6 +40,7 @@ import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CoroutineScope @@ -61,6 +64,8 @@ class DeveloperSettingsPresenter( private val markAllRoomsAsRead: MarkAllRoomsAsRead, private val buildMeta: BuildMeta, private val notificationSettingsService: NotificationSettingsService, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Presenter { @AssistedFactory fun interface Factory { @@ -130,6 +135,11 @@ class DeveloperSettingsPresenter( DeveloperSettingsEvent.DismissPushRulesError -> { pushRulesAction.value = AsyncAction.Uninitialized } + is DeveloperSettingsEvent.CopyToClipboard -> { + clipboardHelper.copyPlainText(event.text) { + toastHelper.show(CommonStrings.common_copied_to_clipboard) + } + } } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt index 992a7e6212..b36521ef0c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt @@ -15,14 +15,12 @@ import androidx.compose.foundation.progressSemantics import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView -import io.element.android.libraries.androidutils.system.copyToClipboard import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog @@ -84,7 +82,10 @@ fun DeveloperSettingsView( AppDeveloperSettingsView( state = state.appDeveloperSettingsState, ) - SessionCategory(deviceId = state.deviceId) + SessionCategory( + deviceId = state.deviceId, + onCopyToClipboard = { state.eventSink(DeveloperSettingsEvent.CopyToClipboard(it)) }, + ) NotificationCategory( onPushRulesClick = { state.eventSink(DeveloperSettingsEvent.OpenPushRules) }, onPushHistoryClick = onPushHistoryClick, @@ -182,19 +183,15 @@ fun DeveloperSettingsView( } @Composable -private fun SessionCategory(deviceId: DeviceId) { +private fun SessionCategory( + deviceId: DeviceId, + onCopyToClipboard: (String) -> Unit, +) { PreferenceCategory(title = "Session") { - val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) - val context = LocalContext.current ListItem( content = { Text("DeviceId") }, supportingContent = { Text(text = deviceId.value) }, - onClick = { - context.copyToClipboard( - text = deviceId.value, - toastMessage = toastMessage, - ) - } + onClick = { onCopyToClipboard(deviceId.value) } ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt index 9f328fdd9b..b3d777c4e7 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt @@ -17,4 +17,5 @@ sealed interface AppDeveloperSettingsEvent { data class SetCustomElementCallBaseUrl(val baseUrl: String?) : AppDeveloperSettingsEvent data class SetTracingLogLevel(val logLevel: LogLevelItem) : AppDeveloperSettingsEvent data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : AppDeveloperSettingsEvent + data class CopyToClipboard(val text: String) : AppDeveloperSettingsEvent } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt index 1bf162d3ea..75519079ed 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt @@ -22,6 +22,8 @@ import io.element.android.features.preferences.impl.developer.tracing.toLogLevel import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem import io.element.android.features.preferences.impl.model.EnabledFeature import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.extensions.runCatchingExceptions @@ -29,6 +31,7 @@ import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.ui.model.FeatureUiModel import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -44,6 +47,8 @@ class AppDeveloperSettingsPresenter( private val rageshakePresenter: Presenter, private val appPreferencesStore: AppPreferencesStore, private val buildMeta: BuildMeta, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Presenter { @Composable override fun present(): AppDeveloperSettingsState { @@ -106,6 +111,11 @@ class AppDeveloperSettingsPresenter( } appPreferencesStore.setTracingLogPacks(currentPacks) } + is AppDeveloperSettingsEvent.CopyToClipboard -> { + clipboardHelper.copyPlainText(event.text) { + toastHelper.show(CommonStrings.common_copied_to_clipboard) + } + } } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt index 7f10745872..4f6f8234fa 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.PreviewParameter @@ -23,7 +22,6 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView -import io.element.android.libraries.androidutils.system.copyToClipboard import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown @@ -108,6 +106,7 @@ fun AppDeveloperSettingsView( GitCategory( gitBranch = state.gitBranch, gitSha = state.gitSha, + onCopyToClipboard = { state.eventSink(AppDeveloperSettingsEvent.CopyToClipboard(it)) }, ) } } @@ -116,29 +115,18 @@ fun AppDeveloperSettingsView( private fun GitCategory( gitBranch: String, gitSha: String, + onCopyToClipboard: (String) -> Unit, ) { PreferenceCategory(title = "Git") { - val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) - val context = LocalContext.current ListItem( content = { Text("Git branch") }, supportingContent = { Text(text = gitBranch) }, - onClick = { - context.copyToClipboard( - text = gitBranch, - toastMessage = toastMessage, - ) - } + onClick = { onCopyToClipboard(gitBranch) } ) ListItem( content = { Text("Git SHA") }, supportingContent = { Text(text = gitSha) }, - onClick = { - context.copyToClipboard( - text = gitSha, - toastMessage = toastMessage, - ) - } + onClick = { onCopyToClipboard(gitSha) } ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsView.kt index c518b346eb..4685718ae2 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsView.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -40,7 +39,6 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar * The contents of the Labs screen. * Design: https://www.figma.com/design/V0dkfRAW6T3yCQKjahpzkX/ER-46-EX--Threads?node-id=2004-27319&t=yssy1yYYigsGON3s-0 */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun LabsView( state: LabsState, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvent.kt index fc175f4c0b..18365fdbb4 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvent.kt @@ -34,4 +34,5 @@ sealed interface NotificationSettingsEvent { data object DismissCallRingtoneDialog : NotificationSettingsEvent data class SelectCallRingtonePreset(val sound: NotificationSound) : NotificationSettingsEvent data object LaunchCallRingtonePicker : NotificationSettingsEvent + data object ToggleShowAllActivityInRoomList : NotificationSettingsEvent } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt index 5256f9ec9c..24a366b52d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt @@ -28,6 +28,8 @@ import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runUpdatingStateNoSuccess import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService @@ -73,6 +75,7 @@ class NotificationSettingsPresenter( private val stringProvider: StringProvider, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + private val featureFlagService: FeatureFlagService, ) : Presenter { // Serializes the pick → copy → persist → recreate pipeline per slot. The copier already locks // its `.tmp` file, but the persist+recreate window outside the copier is unprotected; @@ -196,6 +199,11 @@ class NotificationSettingsPresenter( ) } + val showAllActivityInRoomList by produceState(false) { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowAllActivityInRoomList) + .collect { value = it } + } + fun handleEvent(event: NotificationSettingsEvent) { when (event) { is NotificationSettingsEvent.SetAtRoomNotificationsEnabled -> { @@ -276,6 +284,9 @@ class NotificationSettingsPresenter( NotificationSettingsEvent.DismissCallRingtoneCopyError -> { callRingtoneCopyError = false } + NotificationSettingsEvent.ToggleShowAllActivityInRoomList -> localCoroutineScope.launch { + featureFlagService.setFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList, !showAllActivityInRoomList) + } } } @@ -304,6 +315,7 @@ class NotificationSettingsPresenter( showCallRingtoneDialog = showCallRingtoneDialog, pendingMessageSoundPickerLaunch = pendingMessageSoundPickerLaunch, pendingCallRingtonePickerLaunch = pendingCallRingtonePickerLaunch, + showAllActivityInRoomList = showAllActivityInRoomList, eventSink = ::handleEvent, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt index 379b8589b7..05a3d29413 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt @@ -42,6 +42,10 @@ data class NotificationSettingsState( * auto-opens on screen entry. */ val pendingCallRingtonePickerLaunch: Int, + /** + * Whether to show all activity indicators in the room list, using unread messages instead of notifications. + */ + val showAllActivityInRoomList: Boolean, val eventSink: (NotificationSettingsEvent) -> Unit, ) { sealed interface MatrixSettings { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStatePreviewParam.kt index 33f20cdb6e..00011a5ab7 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStatePreviewParam.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStatePreviewParam.kt @@ -99,6 +99,7 @@ fun aValidNotificationSettingsState( callRingtoneCopyError: Boolean = false, showMessageSoundDialog: Boolean = false, showCallRingtoneDialog: Boolean = false, + showAllActivityInRoomList: Boolean = false, pendingMessageSoundPickerLaunch: Int = 0, pendingCallRingtonePickerLaunch: Int = 0, eventSink: (NotificationSettingsEvent) -> Unit = {}, @@ -133,6 +134,7 @@ fun aValidNotificationSettingsState( showCallRingtoneDialog = showCallRingtoneDialog, pendingMessageSoundPickerLaunch = pendingMessageSoundPickerLaunch, pendingCallRingtonePickerLaunch = pendingCallRingtonePickerLaunch, + showAllActivityInRoomList = showAllActivityInRoomList, eventSink = eventSink, ) @@ -166,6 +168,7 @@ fun aInvalidNotificationSettingsState( showCallRingtoneDialog = false, pendingMessageSoundPickerLaunch = 0, pendingCallRingtonePickerLaunch = 0, + showAllActivityInRoomList = false, eventSink = eventSink, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt index 21f0cc7b51..aa085f3ac6 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt @@ -199,6 +199,16 @@ private fun NotificationSettingsContentView( onCheckedChange = onMentionNotificationsChange ) } + + PreferenceCategory { + PreferenceSwitch( + title = stringResource(R.string.screen_notification_settings_show_all_activity_title), + subtitle = stringResource(R.string.screen_notification_settings_show_all_activity_subtitle), + isChecked = state.showAllActivityInRoomList, + onCheckedChange = { state.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList) } + ) + } + SoundsPreferenceCategory(state = state) PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_additional_settings_section_title)) { // TODO We are removing the call notification toggle until support for call notifications has been added diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt index 57ed6294fa..c4416ad61e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt @@ -19,8 +19,6 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.compound.theme.ElementTheme -import io.element.android.features.logout.api.direct.DirectLogoutEvent -import io.element.android.features.logout.api.direct.DirectLogoutView import io.element.android.features.preferences.impl.account.PreferencesAccountCallback import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.architecture.callback @@ -34,7 +32,6 @@ class PreferencesRootNode( @Assisted plugins: List, private val presenter: PreferencesRootPresenter, private val emojiPickerRenderer: EmojiPickerRenderer, - private val directLogoutView: DirectLogoutView, ) : Node(buildContext, plugins = plugins) { interface Callback : PreferencesAccountCallback, Plugin { fun navigateToAddAccount() @@ -89,16 +86,8 @@ class PreferencesRootNode( onManageAccountClick = { onManageAccountClick(activity, it, isDark) }, onOpenNotificationSettings = callback::navigateToNotificationSettings, onEditProfileClick = callback::navigateToUserProfile, - onSignOutClick = { - if (state.preferencesAccountState.directLogoutState.canDoDirectSignOut) { - state.preferencesAccountState.directLogoutState.eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false)) - } else { - callback.startSignOutFlow() - } - }, + onSignOutClick = callback::startSignOutFlow, onDeactivateClick = callback::startAccountDeactivationFlow ) - - directLogoutView.Render(state = state.preferencesAccountState.directLogoutState) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt index 89e739fe56..f632221f22 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt @@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -56,7 +55,6 @@ import io.element.android.libraries.ui.strings.CommonStrings /** * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=3182-36115&t=U1vS3px9HzlzWYd7-4 */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditUserProfileView( state: EditUserProfileState, diff --git a/features/preferences/impl/src/main/res/values-hr/translations.xml b/features/preferences/impl/src/main/res/values-hr/translations.xml index 1bec1cb75b..387da63a4e 100644 --- a/features/preferences/impl/src/main/res/values-hr/translations.xml +++ b/features/preferences/impl/src/main/res/values-hr/translations.xml @@ -38,7 +38,7 @@ "Ako je to isključeno, nećete moći slati ili primati potvrde o čitanju ili obavijesti o tipkanju." "Uvijek sakrij" "Uvijek prikaži" - "U privatnim sobama" + "Prikaži u privatnim sobama" "Skriveni medij uvijek se može prikazati tako se da se dodirne" "Prikaži medije na vremenskoj traci" "Omogući opciju za prikaz izvora poruke na vremenskoj traci." diff --git a/features/preferences/impl/src/main/res/values-in/translations.xml b/features/preferences/impl/src/main/res/values-in/translations.xml index faad2bece7..379bca4fb0 100644 --- a/features/preferences/impl/src/main/res/values-in/translations.xml +++ b/features/preferences/impl/src/main/res/values-in/translations.xml @@ -85,7 +85,7 @@ Jika Anda melanjutkan, beberapa pengaturan Anda dapat berubah." "Semua" "Sebutan" "Beri tahu saya tentang" - "Beri tahu saya pada @room" + "Penyebutan @room" "Kustom" "Suara kustom" "Terjadi kesalahan saat menghapus file." diff --git a/features/preferences/impl/src/main/res/values-pl/translations.xml b/features/preferences/impl/src/main/res/values-pl/translations.xml index 3142639d94..9b1904e95d 100644 --- a/features/preferences/impl/src/main/res/values-pl/translations.xml +++ b/features/preferences/impl/src/main/res/values-pl/translations.xml @@ -93,6 +93,8 @@ Niektóre ustawienia mogą ulec zmianie, jeśli kontynuujesz." "Wzmianki" "Powiadamiaj mnie przez" "Wzmianki @room" + "Kropki lub liczba nieprzeczytanych wiadomości" + "Pokaż całą aktywność na liście pokoi" "Własny" "Własny dźwięk…" "Błąd usuwania pliku" @@ -109,6 +111,7 @@ Niektóre ustawienia mogą ulec zmianie, jeśli kontynuujesz." "ustawienia systemowe" "Powiadomienia systemowe wyłączone" "Powiadomienia" + "Dodaj konto" "Z dala od urządzenia" "Zaraz wracam" "Własny" diff --git a/features/preferences/impl/src/main/res/values-ru/translations.xml b/features/preferences/impl/src/main/res/values-ru/translations.xml index c0c1383f1c..96430521d5 100644 --- a/features/preferences/impl/src/main/res/values-ru/translations.xml +++ b/features/preferences/impl/src/main/res/values-ru/translations.xml @@ -38,8 +38,8 @@ "Если выключено, Вы не будете видеть, кто печатает и читает сообщения, а также другие пользователи не будут знать, когда Вы печатаете или читаете сообщения." "Всегда скрывать" "Всегда показывать" - "В приватных комнатах" - "Скрытые медиа всегда можно просмотреть, нажав на них." + "Показывать в приватных комнатах" + "Скрытый контент всегда можно отобразить, нажав на него." "Показать медиа в истории сообщений" "Включить опцию просмотра источника сообщения в истории сообщений." "У Вас нет заблокированных пользователей" @@ -62,6 +62,9 @@ "Попробуйте функции в разработке. Эти функции ещё не завершены, они нестабильны и могут измениться." "Хотите попробовать?" "Лаборатория" + "Приглашения" + "Другие пользователи" + "Ваш внешний вид" "Дополнительные параметры" "Мелодия звонка" "Аудио и видео звонки" diff --git a/features/preferences/impl/src/main/res/values-uk/translations.xml b/features/preferences/impl/src/main/res/values-uk/translations.xml index 293f9749f2..bdce557b12 100644 --- a/features/preferences/impl/src/main/res/values-uk/translations.xml +++ b/features/preferences/impl/src/main/res/values-uk/translations.xml @@ -93,7 +93,8 @@ "Згадки" "Сповіщати мене про" "Згадки @room" - "Показати всю діяльність у списку кімнат (крапки або кількість непрочитаних повідомлень)" + "Крапки або кількість непрочитаних повідомлень" + "Показати всю діяльність у списку кімнат" "Власний" "Користувацький звук…" "Помилка видалення файлу" diff --git a/features/preferences/impl/src/main/res/values-zh/translations.xml b/features/preferences/impl/src/main/res/values-zh/translations.xml index f1d1cbb786..bf6bc9fa51 100644 --- a/features/preferences/impl/src/main/res/values-zh/translations.xml +++ b/features/preferences/impl/src/main/res/values-zh/translations.xml @@ -91,7 +91,8 @@ "提及" "通知我以下类型" "含有 @room 的提及" - "在房间列表上显示所有活动(用于未读消息的圆点或数字)" + "用于未读消息的圆点或数字" + "显示房间列表中的所有活动" "自定义" "自定义提示音…" "删除文件时出错" @@ -108,6 +109,7 @@ "系统设置" "系统通知已关闭" "通知" + "添加账户" "离开" "马上回来" "自定义…" diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml index c6350f7a1e..c702ac62a3 100644 --- a/features/preferences/impl/src/main/res/values/localazy.xml +++ b/features/preferences/impl/src/main/res/values/localazy.xml @@ -92,7 +92,8 @@ If you proceed, some of your settings may change." "Mentions" "Notify me for" "Mentions of @room" - "Show all activity in the room list (dots or number of unread messages)" + "Dots or number of unread messages" + "Show all activity in the room list" "Custom" "Custom sound…" "Error deleting file" @@ -109,6 +110,7 @@ If you proceed, some of your settings may change." "system settings" "System notifications turned off" "Notifications" + "Add account" "Away" "Be right back" "Custom…" diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenterTest.kt index cd1bd186f4..dce2d78a0d 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenterTest.kt @@ -10,7 +10,6 @@ package io.element.android.features.preferences.impl.account import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.api.SessionEnterpriseService import io.element.android.features.enterprise.test.FakeSessionEnterpriseService -import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.featureflag.api.FeatureFlagService @@ -65,7 +64,6 @@ class PreferencesAccountPresenterTest { assertThat(initialState.showSecureBackupBadge).isFalse() assertThat(initialState.accountManagementUrl).isNull() assertThat(initialState.showLinkNewDevice).isFalse() - assertThat(initialState.directLogoutState).isEqualTo(aDirectLogoutState()) assertThat(initialState.snackbarMessage).isNull() val loadedState = consumeItemsUntilPredicate { it.accountManagementUrl != null }.last() assertThat(loadedState.showSecureBackup).isFalse() @@ -169,7 +167,6 @@ class PreferencesAccountPresenterTest { sessionVerificationService = sessionVerificationService, snackbarDispatcher = SnackbarDispatcher(), indicatorService = indicatorService, - directLogoutPresenter = { aDirectLogoutState() }, rageshakeFeatureAvailability = rageshakeFeatureAvailability, featureFlagService = featureFlagService, sessionEnterpriseService = sessionEnterpriseService, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt index b76939f72c..c3c08602cd 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt @@ -19,7 +19,11 @@ import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase import io.element.android.features.preferences.impl.tasks.FakeMarkAllRoomsAsRead import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.androidutils.toast.FakeToastHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.data.megaBytes @@ -33,6 +37,7 @@ import io.element.android.libraries.matrix.test.A_DEVICE_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -250,6 +255,38 @@ class DeveloperSettingsPresenterTest { } } + @Test + fun `present - copy to clipboard on old device shows a toast`() = runTest { + val clipboardHelper = FakeClipboardHelper(isOldDevice = true) + val toastHelper = FakeToastHelper() + val presenter = createDeveloperSettingsPresenter( + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + presenter.test { + awaitItem().eventSink(DeveloperSettingsEvent.CopyToClipboard("text")) + assertThat(clipboardHelper.clipboardContents).isEqualTo("text") + assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - copy to clipboard on new device does not show a toast`() = runTest { + val clipboardHelper = FakeClipboardHelper(isOldDevice = false) + val toastHelper = FakeToastHelper() + val presenter = createDeveloperSettingsPresenter( + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + presenter.test { + awaitItem().eventSink(DeveloperSettingsEvent.CopyToClipboard("text")) + assertThat(clipboardHelper.clipboardContents).isEqualTo("text") + assertThat(toastHelper.shownToasts).isEmpty() + cancelAndIgnoreRemainingEvents() + } + } + private fun createDeveloperSettingsPresenter( navigator: DeveloperSettingsNavigator = DeveloperSettingsNavigator { _, _ -> lambdaError() }, sessionId: SessionId = A_SESSION_ID, @@ -262,6 +299,8 @@ class DeveloperSettingsPresenterTest { markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(), buildMeta: BuildMeta = aBuildMeta(), notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(), + clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + toastHelper: ToastHelper = FakeToastHelper(), ): DeveloperSettingsPresenter { return DeveloperSettingsPresenter( navigator = navigator, @@ -277,6 +316,8 @@ class DeveloperSettingsPresenterTest { markAllRoomsAsRead = markAllRoomsAsRead, buildMeta = buildMeta, notificationSettingsService = notificationSettingsService, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, ) } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt index 9300236293..3ec56a64fd 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt @@ -12,12 +12,17 @@ package io.element.android.features.preferences.impl.developer.appsettings import com.google.common.truth.Truth.assertThat import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +import io.element.android.libraries.androidutils.toast.FakeToastHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.featureflag.api.Feature import io.element.android.libraries.featureflag.test.FakeFeature import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -151,6 +156,38 @@ class AppDeveloperSettingsPresenterTest { } } + @Test + fun `present - copy to clipboard on old device shows a toast`() = runTest { + val clipboardHelper = FakeClipboardHelper(isOldDevice = true) + val toastHelper = FakeToastHelper() + val presenter = createAppDeveloperSettingsPresenter( + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + presenter.test { + awaitItem().eventSink(AppDeveloperSettingsEvent.CopyToClipboard("text")) + assertThat(clipboardHelper.clipboardContents).isEqualTo("text") + assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - copy to clipboard on new device does not show a toast`() = runTest { + val clipboardHelper = FakeClipboardHelper(isOldDevice = false) + val toastHelper = FakeToastHelper() + val presenter = createAppDeveloperSettingsPresenter( + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + presenter.test { + awaitItem().eventSink(AppDeveloperSettingsEvent.CopyToClipboard("text")) + assertThat(clipboardHelper.clipboardContents).isEqualTo("text") + assertThat(toastHelper.shownToasts).isEmpty() + cancelAndIgnoreRemainingEvents() + } + } + private fun createAppDeveloperSettingsPresenter( featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService( getAvailableFeaturesResult = { _, _ -> @@ -164,6 +201,8 @@ class AppDeveloperSettingsPresenterTest { } ), preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), + clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + toastHelper: ToastHelper = FakeToastHelper(), ): AppDeveloperSettingsPresenter { return AppDeveloperSettingsPresenter( featureFlagService = featureFlagService, @@ -172,7 +211,9 @@ class AppDeveloperSettingsPresenterTest { buildMeta = aBuildMeta( gitRevision = "1234567890", gitBranchName = "feature/awesome-feature" - ) + ), + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, ) } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt index 8b32ef993c..c8dad6659e 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt @@ -10,6 +10,9 @@ package io.element.android.features.preferences.impl.notifications import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState import io.element.android.libraries.matrix.api.MatrixClient @@ -1104,6 +1107,46 @@ class NotificationSettingsPresenterTest { } } + @Test + fun `present - showAllActivityInRoomList reflects the feature flag value`() = runTest { + val featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.ShowAllActivityInRoomList.key to true) + ) + val presenter = createNotificationSettingsPresenter(featureFlagService = featureFlagService) + presenter.test { + val state = consumeItemsUntilPredicate { it.showAllActivityInRoomList }.last() + assertThat(state.showAllActivityInRoomList).isTrue() + featureFlagService.setFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList, false) + + val updatedState = consumeItemsUntilPredicate { !it.showAllActivityInRoomList }.last() + assertThat(updatedState.showAllActivityInRoomList).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - ToggleShowAllActivityInRoomList toggles the feature flag`() = runTest { + val featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.ShowAllActivityInRoomList.key to false) + ) + val presenter = createNotificationSettingsPresenter(featureFlagService = featureFlagService) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.showAllActivityInRoomList).isFalse() + initialState.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList) + + val enabledState = consumeItemsUntilPredicate { it.showAllActivityInRoomList }.last() + assertThat(enabledState.showAllActivityInRoomList).isTrue() + assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList)).isTrue() + enabledState.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList) + + val disabledState = consumeItemsUntilPredicate { !it.showAllActivityInRoomList }.last() + assertThat(disabledState.showAllActivityInRoomList).isFalse() + assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList)).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + private fun TestScope.createNotificationSettingsPresenter( notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), pushService: PushService = FakePushService(), @@ -1116,6 +1159,7 @@ class NotificationSettingsPresenterTest { NotificationSoundCopier.CopyResult.Success(source, source) }), soundDisplayNameResolver: FakeSoundDisplayNameResolver = FakeSoundDisplayNameResolver(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), ): NotificationSettingsPresenter { val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService) return NotificationSettingsPresenter( @@ -1131,6 +1175,7 @@ class NotificationSettingsPresenterTest { soundDisplayNameResolver = soundDisplayNameResolver, stringProvider = FakeStringProvider(), sessionCoroutineScope = backgroundScope, + featureFlagService = featureFlagService, ) } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt index a28f43638f..fe7238beea 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt @@ -183,6 +183,35 @@ class NotificationSettingsViewTest : RobolectricTest() { ) } + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on disable show all activity emits the expected events`() { + testShowAllActivityToggle(true) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on enable show all activity emits the expected events`() { + testShowAllActivityToggle(false) + } + + private fun testShowAllActivityToggle(initialState: Boolean) = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setNotificationSettingsView( + state = aValidNotificationSettingsState( + showAllActivityInRoomList = initialState, + eventSink = eventsRecorder + ), + ) + clickOn(R.string.screen_notification_settings_show_all_activity_title) + eventsRecorder.assertList( + listOf( + NotificationSettingsEvent.RefreshSystemNotificationsEnabled, + NotificationSettingsEvent.ToggleShowAllActivityInRoomList, + ) + ) + } + @Config(qualifiers = "h1024dp") @Test fun `with an error configuration, clicking on continue emits the expected events`() = runAndroidComposeUiTest { diff --git a/features/rageshake/impl/build.gradle.kts b/features/rageshake/impl/build.gradle.kts index 0200f21e61..578f86a39f 100644 --- a/features/rageshake/impl/build.gradle.kts +++ b/features/rageshake/impl/build.gradle.kts @@ -30,7 +30,6 @@ dependencies { implementation(projects.appconfig) implementation(projects.features.enterprise.api) implementation(projects.features.viewfolder.api) - implementation(projects.services.toolbox.api) implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.network) @@ -52,9 +51,7 @@ dependencies { testImplementation(projects.features.enterprise.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.sessionStorage.test) - testImplementation(projects.features.rageshake.test) testImplementation(projects.features.viewfolder.test) testImplementation(projects.libraries.preferences.test) - testImplementation(projects.services.toolbox.test) testImplementation(libs.network.mockwebserver) } diff --git a/features/rageshake/test/build.gradle.kts b/features/rageshake/test/build.gradle.kts index faad812d73..516562c556 100644 --- a/features/rageshake/test/build.gradle.kts +++ b/features/rageshake/test/build.gradle.kts @@ -16,6 +16,5 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.features.rageshake.api) - implementation(libs.coroutines.core) implementation(projects.tests.testutils) } diff --git a/features/reportroom/impl/build.gradle.kts b/features/reportroom/impl/build.gradle.kts index c945465aec..5a25ba5051 100644 --- a/features/reportroom/impl/build.gradle.kts +++ b/features/reportroom/impl/build.gradle.kts @@ -27,10 +27,8 @@ setupDependencyInjection() dependencies { api(projects.features.reportroom.api) - implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) diff --git a/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomView.kt b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomView.kt index df67b44b22..ec463406a1 100644 --- a/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomView.kt +++ b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomView.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager @@ -40,7 +39,6 @@ import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReportRoomView( state: ReportRoomState, diff --git a/features/rolesandpermissions/api/build.gradle.kts b/features/rolesandpermissions/api/build.gradle.kts index ca29972606..6ea7080314 100644 --- a/features/rolesandpermissions/api/build.gradle.kts +++ b/features/rolesandpermissions/api/build.gradle.kts @@ -17,7 +17,5 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) - api(projects.libraries.usersearch.api) } diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt index aba7a671a8..d97ecc2094 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt @@ -12,7 +12,6 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -31,7 +30,6 @@ import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChangeRoomPermissionsView( state: ChangeRoomPermissionsState, diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt index 05bb77dfd4..c50c38df2e 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -72,7 +71,6 @@ import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChangeRolesView( state: ChangeRolesState, diff --git a/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeChangeRoomMemberRolesEntryPoint.kt b/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/rolesandpermissions/test/FakeChangeRoomMemberRolesEntryPoint.kt similarity index 93% rename from features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeChangeRoomMemberRolesEntryPoint.kt rename to features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/rolesandpermissions/test/FakeChangeRoomMemberRolesEntryPoint.kt index 0526afc938..d50d872a1d 100644 --- a/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeChangeRoomMemberRolesEntryPoint.kt +++ b/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/rolesandpermissions/test/FakeChangeRoomMemberRolesEntryPoint.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.changeroommemberroles.test +package io.element.android.features.rolesandpermissions.test import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node diff --git a/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeRolesAndPermissionsEntryPoint.kt b/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/rolesandpermissions/test/FakeRolesAndPermissionsEntryPoint.kt similarity index 91% rename from features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeRolesAndPermissionsEntryPoint.kt rename to features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/rolesandpermissions/test/FakeRolesAndPermissionsEntryPoint.kt index 62cf3285f3..00d9059a3d 100644 --- a/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeRolesAndPermissionsEntryPoint.kt +++ b/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/rolesandpermissions/test/FakeRolesAndPermissionsEntryPoint.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.changeroommemberroles.test +package io.element.android.features.rolesandpermissions.test import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node diff --git a/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt b/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasresolver/api/RoomAliasResolverEntryPoint.kt similarity index 94% rename from features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt rename to features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasresolver/api/RoomAliasResolverEntryPoint.kt index ce0270033c..a2a1d9c163 100644 --- a/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt +++ b/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasresolver/api/RoomAliasResolverEntryPoint.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.roomaliasesolver.api +package io.element.android.features.roomaliasresolver.api import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node diff --git a/features/roomaliasresolver/impl/build.gradle.kts b/features/roomaliasresolver/impl/build.gradle.kts index 1860c77017..0c5e1c2b07 100644 --- a/features/roomaliasresolver/impl/build.gradle.kts +++ b/features/roomaliasresolver/impl/build.gradle.kts @@ -27,11 +27,9 @@ setupDependencyInjection() dependencies { api(projects.features.roomaliasresolver.api) - implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.androidutils) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPoint.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPoint.kt index c6f8966131..2eb83a1382 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPoint.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPoint.kt @@ -12,7 +12,7 @@ import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint +import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint import io.element.android.libraries.architecture.createNode @ContributesBinding(AppScope::class) diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt index 041d43cb26..bff41efb82 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt @@ -16,7 +16,7 @@ import com.bumble.appyx.core.plugin.Plugin import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode -import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint +import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt index bf339d7592..84326bd2a5 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -132,7 +131,6 @@ private fun RoomAliasResolverContent( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomAliasResolverTopBar( onBackClick: () -> Unit, diff --git a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPointTest.kt b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPointTest.kt index a519032ed2..f261e56adc 100644 --- a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPointTest.kt +++ b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPointTest.kt @@ -11,7 +11,7 @@ package io.element.android.features.roomaliasresolver.impl import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat -import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint +import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.test.A_ROOM_ALIAS import io.element.android.tests.testutils.lambda.lambdaError diff --git a/features/roomcall/api/build.gradle.kts b/features/roomcall/api/build.gradle.kts index 54bb7bbd02..1220f87a25 100644 --- a/features/roomcall/api/build.gradle.kts +++ b/features/roomcall/api/build.gradle.kts @@ -16,6 +16,5 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(libs.androidx.compose.ui.tooling.preview) } diff --git a/features/roomcall/impl/build.gradle.kts b/features/roomcall/impl/build.gradle.kts index 069c890874..20f4eab571 100644 --- a/features/roomcall/impl/build.gradle.kts +++ b/features/roomcall/impl/build.gradle.kts @@ -26,7 +26,6 @@ dependencies { implementation(projects.features.enterprise.api) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) diff --git a/features/roomdetails/api/build.gradle.kts b/features/roomdetails/api/build.gradle.kts index ce393840f8..e6f6dd4f5e 100644 --- a/features/roomdetails/api/build.gradle.kts +++ b/features/roomdetails/api/build.gradle.kts @@ -7,7 +7,7 @@ */ plugins { - id("io.element.android-library") + id("io.element.android-compose-library") id("kotlin-parcelize") } diff --git a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsExtension.kt b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsExtension.kt new file mode 100644 index 0000000000..25f8f9e1f7 --- /dev/null +++ b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsExtension.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.api + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** Optional room-details sections contributed by a build-specific implementation. */ +fun interface RoomDetailsExtension { + @Composable + fun ColumnScope.Render(modifier: Modifier) +} diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts index 77e2d0c229..b8aa939015 100644 --- a/features/roomdetails/impl/build.gradle.kts +++ b/features/roomdetails/impl/build.gradle.kts @@ -34,20 +34,13 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) - implementation(projects.libraries.mediapickers.api) - implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaviewer.api) - implementation(projects.libraries.featureflag.api) - implementation(projects.libraries.permissions.api) implementation(projects.libraries.preferences.api) implementation(projects.libraries.push.api) implementation(projects.libraries.testtags) api(projects.features.roomdetails.api) - api(projects.libraries.usersearch.api) - api(projects.services.apperror.api) implementation(libs.coil.compose) implementation(projects.features.call.api) - implementation(projects.features.startchat.api) implementation(projects.features.leaveroom.api) implementation(projects.features.userprofile.shared) implementation(projects.services.analytics.compose) @@ -65,14 +58,9 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.mediaupload.test) - testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediaviewer.test) - testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.push.test) - testImplementation(projects.libraries.usersearch.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.features.call.test) testImplementation(projects.features.rolesandpermissions.test) testImplementation(projects.features.securityandprivacy.test) @@ -81,7 +69,6 @@ dependencies { testImplementation(projects.features.messages.test) testImplementation(projects.features.poll.test) testImplementation(projects.features.reportroom.test) - testImplementation(projects.features.startchat.test) testImplementation(projects.features.verifysession.test) testImplementation(projects.services.analytics.test) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/NoopRoomDetailsExtension.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/NoopRoomDetailsExtension.kt new file mode 100644 index 0000000000..59178e0d09 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/NoopRoomDetailsExtension.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.roomdetails.api.RoomDetailsExtension +import io.element.android.libraries.di.RoomScope + +@ContributesBinding(RoomScope::class) +class NoopRoomDetailsExtension : RoomDetailsExtension { + @Composable + override fun ColumnScope.Render(modifier: Modifier) = Unit +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt index a82766175d..9d06db371f 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt @@ -13,6 +13,7 @@ sealed interface RoomDetailsEvent { data object MuteNotification : RoomDetailsEvent data object UnmuteNotification : RoomDetailsEvent data class CopyToClipboard(val text: String) : RoomDetailsEvent + data object CopyRoomId : RoomDetailsEvent data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent data object MarkAsRead : RoomDetailsEvent data object MarkAsUnread : RoomDetailsEvent diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt index 7f22c7e111..dd1c10ae82 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt @@ -24,6 +24,7 @@ import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.MobileScreen import io.element.android.annotations.ContributesNode import io.element.android.features.leaveroom.api.LeaveRoomRenderer +import io.element.android.features.roomdetails.api.RoomDetailsExtension import io.element.android.libraries.androidutils.system.startSharePlainTextIntent import io.element.android.libraries.architecture.appyx.launchMolecule import io.element.android.libraries.architecture.callback @@ -46,6 +47,7 @@ class RoomDetailsNode( private val room: BaseRoom, private val analyticsService: AnalyticsService, private val leaveRoomRenderer: LeaveRoomRenderer, + private val roomDetailsExtension: RoomDetailsExtension, ) : Node(buildContext, plugins = plugins), RoomDetailsNavigator { interface Callback : Plugin { fun navigateBack() @@ -120,6 +122,7 @@ class RoomDetailsNode( RoomDetailsView( state = state, + additionalSections = { with(roomDetailsExtension) { Render(Modifier) } }, modifier = modifier, goBack = ::navigateUp, onActionClick = ::onActionClick, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt index 9a602d06c0..e8a8079a54 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -32,6 +32,7 @@ import io.element.android.features.roomdetailsedit.api.roomDetailsEditPermission import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher @@ -74,6 +75,7 @@ class RoomDetailsPresenter( private val dispatchers: CoroutineDispatchers, private val analyticsService: AnalyticsService, private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, private val appPreferencesStore: AppPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore, private val notificationCleaner: NotificationCleaner, @@ -167,8 +169,14 @@ class RoomDetailsPresenter( } is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite) is RoomDetailsEvent.CopyToClipboard -> { - clipboardHelper.copyPlainText(event.text) - snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + clipboardHelper.copyPlainText(event.text) { + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + } + } + is RoomDetailsEvent.CopyRoomId -> { + clipboardHelper.copyPlainText(room.roomId.value) { + toastHelper.show(CommonStrings.common_copied_to_clipboard) + } } is RoomDetailsEvent.MarkAsRead -> scope.markAsRead() is RoomDetailsEvent.MarkAsUnread -> scope.markAsUnread() diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index b2874d01f9..626977007e 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -24,7 +25,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -34,7 +34,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -48,7 +47,6 @@ import io.element.android.features.userprofile.api.UserProfileState import io.element.android.features.userprofile.api.UserProfileVerificationState import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs import io.element.android.features.userprofile.shared.blockuser.BlockUserSection -import io.element.android.libraries.androidutils.system.copyToClipboard import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.designsystem.atomic.atoms.MatrixBadgeAtom import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRowMolecule @@ -124,6 +122,7 @@ fun RoomDetailsView( onProfileClick: (UserId) -> Unit, onReportRoomClick: () -> Unit, modifier: Modifier = Modifier, + additionalSections: @Composable ColumnScope.() -> Unit = {}, leaveRoomView: @Composable () -> Unit, ) { val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) @@ -199,6 +198,8 @@ fun RoomDetailsView( ) } + additionalSections() + PreferenceCategory { if (state.hasNewContent) { ListItem( @@ -336,6 +337,7 @@ fun RoomDetailsView( DebugInfoSection( roomId = state.roomId, roomVersion = state.roomVersion, + onCopyRoomIdClick = { state.eventSink(RoomDetailsEvent.CopyRoomId) }, ) } } @@ -356,7 +358,6 @@ private fun KnockRequestsItem(knockRequestsCount: Int?, onKnockRequestsClick: () ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomDetailsTopBar( goBack: () -> Unit, @@ -863,10 +864,9 @@ private fun OtherActionsSection( private fun DebugInfoSection( roomId: RoomId, roomVersion: String?, + onCopyRoomIdClick: () -> Unit, ) { - val context = LocalContext.current PreferenceCategory { - val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) ListItem( content = { Text("Internal room ID") @@ -880,12 +880,7 @@ private fun DebugInfoSection( }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Code())), trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Copy())), - onClick = { - context.copyToClipboard( - text = roomId.value, - toastMessage = toastMessage, - ) - }, + onClick = onCopyRoomIdClick, ) ListItem( content = { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt index ba9d2b4498..5b9d5630bd 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -76,7 +75,6 @@ fun RoomInviteMembersView( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomInviteMembersTopBar( canSend: Boolean, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt index f19ee95106..c7b66df00c 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -352,7 +351,6 @@ private fun RoomMemberListItem( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomMemberListTopBar( canInvite: Boolean, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt index 85eca4fbae..6428bd097a 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt @@ -118,8 +118,9 @@ class RoomMemberDetailsPresenter( encryptionService.withdrawVerification(roomMemberId) } is UserProfileEvent.CopyToClipboard -> { - clipboardHelper.copyPlainText(event.text) - snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + clipboardHelper.copyPlainText(event.text) { + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + } } else -> userProfileState.eventSink(event) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt index f0676f133e..877f356693 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -160,7 +159,6 @@ private fun RoomSpecificNotificationSettingsView( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomNotificationSettingsTopBar( onBackClick: () -> Unit, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt index befbc9382f..ebdce9330a 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -90,7 +89,6 @@ fun UserDefinedRoomNotificationSettingsView( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun UserDefinedRoomNotificationSettingsTopBar( roomName: String, diff --git a/features/roomdetails/impl/src/main/res/values-pl/translations.xml b/features/roomdetails/impl/src/main/res/values-pl/translations.xml index f446cb8334..4b805104f7 100644 --- a/features/roomdetails/impl/src/main/res/values-pl/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-pl/translations.xml @@ -42,6 +42,9 @@ "Masz niezapisane zmiany." "Zapisać zmiany?" "Dodaj temat" + "Naciśnij i mów" + "Pokój audio" + "Rozmowy wideo" "Szyfrowany" "Nieszyfrowany" "Pokój publiczny" @@ -67,6 +70,9 @@ "Profil" "Prośby o dołączenie" "Role i uprawnienia" + "Naciśnij i mów" + "Pokój audio" + "Rozmowy wideo" "Nazwa" "Bezpieczeństwo i prywatność" "Bezpieczeństwo" diff --git a/features/roomdetails/impl/src/main/res/values-uk/translations.xml b/features/roomdetails/impl/src/main/res/values-uk/translations.xml index 9094e032a9..a523f25f20 100644 --- a/features/roomdetails/impl/src/main/res/values-uk/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-uk/translations.xml @@ -42,6 +42,9 @@ "У вас є не збережені зміни." "Зберегти зміни?" "Додати тему" + "Натисніть, щоб говорити" + "Аудіо в кімнаті" + "Відеовиклики" "Зашифровано" "Не зашифровано" "Загальнодоступна кімната" diff --git a/features/roomdetails/impl/src/main/res/values-zh/translations.xml b/features/roomdetails/impl/src/main/res/values-zh/translations.xml index dea8350422..768e5d7eb5 100644 --- a/features/roomdetails/impl/src/main/res/values-zh/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-zh/translations.xml @@ -42,6 +42,9 @@ "你有未保存的更改。" "保存更改?" "添加主题" + "按下发言" + "房间音频" + "视频通话" "已加密" "未加密" "公共房间" diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml index 25473238fd..4ee26bb578 100644 --- a/features/roomdetails/impl/src/main/res/values/localazy.xml +++ b/features/roomdetails/impl/src/main/res/values/localazy.xml @@ -42,6 +42,9 @@ "You have unsaved changes." "Save changes?" "Add topic" + "Push to talk" + "Room Audio" + "Video calls" "Encrypted" "Not encrypted" "Public room" diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt index 9a97380d21..4baf43c477 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt @@ -13,12 +13,12 @@ import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.google.common.truth.Truth.assertThat import io.element.android.features.call.test.FakeElementCallEntryPoint -import io.element.android.features.changeroommemberroles.test.FakeChangeRoomMemberRolesEntryPoint -import io.element.android.features.changeroommemberroles.test.FakeRolesAndPermissionsEntryPoint import io.element.android.features.knockrequests.test.FakeKnockRequestsListEntryPoint import io.element.android.features.messages.test.FakeMessagesEntryPoint import io.element.android.features.poll.test.history.FakePollHistoryEntryPoint import io.element.android.features.reportroom.test.FakeReportRoomEntryPoint +import io.element.android.features.rolesandpermissions.test.FakeChangeRoomMemberRolesEntryPoint +import io.element.android.features.rolesandpermissions.test.FakeRolesAndPermissionsEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.features.roomdetailsedit.test.FakeRoomDetailsEditEntryPoint import io.element.android.features.securityandprivacy.test.FakeSecurityAndPrivacyEntryPoint diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/NoopRoomDetailsExtensionTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/NoopRoomDetailsExtensionTest.kt new file mode 100644 index 0000000000..9d25a6e78e --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/NoopRoomDetailsExtensionTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.features.roomdetails.impl + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test + +class NoopRoomDetailsExtensionTest : RobolectricTest() { + @Test + fun `default extension renders no additional content`() = runAndroidComposeUiTest { + val extension = NoopRoomDetailsExtension() + setContent { Column { with(extension) { Render(Modifier) } } } + onRoot().onChildren().assertCountEquals(0) + } + + @Test + fun `default extension leaves surrounding content unchanged during updates`() = runAndroidComposeUiTest { + val extension = NoopRoomDetailsExtension() + val text = mutableStateOf("Before") + setContent { + Column { + BasicText(text.value) + with(extension) { Render(Modifier) } + } + } + onNodeWithText("Before").assertExists() + onRoot().onChildren().assertCountEquals(1) + runOnIdle { text.value = "After" } + onNodeWithText("After").assertExists() + onRoot().onChildren().assertCountEquals(1) + } +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt index 8d6ef8d518..da79d436e0 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt @@ -19,6 +19,8 @@ import io.element.android.features.roomdetails.impl.members.details.RoomMemberDe import io.element.android.features.userprofile.shared.aUserProfileState import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +import io.element.android.libraries.androidutils.toast.FakeToastHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.core.RoomId @@ -49,6 +51,7 @@ import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner +import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.EventsRecorder @@ -85,6 +88,7 @@ class RoomDetailsPresenterTest { analyticsService: AnalyticsService = FakeAnalyticsService(), encryptionService: FakeEncryptionService = FakeEncryptionService(), clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + toastHelper: ToastHelper = FakeToastHelper(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), navigator: RoomDetailsNavigator = FakeRoomDetailsNavigator(), notificationCleaner: NotificationCleaner = FakeNotificationCleaner(), @@ -115,12 +119,53 @@ class RoomDetailsPresenterTest { dispatchers = dispatchers, analyticsService = analyticsService, clipboardHelper = clipboardHelper, + toastHelper = toastHelper, appPreferencesStore = appPreferencesStore, notificationCleaner = notificationCleaner, sessionPreferencesStore = sessionPreferencesStore, ) } + @Test + fun `present - copy room id on old device shows a toast`() = runTest { + val room = aJoinedRoom( + roomPermissions = roomPermissions(), + ) + val clipboardHelper = FakeClipboardHelper(isOldDevice = true) + val toastHelper = FakeToastHelper() + val presenter = createRoomDetailsPresenter( + room = room, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + awaitItem().eventSink(RoomDetailsEvent.CopyRoomId) + assertThat(clipboardHelper.clipboardContents).isEqualTo(room.roomId.value) + assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - copy room id on new device does not show a toast`() = runTest { + val room = aJoinedRoom( + roomPermissions = roomPermissions(), + ) + val clipboardHelper = FakeClipboardHelper(isOldDevice = false) + val toastHelper = FakeToastHelper() + val presenter = createRoomDetailsPresenter( + room = room, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + awaitItem().eventSink(RoomDetailsEvent.CopyRoomId) + assertThat(clipboardHelper.clipboardContents).isEqualTo(room.roomId.value) + assertThat(toastHelper.shownToasts).isEmpty() + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - initial state is created from initial room info`() = runTest { val room = aJoinedRoom( diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt index 767ddd5dd9..53cb66da15 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt @@ -11,16 +11,20 @@ package io.element.android.features.roomdetails.impl import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.userprofile.shared.aUserProfileState +import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.notification.CallIntent import io.element.android.libraries.matrix.api.room.RoomNotificationMode @@ -41,6 +45,12 @@ import org.junit.Test import org.robolectric.annotation.Config class RoomDetailsViewTest : RobolectricTest() { + @Test + fun `extension section is rendered`() = runAndroidComposeUiTest { + setRoomDetailView(additionalSections = { Text("Extra section") }) + onNodeWithText("Extra section").assertExists() + } + @Test fun `click on back invokes expected callback`() = runAndroidComposeUiTest { ensureCalledOnce { callback -> @@ -381,6 +391,7 @@ private fun AndroidComposeUiTest.setRoomDetailView( onSecurityAndPrivacyClick: () -> Unit = EnsureNeverCalled(), onProfileClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), onReportRoomClick: () -> Unit = EnsureNeverCalled(), + additionalSections: @Composable ColumnScope.() -> Unit = {}, ) { setContent { RoomDetailsView( @@ -402,6 +413,7 @@ private fun AndroidComposeUiTest.setRoomDetailView( onProfileClick = onProfileClick, onReportRoomClick = onReportRoomClick, leaveRoomView = {}, + additionalSections = additionalSections, ) } } diff --git a/features/roomdetailsedit/impl/build.gradle.kts b/features/roomdetailsedit/impl/build.gradle.kts index 6b1d886abe..8d5303c7a9 100644 --- a/features/roomdetailsedit/impl/build.gradle.kts +++ b/features/roomdetailsedit/impl/build.gradle.kts @@ -35,23 +35,16 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediaupload.api) - implementation(projects.libraries.mediaviewer.api) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.permissions.api) - implementation(projects.libraries.preferences.api) implementation(projects.services.analytics.api) implementation(projects.libraries.testtags) api(projects.features.roomdetailsedit.api) - api(projects.services.apperror.api) implementation(libs.coil.compose) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediapickers.test) - testImplementation(projects.libraries.mediaviewer.test) testImplementation(projects.libraries.permissions.test) - testImplementation(projects.libraries.preferences.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.services.analytics.test) } diff --git a/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditView.kt b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditView.kt index 04c8796285..6c2eb0c889 100644 --- a/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditView.kt +++ b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditView.kt @@ -6,8 +6,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.features.roomdetailsedit.impl import androidx.activity.compose.BackHandler @@ -19,7 +17,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember diff --git a/features/roomdetailsedit/test/build.gradle.kts b/features/roomdetailsedit/test/build.gradle.kts index ff9110eb49..99082d0135 100644 --- a/features/roomdetailsedit/test/build.gradle.kts +++ b/features/roomdetailsedit/test/build.gradle.kts @@ -11,12 +11,11 @@ plugins { } android { - namespace = "io.element.android.features.roomdetetailsedit.test" + namespace = "io.element.android.features.roomdetailsedit.test" } dependencies { implementation(projects.features.roomdetailsedit.api) implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(projects.tests.testutils) } diff --git a/features/roomdirectory/impl/build.gradle.kts b/features/roomdirectory/impl/build.gradle.kts index 1c63872fc8..fa9492ecf7 100644 --- a/features/roomdirectory/impl/build.gradle.kts +++ b/features/roomdirectory/impl/build.gradle.kts @@ -30,12 +30,10 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.androidutils) implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) implementation(projects.libraries.testtags) - implementation(projects.services.analytics.api) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt index 3e9c6e05c4..3da8651c61 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable @@ -83,7 +82,6 @@ fun RoomDirectoryView( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomDirectoryTopBar( onBackClick: () -> Unit, diff --git a/features/roommembermoderation/api/build.gradle.kts b/features/roommembermoderation/api/build.gradle.kts index e168d2c8bf..437f10f2d8 100644 --- a/features/roommembermoderation/api/build.gradle.kts +++ b/features/roommembermoderation/api/build.gradle.kts @@ -16,7 +16,5 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) implementation(projects.libraries.matrix.api) } diff --git a/features/securebackup/impl/build.gradle.kts b/features/securebackup/impl/build.gradle.kts index 54d87ef22e..284517ce4a 100644 --- a/features/securebackup/impl/build.gradle.kts +++ b/features/securebackup/impl/build.gradle.kts @@ -33,9 +33,7 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) - implementation(projects.libraries.oauth.api) implementation(projects.libraries.uiStrings) implementation(projects.libraries.testtags) api(libs.statemachine) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordView.kt index 27612a7b4d..79cadbd132 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordView.kt @@ -9,14 +9,21 @@ package io.element.android.features.securebackup.impl.reset.password import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.PreviewParameter @@ -42,6 +49,11 @@ fun ResetIdentityPasswordView( modifier: Modifier = Modifier, ) { val passwordState = textFieldState(stateValue = "") + val submit = { + if (passwordState.value.isNotEmpty()) { + state.eventSink(ResetIdentityPasswordEvent.Reset(passwordState.value)) + } + } FlowStepPage( modifier = modifier, iconStyle = BigIcon.Style.Default(CompoundIcons.LockSolid()), @@ -58,13 +70,14 @@ fun ResetIdentityPasswordView( passwordState.value = newText }, hasError = state.resetAction.isFailure(), + onSubmit = submit, ) }, buttons = { Button( modifier = Modifier.fillMaxWidth(), text = stringResource(CommonStrings.action_reset_identity), - onClick = { state.eventSink(ResetIdentityPasswordEvent.Reset(passwordState.value)) }, + onClick = submit, destructive = true, enabled = passwordState.value.isNotEmpty(), ) @@ -78,12 +91,20 @@ fun ResetIdentityPasswordView( } @Composable -private fun Content(text: String, onTextChange: (String) -> Unit, hasError: Boolean) { +private fun Content( + text: String, + onTextChange: (String) -> Unit, + hasError: Boolean, + onSubmit: () -> Unit, +) { var showPassword by remember { mutableStateOf(false) } TextField( modifier = Modifier .fillMaxWidth() - .onTabOrEnterKeyFocusNext(LocalFocusManager.current), + .onTabOrEnterKeyFocusNext(LocalFocusManager.current) + .semantics { + contentType = ContentType.Password + }, value = text, onValueChange = onTextChange, placeholder = stringResource(CommonStrings.common_password), @@ -95,6 +116,13 @@ private fun Content(text: String, onTextChange: (String) -> Unit, hasError: Bool onToggle = { showPassword = !showPassword }, ) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { onSubmit() } + ), validity = if (hasError) TextFieldValidity.Invalid else TextFieldValidity.None, supportingText = if (hasError) { stringResource(R.string.screen_reset_encryption_password_error) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvent.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvent.kt index 68221a29e4..a71c235857 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvent.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvent.kt @@ -11,6 +11,7 @@ package io.element.android.features.securebackup.impl.setup sealed interface SecureBackupSetupEvent { data object CreateRecoveryKey : SecureBackupSetupEvent data object RecoveryKeyHasBeenSaved : SecureBackupSetupEvent + data object CopyRecoveryKey : SecureBackupSetupEvent data object Done : SecureBackupSetupEvent data object DismissDialog : SecureBackupSetupEvent } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt index 538128a0d5..88338c6af4 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt @@ -22,9 +22,12 @@ import com.freeletics.flowredux.compose.rememberStateAndDispatch import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import io.element.android.features.securebackup.impl.R import io.element.android.features.securebackup.impl.loggerTagSetup import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress import io.element.android.libraries.matrix.api.encryption.EncryptionService @@ -38,6 +41,8 @@ class SecureBackupSetupPresenter( @Assisted private val isChangeRecoveryKeyUserStory: Boolean, private val stateMachine: SecureBackupSetupStateMachine, private val encryptionService: EncryptionService, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Presenter { @AssistedFactory interface Factory { @@ -60,6 +65,13 @@ class SecureBackupSetupPresenter( } SecureBackupSetupEvent.RecoveryKeyHasBeenSaved -> stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey) + SecureBackupSetupEvent.CopyRecoveryKey -> { + val recoveryKey = setupState.recoveryKey() ?: return + clipboardHelper.copyPlainText(recoveryKey, isSensitive = true) { + toastHelper.show(R.string.screen_recovery_key_copied_to_clipboard) + } + stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey) + } SecureBackupSetupEvent.DismissDialog -> { showSaveConfirmationDialog = false stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.ClearError) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt index c22bd44c99..d7c63e3a39 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt @@ -20,7 +20,6 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.securebackup.impl.R import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView -import io.element.android.libraries.androidutils.system.copyToClipboard import io.element.android.libraries.androidutils.system.startSharePlainTextIntent import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage import io.element.android.libraries.designsystem.components.BigIcon @@ -114,16 +113,10 @@ private fun subtitle(state: SecureBackupSetupState): String { private fun Content( state: SecureBackupSetupState, ) { - val context = LocalContext.current val formattedRecoveryKey = state.recoveryKeyViewState.formattedRecoveryKey - val toastMessage = stringResource(R.string.screen_recovery_key_copied_to_clipboard) val clickLambda = if (formattedRecoveryKey != null) { { - context.copyToClipboard( - text = formattedRecoveryKey, - toastMessage = toastMessage, - ) - state.eventSink.invoke(SecureBackupSetupEvent.RecoveryKeyHasBeenSaved) + state.eventSink.invoke(SecureBackupSetupEvent.CopyRecoveryKey) } } else { if (!state.recoveryKeyViewState.inProgress) { diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPointTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPointTest.kt index e37dab748f..3b029b2ba2 100644 --- a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPointTest.kt +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPointTest.kt @@ -15,6 +15,8 @@ import io.element.android.features.securebackup.api.SecureBackupSetupEntryPoint import io.element.android.features.securebackup.impl.setup.SecureBackupSetupNode import io.element.android.features.securebackup.impl.setup.SecureBackupSetupPresenter import io.element.android.features.securebackup.impl.setup.SecureBackupSetupStateMachine +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +import io.element.android.libraries.androidutils.toast.FakeToastHelper import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.tests.testutils.node.TestParentNode @@ -40,6 +42,8 @@ class DefaultSecureBackupSetupEntryPointTest { isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory, stateMachine = SecureBackupSetupStateMachine(), encryptionService = FakeEncryptionService(), + clipboardHelper = FakeClipboardHelper(), + toastHelper = FakeToastHelper(), ) }, snackbarDispatcher = SnackbarDispatcher(), diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt index 6ab0b257a2..2b79863953 100644 --- a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt @@ -12,8 +12,13 @@ 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.securebackup.impl.R import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +import io.element.android.libraries.androidutils.toast.FakeToastHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.test.A_RECOVERY_KEY @@ -186,16 +191,70 @@ class SecureBackupSetupPresenterTest { } } + @Test + fun `present - copy recovery key on old device shows a toast`() = runTest { + val encryptionService = FakeEncryptionService() + val clipboardHelper = FakeClipboardHelper(isOldDevice = true) + val toastHelper = FakeToastHelper() + val presenter = createSecureBackupSetupPresenter( + encryptionService = encryptionService, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink.invoke(SecureBackupSetupEvent.CreateRecoveryKey) + skipItems(1) + encryptionService.emitEnableRecoveryProgress(EnableRecoveryProgress.Done(A_RECOVERY_KEY)) + val createdState = awaitItem() + createdState.eventSink.invoke(SecureBackupSetupEvent.CopyRecoveryKey) + assertThat(clipboardHelper.clipboardContents).isEqualTo(A_RECOVERY_KEY) + assertThat(clipboardHelper.isSensitive).isTrue() + assertThat(toastHelper.shownToasts).containsExactly(R.string.screen_recovery_key_copied_to_clipboard) + assertThat(awaitItem().setupState).isInstanceOf(SetupState.CreatedAndSaved::class.java) + } + } + + @Test + fun `present - copy recovery key on new device does not show a toast`() = runTest { + val encryptionService = FakeEncryptionService() + val clipboardHelper = FakeClipboardHelper(isOldDevice = false) + val toastHelper = FakeToastHelper() + val presenter = createSecureBackupSetupPresenter( + encryptionService = encryptionService, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink.invoke(SecureBackupSetupEvent.CreateRecoveryKey) + skipItems(1) + encryptionService.emitEnableRecoveryProgress(EnableRecoveryProgress.Done(A_RECOVERY_KEY)) + val createdState = awaitItem() + createdState.eventSink.invoke(SecureBackupSetupEvent.CopyRecoveryKey) + assertThat(clipboardHelper.clipboardContents).isEqualTo(A_RECOVERY_KEY) + assertThat(clipboardHelper.isSensitive).isTrue() + assertThat(toastHelper.shownToasts).isEmpty() + assertThat(awaitItem().setupState).isInstanceOf(SetupState.CreatedAndSaved::class.java) + } + } + private fun createSecureBackupSetupPresenter( isChangeRecoveryKeyUserStory: Boolean = false, encryptionService: EncryptionService = FakeEncryptionService( enableRecoveryLambda = { _, _ -> Result.success("") }, ), + clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + toastHelper: ToastHelper = FakeToastHelper(), ): SecureBackupSetupPresenter { return SecureBackupSetupPresenter( isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory, stateMachine = SecureBackupSetupStateMachine(), encryptionService = encryptionService, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, ) } } diff --git a/features/securityandprivacy/api/build.gradle.kts b/features/securityandprivacy/api/build.gradle.kts index 4260bea661..ec29da2f8b 100644 --- a/features/securityandprivacy/api/build.gradle.kts +++ b/features/securityandprivacy/api/build.gradle.kts @@ -16,6 +16,5 @@ android { } dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) } diff --git a/features/securityandprivacy/impl/build.gradle.kts b/features/securityandprivacy/impl/build.gradle.kts index d42bdef883..0a97231db6 100644 --- a/features/securityandprivacy/impl/build.gradle.kts +++ b/features/securityandprivacy/impl/build.gradle.kts @@ -29,7 +29,6 @@ setupDependencyInjection() dependencies { api(projects.features.securityandprivacy.api) implementation(projects.appconfig) - implementation(projects.appnav) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.core) @@ -39,12 +38,10 @@ dependencies { implementation(projects.libraries.previewutils) implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) - implementation(projects.services.analytics.api) implementation(projects.libraries.featureflag.api) implementation(projects.features.enterprise.api) testCommonDependencies(libs, true) - testImplementation(projects.services.analytics.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.testtags) diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressView.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressView.kt index 9eb15b7af9..5b97323887 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressView.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressView.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -87,7 +86,6 @@ fun EditRoomAddressView( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun EditRoomAddressTopBar( isSaveActionEnabled: Boolean, diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesView.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesView.kt index 0bb6bc49e6..e801eb9844 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesView.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesView.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -166,7 +165,6 @@ private fun CheckableSpaceListItem( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ManageAuthorizedSpacesTopBar( isDoneButtonEnabled: Boolean, diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt index e2cc5522a6..eeaa9bbc13 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.progressSemantics import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ListItemDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -156,7 +155,6 @@ fun SecurityAndPrivacyView( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SecurityAndPrivacyToolbar( isSaveActionEnabled: Boolean, diff --git a/features/securityandprivacy/test/build.gradle.kts b/features/securityandprivacy/test/build.gradle.kts index 903ef6d194..1d6f0ee3a3 100644 --- a/features/securityandprivacy/test/build.gradle.kts +++ b/features/securityandprivacy/test/build.gradle.kts @@ -17,6 +17,5 @@ android { dependencies { implementation(projects.features.securityandprivacy.api) implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(projects.tests.testutils) } diff --git a/features/share/impl/build.gradle.kts b/features/share/impl/build.gradle.kts index 255263cf02..f10783d60c 100644 --- a/features/share/impl/build.gradle.kts +++ b/features/share/impl/build.gradle.kts @@ -27,27 +27,20 @@ android { setupDependencyInjection() dependencies { - implementation(projects.appconfig) implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.architecture) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.roomselect.api) - implementation(projects.libraries.uiStrings) - implementation(projects.libraries.testtags) implementation(projects.services.appnavstate.api) api(libs.statemachine) api(projects.features.share.api) testCommonDependencies(libs, true) - testImplementation(projects.features.share.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediaupload.test) - testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.roomselect.test) testImplementation(projects.services.appnavstate.impl) } diff --git a/features/share/test/build.gradle.kts b/features/share/test/build.gradle.kts index e4a987c113..6b8f8f7572 100644 --- a/features/share/test/build.gradle.kts +++ b/features/share/test/build.gradle.kts @@ -15,7 +15,5 @@ android { dependencies { implementation(projects.features.share.api) - implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(projects.tests.testutils) } diff --git a/features/signedout/impl/build.gradle.kts b/features/signedout/impl/build.gradle.kts index b3801288be..ed5dec3eb9 100644 --- a/features/signedout/impl/build.gradle.kts +++ b/features/signedout/impl/build.gradle.kts @@ -16,6 +16,12 @@ plugins { android { namespace = "io.element.android.features.signedout.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } setupDependencyInjection() @@ -30,7 +36,7 @@ dependencies { implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.uiStrings) - testCommonDependencies(libs) + testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.sessionStorage.test) } diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutEvent.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutEvent.kt index 1e774488e5..39e22ad434 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutEvent.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutEvent.kt @@ -9,5 +9,5 @@ package io.element.android.features.signedout.impl sealed interface SignedOutEvent { - data object SignInAgain : SignedOutEvent + data object Submit : SignedOutEvent } diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt index 1f1a9be1d7..1f35da352a 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt @@ -17,17 +17,18 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch @AssistedInject class SignedOutPresenter( @Assisted private val sessionId: SessionId, private val sessionStore: SessionStore, - private val buildMeta: BuildMeta, ) : Presenter { @AssistedFactory fun interface Factory { @@ -37,24 +38,31 @@ class SignedOutPresenter( @Composable override fun present(): SignedOutState { val signedOutSession by remember { - sessionStore.sessionsFlow().map { sessions -> - sessions.firstOrNull { it.userId == sessionId.value } - } - }.collectAsState(initial = null) + sessionStore.sessionsFlow() + // Ignore the removal of the session, to keep rendering the account data until the screen is closed + .mapNotNull { sessions -> sessions.firstOrNull { it.userId == sessionId.value } } + .map { it.toMatrixUser() } + }.collectAsState(initial = MatrixUser(userId = sessionId)) val coroutineScope = rememberCoroutineScope() fun handleEvent(event: SignedOutEvent) { when (event) { - SignedOutEvent.SignInAgain -> coroutineScope.launch { + SignedOutEvent.Submit -> coroutineScope.launch { sessionStore.removeSession(sessionId.value) } } } return SignedOutState( - appName = buildMeta.applicationName, - signedOutSession = signedOutSession, + signedOutMatrixUser = signedOutSession, eventSink = ::handleEvent, ) } + + private fun SessionData.toMatrixUser() = MatrixUser( + userId = sessionId, + displayName = userDisplayName, + avatarUrl = userAvatarUrl, + avatarThumbnail = userAvatarData, + ) } diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutState.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutState.kt index aeff7c7d2f..c6c4d2a2c9 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutState.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutState.kt @@ -8,10 +8,9 @@ package io.element.android.features.signedout.impl -import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.matrix.api.user.MatrixUser data class SignedOutState( - val appName: String, - val signedOutSession: SessionData?, + val signedOutMatrixUser: MatrixUser, val eventSink: (SignedOutEvent) -> Unit, ) diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt index e0396cba97..d83ebfe7d7 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt @@ -9,44 +9,21 @@ package io.element.android.features.signedout.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.libraries.sessionstorage.api.LoginType -import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUser open class SignedOutStatePreviewParam : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aSignedOutState(), - // Add other states here + aSignedOutState(signedOutSession = aMatrixUser(id = "@alice:server.org", displayName = null)), ) } -private fun aSignedOutState() = SignedOutState( - appName = "AppName", - signedOutSession = aSessionData(), - eventSink = {}, +internal fun aSignedOutState( + signedOutSession: MatrixUser = aMatrixUser(id = "@alice:server.org", displayName = "Alice"), + eventSink: (SignedOutEvent) -> Unit = {}, +) = SignedOutState( + signedOutMatrixUser = signedOutSession, + eventSink = eventSink, ) - -private fun aSessionData( - sessionId: String = "@alice:server.org", - isTokenValid: Boolean = false, -): SessionData { - return SessionData( - userId = sessionId, - deviceId = "aDeviceId", - accessToken = "anAccessToken", - refreshToken = "aRefreshToken", - homeserverUrl = "aHomeserverUrl", - oAuthData = null, - loginTimestamp = null, - isTokenValid = isTokenValid, - loginType = LoginType.UNKNOWN, - passphrase = null, - sessionPath = "/a/path/to/a/session", - cachePath = "/a/path/to/a/cache", - position = 0, - lastUsageIndex = 0, - userDisplayName = null, - userAvatarUrl = null, - userAvatarData = null, - ) -} diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutView.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutView.kt index 73e854c836..40ab488f11 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutView.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutView.kt @@ -9,14 +9,14 @@ package io.element.android.features.signedout.impl import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.runtime.Composable -import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter @@ -31,58 +31,75 @@ import io.element.android.libraries.designsystem.components.visuallist.VisualLis import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCard +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardMode +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardState import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.persistentListOf +/** + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=856-6104 + */ @Composable fun SignedOutView( state: SignedOutState, modifier: Modifier = Modifier, ) { - BackHandler(onBack = { state.eventSink(SignedOutEvent.SignInAgain) }) + BackHandler(onBack = { state.eventSink(SignedOutEvent.Submit) }) HeaderFooterPage( modifier = modifier .fillMaxSize() .systemBarsPadding() .imePadding(), - header = { SignedOutHeader(state) }, - content = { SignedOutContent() }, + isScrollable = true, + header = { SignedOutHeader() }, + content = { SignedOutContent(state.signedOutMatrixUser) }, footer = { SignedOutFooter( - onSignInAgain = { state.eventSink(SignedOutEvent.SignInAgain) }, + onSubmit = { state.eventSink(SignedOutEvent.Submit) }, ) } ) } @Composable -private fun SignedOutHeader(state: SignedOutState) { +private fun SignedOutHeader() { IconTitleSubtitleMolecule( modifier = Modifier.padding(top = 60.dp, bottom = 12.dp), title = stringResource(id = R.string.screen_signed_out_title), - subTitle = stringResource(id = R.string.screen_signed_out_subtitle, state.appName), - iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), + subTitle = stringResource(id = R.string.screen_signed_out_subtitle_v2), + iconStyle = BigIcon.Style.Default( + vectorIcon = CompoundIcons.Devices(), + ), ) } @Composable -private fun SignedOutContent() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = BiasAlignment( - horizontalBias = 0f, - verticalBias = -0.4f - ) +private fun SignedOutContent( + signedOutMatrixUser: MatrixUser, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), ) { + AccountInfoCard( + AccountInfoCardState( + matrixUser = signedOutMatrixUser, + mode = AccountInfoCardMode.Simple, + ) + ) VisualList( items = persistentListOf( VisualListItemData( message = stringResource(id = R.string.screen_signed_out_reason_1), - iconVector = CompoundIcons.Lock(), + iconVector = CompoundIcons.UserProfile(), ), VisualListItemData( message = stringResource(id = R.string.screen_signed_out_reason_2), - iconVector = CompoundIcons.Devices(), + iconVector = CompoundIcons.Lock(), ), VisualListItemData( message = stringResource(id = R.string.screen_signed_out_reason_3), @@ -95,12 +112,12 @@ private fun SignedOutContent() { @Composable private fun SignedOutFooter( - onSignInAgain: () -> Unit, + onSubmit: () -> Unit, ) { ButtonColumnMolecule { Button( - text = stringResource(id = CommonStrings.action_sign_in_again), - onClick = onSignInAgain, + text = stringResource(id = CommonStrings.action_ok), + onClick = onSubmit, modifier = Modifier.fillMaxWidth(), ) } diff --git a/features/signedout/impl/src/main/res/values-pl/translations.xml b/features/signedout/impl/src/main/res/values-pl/translations.xml index 9b83e7d692..7da7ecc2f0 100644 --- a/features/signedout/impl/src/main/res/values-pl/translations.xml +++ b/features/signedout/impl/src/main/res/values-pl/translations.xml @@ -1,8 +1,9 @@ - "Zmieniono hasło w innej sesji" - "Sesja została usunięta z innej sesji" - "Administrator serwera unieważnił Twój dostęp" + "Usunięto urządzenie ze swojego konta" + "Hasło zostało zmienione na innym urządzeniu" + "Sesja serwera wygasła lub administrator zablokował Twój dostęp" "Mogłeś zostać wylogowany z powodów wymienionych poniżej. Zaloguj się ponownie, aby dalej korzystać z %s." - "Zostałeś wylogowany" + "Urządzenie zostało usunięte przez jedną z poniższych przyczyn. Zaloguj się ponownie, aby powiązać to urządzenie ze swoim kontem." + "Urządzenie usunięte" diff --git a/features/signedout/impl/src/main/res/values-uk/translations.xml b/features/signedout/impl/src/main/res/values-uk/translations.xml index ebf364518b..8360646986 100644 --- a/features/signedout/impl/src/main/res/values-uk/translations.xml +++ b/features/signedout/impl/src/main/res/values-uk/translations.xml @@ -1,8 +1,9 @@ - "Ви змінили пароль під час іншого сеансу" - "Ви видалили сеанс з іншого сеансу" - "Адміністратор вашого сервера визнав ваш доступ недійсним" + "Ви вилучили цей пристрій зі свого облікового запису" + "Ви змінили пароль на іншому пристрої" + "Термін дії сеансу на вашому сервері закінчився або адміністратор заблокував ваш доступ" "Можливо, ви вийшли з системи з однієї з причин, наведених нижче. Будь ласка, увійдіть знову, щоб продовжити використання %s." - "Ви вийшли з системи" + "Цей пристрій було видалено з однієї з наведених нижче причин. Будь ласка, увійдіть знову, щоб підключити цей пристрій до вашого облікового запису." + "Пристрій вилучено" diff --git a/features/signedout/impl/src/main/res/values-zh/translations.xml b/features/signedout/impl/src/main/res/values-zh/translations.xml index 6768608294..6c9b5a4bbe 100644 --- a/features/signedout/impl/src/main/res/values-zh/translations.xml +++ b/features/signedout/impl/src/main/res/values-zh/translations.xml @@ -1,8 +1,9 @@ - "你在另一个会话中更改了密码" - "你已从其它会话中删除此会话" - "服务器管理员已禁止你的访问" + "你已为账户移除此设备" + "你已在另一设备上更改密码" + "你的服务器会话已过期,或管理员已阻止你的访问。" "你可能因以下原因而被注销。请重新登录以继续使用 %s。" - "你已注销" + "此设备因以下原因之一已被移除。请重新登录以连接设备到你的账户。" + "设备已移除" diff --git a/features/signedout/impl/src/main/res/values/localazy.xml b/features/signedout/impl/src/main/res/values/localazy.xml index f70b0042d5..7b221a18f2 100644 --- a/features/signedout/impl/src/main/res/values/localazy.xml +++ b/features/signedout/impl/src/main/res/values/localazy.xml @@ -1,8 +1,9 @@ - "You’ve changed your password on another session" - "You have deleted the session from another session" - "Your server’s administrator has invalidated your access" + "You removed this device from your account" + "You’ve changed your password on another device" + "Your server\'s session expired or the administrator has blocked your access" "You might have been signed out for one of the reasons listed below. Please sign in again to continue using %s." - "You’re signed out" + "This device was removed for one of the reasons listed below. Please sign in again to connect this device to your account." + "Device removed" diff --git a/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt index b72ebbcdba..5f15c9473c 100644 --- a/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt +++ b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt @@ -13,13 +13,14 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.test.AN_APPLICATION_NAME +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -30,7 +31,12 @@ class SignedOutPresenterTest { @Test fun `present - initial state`() = runTest { - val aSessionData = aSessionData() + val aSessionData = aSessionData( + sessionId = A_SESSION_ID.value, + userDisplayName = "Alice", + userAvatarUrl = "avatarUrl", + userAvatarData = "avatarData", + ) val sessionStore = InMemorySessionStore( initialList = listOf(aSessionData) ) @@ -38,16 +44,26 @@ class SignedOutPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) val initialState = awaitItem() - assertThat(initialState.appName).isEqualTo(AN_APPLICATION_NAME) - assertThat(initialState.signedOutSession).isEqualTo(aSessionData) + assertThat(initialState.signedOutMatrixUser).isEqualTo(MatrixUser(userId = A_SESSION_ID)) + assertThat(awaitItem().signedOutMatrixUser).isEqualTo( + MatrixUser( + userId = A_SESSION_ID, + displayName = "Alice", + avatarUrl = "avatarUrl", + avatarThumbnail = "avatarData", + ) + ) } } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `present - sign in again`() = runTest { - val aSessionData = aSessionData() + val aSessionData = aSessionData( + sessionId = A_SESSION_ID.value, + userDisplayName = "Alice", + ) val sessionStore = InMemorySessionStore( initialList = listOf(aSessionData) ) @@ -57,13 +73,15 @@ class SignedOutPresenterTest { }.test { skipItems(1) val initialState = awaitItem() - assertThat(initialState.signedOutSession).isEqualTo(aSessionData) + assertThat(initialState.signedOutMatrixUser).isEqualTo(MatrixUser(userId = A_SESSION_ID, displayName = "Alice")) assertThat(sessionStore.getAllSessions()).isNotEmpty() assertThat(sessionStore.numberOfSessions()).isEqualTo(1) - initialState.eventSink(SignedOutEvent.SignInAgain) - assertThat(awaitItem().signedOutSession).isNull() + initialState.eventSink(SignedOutEvent.Submit) + runCurrent() assertThat(sessionStore.getAllSessions()).isEmpty() assertThat(sessionStore.numberOfSessions()).isEqualTo(0) + // The removal of the session must not change the rendered data + expectNoEvents() } } } @@ -75,6 +93,5 @@ internal fun createSignedOutPresenter( return SignedOutPresenter( sessionId = sessionId, sessionStore = sessionStore, - buildMeta = aBuildMeta(applicationName = AN_APPLICATION_NAME), ) } diff --git a/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutViewTest.kt b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutViewTest.kt new file mode 100644 index 0000000000..f9c2438ea3 --- /dev/null +++ b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutViewTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.features.signedout.impl + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.pressBackKey +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test +import org.robolectric.annotation.Config + +class SignedOutViewTest : RobolectricTest() { + @Test + @Config(qualifiers = "h1024dp") + fun `the signed out user is displayed`() = runAndroidComposeUiTest { + setSignedOutView( + aSignedOutState( + signedOutSession = aMatrixUser(id = "@alice:server.org", displayName = "Alice"), + eventSink = EventsRecorder(expectEvents = false), + ), + ) + onNodeWithText("Alice").assertIsDisplayed() + onNodeWithText("@alice:server.org").assertIsDisplayed() + } + + @Test + @Config(qualifiers = "h1024dp") + fun `the signed out user id is displayed when there is no display name`() = runAndroidComposeUiTest { + setSignedOutView( + aSignedOutState( + signedOutSession = aMatrixUser(id = "@alice:server.org", displayName = null), + eventSink = EventsRecorder(expectEvents = false), + ), + ) + onNodeWithText("@alice:server.org").assertIsDisplayed() + } + + @Test + fun `clicking on OK sends a Submit event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setSignedOutView( + aSignedOutState( + eventSink = eventsRecorder, + ), + ) + clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(SignedOutEvent.Submit) + } + + @Test + fun `pressing back sends a Submit event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setSignedOutView( + aSignedOutState( + eventSink = eventsRecorder, + ), + ) + pressBackKey() + eventsRecorder.assertSingle(SignedOutEvent.Submit) + } +} + +private fun AndroidComposeUiTest.setSignedOutView( + state: SignedOutState, +) { + setContent { + SignedOutView( + state = state, + ) + } +} diff --git a/features/space/impl/build.gradle.kts b/features/space/impl/build.gradle.kts index deec862fa3..57bb0f7122 100644 --- a/features/space/impl/build.gradle.kts +++ b/features/space/impl/build.gradle.kts @@ -34,10 +34,8 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) - implementation(projects.libraries.deeplink.api) implementation(projects.services.analytics.api) implementation(libs.coil.compose) - implementation(projects.libraries.featureflag.api) implementation(projects.features.createroom.api) implementation(projects.features.invite.api) implementation(projects.libraries.previewutils) @@ -49,7 +47,6 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.services.analytics.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.features.createroom.test) testImplementation(projects.features.invite.test) testImplementation(projects.features.rolesandpermissions.test) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt index e3698aacb2..486ead4304 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt @@ -6,12 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.features.space.impl import android.os.Parcelable -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt index 4db17634d6..9205587a4f 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -61,7 +60,6 @@ import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AddRoomToSpaceView( state: AddRoomToSpaceState, diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt index e9e13c2c0e..296e72cb86 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt @@ -6,8 +6,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.features.space.impl.leave import androidx.annotation.StringRes @@ -23,7 +21,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.selection.toggleable -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt index 069b00492c..839a76a9a9 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -96,7 +95,6 @@ import io.element.android.libraries.ui.strings.CommonPlurals import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.delay -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SpaceView( state: SpaceState, @@ -359,7 +357,7 @@ private fun EmptySpaceView( IconTitleSubtitleMolecule( title = stringResource(R.string.screen_space_empty_state_title), subTitle = null, - iconStyle = BigIcon.Style.Default(vectorIcon = CompoundIcons.Room(), usePrimaryTint = true), + iconStyle = BigIcon.Style.Default(vectorIcon = CompoundIcons.Room()), modifier = Modifier .fillMaxWidth() .padding(top = 40.dp, start = 24.dp, end = 24.dp, bottom = 24.dp), @@ -402,7 +400,6 @@ private fun LoadingMoreIndicator( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SpaceViewTopBar( spaceInfo: RoomInfo, @@ -519,7 +516,6 @@ private fun SpaceViewTopBar( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ManageModeTopBar( selectedCount: Int, diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsView.kt index b5cfc7702a..bb1b61dbdf 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsView.kt @@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -150,7 +149,6 @@ private fun Section( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SpaceSettingsTopBar( onBackClick: () -> Unit, diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt index c0ec58d267..41fef18a3f 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt @@ -12,8 +12,8 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.google.common.truth.Truth.assertThat -import io.element.android.features.changeroommemberroles.test.FakeChangeRoomMemberRolesEntryPoint -import io.element.android.features.createroom.api.FakeCreateRoomEntryPoint +import io.element.android.features.createroom.test.FakeCreateRoomEntryPoint +import io.element.android.features.rolesandpermissions.test.FakeChangeRoomMemberRolesEntryPoint import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.impl.di.FakeSpaceFlowGraph import io.element.android.libraries.matrix.api.core.RoomId diff --git a/features/startchat/impl/build.gradle.kts b/features/startchat/impl/build.gradle.kts index fe7bfa9ae2..1b04c175e1 100644 --- a/features/startchat/impl/build.gradle.kts +++ b/features/startchat/impl/build.gradle.kts @@ -35,13 +35,9 @@ dependencies { implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) implementation(projects.libraries.deeplink.api) - implementation(projects.libraries.mediapickers.api) - implementation(projects.libraries.mediaupload.api) - implementation(projects.libraries.permissions.api) implementation(projects.libraries.usersearch.api) implementation(projects.services.analytics.api) implementation(libs.coil.compose) - implementation(projects.libraries.featureflag.api) implementation(projects.features.createroom.api) implementation(projects.features.enterprise.api) api(projects.features.startchat.api) @@ -49,12 +45,8 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.services.analytics.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.mediapickers.test) - testImplementation(projects.libraries.mediaupload.test) - testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.usersearch.test) testImplementation(projects.features.createroom.test) testImplementation(projects.features.startchat.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.features.enterprise.test) } diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatFlowNode.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatFlowNode.kt index 9cb8d48778..4f19f0e573 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatFlowNode.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatFlowNode.kt @@ -22,7 +22,6 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.features.createroom.api.CreateRoomEntryPoint -import io.element.android.features.startchat.DefaultStartChatNavigator import io.element.android.features.startchat.api.StartChatEntryPoint import io.element.android.features.startchat.impl.joinbyaddress.JoinRoomByAddressNode import io.element.android.features.startchat.impl.root.StartChatNode diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/StartChatNavigator.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatNavigator.kt similarity index 97% rename from features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/StartChatNavigator.kt rename to features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatNavigator.kt index 125dc2c2b3..2bd2ac2f1b 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/StartChatNavigator.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatNavigator.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.startchat +package io.element.android.features.startchat.impl import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.navmodel.backstack.BackStack diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/SearchUserBar.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/SearchUserBar.kt index c8284c6a4f..4959a6f33b 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/SearchUserBar.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/SearchUserBar.kt @@ -18,7 +18,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable @@ -38,7 +37,6 @@ import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.usersearch.api.UserSearchResult import kotlinx.collections.immutable.ImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchUserBar( queryState: TextFieldState, diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressNode.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressNode.kt index c81cb1af66..acfe51ff92 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressNode.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressNode.kt @@ -17,7 +17,7 @@ import com.bumble.appyx.core.plugin.plugins import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode -import io.element.android.features.startchat.StartChatNavigator +import io.element.android.features.startchat.impl.StartChatNavigator import io.element.android.libraries.di.SessionScope @ContributesNode(SessionScope::class) diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressPresenter.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressPresenter.kt index 533f680674..be8b96c21a 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressPresenter.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressPresenter.kt @@ -19,7 +19,7 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject -import io.element.android.features.startchat.StartChatNavigator +import io.element.android.features.startchat.impl.StartChatNavigator import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.matrix.api.MatrixClient diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatNode.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatNode.kt index 60ef9ee00f..69d82ad37c 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatNode.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatNode.kt @@ -21,7 +21,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.MobileScreen import io.element.android.annotations.ContributesNode -import io.element.android.features.startchat.StartChatNavigator +import io.element.android.features.startchat.impl.StartChatNavigator import io.element.android.libraries.deeplink.api.usecase.InviteFriendsUseCase import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt index 3ef807b777..d1f91cb837 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -146,7 +145,6 @@ fun StartChatView( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun CreateRoomRootViewTopBar( onCloseClick: () -> Unit, diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartChatEntryPointTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartChatEntryPointTest.kt index b00a595747..c5a8f992f1 100644 --- a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartChatEntryPointTest.kt +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartChatEntryPointTest.kt @@ -12,7 +12,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.google.common.truth.Truth.assertThat -import io.element.android.features.createroom.api.FakeCreateRoomEntryPoint +import io.element.android.features.createroom.test.FakeCreateRoomEntryPoint import io.element.android.features.startchat.api.StartChatEntryPoint import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.tests.testutils.lambda.lambdaError diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/FakeStartChatNavigator.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/FakeStartChatNavigator.kt index ba1b6a4e69..4b7b289490 100644 --- a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/FakeStartChatNavigator.kt +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/FakeStartChatNavigator.kt @@ -8,7 +8,6 @@ package io.element.android.features.startchat.impl -import io.element.android.features.startchat.StartChatNavigator import io.element.android.libraries.matrix.api.core.RoomIdOrAlias class FakeStartChatNavigator( diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinBaseRoomByAddressPresenterTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinBaseRoomByAddressPresenterTest.kt index 393f51dd2d..924ca691b7 100644 --- a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinBaseRoomByAddressPresenterTest.kt +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinBaseRoomByAddressPresenterTest.kt @@ -9,8 +9,8 @@ package io.element.android.features.startchat.impl.joinbyaddress import com.google.common.truth.Truth.assertThat -import io.element.android.features.startchat.StartChatNavigator import io.element.android.features.startchat.impl.FakeStartChatNavigator +import io.element.android.features.startchat.impl.StartChatNavigator import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt index 39d6f34b21..c656e83858 100644 --- a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt @@ -10,12 +10,12 @@ package io.element.android.features.startchat.impl.root import androidx.compose.runtime.MutableState import com.google.common.truth.Truth.assertThat -import io.element.android.features.invitepeople.test.FakeStartDMAction import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser import io.element.android.features.startchat.api.StartDMAction import io.element.android.features.startchat.impl.userlist.FakeUserListPresenter import io.element.android.features.startchat.impl.userlist.FakeUserListPresenterFactory import io.element.android.features.startchat.impl.userlist.UserListDataStore +import io.element.android.features.startchat.test.FakeStartDMAction import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId diff --git a/features/startchat/test/build.gradle.kts b/features/startchat/test/build.gradle.kts index d6d1471832..ed47f27fc8 100644 --- a/features/startchat/test/build.gradle.kts +++ b/features/startchat/test/build.gradle.kts @@ -11,13 +11,11 @@ plugins { } android { - namespace = "io.element.android.features.invitepeople.test" + namespace = "io.element.android.features.startchat.test" } dependencies { - implementation(libs.coroutines.core) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrix.test) implementation(projects.libraries.architecture) implementation(projects.tests.testutils) api(projects.features.startchat.api) diff --git a/features/startchat/test/src/main/kotlin/io/element/android/features/invitepeople/test/FakeStartDMAction.kt b/features/startchat/test/src/main/kotlin/io/element/android/features/startchat/test/FakeStartDMAction.kt similarity index 95% rename from features/startchat/test/src/main/kotlin/io/element/android/features/invitepeople/test/FakeStartDMAction.kt rename to features/startchat/test/src/main/kotlin/io/element/android/features/startchat/test/FakeStartDMAction.kt index c4969df4d9..70cbe1883c 100644 --- a/features/startchat/test/src/main/kotlin/io/element/android/features/invitepeople/test/FakeStartDMAction.kt +++ b/features/startchat/test/src/main/kotlin/io/element/android/features/startchat/test/FakeStartDMAction.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.invitepeople.test +package io.element.android.features.startchat.test import androidx.compose.runtime.MutableState import io.element.android.features.startchat.api.StartDMAction diff --git a/features/userprofile/impl/build.gradle.kts b/features/userprofile/impl/build.gradle.kts index 3e68fb2b9c..b04ca61a23 100644 --- a/features/userprofile/impl/build.gradle.kts +++ b/features/userprofile/impl/build.gradle.kts @@ -31,10 +31,8 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) implementation(projects.libraries.mediaviewer.api) - implementation(projects.libraries.featureflag.api) implementation(projects.features.call.api) implementation(projects.features.enterprise.api) implementation(projects.features.verifysession.api) @@ -47,7 +45,6 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediaviewer.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.features.call.test) testImplementation(projects.features.verifysession.test) testImplementation(projects.features.startchat.test) diff --git a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt index 57a7ae273f..b163b04ce3 100644 --- a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt +++ b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt @@ -15,9 +15,9 @@ import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.test.FakeSessionEnterpriseService -import io.element.android.features.invitepeople.test.FakeStartDMAction import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser import io.element.android.features.startchat.api.StartDMAction +import io.element.android.features.startchat.test.FakeStartDMAction import io.element.android.features.userprofile.api.UserProfileEvent import io.element.android.features.userprofile.api.UserProfileState import io.element.android.features.userprofile.api.UserProfileVerificationState diff --git a/features/userprofile/shared/build.gradle.kts b/features/userprofile/shared/build.gradle.kts index 7f4d96c883..1fdce484dd 100644 --- a/features/userprofile/shared/build.gradle.kts +++ b/features/userprofile/shared/build.gradle.kts @@ -30,16 +30,10 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) - implementation(projects.libraries.mediaviewer.api) - implementation(projects.libraries.featureflag.api) - implementation(projects.libraries.permissions.api) - implementation(projects.libraries.preferences.api) implementation(projects.libraries.testtags) api(projects.features.userprofile.api) - api(projects.services.apperror.api) implementation(libs.coil.compose) implementation(projects.features.startchat.api) - implementation(projects.services.analytics.api) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt index 643db456b1..d2d27638b8 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -47,7 +46,6 @@ import io.element.android.libraries.matrix.api.notification.CallIntent import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun UserProfileView( state: UserProfileState, diff --git a/features/verifysession/impl/build.gradle.kts b/features/verifysession/impl/build.gradle.kts index 89a87edf19..24ba7ccb3e 100644 --- a/features/verifysession/impl/build.gradle.kts +++ b/features/verifysession/impl/build.gradle.kts @@ -25,7 +25,6 @@ android { setupDependencyInjection() dependencies { - implementation(projects.appconfig) implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.architecture) @@ -33,15 +32,13 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) - implementation(projects.libraries.preferences.api) + implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.uiStrings) - implementation(projects.features.logout.api) api(libs.statemachine) api(projects.features.verifysession.api) testCommonDependencies(libs, true) - testImplementation(projects.features.logout.test) testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.sessionStorage.test) } diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt index 4cde3f927f..00daf9b5b8 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt @@ -13,6 +13,7 @@ package io.element.android.features.verifysession.impl.incoming import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -26,13 +27,17 @@ import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationRequest +import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber @@ -45,6 +50,8 @@ class IncomingVerificationPresenter( @Assisted private val navigator: IncomingVerificationNavigator, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val sessionVerificationService: SessionVerificationService, + private val sessionId: SessionId, + private val sessionStore: SessionStore, private val stateMachine: IncomingVerificationStateMachine, private val dateFormatter: DateFormatter, ) : Presenter { @@ -92,9 +99,24 @@ class IncomingVerificationPresenter( mode = DateFormatterMode.TimeOrDate, ) } + val currentUser by remember { + sessionStore.sessionsFlow().mapNotNull { sessions -> + sessions.find { it.userId == sessionId.value }?.let { sessionData -> + MatrixUser( + userId = sessionId, + displayName = sessionData.userDisplayName, + avatarUrl = sessionData.userAvatarUrl, + // Locally stored avatar, used as a fallback when the avatar cannot be loaded, + // for instance for a session which is not the current one + avatarThumbnail = sessionData.userAvatarData, + ) + } + } + }.collectAsState(MatrixUser(sessionId)) val step by remember { derivedStateOf { stateAndDispatch.state.value.toVerificationStep( + currentUser = currentUser, sessionVerificationRequestDetails = verificationRequest.details, formattedSignInTime = formattedSignInTime, ) @@ -147,6 +169,7 @@ class IncomingVerificationPresenter( } private fun StateMachineState?.toVerificationStep( + currentUser: MatrixUser, sessionVerificationRequestDetails: SessionVerificationRequestDetails, formattedSignInTime: String, ): Step = @@ -156,6 +179,7 @@ class IncomingVerificationPresenter( StateMachineState.RejectingIncomingVerification, null -> { Step.Initial( + currentUser = currentUser, deviceDisplayName = sessionVerificationRequestDetails.deviceDisplayName, deviceId = sessionVerificationRequestDetails.deviceId, formattedSignInTime = formattedSignInTime, diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt index e3663800b2..bd4e343c49 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt @@ -10,6 +10,7 @@ package io.element.android.features.verifysession.impl.incoming import androidx.compose.runtime.Stable import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.verification.SessionVerificationData import io.element.android.libraries.matrix.api.verification.VerificationRequest @@ -21,6 +22,7 @@ data class IncomingVerificationState( @Stable sealed interface Step { data class Initial( + val currentUser: MatrixUser, val deviceDisplayName: String?, val deviceId: DeviceId, val formattedSignInTime: String, diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStatePreviewParam.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStatePreviewParam.kt index 68672be025..bdf3ad2196 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStatePreviewParam.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStatePreviewParam.kt @@ -13,6 +13,7 @@ import io.element.android.features.verifysession.impl.incoming.IncomingVerificat import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE +import io.element.android.libraries.designsystem.preview.USER_NAME_BOB import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.FlowId import io.element.android.libraries.matrix.api.core.UserId @@ -50,6 +51,11 @@ open class IncomingVerificationStatePreviewParam : PreviewParameterProvider { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - SessionDetailsView( - deviceName = stepInitial.deviceDisplayName, - deviceId = stepInitial.deviceId, - signInFormattedTimestamp = stepInitial.formattedSignInTime, + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + when (request) { + is VerificationRequest.Incoming.OtherSession -> { + AccountInfoCard( + state = AccountInfoCardState( + matrixUser = stepInitial.currentUser, + mode = AccountInfoCardMode.DeviceVerification( + deviceName = stepInitial.deviceDisplayName, + deviceId = stepInitial.deviceId, + signInFormattedTimestamp = stepInitial.formattedSignInTime, + ), + ), ) Text( modifier = Modifier @@ -189,15 +194,15 @@ private fun ContentInitial( textAlign = TextAlign.Center, ) } - } - is VerificationRequest.Incoming.User -> { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp), - ) { - VerificationUserProfileContent( - user = request.details.senderProfile, + is VerificationRequest.Incoming.User -> { + AccountInfoCard( + state = AccountInfoCardState( + matrixUser = stepInitial.currentUser, + mode = AccountInfoCardMode.UserVerification( + otherUser = request.details.senderProfile, + hint = stringResource(R.string.screen_session_verification_user_requesting_verification), + ), + ), ) } } @@ -219,7 +224,7 @@ private fun IncomingVerificationBottomMenu( showProgress = step.isWaiting, onClick = { eventSink(IncomingVerificationViewEvent.StartVerification) }, ) - TextButton( + OutlinedButton( modifier = Modifier.fillMaxWidth(), text = stringResource(CommonStrings.action_ignore), enabled = !step.isWaiting, @@ -264,9 +269,9 @@ private fun IncomingVerificationBottomMenu( @PreviewsDayNight @Composable -internal fun IncomingVerificationViewPreview(@PreviewParameter( - IncomingVerificationStatePreviewParam::class -) state: IncomingVerificationState) = ElementPreview { +internal fun IncomingVerificationViewPreview( + @PreviewParameter(IncomingVerificationStatePreviewParam::class) state: IncomingVerificationState +) = ElementPreview { IncomingVerificationView( state = state, ) diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt deleted file mode 100644 index 308410f259..0000000000 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.verifysession.impl.incoming.ui - -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import io.element.android.compound.theme.ElementTheme -import io.element.android.features.verifysession.impl.R -import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom -import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize -import io.element.android.libraries.designsystem.atomic.molecules.TextWithLabelMolecule -import io.element.android.libraries.designsystem.icons.CompoundDrawables -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.matrix.api.core.DeviceId -import io.element.android.libraries.ui.strings.CommonStrings - -@Composable -fun SessionDetailsView( - deviceName: String?, - deviceId: DeviceId, - signInFormattedTimestamp: String, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .fillMaxWidth() - .border( - width = 1.dp, - color = ElementTheme.colors.borderDisabled, - shape = RoundedCornerShape(8.dp) - ) - .padding(24.dp) - .semantics(mergeDescendants = true) {}, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - RoundedIconAtom( - modifier = Modifier, - size = RoundedIconAtomSize.Big, - resourceId = CompoundDrawables.ic_compound_devices - ) - Text( - text = deviceName ?: deviceId.value, - style = ElementTheme.typography.fontBodyMdMedium, - color = ElementTheme.colors.textPrimary, - ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - TextWithLabelMolecule( - label = stringResource(R.string.screen_session_verification_request_details_timestamp), - text = signInFormattedTimestamp, - modifier = Modifier.weight(2f), - ) - TextWithLabelMolecule( - label = stringResource(CommonStrings.common_device_id), - text = deviceId.value, - modifier = Modifier.weight(5f), - spellText = true, - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun SessionDetailsViewPreview() = ElementPreview { - Column { - SessionDetailsView( - deviceName = "Element X Android", - deviceId = DeviceId("ILAKNDNASDLK"), - signInFormattedTimestamp = "12:34", - ) - SessionDetailsView( - deviceName = null, - deviceId = DeviceId("ILAKNDNASDLK"), - signInFormattedTimestamp = "12:34", - ) - } -} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationView.kt index c9dc4ef45c..8ed13c7cbe 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationView.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationView.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBarDefaults.topAppBarColors import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -53,7 +52,6 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationD import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun OutgoingVerificationView( state: OutgoingVerificationState, diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationUserProfileContent.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationUserProfileContent.kt deleted file mode 100644 index c398d72ddd..0000000000 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationUserProfileContent.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.verifysession.impl.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp -import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.designsystem.components.avatar.Avatar -import io.element.android.libraries.designsystem.components.avatar.AvatarSize -import io.element.android.libraries.designsystem.components.avatar.AvatarType -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE -import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.designsystem.utils.CommonDrawables -import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.matrix.ui.model.getAvatarData -import io.element.android.libraries.matrix.ui.model.getBestName - -/** - * Ref: https://www.figma.com/design/lMrKOhS8BEb75GXVq7FnNI/ER-96--User-Verification-by-Emoji?node-id=116-52049 - */ -@Composable -fun VerificationUserProfileContent( - user: MatrixUser, - modifier: Modifier = Modifier, -) { - val avatarData = remember(user) { - user.getAvatarData(AvatarSize.UserVerification) - } - Row( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(ElementTheme.colors.bgSubtleSecondary) - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Avatar( - avatarData = avatarData, - avatarType = AvatarType.User, - ) - Spacer(modifier = Modifier.width(12.dp)) - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = user.getBestName(), - style = ElementTheme.typography.fontBodyLgMedium, - color = ElementTheme.colors.textPrimary, - ) - - if (user.displayName.isNullOrEmpty().not()) { - Text( - text = user.userId.value, - style = ElementTheme.typography.fontBodyMdRegular, - color = ElementTheme.colors.textSecondary, - ) - } - } - } -} - -@PreviewsDayNight -@Composable -internal fun VerificationUserProfileContentPreview() = ElementPreview( - drawableFallbackForImages = CommonDrawables.sample_avatar -) { - VerificationUserProfileContent( - user = MatrixUser( - userId = UserId("@alice:example.com"), - displayName = USER_NAME_ALICE, - avatarUrl = "https://example.com/avatar.png", - ) - ) -} diff --git a/features/verifysession/impl/src/main/res/values-pl/translations.xml b/features/verifysession/impl/src/main/res/values-pl/translations.xml index 71cea2c403..bc326cbc00 100644 --- a/features/verifysession/impl/src/main/res/values-pl/translations.xml +++ b/features/verifysession/impl/src/main/res/values-pl/translations.xml @@ -44,6 +44,7 @@ "Otwórz aplikację na drugim zweryfikowanym urządzeniu" "Dla dodatkowej ochrony, zweryfikuj tego użytkownika, porównując zestaw emoji na obu urządzeniach. Zrób to, korzystając z zaufanej metody komunikacji." "Zweryfikować tego użytkownika?" + "Użytkownik proszący o weryfikację:" "Dla dodatkowej ochrony, inny użytkownik chce zweryfikować Twoją tożsamość. Pojawi się unikalny zestaw emoji do porównania." "Powinno wyskoczyć okno na drugim urządzeniu. Rozpocznij tam weryfikację." "Rozpocznij weryfikację na drugim urządzeniu" diff --git a/features/verifysession/impl/src/main/res/values-uk/translations.xml b/features/verifysession/impl/src/main/res/values-uk/translations.xml index 803e57fa39..620229e2f6 100644 --- a/features/verifysession/impl/src/main/res/values-uk/translations.xml +++ b/features/verifysession/impl/src/main/res/values-uk/translations.xml @@ -44,6 +44,7 @@ "Відкрийте застосунок на іншому звіреному пристрої" "Для додаткової безпеки звірте цього користувача, порівнявши набір емоджі на ваших пристроях. Зробіть це, використовуючи надійний спосіб спілкування." "Звірити цього користувача?" + "Користувач, який запитує звірку:" "Для додаткової безпеки інший користувач хоче звірити вашу цифрову ідентичність. Вам буде показано набір емодзі для порівняння." "На іншому пристрої має з’явитися спливне вікно. Розпочніть звіряння звідти." "Розпочати звіряння на іншому пристрої" diff --git a/features/verifysession/impl/src/main/res/values-zh/translations.xml b/features/verifysession/impl/src/main/res/values-zh/translations.xml index 4b32d53ea6..3ae122cbb7 100644 --- a/features/verifysession/impl/src/main/res/values-zh/translations.xml +++ b/features/verifysession/impl/src/main/res/values-zh/translations.xml @@ -44,6 +44,7 @@ "在另一台验证的设备上打开应用" "为提高安全性,请通过比较设备上的一组 Emoji 以验证此用户。通过使用安全的方式比如面对面来实施此步骤。" "验证此用户?" + "用户正在请求验证:" "为提高安全性,另一用户想要验证你的数字身份。你将看到一组 Emoij 供你比较。" "你应该会在另一台设备上看到一个弹出窗口。现在从该处开始验证。" "在另一台设备上开始验证" diff --git a/features/verifysession/impl/src/main/res/values/localazy.xml b/features/verifysession/impl/src/main/res/values/localazy.xml index 4aa672cdeb..012437792e 100644 --- a/features/verifysession/impl/src/main/res/values/localazy.xml +++ b/features/verifysession/impl/src/main/res/values/localazy.xml @@ -44,6 +44,7 @@ "Open the app on another verified device" "For extra security, verify this user by comparing a set of emojis on your devices. Do this by using a trusted way to communicate." "Verify this user?" + "User requesting verification:" "For extra security, another user wants to verify your digital identity. You’ll be shown a set of emojis to compare." "You should see a popup on the other device. Start the verification from there now." "Start verification on the other device" diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt index 45d5c623c4..3a969938a1 100644 --- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt @@ -18,10 +18,17 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationR import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationRequest +import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.A_DEVICE_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -41,6 +48,51 @@ class IncomingVerificationPresenterTest { @get:Rule val warmUpRule = WarmUpRule() + @Test + fun `present - the current user is built from the stored session`() = runTest { + assertCurrentUserIsBuiltFromStoredSession(anIncomingSessionVerificationRequest) + } + + @Test + fun `present - incoming user verification - the current user is built from the stored session`() = runTest { + assertCurrentUserIsBuiltFromStoredSession(anIncomingUserVerificationRequest) + } + + private suspend fun TestScope.assertCurrentUserIsBuiltFromStoredSession( + verificationRequest: VerificationRequest.Incoming, + ) { + createPresenter( + verificationRequest = verificationRequest, + service = FakeSessionVerificationService( + acknowledgeVerificationRequestLambda = { }, + resetLambda = { }, + ), + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_SESSION_ID.value, + userDisplayName = A_USER_NAME, + userAvatarUrl = AN_AVATAR_URL, + userAvatarData = "avatarData", + ) + ), + ), + ).test { + val step = awaitItem().step as IncomingVerificationState.Step.Initial + assertThat(step.currentUser).isEqualTo(MatrixUser(A_SESSION_ID)) + val stepWithUser = awaitItem().step as IncomingVerificationState.Step.Initial + assertThat(stepWithUser.currentUser).isEqualTo( + MatrixUser( + userId = A_SESSION_ID, + displayName = A_USER_NAME, + avatarUrl = AN_AVATAR_URL, + avatarThumbnail = "avatarData", + ) + ) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - nominal case - incoming verification successful`() = runTest { val acknowledgeVerificationRequestLambda = lambdaRecorder { _ -> } @@ -59,6 +111,7 @@ class IncomingVerificationPresenterTest { val initialState = awaitItem() assertThat(initialState.step).isEqualTo( IncomingVerificationState.Step.Initial( + currentUser = MatrixUser(A_SESSION_ID), deviceDisplayName = "a device name", deviceId = A_DEVICE_ID, formattedSignInTime = "567 TimeOrDate false", @@ -128,6 +181,7 @@ class IncomingVerificationPresenterTest { val initialState = awaitItem() assertThat(initialState.step).isEqualTo( IncomingVerificationState.Step.Initial( + currentUser = MatrixUser(A_SESSION_ID), deviceDisplayName = "a device name", deviceId = A_DEVICE_ID, formattedSignInTime = "567 TimeOrDate false", @@ -193,6 +247,7 @@ class IncomingVerificationPresenterTest { val initialState = awaitItem() assertThat(initialState.step).isEqualTo( IncomingVerificationState.Step.Initial( + currentUser = MatrixUser(A_SESSION_ID), deviceDisplayName = "a device name", deviceId = A_DEVICE_ID, formattedSignInTime = "567 TimeOrDate false", @@ -228,6 +283,7 @@ class IncomingVerificationPresenterTest { val initialState = awaitItem() assertThat(initialState.step).isEqualTo( IncomingVerificationState.Step.Initial( + currentUser = MatrixUser(A_SESSION_ID), deviceDisplayName = "a device name", deviceId = A_DEVICE_ID, formattedSignInTime = "567 TimeOrDate false", @@ -310,15 +366,32 @@ private val anIncomingSessionVerificationRequest = VerificationRequest.Incoming. ) ) +private val anIncomingUserVerificationRequest = VerificationRequest.Incoming.User( + details = SessionVerificationRequestDetails( + senderProfile = MatrixUser( + userId = A_USER_ID_2, + displayName = "another user name", + avatarUrl = null, + ), + flowId = FlowId("flowId"), + deviceId = A_DEVICE_ID, + deviceDisplayName = "a device name", + firstSeenTimestamp = A_TIMESTAMP, + ) +) + internal fun TestScope.createPresenter( verificationRequest: VerificationRequest.Incoming = anIncomingSessionVerificationRequest, navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() }, service: SessionVerificationService = FakeSessionVerificationService(), dateFormatter: DateFormatter = FakeDateFormatter(), + sessionStore: SessionStore = InMemorySessionStore(), ) = IncomingVerificationPresenter( verificationRequest = verificationRequest, navigator = navigator, sessionVerificationService = service, + sessionId = A_SESSION_ID, + sessionStore = sessionStore, stateMachine = IncomingVerificationStateMachine(service), dateFormatter = dateFormatter, sessionCoroutineScope = backgroundScope, diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultTextFileViewer.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultTextFileViewer.kt index ae3e6932ac..bcbacf00fa 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultTextFileViewer.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultTextFileViewer.kt @@ -15,10 +15,16 @@ import dev.zacsweers.metro.ContributesBinding import io.element.android.features.viewfolder.api.TextFileViewer import io.element.android.features.viewfolder.impl.file.ColorationMode import io.element.android.features.viewfolder.impl.file.FileContent +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.toast.ToastHelper +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList @ContributesBinding(AppScope::class) -class DefaultTextFileViewer : TextFileViewer { +class DefaultTextFileViewer( + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, +) : TextFileViewer { @Composable override fun Render( lines: ImmutableList, @@ -27,6 +33,11 @@ class DefaultTextFileViewer : TextFileViewer { FileContent( lines = lines, colorationMode = ColorationMode.None, + onLineClick = { line -> + clipboardHelper.copyPlainText(line) { + toastHelper.show(CommonStrings.common_line_copied_to_clipboard) + } + }, modifier = modifier ) } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContent.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContent.kt index c68d630869..a0afd75a28 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContent.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContent.kt @@ -23,12 +23,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.androidutils.system.copyToClipboard import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList @@ -37,6 +35,7 @@ import kotlinx.collections.immutable.ImmutableList internal fun FileContent( lines: ImmutableList, colorationMode: ColorationMode, + onLineClick: (String) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( @@ -60,6 +59,7 @@ internal fun FileContent( lineNumber = index + 1, line = line, colorationMode = colorationMode, + onClick = { onLineClick(line) }, ) } } @@ -71,18 +71,12 @@ private fun LineRow( lineNumber: Int, line: String, colorationMode: ColorationMode, + onClick: () -> Unit, ) { - val context = LocalContext.current - val toastMessage = stringResource(CommonStrings.common_line_copied_to_clipboard) Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = { - context.copyToClipboard( - text = line, - toastMessage = toastMessage, - ) - }) + .clickable(onClick = onClick) ) { Text( modifier = Modifier diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvent.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvent.kt index 2ff6faf4ef..7ba5338fdf 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvent.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvent.kt @@ -11,4 +11,5 @@ package io.element.android.features.viewfolder.impl.file sealed interface ViewFileEvent { data object SaveOnDisk : ViewFileEvent data object Share : ViewFileEvent + data class CopyToClipboard(val text: String) : ViewFileEvent } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt index 571f3eb053..0ffea1dc08 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt @@ -18,8 +18,11 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -30,6 +33,8 @@ class ViewFilePresenter( private val fileContentReader: FileContentReader, private val fileShare: FileShare, private val fileSave: FileSave, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Presenter { @AssistedFactory interface Factory { @@ -48,6 +53,9 @@ class ViewFilePresenter( when (event) { ViewFileEvent.Share -> coroutineScope.share(path) ViewFileEvent.SaveOnDisk -> coroutineScope.save(path) + is ViewFileEvent.CopyToClipboard -> clipboardHelper.copyPlainText(event.text) { + toastHelper.show(CommonStrings.common_line_copied_to_clipboard) + } } } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt index 3c4ce9674c..ee35614a52 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt @@ -11,7 +11,6 @@ package io.element.android.features.viewfolder.impl.file import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -30,7 +29,6 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.toImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ViewFileView( state: ViewFileState, @@ -82,6 +80,7 @@ fun ViewFileView( modifier = Modifier.weight(1f), lines = state.lines.data.toImmutableList(), colorationMode = state.colorationMode, + onLineClick = { state.eventSink(ViewFileEvent.CopyToClipboard(it)) }, ) is AsyncData.Failure -> AsyncFailure(throwable = state.lines.error, onRetry = null) } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt index 89fb3da7f4..0817ca7234 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.SubdirectoryArrowLeft -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -39,7 +38,6 @@ 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 -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ViewFolderView( state: ViewFolderState, diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileContentReader.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileContentReader.kt similarity index 80% rename from features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileContentReader.kt rename to features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileContentReader.kt index 6dd621ac6a..9e5b158b5d 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileContentReader.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileContentReader.kt @@ -6,9 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.viewfolder.test.file - -import io.element.android.features.viewfolder.impl.file.FileContentReader +package io.element.android.features.viewfolder.impl.file class FakeFileContentReader : FileContentReader { private var result: Result> = Result.success(emptyList()) diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileSave.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileSave.kt similarity index 76% rename from features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileSave.kt rename to features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileSave.kt index 3649d30759..87af5947bb 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileSave.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileSave.kt @@ -6,9 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.viewfolder.test.file - -import io.element.android.features.viewfolder.impl.file.FileSave +package io.element.android.features.viewfolder.impl.file class FakeFileSave : FileSave { var hasBeenCalled = false diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileShare.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileShare.kt similarity index 76% rename from features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileShare.kt rename to features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileShare.kt index 7348e4bed0..3b3a4a7245 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileShare.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileShare.kt @@ -6,9 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.viewfolder.test.file - -import io.element.android.features.viewfolder.impl.file.FileShare +package io.element.android.features.viewfolder.impl.file class FakeFileShare : FileShare { var hasBeenCalled = false diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenterTest.kt similarity index 71% rename from features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt rename to features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenterTest.kt index c3be8d84b9..e61dfebad2 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenterTest.kt @@ -6,20 +6,19 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.viewfolder.test.file +package io.element.android.features.viewfolder.impl.file 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.viewfolder.impl.file.ColorationMode -import io.element.android.features.viewfolder.impl.file.FileContentReader -import io.element.android.features.viewfolder.impl.file.FileSave -import io.element.android.features.viewfolder.impl.file.FileShare -import io.element.android.features.viewfolder.impl.file.ViewFileEvent -import io.element.android.features.viewfolder.impl.file.ViewFilePresenter +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +import io.element.android.libraries.androidutils.toast.FakeToastHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -126,17 +125,51 @@ class ViewFilePresenterTest { } } + @Test + fun `present - copy line on old device shows a toast`() = runTest { + val clipboardHelper = FakeClipboardHelper(isOldDevice = true) + val toastHelper = FakeToastHelper() + val presenter = createPresenter(clipboardHelper = clipboardHelper, toastHelper = toastHelper) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(ViewFileEvent.CopyToClipboard("aLine")) + assertThat(clipboardHelper.clipboardContents).isEqualTo("aLine") + assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_line_copied_to_clipboard) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - copy line on new device does not show a toast`() = runTest { + val clipboardHelper = FakeClipboardHelper(isOldDevice = false) + val toastHelper = FakeToastHelper() + val presenter = createPresenter(clipboardHelper = clipboardHelper, toastHelper = toastHelper) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(ViewFileEvent.CopyToClipboard("aLine")) + assertThat(clipboardHelper.clipboardContents).isEqualTo("aLine") + assertThat(toastHelper.shownToasts).isEmpty() + cancelAndIgnoreRemainingEvents() + } + } + private fun createPresenter( path: String = "aPath", name: String = "aName", fileContentReader: FileContentReader = FakeFileContentReader(), fileShare: FileShare = FakeFileShare(), fileSave: FileSave = FakeFileSave(), + clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + toastHelper: ToastHelper = FakeToastHelper(), ) = ViewFilePresenter( path = path, name = name, fileContentReader = fileContentReader, fileShare = fileShare, fileSave = fileSave, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, ) } diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/FakeFolderExplorer.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/folder/FakeFolderExplorer.kt similarity index 80% rename from features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/FakeFolderExplorer.kt rename to features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/folder/FakeFolderExplorer.kt index 7761bfb976..fd28fc8040 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/FakeFolderExplorer.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/folder/FakeFolderExplorer.kt @@ -6,9 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.viewfolder.test.folder +package io.element.android.features.viewfolder.impl.folder -import io.element.android.features.viewfolder.impl.folder.FolderExplorer import io.element.android.features.viewfolder.impl.model.Item class FakeFolderExplorer : FolderExplorer { diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/ViewFolderPresenterTest.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderPresenterTest.kt similarity index 94% rename from features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/ViewFolderPresenterTest.kt rename to features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderPresenterTest.kt index 31fd91eabe..1e8f90976f 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/ViewFolderPresenterTest.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderPresenterTest.kt @@ -6,14 +6,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.viewfolder.test.folder +package io.element.android.features.viewfolder.impl.folder 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.viewfolder.impl.folder.FolderExplorer -import io.element.android.features.viewfolder.impl.folder.ViewFolderPresenter import io.element.android.features.viewfolder.impl.model.Item import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta diff --git a/gradle.properties b/gradle.properties index 26ebbabf4d..27f8a43117 100644 --- a/gradle.properties +++ b/gradle.properties @@ -65,3 +65,9 @@ android.r8.gradual.support=true # Enabled parallel sync for Gradle 9.4+ org.gradle.tooling.parallel=true + +# When re-recording, keep the existing screenshot if it differs by less than `maxPercentDifference` +# (see PaparazziPreviewRule in tests/uitests/.../base/ScreenshotTest.kt). Without this, recording has +# no tolerance at all while verification has one, so renderer noise far below the threshold that +# `verifyPaparazziDebug` accepts still rewrites the Git LFS blob of every affected screenshot. +app.cash.paparazzi.overwriteOnMaxPercentDifference=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8545ca65c5..9b71e6bc96 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,21 +5,20 @@ # Project android_gradle_plugin = "9.3.2" # When updating this, please also update the version in the file ./idea/kotlinc.xml -kotlin = "2.4.10" +kotlin = "2.4.20" kotlinpoet = "2.4.0" -ksp = "2.3.11" +ksp = "2.3.12" firebaseAppDistribution = "5.3.0" # AndroidX core = "1.18.0" datastore = "1.2.1" -constraintlayout = "2.2.2" constraintlayout_compose = "1.1.2" lifecycle = "2.11.0" activity = "1.13.0" media3 = "1.11.1" camera = "1.6.2" -work = "2.11.2" +work = "2.12.0" # Compose compose_bom = "2026.09.00" @@ -42,16 +41,16 @@ coil = "3.6.3" # There is some custom logic in `RootFlowNode` that may break because it reuses some Appyx internal APIs. # When upgrading this version, check state restoration still works fine. appyx = "1.7.1" -sqldelight = "2.3.2" +sqldelight = "2.4.0" wysiwyg = "2.42.1" telephoto = "0.19.0" -maplibre-compose = "0.15.0" +maplibre-compose = "0.18.0" # Dependency analysis dependencyAnalysis = "3.19.2" # DI -metro = "1.4.4" +metro = "1.4.5" # Auto service autoservice = "1.1.1" @@ -64,6 +63,8 @@ detekt_compose_rules = "0.4.28" ktlint = "1.8.0" androidx-test-ext-junit = "1.3.0" kover = "0.9.9" +# The native MatrixRTC call component, https://github.com/element-hq/element-call-android +element_call = "0.1.0-rc.6" [libraries] # Project @@ -81,7 +82,7 @@ kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlin kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } # https://firebase.google.com/docs/android/setup#available-libraries -google_firebase_bom = "com.google.firebase:firebase-bom:34.18.0" +google_firebase_bom = "com.google.firebase:firebase-bom:34.19.0" firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" } autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" } ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } @@ -91,16 +92,15 @@ jna = "net.java.dev.jna:jna:5.19.1" # AndroidX androidx_core = { module = "androidx.core:core", version.ref = "core" } androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" } -androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.10.0" +androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.11.0" androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx_exifinterface = "androidx.exifinterface:exifinterface:1.4.2" -androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } androidx_constraintlayout_compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayout_compose" } androidx_camera_lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } androidx_camera_view = { module = "androidx.camera:camera-view", version.ref = "camera" } androidx_camera_camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } -androidx_javascriptengine = "androidx.javascriptengine:javascriptengine:1.1.0" +androidx_javascriptengine = "androidx.javascriptengine:javascriptengine:1.1.1" androidx_workmanager_runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } androidx_recyclerview = "androidx.recyclerview:recyclerview:1.4.0" @@ -120,10 +120,10 @@ androidx_activity_activity = { module = "androidx.activity:activity", version.re androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } androidx_startup = "androidx.startup:startup-runtime:1.2.0" androidx_preference = "androidx.preference:preference:1.2.1" -androidx_webkit = "androidx.webkit:webkit:1.17.0" +androidx_webkit = "androidx.webkit:webkit:1.17.1" androidx_compose_bom = { module = "androidx.compose:compose-bom", version.ref = "compose_bom" } -androidx_compose_material3 = { module = "androidx.compose.material3:material3", version = '1.5.0-alpha23' } +androidx_compose_material3 = { module = "androidx.compose.material3:material3", version = '1.5.0-alpha29' } androidx_compose_material3_windowsizeclass = { module = "androidx.compose.material3:material3-window-size-class" } androidx_compose_material3_adaptive = "androidx.compose.material3:material3-adaptive-android:1.0.0-alpha06" androidx_compose_ui = { module = "androidx.compose.ui:ui" } @@ -169,8 +169,8 @@ test_mockk = "io.mockk:mockk:1.14.11" test_konsist = "com.github.jmartinesp:konsist:0.18.0" test_turbine = "app.cash.turbine:turbine:1.2.1" test_truth = "com.google.truth:truth:1.4.5" -test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.22" -test_robolectric = "org.robolectric:robolectric:4.16.1" +test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.24" +test_robolectric = "org.robolectric:robolectric:4.17" test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } test_composable_preview_scanner = "io.github.sergio-sastre.ComposablePreviewScanner:android:0.9.3" test_detekt_api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" } @@ -182,7 +182,12 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # All new features should not be implemented in the pull request that upgrades the version, developers should # only fix API breaks and may add some TODOs. -matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.09.26" +# `strictly` because element-call-matrix is a published AAR compiled against one SDK version and declares +# it as a plain `require`: without this, a library built against a newer SDK would silently drag this app +# onto it. With it, this pin always wins, and a library that needs something newer fails at configuration +# time naming both versions instead of at runtime with NoSuchMethodError. +matrix_sdk = { module = "org.matrix.rustcomponents:sdk-android", version = { strictly = "26.09.28" } } +rustls_platform_verifier = "org.rustls:rustls-platform-verifier:0.2.0" # Others coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } @@ -222,7 +227,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0" # Analytics posthog = "com.posthog:posthog-android:3.60.7" -sentry = "io.sentry:sentry-android:8.55.0" +sentry = "io.sentry:sentry-android:8.57.0" # main branch can be tested replacing the version with main-SNAPSHOT matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.38.0" @@ -235,6 +240,13 @@ metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" # Element Call element_call_embedded = "io.element.android:element-call-embedded:0.26.0" +# The native MatrixRTC call component. Versions come from the BOM, so every artifact moves together. +element_call_bom = { module = "io.element.android:element-call-bom", version.ref = "element_call" } +element_call_api = { module = "io.element.android:element-call-api" } +element_call_impl = { module = "io.element.android:element-call" } +element_call_ui = { module = "io.element.android:element-call-ui" } +element_call_matrix = { module = "io.element.android:element-call-matrix" } +element_call_test = { module = "io.element.android:element-call-test" } # Auto services google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" } @@ -259,7 +271,7 @@ ktlint = "org.jlleitschuh.gradle.ktlint:14.2.0" dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12" dependencycheck = "org.owasp.dependencycheck:13.0.0" dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" } -paparazzi = "app.cash.paparazzi:2.0.0-alpha05" +paparazzi = "app.cash.paparazzi:2.0.0-alpha05.1" sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" } sonarqube = "org.sonarqube:7.5.0.8588" diff --git a/libraries/accountselect/impl/build.gradle.kts b/libraries/accountselect/impl/build.gradle.kts index 2497299f5b..e28b13bc44 100644 --- a/libraries/accountselect/impl/build.gradle.kts +++ b/libraries/accountselect/impl/build.gradle.kts @@ -20,8 +20,6 @@ android { setupDependencyInjection() dependencies { - implementation(projects.libraries.core) - implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt index 74f15f8cfa..0df2bde377 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -33,7 +32,6 @@ import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.ui.strings.CommonStrings @Suppress("MultipleEmitters") // False positive -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AccountSelectView( state: AccountSelectState, diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt index ce6f61f280..c452902404 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt @@ -9,8 +9,11 @@ package io.element.android.libraries.androidutils.clipboard import android.content.ClipData +import android.content.ClipDescription import android.content.ClipboardManager import android.content.Context +import android.os.Build +import android.os.PersistableBundle import androidx.core.content.getSystemService import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -24,7 +27,30 @@ class AndroidClipboardHelper( ) : ClipboardHelper { private val clipboardManager = requireNotNull(context.getSystemService()) - override fun copyPlainText(text: String) { - clipboardManager.setPrimaryClip(ClipData.newPlainText("", text)) + override fun copyPlainText( + text: String, + isSensitive: Boolean, + onSuccessOnOldDevice: () -> Unit, + ) { + val clipData = ClipData.newPlainText("", text) + if (isSensitive) { + clipData.description.extras = PersistableBundle().apply { + putBoolean(EXTRA_IS_SENSITIVE, true) + } + } + clipboardManager.setPrimaryClip(clipData) + // On Android 13+, the system already displays a confirmation when content is copied to the clipboard. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + onSuccessOnOldDevice() + } + } + + private companion object { + // The constant only exists from API 33, but the documentation recommends using its value on older versions too. + val EXTRA_IS_SENSITIVE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ClipDescription.EXTRA_IS_SENSITIVE + } else { + "android.content.extra.IS_SENSITIVE" + } } } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt index 1d0ab8e3c4..0add39fe76 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt @@ -8,9 +8,18 @@ package io.element.android.libraries.androidutils.clipboard -/** - * Wrapper class for handling clipboard operations so it can be used in JVM environments. - */ interface ClipboardHelper { - fun copyPlainText(text: String) + /** + * Copy a text to the clipboard. + * + * @param text the text to copy + * @param isSensitive if true, the clip is flagged so the system clipboard preview (Android 13+) and keyboards hide its content. + * @param onSuccessOnOldDevice invoked after the copy only on Android versions older than 13, since newer versions already + * display a confirmation. Can be used to render a Toast or a Snackbar. + */ + fun copyPlainText( + text: String, + isSensitive: Boolean = false, + onSuccessOnOldDevice: () -> Unit, + ) } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt index 9f91b3a4ce..a46053662e 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt @@ -8,10 +8,21 @@ package io.element.android.libraries.androidutils.clipboard -class FakeClipboardHelper : ClipboardHelper { +class FakeClipboardHelper( + private val isOldDevice: Boolean = true, +) : ClipboardHelper { var clipboardContents: Any? = null + var isSensitive: Boolean? = null - override fun copyPlainText(text: String) { + override fun copyPlainText( + text: String, + isSensitive: Boolean, + onSuccessOnOldDevice: () -> Unit, + ) { clipboardContents = text + this.isSensitive = isSensitive + if (isOldDevice) { + onSuccessOnOldDevice() + } } } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt deleted file mode 100644 index 53a1c33995..0000000000 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2022-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.androidutils.system - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import androidx.core.content.getSystemService - -class CopyToClipboardUseCase( - private val context: Context, -) { - fun execute(text: CharSequence) { - context.getSystemService() - ?.setPrimaryClip(ClipData.newPlainText("", text)) - } -} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt index 861aca032d..234697226f 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt @@ -50,25 +50,6 @@ fun Context.getVersionCodeFromManifest(): Long { ) } -// ============================================================================================================== -// Clipboard helper -// ============================================================================================================== - -/** - * Copy a text to the clipboard, and display a Toast when done. - * - * @receiver the context - * @param text the text to copy - * @param toastMessage content of the toast message as a String resource. Null for no toast - */ -fun Context.copyToClipboard( - text: CharSequence, - toastMessage: String? = null -) { - CopyToClipboardUseCase(this).execute(text) - toastMessage?.let { toast(it) } -} - /** * Shows notification settings for the current app. * In android O will directly opens the notification settings, in lower version it will show the App settings diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/AndroidToastHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/AndroidToastHelper.kt new file mode 100644 index 0000000000..d2ba027f19 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/AndroidToastHelper.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.toast + +import android.content.Context +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.system.toast +import io.element.android.libraries.di.annotations.ApplicationContext + +@ContributesBinding(AppScope::class) +class AndroidToastHelper( + @ApplicationContext private val context: Context, +) : ToastHelper { + override fun show(resId: Int) { + context.toast(resId) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/FakeToastHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/FakeToastHelper.kt new file mode 100644 index 0000000000..a4b26d1343 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/FakeToastHelper.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.toast + +class FakeToastHelper : ToastHelper { + val shownToasts = mutableListOf() + + override fun show(resId: Int) { + shownToasts.add(resId) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/ToastHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/ToastHelper.kt new file mode 100644 index 0000000000..e48022a455 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/ToastHelper.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.toast + +import androidx.annotation.StringRes + +interface ToastHelper { + fun show(@StringRes resId: Int) +} diff --git a/libraries/audio/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeAudioFocus.kt b/libraries/audio/test/src/main/kotlin/io/element/android/libraries/audio/test/FakeAudioFocus.kt similarity index 94% rename from libraries/audio/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeAudioFocus.kt rename to libraries/audio/test/src/main/kotlin/io/element/android/libraries/audio/test/FakeAudioFocus.kt index ecb263dbb3..a75a71fc57 100644 --- a/libraries/audio/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeAudioFocus.kt +++ b/libraries/audio/test/src/main/kotlin/io/element/android/libraries/audio/test/FakeAudioFocus.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.mediaplayer.test +package io.element.android.libraries.audio.test import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.audio.api.AudioFocusRequester diff --git a/libraries/cachestore/impl/build.gradle.kts b/libraries/cachestore/impl/build.gradle.kts index f0c7ba237c..f357cf8446 100644 --- a/libraries/cachestore/impl/build.gradle.kts +++ b/libraries/cachestore/impl/build.gradle.kts @@ -19,8 +19,6 @@ android { setupDependencyInjection() dependencies { - implementation(projects.libraries.androidutils) - implementation(projects.libraries.core) implementation(projects.libraries.encryptedDb) api(projects.libraries.cachestore.api) implementation(libs.sqldelight.driver.android) diff --git a/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseCacheStoreTest.kt b/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/cachestore/impl/DatabaseCacheStoreTest.kt similarity index 93% rename from libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseCacheStoreTest.kt rename to libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/cachestore/impl/DatabaseCacheStoreTest.kt index 36d7e05532..d3581f9686 100644 --- a/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseCacheStoreTest.kt +++ b/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/cachestore/impl/DatabaseCacheStoreTest.kt @@ -5,13 +5,11 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.sessionstorage.impl +package io.element.android.libraries.cachestore.impl import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver import com.google.common.truth.Truth.assertThat import io.element.android.libraries.cachestore.api.CacheData -import io.element.android.libraries.cachestore.impl.CacheDatabase -import io.element.android.libraries.cachestore.impl.DatabaseCacheStore import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Before diff --git a/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt b/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/cachestore/impl/Fixtures.kt similarity index 89% rename from libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt rename to libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/cachestore/impl/Fixtures.kt index 3dca9efaf3..149f21ecfb 100644 --- a/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt +++ b/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/cachestore/impl/Fixtures.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.sessionstorage.impl +package io.element.android.libraries.cachestore.impl import io.element.android.libraries.cachestore.CacheData import java.util.Date diff --git a/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/CacheData.kt b/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/cachestore/test/CacheData.kt similarity index 87% rename from libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/CacheData.kt rename to libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/cachestore/test/CacheData.kt index 30633e8ff9..b7800bdf86 100644 --- a/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/CacheData.kt +++ b/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/cachestore/test/CacheData.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.sessionstorage.test +package io.element.android.libraries.cachestore.test import io.element.android.libraries.cachestore.api.CacheData diff --git a/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemoryCacheStore.kt b/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/cachestore/test/InMemoryCacheStore.kt similarity index 93% rename from libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemoryCacheStore.kt rename to libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/cachestore/test/InMemoryCacheStore.kt index f029c8bcde..132063541e 100644 --- a/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemoryCacheStore.kt +++ b/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/cachestore/test/InMemoryCacheStore.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.sessionstorage.test +package io.element.android.libraries.cachestore.test import io.element.android.libraries.cachestore.api.CacheData import io.element.android.libraries.cachestore.api.CacheStore diff --git a/libraries/cryptography/impl/build.gradle.kts b/libraries/cryptography/impl/build.gradle.kts index 3a1f55126e..b347ce1cbc 100644 --- a/libraries/cryptography/impl/build.gradle.kts +++ b/libraries/cryptography/impl/build.gradle.kts @@ -20,7 +20,6 @@ android { setupDependencyInjection() dependencies { - implementation(projects.libraries.di) implementation(libs.coroutines.core) api(projects.libraries.cryptography.api) diff --git a/libraries/dateformatter/impl/build.gradle.kts b/libraries/dateformatter/impl/build.gradle.kts index c9ddf459d6..84f8a0aea6 100644 --- a/libraries/dateformatter/impl/build.gradle.kts +++ b/libraries/dateformatter/impl/build.gradle.kts @@ -37,13 +37,10 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.designsystem) implementation(projects.libraries.di) - implementation(projects.libraries.uiStrings) implementation(projects.services.toolbox.api) api(projects.libraries.dateformatter.api) api(libs.datetime) testCommonDependencies(libs, true) - testImplementation(projects.libraries.dateformatter.test) - testImplementation(projects.services.toolbox.test) } diff --git a/libraries/dateformatter/test/build.gradle.kts b/libraries/dateformatter/test/build.gradle.kts index 0367030ae1..fa0861c1a2 100644 --- a/libraries/dateformatter/test/build.gradle.kts +++ b/libraries/dateformatter/test/build.gradle.kts @@ -16,5 +16,4 @@ android { dependencies { api(projects.libraries.dateformatter.api) - api(libs.datetime) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt index 56255d7623..a0339b8572 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt @@ -32,6 +32,8 @@ import io.element.android.libraries.designsystem.theme.components.Text /** * IconTitleSubtitleMolecule is a molecule which displays an icon, a title and a subtitle. * + * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1964-3385 + * * @param title the title to display * @param subTitle the subtitle to display * @param iconStyle the style of the [BigIcon] to display diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt index a29e1b743c..5c28566700 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -40,7 +39,6 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar * - a content. * - a footer, as ButtonColumnMolecule */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun FlowStepPage( iconStyle: BigIcon.Style, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BigIcon.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BigIcon.kt index cf6329ee66..668c9b571c 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BigIcon.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BigIcon.kt @@ -53,13 +53,11 @@ object BigIcon { * @param vectorIcon the [ImageVector] to display * @param contentDescription the content description of the icon, if any. It defaults to `null` * @param useCriticalTint whether the icon and background should be rendered using critical tint - * @param usePrimaryTint whether the icon should be rendered using primary tint */ data class Default( val vectorIcon: ImageVector, val contentDescription: String? = null, val useCriticalTint: Boolean = false, - val usePrimaryTint: Boolean = false, ) : Style /** @@ -129,10 +127,8 @@ object BigIcon { val iconTint = when (style) { is Style.Default -> if (style.useCriticalTint) { ElementTheme.colors.iconCriticalPrimary - } else if (style.usePrimaryTint) { - ElementTheme.colors.iconPrimary } else { - ElementTheme.colors.iconSecondary + ElementTheme.colors.iconPrimary } Style.Alert, Style.AlertSolid -> ElementTheme.colors.iconCriticalPrimary diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/TopAppBarScrollBehaviorLayout.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/TopAppBarScrollBehaviorLayout.kt index 566c677766..b0a118049b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/TopAppBarScrollBehaviorLayout.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/TopAppBarScrollBehaviorLayout.kt @@ -8,7 +8,6 @@ package io.element.android.libraries.designsystem.components -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Surface import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.contentColorFor @@ -24,7 +23,6 @@ import io.element.android.compound.theme.ElementTheme * It places the content according to the current height offset of the scroll behavior. * */ -@ExperimentalMaterial3Api @Composable fun TopAppBarScrollBehaviorLayout( scrollBehavior: TopAppBarScrollBehavior, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index e4c8ebc641..ed1b0b2e6f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -31,6 +31,7 @@ enum class AvatarSize(val dp: Dp) { UserHeader(96.dp), UserListItem(36.dp), + AccountInfoUser(36.dp), SelectedUser(52.dp), SelectedRoom(56.dp), @@ -72,8 +73,6 @@ enum class AvatarSize(val dp: Dp) { DmCreationConfirmation(64.dp), - UserVerification(52.dp), - OrganizationHeader(64.dp), SpaceHeader(64.dp), RoomPreviewHeader(64.dp), diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialog.kt index 3c9204ac3e..c3175f1a84 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialog.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.designsystem.components.dialogs import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -22,7 +21,6 @@ import io.element.android.libraries.designsystem.theme.components.DialogPreview import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AlertDialog( content: String, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt index 9d217da059..1a0391da83 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.designsystem.components.dialogs import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -22,7 +21,6 @@ import io.element.android.libraries.designsystem.theme.components.DialogPreview import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ConfirmationDialog( content: String, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt index 3c79dd7714..3e6d271c87 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.designsystem.components.dialogs import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -23,7 +22,6 @@ import io.element.android.libraries.designsystem.theme.components.DialogPreview import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ErrorDialog( content: String, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialogWithDoNotShowAgain.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialogWithDoNotShowAgain.kt index e85725cfc0..8f97df5317 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialogWithDoNotShowAgain.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialogWithDoNotShowAgain.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -31,7 +30,6 @@ import io.element.android.libraries.designsystem.theme.components.SimpleAlertDia import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ErrorDialogWithDoNotShowAgain( content: String, 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 91c058a0e4..cf179a31a3 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 @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -29,7 +28,6 @@ import io.element.android.libraries.designsystem.theme.components.ListSupporting import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ListDialog( onSubmit: () -> Unit, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt index 6fe899287b..5d5ea3539b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.toMutableStateList @@ -32,7 +31,6 @@ import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -@OptIn(ExperimentalMaterial3Api::class) @Composable fun MultipleSelectionDialog( options: ImmutableList, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt index 0b313e1180..25fcf60cad 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.designsystem.components.dialogs import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -22,7 +21,6 @@ import io.element.android.libraries.designsystem.theme.components.DialogPreview import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun RetryDialog( content: String, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt index 8a64fce510..a5e72c44f3 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -29,7 +28,6 @@ import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SingleSelectionDialog( options: ImmutableList, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSync.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSync.kt index aacc8ccc1c..0afd5e047d 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSync.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSync.kt @@ -152,8 +152,10 @@ internal fun remainingPlaybackAnimationMs( durationMs: Long, playbackSpeed: Float, ): Int { - val remainingProgress = (1f - progress).coerceAtLeast(0f) - return (remainingProgress * durationMs / playbackSpeed.coerceAtLeast(0.01f)) + if (!progress.isFinite() || !playbackSpeed.isFinite() || durationMs <= 0L) return 0 + val remainingProgress = (1f - progress).coerceIn(0f, 1f) + val speed = playbackSpeed.coerceAtLeast(0.01f) + return (remainingProgress * durationMs / speed) .roundToInt() .coerceAtLeast(0) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt index 093791a71f..d7bba88d2f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt @@ -6,8 +6,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.libraries.designsystem.components.preferences import androidx.annotation.DrawableRes @@ -16,7 +14,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt index 61dff418b1..b48cdd8661 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.plus import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.heading @@ -68,7 +67,6 @@ fun PreferencePage( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun PreferenceTopAppBar( title: String, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt index 299744cebf..a33bedc3c8 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt @@ -109,6 +109,7 @@ fun TextButton( showProgress: Boolean = false, destructive: Boolean = false, leadingIcon: IconSource? = null, + colors: ButtonColors = ButtonStyle.Text.getColors(destructive), ) = ButtonInternal( text = text, onClick = onClick, @@ -118,7 +119,8 @@ fun TextButton( size = size, showProgress = showProgress, destructive = destructive, - leadingIcon = leadingIcon + leadingIcon = leadingIcon, + colors = colors, ) @Composable diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt index 74cdc33f17..8d399e88ff 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.BadgedBox import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingToolbarColors import androidx.compose.material3.FloatingToolbarDefaults import androidx.compose.material3.FloatingToolbarHorizontalFabPosition @@ -46,7 +45,6 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight /** * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=4457-1136 */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun HorizontalFloatingToolbar( modifier: Modifier = Modifier, @@ -226,7 +224,6 @@ internal fun HorizontalFloatingToolbarNoFabPreview() = ElementPreview { ) } -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable private fun ContentToPreview( count: Int? = 6, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/MediumTopAppBar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/MediumTopAppBar.kt index a49a3595f7..8ee219356d 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/MediumTopAppBar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/MediumTopAppBar.kt @@ -10,7 +10,6 @@ package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LocalContentColor import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults @@ -25,7 +24,6 @@ import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup -@OptIn(ExperimentalMaterial3Api::class) @Composable fun MediumTopAppBar( title: @Composable () -> Unit, @@ -51,7 +49,6 @@ fun MediumTopAppBar( ) } -@OptIn(ExperimentalMaterial3Api::class) @Preview(group = PreviewGroup.AppBars) @Composable internal fun MediumTopAppBarPreview() = ElementThemedPreview { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarIcon.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarIcon.kt index 7c6332bd51..58e55f5599 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarIcon.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarIcon.kt @@ -8,12 +8,10 @@ package io.element.android.libraries.designsystem.theme.components -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.offset +import androidx.compose.material3.BadgedBox import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.designsystem.atomic.atoms.CounterAtom @@ -24,16 +22,19 @@ fun NavigationBarIcon( count: Int = 0, isCritical: Boolean = false, ) { - Box(modifier) { + BadgedBox( + modifier = modifier, + badge = { + CounterAtom( + textStyle = ElementTheme.typography.fontBodyXsMedium, + count = count, + isCritical = isCritical, + ) + } + ) { Icon( imageVector = imageVector, contentDescription = null, ) - CounterAtom( - modifier = Modifier.offset(11.dp, (-11).dp), - textStyle = ElementTheme.typography.fontBodyXsMedium, - count = count, - isCritical = isCritical, - ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt index 28a62a2c36..0137c86870 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt @@ -79,6 +79,9 @@ fun SearchBar( } } + // TODO Migrate to the SearchBarState API. The replacement (ExpandedFullScreenSearchBar) displays the results in a dialog, + // which would change the UX, in particular for the emoji picker bottom sheet. + @Suppress("DEPRECATION") SearchBar( inputField = { SearchBarDefaults.InputField( @@ -156,7 +159,6 @@ fun SearchBar( } object ElementSearchBarDefaults { - @OptIn(ExperimentalMaterial3Api::class) @Composable fun inactiveColors() = SearchBarDefaults.colors( containerColor = Color.Transparent, @@ -176,7 +178,6 @@ object ElementSearchBarDefaults { errorContainerColor = ElementTheme.colors.bgSubtleSecondary, ) - @OptIn(ExperimentalMaterial3Api::class) @Composable fun activeColors() = SearchBarDefaults.colors( containerColor = Color.Transparent, @@ -286,7 +287,6 @@ internal fun SearchBarActiveWithContentPreview() = ElementThemedPreview { } } -@OptIn(ExperimentalMaterial3Api::class) @Composable @ExcludeFromCoverage private fun ContentToPreview( diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SegmentedButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SegmentedButton.kt index 7fd195bd49..fb59be0094 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SegmentedButton.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SegmentedButton.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRowScope @@ -19,7 +18,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import io.element.android.compound.theme.ElementTheme -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SingleChoiceSegmentedButtonRowScope.SegmentedButton( index: Int, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt index 7b6e6f3062..f8ff23c8bc 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt @@ -6,8 +6,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.interaction.DragInteraction @@ -15,9 +13,9 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.height -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SliderColors import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.SliderState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -60,16 +58,16 @@ fun Slider( } } } + val sliderState = remember(steps, valueRange) { SliderState(value = value, steps = steps, trackRange = valueRange) } + sliderState.value = value androidx.compose.material3.Slider( - value = value, + state = sliderState, onValueChange = onValueChange, modifier = modifier, enabled = enabled, - valueRange = valueRange, - steps = steps, onValueChangeFinished = onValueChangeFinish, colors = colors, - thumb = { + thumb = { _ -> if (useCustomLayout) { SliderDefaults.Thumb( modifier = Modifier.drawWithContent { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TopAppBar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TopAppBar.kt index d6c876d243..31fde41a50 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TopAppBar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TopAppBar.kt @@ -10,7 +10,6 @@ package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LocalContentColor import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults @@ -38,7 +37,6 @@ import io.element.android.libraries.designsystem.theme.aliasScreenTitle * @param colors The colors used for this top app bar. * @param scrollBehavior Optional scroll behavior for this top app bar. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun TopAppBar( titleStr: String, @@ -78,7 +76,6 @@ fun TopAppBar( * @param colors The colors used for this top app bar. * @param scrollBehavior Optional scroll behavior for this top app bar. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun TopAppBar( title: @Composable () -> Unit, @@ -104,7 +101,6 @@ fun TopAppBar( ) } -@OptIn(ExperimentalMaterial3Api::class) @Preview(group = PreviewGroup.AppBars) @Composable internal fun TopAppBarPreview() = ElementThemedPreview { @@ -123,7 +119,6 @@ internal fun TopAppBarPreview() = ElementThemedPreview { ) } -@OptIn(ExperimentalMaterial3Api::class) @Preview(group = PreviewGroup.AppBars) @Composable internal fun TopAppBarStrPreview() = ElementThemedPreview { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/DatePickerPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/DatePickerPreview.kt index b5dfe42587..0ce9f0169e 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/DatePickerPreview.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/DatePickerPreview.kt @@ -10,7 +10,6 @@ package io.element.android.libraries.designsystem.theme.components.previews import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.DatePicker -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview @@ -33,7 +32,6 @@ internal fun DatePickerDarkPreview() { ElementPreviewDark { ContentToPreview() } } -@OptIn(ExperimentalMaterial3Api::class) @ExcludeFromCoverage @Composable private fun ContentToPreview() { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/TimePickerPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/TimePickerPreview.kt index f4562f1058..bdc15222e0 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/TimePickerPreview.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/TimePickerPreview.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.designsystem.theme.components.previews import androidx.compose.material3.AlertDialogDefaults -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TimePicker import androidx.compose.material3.TimePickerLayoutType import androidx.compose.material3.rememberTimePickerState @@ -22,7 +21,6 @@ import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.theme.components.AlertDialogContent -@OptIn(ExperimentalMaterial3Api::class) @Preview(widthDp = 600, group = PreviewGroup.DateTimePickers) @Composable internal fun TimePickerHorizontalPreview() { @@ -44,7 +42,6 @@ internal fun TimePickerHorizontalPreview() { } } -@OptIn(ExperimentalMaterial3Api::class) @Preview(group = PreviewGroup.DateTimePickers) @Composable internal fun TimePickerVerticalLightPreview() { @@ -66,7 +63,6 @@ internal fun TimePickerVerticalLightPreview() { } } -@OptIn(ExperimentalMaterial3Api::class) @Preview(group = PreviewGroup.DateTimePickers) @Composable internal fun TimePickerVerticalDarkPreview() { diff --git a/libraries/emoji/api/build.gradle.kts b/libraries/emoji/api/build.gradle.kts index 48143d9512..d9e56fb68f 100644 --- a/libraries/emoji/api/build.gradle.kts +++ b/libraries/emoji/api/build.gradle.kts @@ -15,7 +15,6 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(libs.kotlinx.collections.immutable) api(libs.matrix.emojibase.bindings) diff --git a/libraries/emoji/impl/build.gradle.kts b/libraries/emoji/impl/build.gradle.kts index 59a1ad6575..844f8ce403 100644 --- a/libraries/emoji/impl/build.gradle.kts +++ b/libraries/emoji/impl/build.gradle.kts @@ -30,7 +30,6 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.di) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.uiStrings) implementation(libs.kotlinx.collections.immutable) implementation(libs.matrix.emojibase.bindings) diff --git a/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt index c41cbb3b57..e748628d66 100644 --- a/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt +++ b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Tab import androidx.compose.runtime.Composable @@ -47,7 +46,6 @@ import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun EmojiPickerView( state: DefaultEmojiPickerState, diff --git a/libraries/featureflag/api/build.gradle.kts b/libraries/featureflag/api/build.gradle.kts index b34e082cd3..f9177a23bc 100644 --- a/libraries/featureflag/api/build.gradle.kts +++ b/libraries/featureflag/api/build.gradle.kts @@ -15,7 +15,6 @@ android { } dependencies { - implementation(projects.appconfig) implementation(projects.libraries.core) implementation(libs.coroutines.core) } 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 98bb55fecf..4b40236c5f 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 @@ -121,14 +121,14 @@ enum class FeatureFlags( title = "Automatic back pagination of rooms", description = "Allow the app to automatically back paginate in rooms to pre-fetch older messages in background." + "\nRequires an app restart to take effect.", - defaultValue = { false }, + defaultValue = { true }, isFinished = false, ), UnreadIndicatorCount( key = "feature.unread_indicator_count", title = "Unread indicator count", description = "Show the number of unread messages on the unread indicator in the room list.", - defaultValue = { false }, + defaultValue = { true }, isFinished = false, ), SendGalleryMessages( @@ -162,4 +162,20 @@ enum class FeatureFlags( isFinished = false, isInLabs = false, ), + NativeCall( + key = "feature.native_call", + title = "Native calls (experimental)", + description = "Place calls with the native MatrixRTC stack instead of the Element Call WebView.", + defaultValue = { false }, + isFinished = false, + ), + ShowAllActivityInRoomList( + key = "feature.show_all_activity_in_room_list", + title = "Show all activity in room list", + description = "Show all activity indicators in the room list, using unread messages instead of notifications.", + defaultValue = { true }, + // To hide it from the developer options, since it's displayed in the notification settings instead + isFinished = true, + isInLabs = false, + ) } diff --git a/libraries/featureflag/impl/build.gradle.kts b/libraries/featureflag/impl/build.gradle.kts index 63d968467a..6d2db6f365 100644 --- a/libraries/featureflag/impl/build.gradle.kts +++ b/libraries/featureflag/impl/build.gradle.kts @@ -23,9 +23,6 @@ setupDependencyInjection() dependencies { api(projects.libraries.featureflag.api) implementation(libs.androidx.datastore.preferences) - implementation(projects.appconfig) - implementation(projects.libraries.di) - implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.preferences.api) implementation(libs.coroutines.core) diff --git a/libraries/fullscreenintent/api/build.gradle.kts b/libraries/fullscreenintent/api/build.gradle.kts index 8d6be454ea..e8ad77c3a9 100644 --- a/libraries/fullscreenintent/api/build.gradle.kts +++ b/libraries/fullscreenintent/api/build.gradle.kts @@ -16,6 +16,4 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.permissions.api) - implementation(projects.libraries.preferences.api) } diff --git a/libraries/fullscreenintent/impl/build.gradle.kts b/libraries/fullscreenintent/impl/build.gradle.kts index 57c4f5b836..326411a635 100644 --- a/libraries/fullscreenintent/impl/build.gradle.kts +++ b/libraries/fullscreenintent/impl/build.gradle.kts @@ -23,16 +23,12 @@ dependencies { api(projects.libraries.fullscreenintent.api) implementation(projects.libraries.core) implementation(projects.libraries.architecture) - implementation(projects.libraries.permissions.api) - implementation(projects.libraries.permissions.noop) implementation(projects.libraries.preferences.api) implementation(projects.services.toolbox.api) implementation(libs.androidx.datastore.preferences) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) - testImplementation(projects.libraries.testtags) testImplementation(projects.services.toolbox.test) } diff --git a/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt b/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt index 7d726ccdcc..201bc65e79 100644 --- a/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt +++ b/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt @@ -15,11 +15,15 @@ import android.provider.Settings import androidx.compose.runtime.Composable 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 androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.lifecycle.compose.LifecycleResumeEffect import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn @@ -61,9 +65,15 @@ class FullScreenIntentPermissionsPresenter( @Composable override fun present(): FullScreenIntentPermissionsState { val coroutineScope = rememberCoroutineScope() - val isGranted = notificationManagerCompat.canUseFullScreenIntent() + var isGranted by remember { mutableStateOf(notificationManagerCompat.canUseFullScreenIntent()) } val isBannerDismissed by isFullScreenIntentBannerDismissed.collectAsState(initial = true) + // The permission can be granted from the system settings, so check it again when the user comes back to the app. + LifecycleResumeEffect(Unit) { + isGranted = notificationManagerCompat.canUseFullScreenIntent() + onPauseOrDispose {} + } + fun handleEvent(event: FullScreenIntentPermissionsEvent) { when (event) { FullScreenIntentPermissionsEvent.Dismiss -> coroutineScope.launch { diff --git a/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/test/FullScreenIntentPermissionsPresenterTest.kt b/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenterTest.kt similarity index 76% rename from libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/test/FullScreenIntentPermissionsPresenterTest.kt rename to libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenterTest.kt index 08bc2ed885..593fd71492 100644 --- a/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/test/FullScreenIntentPermissionsPresenterTest.kt +++ b/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenterTest.kt @@ -6,25 +6,24 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.fullscreenintent.test +package io.element.android.libraries.fullscreenintent.impl import android.content.Intent import android.os.Build import androidx.core.app.NotificationManagerCompat -import app.cash.molecule.RecompositionMode -import app.cash.molecule.moleculeFlow -import app.cash.turbine.test +import androidx.lifecycle.Lifecycle import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvent -import io.element.android.libraries.fullscreenintent.impl.FullScreenIntentPermissionsPresenter import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher import io.element.android.services.toolbox.test.intent.FakeExternalIntentLauncher import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import io.element.android.tests.testutils.FakeLifecycleOwner import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.testWithLifecycleOwner import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -45,9 +44,7 @@ class FullScreenIntentPermissionsPresenterTest { every { canUseFullScreenIntent() } returns false } ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.testWithLifecycleOwner { skipItems(1) val initialItem = awaitItem() assertThat(initialItem.shouldDisplayBanner).isTrue() @@ -61,21 +58,39 @@ class FullScreenIntentPermissionsPresenterTest { every { canUseFullScreenIntent() } returns true } ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.testWithLifecycleOwner { skipItems(1) val initialItem = awaitItem() assertThat(initialItem.shouldDisplayBanner).isFalse() } } + @Test + fun `shouldDisplay - becomes false when permission is granted while the app is in background`() = runTest { + var canUseFullScreenIntent = false + val presenter = createPresenter( + notificationManagerCompat = mockk { + every { canUseFullScreenIntent() } answers { canUseFullScreenIntent } + } + ) + val lifecycleOwner = FakeLifecycleOwner(Lifecycle.State.RESUMED) + presenter.testWithLifecycleOwner(lifecycleOwner) { + skipItems(1) + assertThat(awaitItem().shouldDisplayBanner).isTrue() + // User opens the system settings and grants the permission + lifecycleOwner.givenState(Lifecycle.State.STARTED) + canUseFullScreenIntent = true + lifecycleOwner.givenState(Lifecycle.State.RESUMED) + val finalItem = awaitItem() + assertThat(finalItem.permissionGranted).isTrue() + assertThat(finalItem.shouldDisplayBanner).isFalse() + } + } + @Test fun `dismissFullScreenIntentBanner - makes shouldDisplay false`() = runTest { val presenter = createPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.testWithLifecycleOwner { skipItems(1) val loadedItem = awaitItem() loadedItem.eventSink(FullScreenIntentPermissionsEvent.Dismiss) @@ -89,9 +104,7 @@ class FullScreenIntentPermissionsPresenterTest { val launchLambda = lambdaRecorder { _ -> } val externalIntentLauncher = FakeExternalIntentLauncher(launchLambda) val presenter = createPresenter(externalIntentLauncher = externalIntentLauncher) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.testWithLifecycleOwner { skipItems(1) val loadedItem = awaitItem() loadedItem.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) @@ -108,9 +121,7 @@ class FullScreenIntentPermissionsPresenterTest { buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.Q), externalIntentLauncher = externalIntentLauncher, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.testWithLifecycleOwner { skipItems(1) val loadedItem = awaitItem() loadedItem.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt index e113856cf7..3c464a2ea8 100644 --- a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt +++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.SideEffect @@ -218,7 +219,7 @@ private fun ParagraphView( TextBlock( text = node.text, inlineContent = node.inlineContent, - textStyle = ElementTheme.typography.fontBodyMdRegular, + textStyle = LocalTextStyle.current, context = context, modifier = modifier, onTextLayout = { measureLastLine?.invoke(it) }, diff --git a/libraries/htmlrenderer/impl/build.gradle.kts b/libraries/htmlrenderer/impl/build.gradle.kts index ba17d855f1..3aee77ded6 100644 --- a/libraries/htmlrenderer/impl/build.gradle.kts +++ b/libraries/htmlrenderer/impl/build.gradle.kts @@ -27,7 +27,6 @@ setupDependencyInjection() dependencies { api(projects.libraries.htmlrenderer.api) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.uiCommon) implementation(libs.jsoup) testCommonDependencies(libs) diff --git a/libraries/indicator/api/build.gradle.kts b/libraries/indicator/api/build.gradle.kts index c6f33ec83f..a53cfb7707 100644 --- a/libraries/indicator/api/build.gradle.kts +++ b/libraries/indicator/api/build.gradle.kts @@ -13,7 +13,3 @@ plugins { android { namespace = "io.element.android.libraries.indicator.api" } - -dependencies { - implementation(libs.coroutines.core) -} diff --git a/libraries/indicator/impl/build.gradle.kts b/libraries/indicator/impl/build.gradle.kts index 8f1e9ea56d..4b723b6e75 100644 --- a/libraries/indicator/impl/build.gradle.kts +++ b/libraries/indicator/impl/build.gradle.kts @@ -21,7 +21,6 @@ android { dependencies { implementation(projects.libraries.di) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) implementation(libs.coroutines.core) @@ -29,6 +28,5 @@ dependencies { api(projects.libraries.indicator.api) testCommonDependencies(libs) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) } diff --git a/libraries/indicator/test/build.gradle.kts b/libraries/indicator/test/build.gradle.kts index a5ce0c57c2..e97c6f5513 100644 --- a/libraries/indicator/test/build.gradle.kts +++ b/libraries/indicator/test/build.gradle.kts @@ -15,6 +15,5 @@ android { } dependencies { - implementation(projects.libraries.matrix.api) api(projects.libraries.indicator.api) } diff --git a/libraries/matrix/api/build.gradle.kts b/libraries/matrix/api/build.gradle.kts index d0790a6905..65919e69f2 100644 --- a/libraries/matrix/api/build.gradle.kts +++ b/libraries/matrix/api/build.gradle.kts @@ -53,7 +53,6 @@ dependencies { implementation(libs.serialization.json) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) - implementation(projects.libraries.sessionStorage.api) implementation(projects.services.analytics.api) testCommonDependencies(libs) diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index 2499b720e3..3901deb9c9 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -20,15 +20,46 @@ android { setupDependencyInjection() +// Temporary: `-PelementCallLocalVersion` points this at a locally published build of +// element-call-android, because the library has no remote repository yet. Goes with the mavenLocal +// block in settings.gradle.kts. Only the BOM carries a version; every other artifact follows it. +val elementCallVersion: String = providers.gradleProperty("elementCallLocalVersion") + .getOrElse(libs.versions.element.call.get()) + +val usesLocalRustSdk = file("${rootDir.path}/libraries/rustsdk/matrix-rust-sdk.aar").exists() + +if (usesLocalRustSdk) { + // element-call-matrix declares the Rust SDK as an ordinary Maven dependency, so with the local + // AAR in place a debug classpath would carry two copies of org.matrix.rustcomponents.sdk. + // Point the library's dependency at the same local AAR the app is using. + configurations.matching { it.name.startsWith("debug") }.configureEach { + resolutionStrategy.dependencySubstitution { + substitute(module("org.matrix.rustcomponents:sdk-android")) + .using(project(":libraries:rustsdk")) + .because("the local Rust SDK AAR replaces the published one on debug builds") + } + } +} + 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") + if (usesLocalRustSdk) { + println( + "\nNote: Using local binary of the Rust SDK." + + "\n element-call-matrix is substituted onto it as well, so the native call runs" + + "\n against this SDK rather than the one it was published against.\n" + ) debugImplementation(projects.libraries.rustsdk) } else { debugImplementation(libs.matrix.sdk) } - implementation(projects.libraries.rustlsTls) + implementation(variantOf(libs.rustls.platform.verifier) { artifactType("aar") }) + + // The native call's Matrix transport. Only this module may hand it a raw SDK client. + implementation(platform("io.element.android:element-call-bom:$elementCallVersion")) + implementation(libs.element.call.api) + implementation(projects.features.callnative.api) + implementation(libs.element.call.matrix) implementation(projects.appconfig) implementation(projects.features.enterprise.api) @@ -37,7 +68,6 @@ dependencies { implementation(projects.libraries.di) implementation(projects.libraries.featureflag.api) implementation(projects.libraries.network) - implementation(projects.libraries.preferences.api) implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.workmanager.api) implementation(projects.services.analytics.api) @@ -46,14 +76,12 @@ dependencies { implementation(projects.libraries.core) implementation(variantOf(libs.jna) { artifactType("aar") }) implementation(libs.androidx.datastore.preferences) - implementation(libs.serialization.json) implementation(libs.kotlinx.collections.immutable) testCommonDependencies(libs) testImplementation(projects.features.enterprise.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.previewutils) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.workmanager.test) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/DefaultElementCallTransportFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/DefaultElementCallTransportFactory.kt new file mode 100644 index 0000000000..14b59b14e6 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/DefaultElementCallTransportFactory.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.call.api.matrix.ElementCallMatrixTransport +import io.element.android.call.matrix.ElementCallSdkTransport +import io.element.android.features.callnative.api.ElementCallTransportFactory +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.impl.RustMatrixClient + +/** + * Gives the native call component its view of Matrix. + * + * Here because this is the only module in the app compiled against the Rust SDK, and the component's + * turnkey transport needs a raw SDK client. No feature module ever sees the SDK, the rule that no + * feature depends on `libraries/matrix/impl` still holds, and the SDK version contract is checked where + * the app's own SDK pin lives. + * + * The transport is built on the client's own coroutine scope, which is what ties it to the session: the + * component has no shutdown of its own, so cancelling that scope at logout is the only thing that stops + * the RTC core and its to-device subscription. + */ +@ContributesBinding(AppScope::class) +class DefaultElementCallTransportFactory : ElementCallTransportFactory { + override fun create(client: MatrixClient): ElementCallMatrixTransport { + return ElementCallSdkTransport( + client = (client as RustMatrixClient).innerClient, + sessionScope = client.sessionCoroutineScope, + ) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventFilterFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventFilterFactory.kt index 1d2f465b64..e75d6dc0da 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventFilterFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventFilterFactory.kt @@ -46,10 +46,13 @@ class RustTimelineEventFilterFactory : TimelineEventFilterFactory { } else { emptyList() } + + // Exclude any custom state events not supported by the SDK + val customStateEventsFilter = listOf(TimelineEventCondition.AnyCustomStateEvent) return if (excludedEventTypes.isNotEmpty() || excludedMembershipChanges.isNotEmpty()) { - TimelineEventFilter.Exclude(excludedEventTypes + excludedMembershipChanges) + TimelineEventFilter.Exclude(customStateEventsFilter + excludedEventTypes + excludedMembershipChanges) } else { - null + TimelineEventFilter.Exclude(customStateEventsFilter) } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt index 760937c567..a96d5ec214 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt @@ -245,6 +245,7 @@ class RustSessionVerificationService( fun destroy() { Timber.d("Destroying RustSessionVerificationService") + listener = null verificationStateListenerTaskHandle.cancelAndDestroy() recoveryStateListenerTaskHandle.cancelAndDestroy() if (this::verificationController.isInitialized) { diff --git a/libraries/matrix/impl/src/main/kotlin/org/matrix/rustsdk/Android.kt b/libraries/matrix/impl/src/main/kotlin/org/matrix/rustsdk/Android.kt new file mode 100644 index 0000000000..8e4b03e1da --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/org/matrix/rustsdk/Android.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package org.matrix.rustsdk + +import android.content.Context + +/** + * This class is used to initialize the Rust SDK on Android and give it access to an Android [Context]. + */ +object Android { + init { + System.loadLibrary("matrix_sdk_ffi") + } + + @JvmStatic + external fun init(context: Context) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt index 68990560ce..d0e9592948 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt @@ -40,6 +40,8 @@ internal fun aRustEventTimelineItem( canBeRepliedTo: Boolean = true, reactions: List = emptyList(), lazyProvider: LazyTimelineItemProvider = FakeFfiLazyTimelineItemProvider(), + editSendState: EventSendState? = null, + redactionSendState: EventSendState? = null, ) = EventTimelineItem( isRemote = isRemote, eventOrTransactionId = eventOrTransactionId, @@ -59,4 +61,6 @@ internal fun aRustEventTimelineItem( canBeRepliedTo = canBeRepliedTo, reactions = reactions, lazyProvider = lazyProvider, + editSendState = editSendState, + redactionSendState = redactionSendState, ) diff --git a/libraries/matrixmedia/api/build.gradle.kts b/libraries/matrixmedia/api/build.gradle.kts index 90c7ee635e..500b714c6a 100644 --- a/libraries/matrixmedia/api/build.gradle.kts +++ b/libraries/matrixmedia/api/build.gradle.kts @@ -11,7 +11,7 @@ plugins { } android { - namespace = "io.element.android.libraries.matrix.ui.media.api" + namespace = "io.element.android.libraries.matrixmedia.api" } dependencies { diff --git a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/Avatar.kt b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/Avatar.kt similarity index 92% rename from libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/Avatar.kt rename to libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/Avatar.kt index 4d4dd402d6..09c0d19f2c 100644 --- a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/Avatar.kt +++ b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/Avatar.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.api /** * The size in pixel of the thumbnail to generate for the avatar. diff --git a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderHolder.kt b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/ImageLoaderHolder.kt similarity index 95% rename from libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderHolder.kt rename to libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/ImageLoaderHolder.kt index 228fbd2cff..e78bdfc3b5 100644 --- a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderHolder.kt +++ b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/ImageLoaderHolder.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.api import coil3.ImageLoader import io.element.android.libraries.matrix.api.MatrixClient diff --git a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/InitialsAvatarBitmapGenerator.kt b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/InitialsAvatarBitmapGenerator.kt similarity index 95% rename from libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/InitialsAvatarBitmapGenerator.kt rename to libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/InitialsAvatarBitmapGenerator.kt index f145383e87..f77fd5f032 100644 --- a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/InitialsAvatarBitmapGenerator.kt +++ b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/InitialsAvatarBitmapGenerator.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.api import android.graphics.Bitmap import io.element.android.libraries.designsystem.components.avatar.AvatarData diff --git a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/MediaRequestData.kt similarity index 96% rename from libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt rename to libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/MediaRequestData.kt index 47841a573b..a1f4d2af04 100644 --- a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt +++ b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/MediaRequestData.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.api import io.element.android.libraries.matrix.api.media.MediaSource diff --git a/libraries/matrixmedia/impl/build.gradle.kts b/libraries/matrixmedia/impl/build.gradle.kts index 62799b5eaa..3a05a6c1c7 100644 --- a/libraries/matrixmedia/impl/build.gradle.kts +++ b/libraries/matrixmedia/impl/build.gradle.kts @@ -13,7 +13,7 @@ plugins { } android { - namespace = "io.element.android.libraries.matrix.ui.media.impl" + namespace = "io.element.android.libraries.matrixmedia.impl" } setupDependencyInjection() @@ -31,6 +31,5 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.sessionStorage.test) } diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataExt.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataExt.kt similarity index 73% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataExt.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataExt.kt index 0b1a09bdf9..1310f0f750 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataExt.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataExt.kt @@ -5,10 +5,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrixmedia.api.AVATAR_THUMBNAIL_SIZE_IN_PIXEL +import io.element.android.libraries.matrixmedia.api.MediaRequestData internal fun AvatarData.toMediaRequestData(): MediaRequestData { return MediaRequestData( diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactory.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataFetcherFactory.kt similarity index 96% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactory.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataFetcherFactory.kt index 9d7767e42d..264be31e6e 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactory.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataFetcherFactory.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import coil3.ImageLoader import coil3.fetch.Fetcher diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/CoilMediaFetcher.kt similarity index 97% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/CoilMediaFetcher.kt index d31b5671eb..cd9073bb51 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/CoilMediaFetcher.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import coil3.decode.DataSource import coil3.decode.ImageSource @@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.exception.isNetworkError import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.toFile +import io.element.android.libraries.matrixmedia.api.MediaRequestData import okio.Buffer import okio.FileSystem import okio.Path.Companion.toOkioPath diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolder.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultImageLoaderHolder.kt similarity index 93% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolder.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultImageLoaderHolder.kt index a0957a3b13..0aec82dcbd 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolder.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultImageLoaderHolder.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import coil3.ImageLoader import dev.zacsweers.metro.AppScope @@ -14,6 +14,7 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionObserver diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultInitialsAvatarBitmapGenerator.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultInitialsAvatarBitmapGenerator.kt similarity index 97% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultInitialsAvatarBitmapGenerator.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultInitialsAvatarBitmapGenerator.kt index 71c894f305..0baa317c5c 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultInitialsAvatarBitmapGenerator.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultInitialsAvatarBitmapGenerator.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import android.graphics.Canvas import android.graphics.Paint @@ -36,6 +36,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrixmedia.api.InitialsAvatarBitmapGenerator @ContributesBinding(AppScope::class) class DefaultInitialsAvatarBitmapGenerator : InitialsAvatarBitmapGenerator { diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/ImageLoaderFactories.kt similarity index 97% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/ImageLoaderFactories.kt index b414560fc9..a37a5bcf53 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/ImageLoaderFactories.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import android.content.Context import android.os.Build diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataFetcherFactory.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/MediaRequestDataFetcherFactory.kt similarity index 86% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataFetcherFactory.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/MediaRequestDataFetcherFactory.kt index f0cfd2047b..32c196afe7 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataFetcherFactory.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/MediaRequestDataFetcherFactory.kt @@ -6,12 +6,13 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import coil3.ImageLoader import coil3.fetch.Fetcher import coil3.request.Options import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrixmedia.api.MediaRequestData internal class MediaRequestDataFetcherFactory( private val matrixMediaLoader: MatrixMediaLoader, diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/MediaRequestDataKeyer.kt similarity index 87% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/MediaRequestDataKeyer.kt index 660b80500a..c8a654d634 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/MediaRequestDataKeyer.kt @@ -6,11 +6,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import coil3.key.Keyer import coil3.request.Options import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrixmedia.api.MediaRequestData internal class AvatarDataKeyer : Keyer { override fun key(data: AvatarData, options: Options): String? { diff --git a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactoryTest.kt b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataFetcherFactoryTest.kt similarity index 98% rename from libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactoryTest.kt rename to libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataFetcherFactoryTest.kt index eefacb3065..d6989aa72a 100644 --- a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactoryTest.kt +++ b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataFetcherFactoryTest.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import android.graphics.Bitmap import coil3.ComponentRegistry diff --git a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolderTest.kt b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultImageLoaderHolderTest.kt similarity index 98% rename from libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolderTest.kt rename to libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultImageLoaderHolderTest.kt index 2bb1ddcae4..b84fa9153c 100644 --- a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolderTest.kt +++ b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultImageLoaderHolderTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import androidx.test.platform.app.InstrumentationRegistry import coil3.ImageLoader diff --git a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/FakeImageLoaderFactory.kt b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/FakeImageLoaderFactory.kt similarity index 93% rename from libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/FakeImageLoaderFactory.kt rename to libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/FakeImageLoaderFactory.kt index bcafad0737..506db985d0 100644 --- a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/FakeImageLoaderFactory.kt +++ b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/FakeImageLoaderFactory.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import coil3.ImageLoader import io.element.android.libraries.matrix.api.media.MatrixMediaLoader diff --git a/libraries/matrixmedia/test/build.gradle.kts b/libraries/matrixmedia/test/build.gradle.kts index 5b8e966d08..86aac509c9 100644 --- a/libraries/matrixmedia/test/build.gradle.kts +++ b/libraries/matrixmedia/test/build.gradle.kts @@ -10,7 +10,7 @@ plugins { } android { - namespace = "io.element.android.libraries.matrix.ui.media.test" + namespace = "io.element.android.libraries.matrixmedia.test" } dependencies { diff --git a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoader.kt b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeImageLoader.kt similarity index 96% rename from libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoader.kt rename to libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeImageLoader.kt index d3cd7388e5..d35d29c4f8 100644 --- a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoader.kt +++ b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeImageLoader.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media.test +package io.element.android.libraries.matrixmedia.test import coil3.ComponentRegistry import coil3.ImageLoader diff --git a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoaderHolder.kt b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeImageLoaderHolder.kt similarity index 85% rename from libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoaderHolder.kt rename to libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeImageLoaderHolder.kt index 59b9efda7d..8adb434438 100644 --- a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoaderHolder.kt +++ b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeImageLoaderHolder.kt @@ -6,12 +6,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media.test +package io.element.android.libraries.matrixmedia.test import coil3.ImageLoader import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder class FakeImageLoaderHolder( val fakeImageLoader: ImageLoader = FakeImageLoader(), diff --git a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeInitialsAvatarBitmapGenerator.kt b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeInitialsAvatarBitmapGenerator.kt similarity index 87% rename from libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeInitialsAvatarBitmapGenerator.kt rename to libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeInitialsAvatarBitmapGenerator.kt index 2d0df756ac..83759c4690 100644 --- a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeInitialsAvatarBitmapGenerator.kt +++ b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeInitialsAvatarBitmapGenerator.kt @@ -5,11 +5,11 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media.test +package io.element.android.libraries.matrixmedia.test import coil3.Bitmap import io.element.android.libraries.designsystem.components.avatar.AvatarData -import io.element.android.libraries.matrix.ui.media.InitialsAvatarBitmapGenerator +import io.element.android.libraries.matrixmedia.api.InitialsAvatarBitmapGenerator import io.element.android.tests.testutils.lambda.lambdaError class FakeInitialsAvatarBitmapGenerator( diff --git a/libraries/matrixui/build.gradle.kts b/libraries/matrixui/build.gradle.kts index 5513dc240e..408751a8b1 100644 --- a/libraries/matrixui/build.gradle.kts +++ b/libraries/matrixui/build.gradle.kts @@ -41,6 +41,4 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.dateformatter.test) - testImplementation(projects.libraries.sessionStorage.test) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt index 38c5732875..1b8da50426 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt @@ -28,7 +28,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrixmedia.api.MediaRequestData import kotlinx.parcelize.Parcelize @Composable diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt index e3f6478b76..4c2b7feba7 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt @@ -6,8 +6,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.libraries.matrix.ui.components import androidx.activity.compose.BackHandler diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt index 188a8bc1a4..875925cda9 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -57,11 +58,13 @@ fun MatrixUserHeader( verticalArrangement = Arrangement.spacedBy(2.dp) ) { // Name - DisplayNameWithStatus( - name = matrixUser.getBestName(), - status = matrixUser.displayedStatus, - nameColor = ElementTheme.colors.textPrimary, + Text( + modifier = Modifier.clipToBounds(), + text = matrixUser.getBestName(), + maxLines = 1, style = ElementTheme.typography.fontHeadingMdRegular, + overflow = TextOverflow.Ellipsis, + color = ElementTheme.colors.textPrimary, ) // Id if (matrixUser.displayName.isNullOrEmpty().not()) { diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt index d10698662f..49dee6e13a 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt @@ -169,7 +169,8 @@ private fun NameAndIndicatorRow( ) if (showIndicator) { UnreadIndicatorAtom( - color = ElementTheme.colors.unreadIndicator + color = ElementTheme.colors.unreadIndicator, + count = 0, ) } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCard.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCard.kt new file mode 100644 index 0000000000..2af66d7356 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCard.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components.accountinfo + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom +import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize +import io.element.android.libraries.designsystem.atomic.molecules.TextWithLabelMolecule +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.matrix.ui.model.getBestName +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=5873-996 + */ +@Composable +fun AccountInfoCard( + state: AccountInfoCardState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .border(1.dp, ElementTheme.colors.borderDisabled, RoundedCornerShape(8.dp)) + .padding(20.dp), + ) { + AccountInfoCardUser( + matrixUser = state.matrixUser, + ) + when (state.mode) { + AccountInfoCardMode.Simple -> Unit + is AccountInfoCardMode.DeviceVerification -> { + AccountInfoSeparator() + DeviceInfoRow(state.mode) + } + is AccountInfoCardMode.UserVerification -> { + AccountInfoSeparator() + Text( + text = state.mode.hint, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + Spacer(modifier = Modifier.height(8.dp)) + AccountInfoCardUser( + matrixUser = state.mode.otherUser, + ) + } + } + } +} + +@Composable +private fun AccountInfoSeparator() { + HorizontalDivider( + modifier = Modifier.padding(vertical = 20.dp), + color = ElementTheme.colors.separatorPrimary, + ) +} + +@Composable +private fun DeviceInfoRow( + mode: AccountInfoCardMode.DeviceVerification, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {}, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RoundedIconAtom( + modifier = Modifier, + size = RoundedIconAtomSize.Big, + resourceId = CompoundDrawables.ic_compound_devices + ) + Text( + text = mode.deviceName ?: mode.deviceId.value, + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textPrimary, + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextWithLabelMolecule( + label = stringResource(CommonStrings.common_signed_in), + text = mode.signInFormattedTimestamp, + modifier = Modifier.weight(1f), + ) + TextWithLabelMolecule( + label = stringResource(CommonStrings.common_device_id), + text = mode.deviceId.value, + modifier = Modifier.weight(1f), + spellText = true, + ) + } + } +} + +@Composable +private fun AccountInfoCardUser(matrixUser: MatrixUser) { + val subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + avatarData = matrixUser.getAvatarData(AvatarSize.AccountInfoUser), + avatarType = AvatarType.User, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column( + modifier = Modifier.weight(1f), + ) { + // Name + Text( + modifier = Modifier.clipToBounds(), + text = matrixUser.getBestName(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyLgMedium, + ) + // Id + subtext?.let { + Text( + text = subtext, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun AccountInfoCardPreview( + @PreviewParameter(AccountInfoCardStatePreviewParam::class) state: AccountInfoCardState +) = ElementPreview { + // Add some padding to view the border + Box(modifier = Modifier.padding(16.dp)) { + AccountInfoCard(state) + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCardState.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCardState.kt new file mode 100644 index 0000000000..22697217fb --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCardState.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components.accountinfo + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.user.MatrixUser + +data class AccountInfoCardState( + val matrixUser: MatrixUser, + val mode: AccountInfoCardMode, +) + +@Immutable +sealed interface AccountInfoCardMode { + data object Simple : AccountInfoCardMode + + data class UserVerification( + val otherUser: MatrixUser, + val hint: String, + ) : AccountInfoCardMode + + data class DeviceVerification( + val deviceName: String?, + val deviceId: DeviceId, + val signInFormattedTimestamp: String, + ) : AccountInfoCardMode +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCardStatePreviewParam.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCardStatePreviewParam.kt new file mode 100644 index 0000000000..0dac3c46b7 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCardStatePreviewParam.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components.accountinfo + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUser + +open class AccountInfoCardStatePreviewParam : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anAccountInfoCardState(), + anAccountInfoCardState(aMatrixUser(displayName = null)), + anAccountInfoCardState( + mode = AccountInfoCardMode.UserVerification( + otherUser = aMatrixUser(displayName = "Other User"), + hint = "User request verification hint", + ) + ), + anAccountInfoCardState( + mode = AccountInfoCardMode.DeviceVerification( + deviceName = "My Device", + deviceId = DeviceId("DEVICEID"), + signInFormattedTimestamp = "2024-06-01 12:00:00", + ) + ), + ) +} + +private fun anAccountInfoCardState( + matrixUser: MatrixUser = aMatrixUser(), + mode: AccountInfoCardMode = AccountInfoCardMode.Simple, +) = AccountInfoCardState( + matrixUser = matrixUser, + mode = mode, +) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt index a6798a377b..dd8655af26 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.matrix.ui.model import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle @@ -21,7 +20,6 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.ui.R -@Immutable data class InviteSender( val userId: UserId, val displayName: String, diff --git a/libraries/mediapickers/api/build.gradle.kts b/libraries/mediapickers/api/build.gradle.kts index 5e3d78dbc4..9bbf322536 100644 --- a/libraries/mediapickers/api/build.gradle.kts +++ b/libraries/mediapickers/api/build.gradle.kts @@ -17,9 +17,7 @@ android { } dependencies { - implementation(projects.libraries.uiStrings) implementation(projects.libraries.core) - implementation(projects.libraries.di) testCommonDependencies(libs) } diff --git a/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTest.kt b/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/api/PickerTypeTest.kt similarity index 94% rename from libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTest.kt rename to libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/api/PickerTypeTest.kt index 752398f7c4..bdbf517886 100644 --- a/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTest.kt +++ b/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/api/PickerTypeTest.kt @@ -6,13 +6,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.mediapickers +package io.element.android.libraries.mediapickers.api import android.net.Uri import androidx.activity.result.contract.ActivityResultContracts import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.mimetype.MimeTypes -import io.element.android.libraries.mediapickers.api.PickerType import io.element.android.tests.testutils.robolectric.RobolectricTest import org.junit.Test diff --git a/libraries/mediapickers/impl/build.gradle.kts b/libraries/mediapickers/impl/build.gradle.kts index 6ed9cd97dc..8363e2b2b3 100644 --- a/libraries/mediapickers/impl/build.gradle.kts +++ b/libraries/mediapickers/impl/build.gradle.kts @@ -19,7 +19,6 @@ android { } dependencies { - implementation(projects.libraries.core) implementation(projects.libraries.di) api(projects.libraries.mediapickers.api) } diff --git a/libraries/mediapickers/test/build.gradle.kts b/libraries/mediapickers/test/build.gradle.kts index d7c2989871..46729db7de 100644 --- a/libraries/mediapickers/test/build.gradle.kts +++ b/libraries/mediapickers/test/build.gradle.kts @@ -16,6 +16,5 @@ android { dependencies { implementation(projects.libraries.core) - implementation(projects.libraries.di) api(projects.libraries.mediapickers.api) } diff --git a/libraries/mediaplayer/impl/build.gradle.kts b/libraries/mediaplayer/impl/build.gradle.kts index aa9140df35..0eddeb92ec 100644 --- a/libraries/mediaplayer/impl/build.gradle.kts +++ b/libraries/mediaplayer/impl/build.gradle.kts @@ -23,7 +23,6 @@ dependencies { implementation(libs.androidx.media3.exoplayer) implementation(projects.libraries.audio.api) - implementation(projects.libraries.core) implementation(projects.libraries.di) implementation(libs.coroutines.core) diff --git a/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt index f7a748b636..7807b3ac65 100644 --- a/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt +++ b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt @@ -14,8 +14,8 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.audio.api.AudioFocusRequester +import io.element.android.libraries.audio.test.FakeAudioFocus import io.element.android.libraries.mediaplayer.api.MediaPlayer -import io.element.android.libraries.mediaplayer.test.FakeAudioFocus import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.TimeoutCancellationException diff --git a/libraries/mediaplayer/test/build.gradle.kts b/libraries/mediaplayer/test/build.gradle.kts index e2f1c8ac29..bef4437b24 100644 --- a/libraries/mediaplayer/test/build.gradle.kts +++ b/libraries/mediaplayer/test/build.gradle.kts @@ -19,5 +19,4 @@ dependencies { implementation(projects.tests.testutils) implementation(libs.coroutines.test) - implementation(libs.test.truth) } diff --git a/libraries/mediaupload/api/build.gradle.kts b/libraries/mediaupload/api/build.gradle.kts index a9f876c67f..fd3e2cfd8e 100644 --- a/libraries/mediaupload/api/build.gradle.kts +++ b/libraries/mediaupload/api/build.gradle.kts @@ -15,11 +15,7 @@ android { } dependencies { - implementation(projects.libraries.architecture) implementation(projects.libraries.androidutils) - implementation(projects.libraries.core) - implementation(projects.libraries.di) api(projects.libraries.matrix.api) api(projects.libraries.preferences.api) - implementation(libs.coroutines.core) } diff --git a/libraries/mediaviewer/impl/build.gradle.kts b/libraries/mediaviewer/impl/build.gradle.kts index a5d7369056..891eb4e862 100644 --- a/libraries/mediaviewer/impl/build.gradle.kts +++ b/libraries/mediaviewer/impl/build.gradle.kts @@ -34,7 +34,6 @@ dependencies { implementation(libs.androidx.media3.exoplayer.midi) implementation(libs.androidx.media3.ui) implementation(libs.telephoto.zoomableimage) - implementation(libs.vanniktech.blurhash) implementation(libs.telephoto.flick) implementation(projects.features.enterprise.api) @@ -47,7 +46,6 @@ dependencies { implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.di) implementation(projects.libraries.designsystem) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixmedia.api) @@ -66,9 +64,7 @@ dependencies { testImplementation(projects.features.enterprise.test) testImplementation(projects.libraries.audio.test) testImplementation(projects.libraries.dateformatter.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.matrixui) testImplementation(projects.libraries.mediaviewer.test) testImplementation(projects.services.toolbox.test) testImplementation(libs.coroutines.core) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt index 6aff6cf136..6b3b6a30ea 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt @@ -44,11 +44,14 @@ import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.mediaviewer.impl.R import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.Strings +/** + * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=4168-7384 + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun MediaDeleteConfirmationBottomSheet( diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerView.kt index 613e3af269..6566146d86 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerView.kt @@ -5,13 +5,10 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.libraries.mediaviewer.impl.fileviewer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt index 132fb4af91..bb44b8c836 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt @@ -34,7 +34,6 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -99,7 +98,6 @@ import io.element.android.libraries.mediaviewer.impl.model.id import io.element.android.libraries.voiceplayer.api.VoiceMessageState import kotlinx.collections.immutable.ImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable fun MediaGalleryView( state: MediaGalleryState, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaFileView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaFileView.kt index 89e02b616a..aac834a6e2 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaFileView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaFileView.kt @@ -71,7 +71,6 @@ fun MediaFileView( modifier = Modifier.align(Alignment.CenterHorizontally), style = BigIcon.Style.Default( vectorIcon = icon, - usePrimaryTint = true, ), ) if (info != null) { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt index 5367751a19..95ebb6c818 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt @@ -12,9 +12,9 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.timeline.Timeline -import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.mediaviewer.api.MediaInfo sealed interface MediaItem { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index 343bf99ec9..aa4c0c92a6 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -6,8 +6,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.libraries.mediaviewer.impl.viewer import androidx.activity.compose.BackHandler @@ -36,7 +34,6 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle @@ -97,8 +94,8 @@ import io.element.android.libraries.designsystem.utils.hasCompactHeightWindowSiz import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState @@ -548,7 +545,6 @@ private fun rememberShowProgress(downloadedMedia: AsyncData): Boolea return showProgress } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun MediaViewerTopBar( data: MediaViewerPageData.MediaViewerData, diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt index 3ca702a47f..bd25229237 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt @@ -14,12 +14,12 @@ import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat import io.element.android.features.contentscanner.api.ContentScannerService import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.libraries.audio.test.FakeAudioFocus import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache -import io.element.android.libraries.mediaplayer.test.FakeAudioFocus import io.element.android.libraries.mediaviewer.api.AvatarInfo import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.impl.datasource.createTimelineMediaGalleryDataSource diff --git a/libraries/network/build.gradle.kts b/libraries/network/build.gradle.kts index faabe91a1c..1546358b7b 100644 --- a/libraries/network/build.gradle.kts +++ b/libraries/network/build.gradle.kts @@ -21,7 +21,6 @@ dependencies { implementation(libs.coroutines.core) implementation(projects.libraries.androidutils) implementation(projects.libraries.core) - implementation(projects.libraries.di) implementation(projects.libraries.matrix.api) implementation(projects.libraries.preferences.api) implementation(platform(libs.network.okhttp.bom)) diff --git a/libraries/oauth/impl/build.gradle.kts b/libraries/oauth/impl/build.gradle.kts index d051c06497..5a3b605277 100644 --- a/libraries/oauth/impl/build.gradle.kts +++ b/libraries/oauth/impl/build.gradle.kts @@ -28,20 +28,13 @@ android { setupDependencyInjection() dependencies { - implementation(projects.appconfig) - implementation(projects.libraries.core) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) implementation(libs.androidx.browser) implementation(platform(libs.network.retrofit.bom)) - implementation(libs.network.retrofit) - implementation(libs.serialization.json) api(projects.libraries.oauth.api) testCommonDependencies(libs) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.permissions.test) } diff --git a/libraries/permissions/impl/build.gradle.kts b/libraries/permissions/impl/build.gradle.kts index 70e27db7f0..fa033dc064 100644 --- a/libraries/permissions/impl/build.gradle.kts +++ b/libraries/permissions/impl/build.gradle.kts @@ -32,11 +32,7 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.troubleshoot.api) - implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) implementation(projects.libraries.preferences.api) implementation(projects.services.toolbox.api) api(projects.libraries.permissions.api) diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt index 9dd53ade09..16226813d2 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt @@ -12,7 +12,7 @@ import android.graphics.Bitmap import androidx.core.graphics.drawable.IconCompat import coil3.ImageLoader import io.element.android.libraries.designsystem.components.avatar.AvatarData -import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL +import io.element.android.libraries.matrixmedia.api.AVATAR_THUMBNAIL_SIZE_IN_PIXEL /** * Loads the avatars used to decorate notifications, since the notification APIs need bitmaps rather than Compose images. diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationCleaner.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationCleaner.kt index 95966f8eb1..0e41d63e26 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationCleaner.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationCleaner.kt @@ -65,4 +65,11 @@ interface NotificationCleaner { * @param roomId the room whose notifications are dismissed. */ fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) + + /** + * Dismisses the notifications of every read room. + * + * @param sessionId the session whose notifications should be dismissed. + */ + fun clearReadRoomsNotifications(sessionId: SessionId) } diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index 8615485bba..cd83392383 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -86,7 +86,6 @@ dependencies { implementation(projects.libraries.uiStrings) implementation(projects.libraries.troubleshoot.api) implementation(projects.libraries.workmanager.api) - implementation(projects.features.announcement.api) implementation(projects.features.call.api) implementation(projects.features.enterprise.api) implementation(projects.features.lockscreen.api) @@ -110,7 +109,6 @@ dependencies { testImplementation(projects.libraries.pushstore.test) testImplementation(projects.libraries.troubleshoot.test) testImplementation(projects.libraries.workmanager.test) - testImplementation(projects.features.announcement.test) testImplementation(projects.features.call.test) testImplementation(projects.features.enterprise.test) testImplementation(projects.features.lockscreen.test) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt index 086914ecf5..e891fa3ea6 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt @@ -23,9 +23,9 @@ import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL -import io.element.android.libraries.matrix.ui.media.InitialsAvatarBitmapGenerator -import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrixmedia.api.AVATAR_THUMBNAIL_SIZE_IN_PIXEL +import io.element.android.libraries.matrixmedia.api.InitialsAvatarBitmapGenerator +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import timber.log.Timber diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt index 6834f404ed..12109381df 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt @@ -12,6 +12,7 @@ import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn import io.element.android.features.lockscreen.api.LockScreenService +import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.core.EventId @@ -19,7 +20,7 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator @@ -53,7 +54,7 @@ class DefaultNotificationDrawerManager( private val notificationRenderer: NotificationRenderer, private val appNavigationStateService: AppNavigationStateService, @AppCoroutineScope - coroutineScope: CoroutineScope, + private val coroutineScope: CoroutineScope, private val matrixClientProvider: MatrixClientProvider, private val imageLoaderHolder: ImageLoaderHolder, private val activeNotificationsProvider: ActiveNotificationsProvider, @@ -187,6 +188,31 @@ class DefaultNotificationDrawerManager( clearSummaryNotificationIfNeeded(sessionId) } + override fun clearReadRoomsNotifications(sessionId: SessionId) { + val notifications = activeNotificationsProvider.getNotificationsForSession(sessionId) + val notificationsGroupedByRoomId = notifications.mapNotNull { + tryOrNull { RoomId(it.tag) }?.let { roomId -> roomId to it } + } + + if (notificationsGroupedByRoomId.isEmpty()) return + + coroutineScope.launch { + val client = matrixClientProvider.getOrRestore(sessionId).getOrElse { return@launch } + + notificationsGroupedByRoomId.forEach { (roomId, notification) -> + val roomInfo = client.getRoomInfo(roomId).getOrNull() ?: return@forEach + + if (roomInfo.numUnreadNotifications > 0L || roomInfo.numUnreadMessages > 0 || roomInfo.numUnreadMentions > 0) { + return@forEach + } + + notificationDisplayer.cancelNotification(notification.tag, notification.id) + } + + clearSummaryNotificationIfNeeded(sessionId) + } + } + private fun clearSummaryNotificationIfNeeded(sessionId: SessionId) { val summaryNotification = activeNotificationsProvider.getSummaryNotification(sessionId) if (summaryNotification != null && activeNotificationsProvider.count(sessionId) == 1) { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt index 7b08958cd1..583a10f6fb 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt @@ -27,7 +27,7 @@ import io.element.android.libraries.di.annotations.ApplicationContext 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.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService import io.element.android.libraries.push.impl.intent.IntentProvider diff --git a/libraries/push/impl/src/main/res/values-in/translations.xml b/libraries/push/impl/src/main/res/values-in/translations.xml index db9147c0cf..fcd8eaa76b 100644 --- a/libraries/push/impl/src/main/res/values-in/translations.xml +++ b/libraries/push/impl/src/main/res/values-in/translations.xml @@ -45,7 +45,7 @@ "%1$s disebut atau dibalas" "Kami mengundang Anda untuk bergabung di ruang ini." "%1$s mengundang Anda untuk bergabung ke ruang ini." - "Anda sedang melihat pemberitahuan ini! Klik saya!" + "Anda sedang melihat notifikasi ini! Klik saya!" "Diskusi di %1$s" "%1$s: %2$s" "%1$s: %2$s %3$s" @@ -87,7 +87,7 @@ "Tidak dapat menampilkan notifikasi." "Notifikasi telah diklik!" "Tampilan notifikasi" - "Silakan klik pada notifikasi untuk melanjutkan tes." + "Silakan klik notifikasi untuk melanjutkan tes." "Pastikan aplikasi menerima notifikasi dorongan." "Kesalahan: pendorong telah menolak permintaan." "Kesalahan: %1$s." diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultBaseRoomGroupMessageCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultBaseRoomGroupMessageCreatorTest.kt index 408063712a..17ab5b3967 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultBaseRoomGroupMessageCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultBaseRoomGroupMessageCreatorTest.kt @@ -19,10 +19,10 @@ import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.ui.components.aMatrixUser -import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL -import io.element.android.libraries.matrix.ui.media.MediaRequestData -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoader -import io.element.android.libraries.matrix.ui.media.test.FakeInitialsAvatarBitmapGenerator +import io.element.android.libraries.matrixmedia.api.AVATAR_THUMBNAIL_SIZE_IN_PIXEL +import io.element.android.libraries.matrixmedia.api.MediaRequestData +import io.element.android.libraries.matrixmedia.test.FakeImageLoader +import io.element.android.libraries.matrixmedia.test.FakeInitialsAvatarBitmapGenerator import io.element.android.libraries.push.impl.notifications.factories.MARK_AS_READ_ACTION_TITLE import io.element.android.libraries.push.impl.notifications.factories.QUICK_REPLY_ACTION_TITLE import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt index cdc6a01fad..ce7690ed0a 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.push.impl.notifications import android.app.Notification +import android.service.notification.StatusBarNotification import androidx.compose.ui.graphics.Color import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.api.EnterpriseService @@ -16,6 +17,8 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.test.FakeLockScreenService import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -27,8 +30,9 @@ import io.element.android.libraries.matrix.test.A_THREAD_ID_2 import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.ui.components.aMatrixUser -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator @@ -57,6 +61,7 @@ import io.element.android.services.appnavstate.test.aNavigationState import io.element.android.services.appnavstate.test.anAppNavigationState import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder +import io.element.android.tests.testutils.lambda.LambdaTwoParamsRecorder import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -569,6 +574,126 @@ class DefaultNotificationDrawerManagerTest { createFallbackNotificationResult.assertions().isNeverCalled() } + @Test + fun `clearReadRoomsNotifications cancels notifications of rooms without unread counts`() = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val sut = createClearReadRoomsManager( + cancelNotificationResult = cancelNotificationResult, + notifications = listOf(aRoomStatusBarNotification(A_ROOM_ID, 1)), + roomInfo = aRoomInfo(id = A_ROOM_ID), + ) + sut.clearReadRoomsNotifications(A_SESSION_ID) + runCurrent() + cancelNotificationResult.assertions().isCalledOnce().with(value(A_ROOM_ID.value), value(1)) + } + + @Test + fun `clearReadRoomsNotifications keeps notifications of rooms with unread messages`() = testKeepsNotification( + aRoomInfo(id = A_ROOM_ID, numUnreadMessages = 1) + ) + + @Test + fun `clearReadRoomsNotifications keeps notifications of rooms with unread notifications`() = + testKeepsNotification(aRoomInfo(id = A_ROOM_ID, numUnreadNotifications = 1)) + + @Test + fun `clearReadRoomsNotifications keeps notifications of rooms with unread mentions`() = testKeepsNotification( + aRoomInfo(id = A_ROOM_ID, numUnreadMentions = 1) + ) + + @Test + fun `clearReadRoomsNotifications keeps notifications when the room info is not found`() = testKeepsNotification(null) + + @Test + fun `clearReadRoomsNotifications does nothing when the client cannot be restored`() = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val sut = createClearReadRoomsManager( + cancelNotificationResult = cancelNotificationResult, + notifications = listOf(aRoomStatusBarNotification(A_ROOM_ID, 1)), + roomInfo = aRoomInfo(id = A_ROOM_ID), + clientResult = Result.failure(IllegalStateException("No client")), + ) + sut.clearReadRoomsNotifications(A_SESSION_ID) + runCurrent() + cancelNotificationResult.assertions().isNeverCalled() + } + + @Test + fun `clearReadRoomsNotifications ignores notifications not linked to a room`() = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val sut = createClearReadRoomsManager( + cancelNotificationResult = cancelNotificationResult, + notifications = listOf( + mockk { + every { id } returns 1 + every { tag } returns null + }, + ), + roomInfo = aRoomInfo(id = A_ROOM_ID), + ) + sut.clearReadRoomsNotifications(A_SESSION_ID) + runCurrent() + cancelNotificationResult.assertions().isNeverCalled() + } + + @Test + fun `clearReadRoomsNotifications clears the summary when it is the only notification left`() = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val summaryId = NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID) + val sut = createClearReadRoomsManager( + cancelNotificationResult = cancelNotificationResult, + notifications = listOf(aRoomStatusBarNotification(A_ROOM_ID, 1)), + roomInfo = aRoomInfo(id = A_ROOM_ID), + summaryNotification = mockk { every { id } returns summaryId }, + count = 1, + ) + sut.clearReadRoomsNotifications(A_SESSION_ID) + runCurrent() + cancelNotificationResult.assertions().isCalledExactly(2).withSequence( + listOf(value(A_ROOM_ID.value), value(1)), + listOf(value(null), value(summaryId)), + ) + } + + private fun testKeepsNotification(roomInfo: RoomInfo?) = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val sut = createClearReadRoomsManager( + cancelNotificationResult = cancelNotificationResult, + notifications = listOf(aRoomStatusBarNotification(A_ROOM_ID, 1)), + roomInfo = roomInfo, + ) + sut.clearReadRoomsNotifications(A_SESSION_ID) + runCurrent() + cancelNotificationResult.assertions().isNeverCalled() + } + + private fun aRoomStatusBarNotification(roomId: RoomId, notificationId: Int): StatusBarNotification = mockk { + every { id } returns notificationId + every { tag } returns roomId.value + } + + private fun TestScope.createClearReadRoomsManager( + cancelNotificationResult: LambdaTwoParamsRecorder, + notifications: List, + roomInfo: RoomInfo?, + clientResult: Result? = null, + summaryNotification: StatusBarNotification? = null, + count: Int = notifications.size, + ): DefaultNotificationDrawerManager { + val matrixClient = FakeMatrixClient().apply { + getRoomInfoLambda = { Result.success(roomInfo) } + } + return createDefaultNotificationDrawerManager( + notificationDisplayer = FakeNotificationDisplayer(cancelNotificationResult = cancelNotificationResult), + activeNotificationsProvider = FakeActiveNotificationsProvider( + getNotificationsForSessionResult = { notifications }, + getSummaryNotificationResult = { summaryNotification }, + countResult = { count }, + ), + matrixClientProvider = FakeMatrixClientProvider(getClient = { clientResult ?: Result.success(matrixClient) }), + ) + } + /** * The content of the original event must be stripped, only the noisy flag, the ids and the timestamp are kept. */ diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt index 1a46546db8..46845fd55b 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt @@ -14,7 +14,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoader +import io.element.android.libraries.matrixmedia.test.FakeImageLoader import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt index 43991444fe..4a1970b4f2 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt @@ -14,7 +14,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoader +import io.element.android.libraries.matrixmedia.test.FakeImageLoader import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt index cb5fad7509..0296836ccb 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt @@ -22,7 +22,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClientProvider -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder import io.element.android.libraries.push.impl.notifications.factories.FakeIntentProvider import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt index ead16c02d7..513db1ef0c 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt @@ -27,8 +27,8 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.ui.components.aMatrixUser -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoader -import io.element.android.libraries.matrix.ui.media.test.FakeInitialsAvatarBitmapGenerator +import io.element.android.libraries.matrixmedia.test.FakeImageLoader +import io.element.android.libraries.matrixmedia.test.FakeInitialsAvatarBitmapGenerator import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader import io.element.android.libraries.push.impl.notifications.DefaultNotificationBitmapLoader import io.element.android.libraries.push.impl.notifications.NotificationActionIds diff --git a/libraries/push/test/build.gradle.kts b/libraries/push/test/build.gradle.kts index d125a2387e..f97a23a907 100644 --- a/libraries/push/test/build.gradle.kts +++ b/libraries/push/test/build.gradle.kts @@ -20,7 +20,6 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.matrix.api) implementation(projects.libraries.preferences.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.workmanager.api) implementation(projects.tests.testutils) implementation(libs.androidx.core) diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationCleaner.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationCleaner.kt index 28a249dc9b..82202111dd 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationCleaner.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationCleaner.kt @@ -21,7 +21,8 @@ class FakeNotificationCleaner( private val clearMessagesForThreadLambda: (SessionId, RoomId, ThreadId) -> Unit = { _, _, _ -> lambdaError() }, private val clearEventLambda: (SessionId, EventId) -> Unit = { _, _ -> lambdaError() }, private val clearMembershipNotificationForSessionLambda: (SessionId) -> Unit = { lambdaError() }, - private val clearMembershipNotificationForRoomLambda: (SessionId, RoomId) -> Unit = { _, _ -> lambdaError() } + private val clearMembershipNotificationForRoomLambda: (SessionId, RoomId) -> Unit = { _, _ -> lambdaError() }, + private val clearReadRoomsNotificationsLambda: (SessionId) -> Unit = { lambdaError() }, ) : NotificationCleaner { override fun clearAllMessagesEvents(sessionId: SessionId) { clearAllMessagesEventsLambda(sessionId) @@ -46,4 +47,8 @@ class FakeNotificationCleaner( override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { clearMembershipNotificationForRoomLambda(sessionId, roomId) } + + override fun clearReadRoomsNotifications(sessionId: SessionId) { + clearReadRoomsNotificationsLambda(sessionId) + } } diff --git a/libraries/pushproviders/api/build.gradle.kts b/libraries/pushproviders/api/build.gradle.kts index 3581a43952..d489ec5bfe 100644 --- a/libraries/pushproviders/api/build.gradle.kts +++ b/libraries/pushproviders/api/build.gradle.kts @@ -14,6 +14,5 @@ android { } dependencies { - implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) } diff --git a/libraries/pushproviders/firebase/build.gradle.kts b/libraries/pushproviders/firebase/build.gradle.kts index cfe5dd01fb..35fef58fa0 100644 --- a/libraries/pushproviders/firebase/build.gradle.kts +++ b/libraries/pushproviders/firebase/build.gradle.kts @@ -51,7 +51,6 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.push.api) implementation(projects.libraries.sessionStorage.api) - implementation(projects.libraries.uiStrings) implementation(projects.libraries.troubleshoot.api) implementation(projects.services.toolbox.api) @@ -66,8 +65,6 @@ dependencies { } testCommonDependencies(libs) - testImplementation(libs.kotlinx.collections.immutable) - testImplementation(projects.features.enterprise.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushstore.test) diff --git a/libraries/pushproviders/unifiedpush/build.gradle.kts b/libraries/pushproviders/unifiedpush/build.gradle.kts index 1b24bc4b1f..7aa19b093b 100644 --- a/libraries/pushproviders/unifiedpush/build.gradle.kts +++ b/libraries/pushproviders/unifiedpush/build.gradle.kts @@ -25,7 +25,6 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) implementation(projects.libraries.push.api) - implementation(projects.libraries.uiStrings) api(projects.libraries.troubleshoot.api) implementation(projects.libraries.pushstore.api) @@ -46,8 +45,6 @@ dependencies { api(libs.unifiedpush) testCommonDependencies(libs) - testImplementation(libs.kotlinx.collections.immutable) - testImplementation(projects.features.enterprise.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushproviders.test) diff --git a/libraries/pushstore/impl/build.gradle.kts b/libraries/pushstore/impl/build.gradle.kts index 4e643fd5f9..8691bd2196 100644 --- a/libraries/pushstore/impl/build.gradle.kts +++ b/libraries/pushstore/impl/build.gradle.kts @@ -13,7 +13,7 @@ plugins { } android { - namespace = "io.element.android.libraries.push.pushstore.impl" + namespace = "io.element.android.libraries.pushstore.impl" defaultConfig { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/libraries/pushstore/impl/src/androidTest/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactoryTest.kt b/libraries/pushstore/impl/src/androidTest/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactoryTest.kt index a3b31acf6c..d06bd43582 100644 --- a/libraries/pushstore/impl/src/androidTest/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactoryTest.kt +++ b/libraries/pushstore/impl/src/androidTest/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactoryTest.kt @@ -18,7 +18,7 @@ import kotlin.concurrent.thread /** * Note: to clear the emulator, invoke: - * adb uninstall io.element.android.libraries.push.pushstore.impl.test + * adb uninstall io.element.android.libraries.pushstore.impl.test */ class DefaultUserPushStoreFactoryTest { /** diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt index 43b91068f9..ac6990f194 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -70,7 +69,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @Suppress("MultipleEmitters") // False positive -@OptIn(ExperimentalMaterial3Api::class) @Composable fun RoomSelectView( state: RoomSelectState, diff --git a/libraries/rustls-tls/README.md b/libraries/rustls-tls/README.md deleted file mode 100644 index aa45fe6743..0000000000 --- a/libraries/rustls-tls/README.md +++ /dev/null @@ -1,9 +0,0 @@ -This module is a wrapper for the Android code distributed in the rustls-platform-verifier-android crate. - -To avoid the distribution mess that this library has (download a Rust crate, then search for it using Gradle and use it as local maven repo), -we previously just manually updated the AAR file instead using a script. This won't work for F-Droid because the AAR library is a black box with -no sources attached to it, so we can't use it like that. - -Instead, for the time being, we're adding the single `CertificateVerifier.kt` class this AAR had in it as part of our sources. - -When this file is updated, the [UPDATED.md](./UPDATED.md) file should be updated too with the commit SHA of the new version. diff --git a/libraries/rustls-tls/UPDATED.md b/libraries/rustls-tls/UPDATED.md deleted file mode 100644 index 10dba2dd53..0000000000 --- a/libraries/rustls-tls/UPDATED.md +++ /dev/null @@ -1,7 +0,0 @@ -Below is the commit SHA in [rustls-platform-verifier](https://github.com/rustls/rustls-platform-verifier) library used to update the code in this module: - -``` -996b1c903491641b17b3c9afb65d1352f6fc6b76 -``` - -Please update it after making manual changes. diff --git a/libraries/rustls-tls/build.gradle.kts b/libraries/rustls-tls/build.gradle.kts deleted file mode 100644 index 85f3f4c476..0000000000 --- a/libraries/rustls-tls/build.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -import extension.buildConfigFieldBoolean - -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -plugins { - id("io.element.android-library") -} - -android { - namespace = "org.rustls.platformverifier" - - buildFeatures { - buildConfig = true - } - - defaultConfig { - buildConfigFieldBoolean("TEST", false) - } -} diff --git a/libraries/rustls-tls/src/main/kotlin/org/rustls/platformverifier/CertificateVerifier.kt b/libraries/rustls-tls/src/main/kotlin/org/rustls/platformverifier/CertificateVerifier.kt deleted file mode 100644 index b70413ffe5..0000000000 --- a/libraries/rustls-tls/src/main/kotlin/org/rustls/platformverifier/CertificateVerifier.kt +++ /dev/null @@ -1,398 +0,0 @@ -@file:SuppressLint("LogNotTimber", "ObsoleteSdkInt") -@file:Suppress("KotlinConstantConditions") - -// IMPORTANT: this file comes from rustls-platform-verifier and should not be modified locally. - -/* - * Copyright (c) 2022 1Password - * - * SPDX-License-Identifier: MIT - */ - -package org.rustls.platformverifier - -import android.annotation.SuppressLint -import android.content.Context -import android.net.http.X509TrustManagerExtensions -import android.util.Log -import java.io.ByteArrayInputStream -import java.io.File -import java.security.KeyStore -import java.security.KeyStoreException -import java.security.MessageDigest -import java.security.PublicKey -import java.security.cert.CertificateException -import java.security.cert.CertificateExpiredException -import java.security.cert.CertificateFactory -import java.security.cert.CertificateNotYetValidException -import java.security.cert.CertificateParsingException -import java.security.cert.X509Certificate -import java.util.Date -import javax.net.ssl.TrustManagerFactory -import javax.net.ssl.X509TrustManager -import javax.security.auth.x500.X500Principal - -// If this is updated, update the Rust definition too. -// Marked private as this is not meant to be used in Android code. -private enum class StatusCode(val value: Int) { - Ok(0), - Unavailable(1), - Expired(2), - UnknownCert(3), - Revoked(4), - InvalidEncoding(5), - InvalidExtension(6), -} - -// Marked private as this is not meant to be used in Android code. -private class VerificationResult( - status: StatusCode, - @Suppress("unused") val message: String? = null -) { - @Suppress("unused") - private val code: Int = status.value -} - -// NOTE: All TrustManager and certificate validation methods are not thread safe. These -// are all guarded by Kotlin's `Synchronized` accessors to prevent undefined behavior. - -// Only JNI and test code calls this, so unused code warnings are suppressed. -// Internal for test code - no other Kotlin code should use this object directly. -@Suppress("unused") -// We want to show a difference between Kotlin-side logs and those in Rust code -@SuppressLint("LongLogTag") -internal object CertificateVerifier { - private const val TAG = "rustls-platform-verifier-android" - - private fun createTrustManager(keystore: KeyStore?): X509TrustManagerExtensions? { - // This can never throw since the default algorithm is used. - val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - - factory.init(keystore) - - val availableTrustManagers = try { - factory.trustManagers - } catch (e: RuntimeException) { - Log.w(TAG, "exception thrown creating a TrustManager: $e") - return null - } - - for (manager in availableTrustManagers) { - if (manager is X509TrustManager) { - // Kotlin ensures this can't throw at runtime since it knows that - // it must be the correct type by now. - return X509TrustManagerExtensions(manager) - } - } - - Log.e(TAG, "failed to find a usable trust manager") - return null - } - - private fun makeLazyTrustManager(keystore: KeyStore?): Lazy { - // Ensure the keystore is loaded. Since all of the trust managers are initialized in a - // `Lazy`, this will only run once. - keystore?.load(null) - - return lazy { createTrustManager(keystore) } - } - - // -- Test only -- - // Ideally, all of this will be optimized out at compile time due to not being accessed - // in release builds. - - @get:Synchronized - private val mockKeystore: KeyStore = KeyStore.getInstance(KeyStore.getDefaultType()) - - @get:Synchronized - private var mockTrustManager: Lazy = - makeLazyTrustManager(mockKeystore) - - @JvmStatic - private fun addMockRoot(root: ByteArray) { - if (!BuildConfig.TEST) { - throw Exception("attempted to add a mock root outside a test!") - } - - val alias = "root_${mockKeystore.size()}" - // Throwing here is fine since test roots should always be well-formed - val cert = certFactory.generateCertificate(ByteArrayInputStream(root)) - mockKeystore.setCertificateEntry(alias, cert) - - reloadMockData() - } - - @JvmStatic - private fun clearMockRoots() { - // Reload to get a completely fresh internal state - mockKeystore.load(null) - reloadMockData() - } - - @JvmStatic - private fun reloadMockData() { - if (mockTrustManager.isInitialized()) { - mockTrustManager = makeLazyTrustManager(mockKeystore) - } - } - - // Get a list of the system's root CAs. - // Function is public for testing only. - @JvmStatic - fun getSystemRootCAs(): List { - val rootCAs = mutableListOf() - - val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - factory.init(systemKeystore) - - val availableTrustManagers = try { - factory.trustManagers - } catch (e: RuntimeException) { - Log.w(TAG, "exception thrown creating a TrustManager: $e") - return rootCAs - } - - availableTrustManagers.forEach { trustManager -> - if (trustManager is X509TrustManager) { - rootCAs.addAll(trustManager.acceptedIssuers) - } - } - - return rootCAs - } - - // -- End testing requirements -- - - private val certFactory: CertificateFactory = CertificateFactory.getInstance("X.509") - - private var systemTrustAnchorCache = hashSetOf>() - - @get:Synchronized - private var systemCertificateDirectory: File? = System.getenv("ANDROID_ROOT")?.let { rootPath -> - File("$rootPath/etc/security/cacerts") - } - - @get:Synchronized - private val systemKeystore: KeyStore? = try { - KeyStore.getInstance("AndroidCAStore") - } catch (_: KeyStoreException) { - null - } - - @get:Synchronized - private val systemTrustManager: Lazy = - makeLazyTrustManager(systemKeystore) - - @JvmStatic - private fun verifyCertificateChain( - @Suppress("UNUSED_PARAMETER") context: Context, - serverName: String, - authMethod: String, - allowedEkus: Array, - ocspResponse: ByteArray?, - time: Long, - certChain: Array - ): VerificationResult { - // Convert the array of (supposedly) DER bytes into certificates. - val certificateChain = mutableListOf() - certChain.forEach { certBytes -> - val certificate = try { - certFactory.generateCertificate(ByteArrayInputStream(certBytes)) - } catch (e: CertificateException) { - return VerificationResult(StatusCode.InvalidEncoding) - } - certificateChain.add(certificate as X509Certificate) - } - - // Will never throw `ArrayIndexOutOfBoundsException` because `rustls`'s `ServerCertVerifier` trait - // has a mandatory `end_entity` parameter in `verify_server_cert`. - val endEntity = certificateChain[0] - - // Check that the certificate is valid at the point of time provided by `rustls`. - try { - endEntity.checkValidity(Date(time)) - } catch (e: CertificateExpiredException) { - return VerificationResult(StatusCode.Expired) - } catch (e: CertificateNotYetValidException) { - return VerificationResult(StatusCode.Expired) - } - - // Check that this certificate can be used in a TLS server. - if (!verifyCertUsage(endEntity, allowedEkus)) { - return VerificationResult(StatusCode.InvalidExtension) - } - - // Select the trust manager to use. - // - // We select them as follows: - // - If built for release, only use the system trust manager. This should let all test-related - // code be optimized out. - // - If built for tests: - // - If the mock CA store has any values, use the mock trust manager. - // - Otherwise, use the system trust manager. - val (trustManager, keystore) = if (!BuildConfig.TEST) { - val trustManager = - systemTrustManager.value ?: return VerificationResult(StatusCode.Unavailable) - Pair(trustManager, systemKeystore) - } else { - if (mockKeystore.size() != 0) { - val trustManager = mockTrustManager.value!! - Pair(trustManager, mockKeystore) - } else { - val trustManager = - systemTrustManager.value ?: return VerificationResult(StatusCode.Unavailable) - Pair(trustManager, systemKeystore) - } - } - - // Verify that the certificate chain is valid and correct, and nothing more. - // - // NOTE: This does not validate `serverName` is valid for the end-entity certificate. - // That is handled in Rust as Android/Java do not currently provide a RFC 6125 compliant - // hostname verifier. Additionally, even the RFC 2818 verifier is not available until API 24. - // - // `serverName` is only used for pinning/CT requirements. - // - // Returns the "the properly ordered chain used for verification as a list of X509Certificates.", - // meaning a list from end-entity certificate to trust-anchor. - val validChain = try { - trustManager.checkServerTrusted(certificateChain.toTypedArray(), authMethod, serverName) - } catch (e: CertificateException) { - // In test configurations we may see `checkServerTrusted` fail once vendored test - // certificates pass their expiry date. We try to avoid that by using a fixed - // verification time when calling `endEntity.checkValidity` above, however we can't - // fix the time for the `checkServerTrusted` call. - // - // To make diagnosing CI test failures easier we try to find the root cause of - // checkServerTrusted failing, returning a different `StatusCode` as appropriate. - if (BuildConfig.TEST) { - var rootCause: Throwable? = e - while (rootCause?.cause != null && rootCause.cause != rootCause) { - rootCause = rootCause.cause - } - return when (rootCause) { - is CertificateExpiredException, is CertificateNotYetValidException -> VerificationResult( - StatusCode.Expired, - rootCause.toString() - ) - - else -> VerificationResult(StatusCode.UnknownCert, rootCause.toString()) - } - } - // In non-test configurations we should have caught expiry errors earlier and - // can simply return an unknown cert error without digging through the exception - // cause chain. - return VerificationResult(StatusCode.UnknownCert, e.toString()) - } - - // TEST ONLY: Mock test suite cannot attempt to check revocation status if no OSCP data has been stapled, - // because Android requires certificates to an specify OCSP responder for network fetch in this case. - // If in testing w/o OCSP stapled, short-circuit here - only prior checks apply. - if (BuildConfig.TEST && (mockKeystore.size() != 0) && (ocspResponse == null)) { - return VerificationResult(StatusCode.Ok) - } - - // ================================================================================================= - // IMPORTANT! DIFF WITH THE ORIGINAL SOURCES HERE: - // - // We removed the cert revocation checks because Android won't do them by default - // and supporting them would be quite difficult given CRLs won't work out of the box. - // - // See https://github.com/rustls/rustls-platform-verifier/issues/221 for more info. - // ================================================================================================= - - return VerificationResult(StatusCode.Ok) - } - - private fun verifyCertUsage(certificate: X509Certificate, allowedEkus: Array): Boolean { - val ekus = try { - certificate.extendedKeyUsage - } - // This should be unreachable, but could happen. - catch (_: CertificateParsingException) { - return false - } catch (_: NullPointerException) { - // According to Chromium's implementation, this can crash when the EKU data is malformed. - Log.w(TAG, "exception handling certificate EKU") - return false - } ?: return true // If the list is empty, we have nothing to do. - - return ekus.any { allowedEkus.contains(it) } - } - - // Android hashes a principal using the first four bytes of its MD5 digest, encoded in - // lowercase hex and reversed. - // - // Ref: https://source.chromium.org/chromium/chromium/src/+/main:net/android/java/src/org/chromium/net/X509Util.java;l=339 - private fun hashPrincipal(principal: X500Principal): String { - val hexDigits = "0123456789abcdef".toCharArray() - val digest = MessageDigest.getInstance("MD5").digest(principal.encoded) - val hexChars = CharArray(8) - - for (i in 0..3) { - // Kotlin doesn't support bitwise operators for bytes, only Int and Long. - val digestByte = digest[3 - i].toInt() - hexChars[2 * i] = hexDigits[(digestByte shr 4) and 0xf] - hexChars[2 * i + 1] = hexDigits[digestByte and 0xf] - } - - return String(hexChars) - } - - // Check if CA root is known or not. - // Known means installed in root CA store, either a preset public CA or a custom one installed by an enterprise/user. - // - // Ref: https://source.chromium.org/chromium/chromium/src/+/main:net/android/java/src/org/chromium/net/X509Util.java;l=351 - fun isKnownRoot(root: X509Certificate): Boolean { - // System keystore and cert directory must be non-null to perform checking - systemKeystore?.let { loadedSystemKeystore -> - systemCertificateDirectory?.let { loadedSystemCertificateDirectory -> - - // Check the in-memory cache first - val key = Pair(root.subjectX500Principal, root.publicKey) - if (systemTrustAnchorCache.contains(key)) { - return true - } - - // System trust anchors are stored under a hash of the principal. - // In case of collisions, append number. - val hash = hashPrincipal(root.subjectX500Principal) - var i = 0 - while (true) { - val alias = "$hash.$i" - - if (!File(loadedSystemCertificateDirectory, alias).exists()) { - break - } - - val anchor = loadedSystemKeystore.getCertificate("system:$alias") - - // It's possible for `anchor` to be `null` if the user deleted a trust anchor. - // Continue iterating as there may be further collisions after the deleted anchor. - if (anchor == null) { - continue - // This should never happen - } else if (anchor !is X509Certificate) { - // SAFETY: This logs a unique identifier (hash value) only in cases where a file within the - // system's root trust store is not a valid X509 certificate (extremely unlikely error). - // The hash doesn't tell us any sensitive information about the invalid cert or reveal any of - // its contents - it just lets us ID the bad file if a user is having TLS failure issues. - Log.e(TAG, "anchor is not a certificate, alias: $alias") - continue - // If subject and public key match, it's a system root. - } else { - if ((root.subjectX500Principal == anchor.subjectX500Principal) && (root.publicKey == anchor.publicKey)) { - systemTrustAnchorCache.add(key) - return true - } - } - - i += 1 - } - } - } - - // Not found in cache or store: non-public - return false - } -} diff --git a/libraries/session-storage/impl/build.gradle.kts b/libraries/session-storage/impl/build.gradle.kts index 8b7d9d6e15..9d00237239 100644 --- a/libraries/session-storage/impl/build.gradle.kts +++ b/libraries/session-storage/impl/build.gradle.kts @@ -20,7 +20,6 @@ android { setupDependencyInjection() dependencies { - implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.encryptedDb) api(projects.libraries.sessionStorage.api) diff --git a/libraries/session-storage/test/build.gradle.kts b/libraries/session-storage/test/build.gradle.kts index 7a89746812..35e40e1fed 100644 --- a/libraries/session-storage/test/build.gradle.kts +++ b/libraries/session-storage/test/build.gradle.kts @@ -15,6 +15,5 @@ android { dependencies { implementation(libs.coroutines.core) - implementation(projects.libraries.matrix.api) implementation(projects.libraries.sessionStorage.api) } diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt index d08048241e..206da5a3b0 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt @@ -162,7 +162,9 @@ class MarkdownTextInputTest : RobolectricTest() { val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = false) setMarkdownTextInput(state = state) val editor = activity!!.findEditor() - editor.requestFocus() + // The test window is in touch mode, where the editor is not focusable in Robolectric. + // requestFocusFromTouch() leaves the touch mode first, like a hardware keyboard would do. + editor.requestFocusFromTouch() // Focus state is updated assertThat(state.hasFocus).isTrue() } diff --git a/libraries/troubleshoot/api/build.gradle.kts b/libraries/troubleshoot/api/build.gradle.kts index 177b2a5fce..5eda87d84f 100644 --- a/libraries/troubleshoot/api/build.gradle.kts +++ b/libraries/troubleshoot/api/build.gradle.kts @@ -16,6 +16,5 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(libs.androidx.corektx) implementation(libs.coroutines.core) } diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt index be10a2845e..3bc6ba64fb 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt @@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -53,7 +52,6 @@ import io.element.android.libraries.push.api.history.PushHistoryItem import io.element.android.libraries.troubleshoot.impl.R import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PushHistoryView( state: PushHistoryState, diff --git a/libraries/ui-strings/src/main/res/values-pl/translations.xml b/libraries/ui-strings/src/main/res/values-pl/translations.xml index 0555716c57..cad2548b2d 100644 --- a/libraries/ui-strings/src/main/res/values-pl/translations.xml +++ b/libraries/ui-strings/src/main/res/values-pl/translations.xml @@ -101,6 +101,7 @@ "Wybierz plik" "Wybierz zdjęcie" "Wyczyść" + "Wyczyść wszystko" "Zamknij" "Dokończ weryfikację" "Potwierdź" @@ -139,6 +140,11 @@ "Zakończ" "Nie pamiętasz hasła?" "Przekaż dalej" + + "Przekaż %1$d wiadomość" + "Przekaż %1$d wiadomości" + "Przekaż %1$d wiadomości" + "Wróć" "Przejdź do ról i uprawnień" "Przejdź do ustawień" @@ -245,6 +251,7 @@ "Rozpoczęto rozmowę" "Odrzuciłeś połączenie" "Backup czatu" + "Czaty" "Blok kodu" "Skopiowano do schowka" "Prawa autorskie" @@ -285,6 +292,7 @@ Powód: %1$s." "Plik zapisany" "Plik zapisany do folderu Pobrane" "Przekaż wiadomość" + "Przekaż do" "Często używane" "Galeria" @@ -299,6 +307,7 @@ Powód: %1$s." "GIF" "Trwa połączenie grupowe" + "Pokoje" "Zdjęcie" "W odpowiedzi do %1$s" "Zainstaluj APK" @@ -419,6 +428,7 @@ Powód: %1$s." "Udostępniona lokalizacja na żywo" "Udostępniona lokalizacja" "Udostępniona przestrzeń" + "Zalogowano" "Usuwanie urządzenia" "Coś poszło nie tak" "Napotkaliśmy problem. Spróbuj ponownie." @@ -579,6 +589,11 @@ Czy na pewno chcesz kontynuować?" "Zmniejsz" "Udostępnianie lokalizacji na żywo" "Zaznaczono maksymalną liczbę wiadomości" + + "Przekazano %1$d wiadomość" + "Przekazano %1$d wiadomości" + "Przekazano %1$d wiadomości" + "Już oglądasz ten pokój!" "%1$s z %2$s" "%1$s przypiętych wiadomości" 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 c80403468f..7279b906c0 100644 --- a/libraries/ui-strings/src/main/res/values-ru/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml @@ -111,6 +111,7 @@ "Скопировать ссылку" "Скопировать ссылку на сообщение" "Копировать текст" + "Скопировать в буфер обмена" "Создать" "Создать комнату" "Создать пространство" @@ -224,6 +225,7 @@ "Доступно обновление" "О приложении" "Политика допустимого использования" + "Настройки аккаунта" "Добавить аккаунт" "Добавить другой аккаунт" "Добавление подписи" @@ -232,6 +234,7 @@ "Аналитика" "Вы покинули комнату" "Вы вышли из сессии" + "Настройки приложения" "Внешний вид" "Аудио" "Бета-версия" @@ -242,6 +245,7 @@ "Звонок начат" "Ты отклонил звонок" "Резервная копия чатов" + "Блок кода" "Скопировано в буфер обмена" "Авторское право" "Создание комнаты…" @@ -308,11 +312,13 @@ "Трансляция местоположения завершена" "Загрузка…" "Загрузить больше…" + "Обмен местоположением" "%d" "%d другие" "%d другие" + "Качество загружаемого медиа" "%1$d участник" "%1$d участников" @@ -323,6 +329,7 @@ "Ошибка отправки сообщения" "Оформление сообщения" "Сообщение удалено" + "Модерация и безопасность" "Современный" "Выкл. звук" "Имя" @@ -428,6 +435,7 @@ "Успешно" "Рекомендуемые" "Предложения" + "Сменить аккаунт" "Синхронизация" "Текст" "Уведомления о третьих лицах" @@ -467,6 +475,7 @@ "Ожидание трансляции местоположения…" "Любой может видеть историю" "Вы" + "Ваш аккаунт" "Загрузка недоступна." "Файл не найден" "Сканирование…" diff --git a/libraries/ui-strings/src/main/res/values-uk/translations.xml b/libraries/ui-strings/src/main/res/values-uk/translations.xml index e791c4b5be..0bc43a3027 100644 --- a/libraries/ui-strings/src/main/res/values-uk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-uk/translations.xml @@ -101,6 +101,7 @@ "Вибрати файл" "Вибрати фото" "Очистити" + "Очистити все" "Закрити" "Завершити звіряння" "Підтвердити" @@ -250,6 +251,7 @@ "Виклик розпочато" "Ви відхилили виклик" "Резервне копіювання бесіди" + "Бесіди" "Блок коду" "Скопійовано до буферу обміну" "Авторське право" @@ -290,6 +292,7 @@ "Файл збережено" "Файл збережений у Завантаження" "Переслати повідомлення" + "Переслати" "Частовживані" "Галерея" @@ -424,6 +427,7 @@ "Поділитися місцезнаходженням в реальному часі" "Поширене розташування" "Простір спільного користування" + "Вхід виконано" "Вилучення пристрою" "Щось пішло не так" "Ми зіткнулися з проблемою. Будь ласка, повторіть спробу." diff --git a/libraries/ui-strings/src/main/res/values-zh/translations.xml b/libraries/ui-strings/src/main/res/values-zh/translations.xml index 36331e302b..74cc7bf9f7 100644 --- a/libraries/ui-strings/src/main/res/values-zh/translations.xml +++ b/libraries/ui-strings/src/main/res/values-zh/translations.xml @@ -95,6 +95,7 @@ "选择文件" "选择照片" "清除" + "全部清除" "关闭" "完成验证" "确认" @@ -242,6 +243,7 @@ "通话已开始" "你已拒接来电" "聊天记录备份" + "聊天" "代码块" "已复制到剪贴板" "版权" @@ -282,6 +284,7 @@ "文件已保存" "文件已保存到“下载”" "转发消息" + "转发到" "常用" "媒体组" @@ -292,6 +295,7 @@ "GIF" "群组通话进行中" + "房间" "图片" "回复 %1$s" "安装 APK" @@ -349,7 +353,7 @@ "正在准备…" "隐私政策" - "私密" + "私有" "私有房间" "私有空间" "公共" @@ -400,6 +404,7 @@ "已共享实时位置" "共享位置" "已共享的空间" + "已登录" "正在移除设备" "发生了一些错误" "我们遇到了一个问题。请重试。" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 7a0ce8fd4e..0eeb366d72 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -98,6 +98,7 @@ "Choose file" "Choose photo" "Clear" + "Clear all" "Close" "Complete verification" "Confirm" @@ -246,6 +247,7 @@ "Call started" "You declined a call" "Chat backup" + "Chats" "Code block" "Copied to clipboard" "Copyright" @@ -286,6 +288,7 @@ Reason: %1$s." "File saved" "File saved to Downloads" "Forward message" + "Forward to" "Frequently used" "Gallery" @@ -298,6 +301,7 @@ Reason: %1$s." "GIF" "Group call in progress" + "Rooms" "Image" "In reply to %1$s" "Install APK" @@ -412,6 +416,7 @@ Reason: %1$s." "Shared live location" "Shared location" "Shared space" + "Signed in" "Removing device" "Something went wrong" "We encountered an issue. Please try again." diff --git a/libraries/usersearch/impl/build.gradle.kts b/libraries/usersearch/impl/build.gradle.kts index bd8eb47cd1..897f63da12 100644 --- a/libraries/usersearch/impl/build.gradle.kts +++ b/libraries/usersearch/impl/build.gradle.kts @@ -20,7 +20,6 @@ android { setupDependencyInjection() dependencies { - implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.di) implementation(projects.libraries.matrixui) diff --git a/libraries/usersearch/test/build.gradle.kts b/libraries/usersearch/test/build.gradle.kts index 21cc140a7d..6899ba8c9c 100644 --- a/libraries/usersearch/test/build.gradle.kts +++ b/libraries/usersearch/test/build.gradle.kts @@ -11,12 +11,11 @@ plugins { } android { - namespace = "io.element.android.libraries.usersearch" + namespace = "io.element.android.libraries.usersearch.test" } dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.matrixui) implementation(projects.libraries.matrix.api) api(projects.libraries.usersearch.api) } diff --git a/libraries/voiceplayer/api/build.gradle.kts b/libraries/voiceplayer/api/build.gradle.kts index e058210b7d..604fd3f5a2 100644 --- a/libraries/voiceplayer/api/build.gradle.kts +++ b/libraries/voiceplayer/api/build.gradle.kts @@ -14,8 +14,6 @@ android { } dependencies { - implementation(libs.androidx.annotationjvm) - implementation(libs.coroutines.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) } diff --git a/libraries/voiceplayer/impl/build.gradle.kts b/libraries/voiceplayer/impl/build.gradle.kts index 8fe79fb774..1e0040d98e 100644 --- a/libraries/voiceplayer/impl/build.gradle.kts +++ b/libraries/voiceplayer/impl/build.gradle.kts @@ -22,7 +22,6 @@ dependencies { api(projects.libraries.voiceplayer.api) implementation(projects.libraries.architecture) - implementation(projects.libraries.audio.api) implementation(projects.libraries.core) implementation(projects.libraries.di) implementation(projects.libraries.matrix.api) @@ -31,7 +30,6 @@ dependencies { implementation(projects.libraries.uiUtils) implementation(projects.services.analytics.api) - implementation(libs.androidx.annotationjvm) implementation(libs.androidx.datastore.preferences) implementation(libs.coroutines.core) diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt index 070b341d76..62f91d3ccf 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt @@ -17,13 +17,13 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.voicerecorder.api.VoiceRecorderState import io.element.android.libraries.voicerecorder.impl.audio.Audio import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig +import io.element.android.libraries.voicerecorder.impl.audio.FakeAudioLevelCalculator +import io.element.android.libraries.voicerecorder.impl.audio.FakeAudioReaderFactory +import io.element.android.libraries.voicerecorder.impl.audio.FakeEncoder import io.element.android.libraries.voicerecorder.impl.audio.SampleRate import io.element.android.libraries.voicerecorder.impl.di.VoiceRecorderBindingContainer -import io.element.android.libraries.voicerecorder.test.FakeAudioLevelCalculator -import io.element.android.libraries.voicerecorder.test.FakeAudioReaderFactory -import io.element.android.libraries.voicerecorder.test.FakeEncoder -import io.element.android.libraries.voicerecorder.test.FakeFileSystem -import io.element.android.libraries.voicerecorder.test.FakeVoiceFileManager +import io.element.android.libraries.voicerecorder.impl.file.FakeFileSystem +import io.element.android.libraries.voicerecorder.impl.file.FakeVoiceFileManager import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk import kotlinx.coroutines.test.TestScope diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioLevelCalculator.kt similarity index 77% rename from libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt rename to libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioLevelCalculator.kt index 48e12c3192..583d36fdfd 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioLevelCalculator.kt @@ -6,9 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.voicerecorder.test +package io.element.android.libraries.voicerecorder.impl.audio -import io.element.android.libraries.voicerecorder.impl.audio.AudioLevelCalculator import kotlin.math.abs class FakeAudioLevelCalculator : AudioLevelCalculator { diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioReader.kt similarity index 85% rename from libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt rename to libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioReader.kt index b14bb400e5..e946abecd8 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioReader.kt @@ -6,11 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.voicerecorder.test +package io.element.android.libraries.voicerecorder.impl.audio import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.voicerecorder.impl.audio.Audio -import io.element.android.libraries.voicerecorder.impl.audio.AudioReader import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import kotlinx.coroutines.yield diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioReaderFactory.kt similarity index 70% rename from libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt rename to libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioReaderFactory.kt index d3e434538e..2b5a998a90 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioReaderFactory.kt @@ -6,12 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.voicerecorder.test +package io.element.android.libraries.voicerecorder.impl.audio import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.voicerecorder.impl.audio.Audio -import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig -import io.element.android.libraries.voicerecorder.impl.audio.AudioReader class FakeAudioReaderFactory( private val audio: List