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