Merge branch 'develop' into renovate/com.github.matrix-org-matrix-analytics-events-0.x

This commit is contained in:
ganfra
2026-10-06 17:34:34 +02:00
committed by GitHub
1558 changed files with 9352 additions and 5512 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APKs - name: Assemble debug APKs
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug Gplay Enterprise APK - name: Assemble debug Gplay Enterprise APK
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12
+14 -1
View File
@@ -53,7 +53,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK - name: Assemble debug APK
@@ -124,6 +124,19 @@ jobs:
disk-size: 3G disk-size: 3G
script: | script: |
.github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh app-gplay-x86_64-debug.apk .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 - name: Upload test results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
+2 -2
View File
@@ -43,7 +43,7 @@ jobs:
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: false cache-read-only: false
@@ -85,7 +85,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis - name: Dependency analysis
+7 -7
View File
@@ -74,7 +74,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12
@@ -113,7 +113,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Konsist tests - name: Run Konsist tests
@@ -154,7 +154,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run compose tests - name: Run compose tests
@@ -188,7 +188,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build Gplay Debug - name: Build Gplay Debug
@@ -233,7 +233,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Detekt - name: Run Detekt
@@ -274,7 +274,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Ktlint check - name: Run Ktlint check
@@ -336,7 +336,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 - uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
upload_reports: upload_reports:
name: Project Check Suite name: Project Check Suite
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
java-version: '21' java-version: '21'
# Add gradle cache, this should speed up the process # Add gradle cache, this should speed up the process
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots - name: Record screenshots
+2 -2
View File
@@ -43,7 +43,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - 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 - name: Create app bundle
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -93,7 +93,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
- name: Create APKs - name: Create APKs
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -51,9 +51,10 @@ if [[ -z ${REPO} ]]; then
exit 1 exit 1
fi fi
echo "Deleting previous screenshots" # Note: the previous screenshots are deliberately kept, so that Paparazzi can leave the ones that only
./gradlew removeOldSnapshots --stacktrace --warn $GRADLE_ARGS # 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" echo "Record screenshots"
./gradlew recordPaparazziDebug --stacktrace $GRADLE_ARGS ./gradlew recordPaparazziDebug --stacktrace $GRADLE_ARGS
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build debug code and test fixtures - name: Build debug code and test fixtures
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12
+1 -1
View File
@@ -68,7 +68,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
+1 -1
View File
@@ -2,6 +2,6 @@
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" /> <option name="externalSystemId" value="Gradle" />
<option name="version" value="2.4.10" /> <option name="version" value="2.4.20" />
</component> </component>
</project> </project>
+12 -4
View File
@@ -2,17 +2,25 @@ appId: ${MAESTRO_APP_ID}
--- ---
- tapOn: - tapOn:
id: "home_screen-settings" id: "home_screen-settings"
- tapOn:
id: "settings-user_profile"
- assertVisible: "Your account"
- tapOn: "Remove this device" - tapOn: "Remove this device"
- takeScreenshot: build/maestro/900-SignOutScreen - takeScreenshot: build/maestro/900-SignOutScreen
- back - back
- tapOn: "Remove this device" - tapOn: "Remove this device"
# Ensure pressing back cancels
- back
- tapOn: "Remove this device"
# Ensure cancel cancels # Ensure cancel cancels
- tapOn: "Cancel"
- tapOn: "Remove this device"
- tapOn:
text: "Remove this device"
index: 1
# Cancel the dialog cancels the logout
- tapOn: - tapOn:
id: "dialog-negative" id: "dialog-negative"
- tapOn: "Remove this device" - tapOn:
text: "Remove this device"
index: 1
- tapOn: - tapOn:
id: "dialog-positive" id: "dialog-positive"
- runFlow: ../assertions/assertInitDisplayed.yaml - runFlow: ../assertions/assertInitDisplayed.yaml
+1 -1
View File
@@ -37,7 +37,7 @@ PRs must meet these rules.
- Create previews for **all main states** of a Composable. - Create previews for **all main states** of a Composable.
- Use `@PreviewsDayNight` for consistency. - 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 { ... }`. - Wrap previews in `ElementPreview { ... }`.
- When writing tests, never try to record the screenshots, the CI will do it. - When writing tests, never try to record the screenshots, the CI will do it.
+9
View File
@@ -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 Changes in Element X v26.09.3
============================= =============================
+3 -6
View File
@@ -280,7 +280,6 @@ dependencies {
implementation(projects.features.migration.api) implementation(projects.features.migration.api)
implementation(projects.appnav) implementation(projects.appnav)
implementation(projects.appconfig) implementation(projects.appconfig)
implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.compose) implementation(projects.services.analytics.compose)
if (ModulesConfig.pushProvidersConfig.includeFirebase) { if (ModulesConfig.pushProvidersConfig.includeFirebase) {
@@ -298,18 +297,13 @@ dependencies {
implementation(libs.androidx.splash) implementation(libs.androidx.splash)
implementation(libs.androidx.core) implementation(libs.androidx.core)
implementation(libs.androidx.corektx) implementation(libs.androidx.corektx)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.startup) implementation(libs.androidx.startup)
implementation(libs.androidx.preference) implementation(libs.androidx.preference)
implementation(libs.coil)
implementation(platform(libs.network.okhttp.bom)) implementation(platform(libs.network.okhttp.bom))
implementation(libs.network.okhttp.logging) implementation(libs.network.okhttp.logging)
implementation(libs.serialization.json)
implementation(libs.matrix.emojibase.bindings)
testCommonDependencies(libs) testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
@@ -342,6 +336,9 @@ licensee {
ignoreDependencies("com.github.matrix-org", "matrix-analytics-events") ignoreDependencies("com.github.matrix-org", "matrix-analytics-events")
// Ignore dependency that are not third-party licenses to us. // Ignore dependency that are not third-party licenses to us.
ignoreDependencies(groupId = "io.element.android") 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) { fun Project.configureLicensesTasks(reportingExtension: ReportingExtension) {
@@ -1,2 +1,9 @@
# Don't obfuscate anything for non-enterprise builds # Don't obfuscate anything for non-enterprise builds
-dontobfuscate -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;
}
+2 -1
View File
@@ -53,9 +53,10 @@
--> -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTask"
android:supportsPictureInPicture="true"
android:theme="@style/Theme.ElementX.Splash" android:theme="@style/Theme.ElementX.Splash"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
@@ -9,6 +9,7 @@
package io.element.android.x package io.element.android.x
import android.app.Application import android.app.Application
import android.os.StrictMode
import androidx.compose.material3.ComposeMaterial3Flags.isAnchoredDraggableComponentsStrictOffsetCheckEnabled import androidx.compose.material3.ComposeMaterial3Flags.isAnchoredDraggableComponentsStrictOffsetCheckEnabled
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.startup.AppInitializer import androidx.startup.AppInitializer
@@ -38,6 +39,9 @@ class ElementXApplication : Application(), DependencyInjectionGraphOwner, Config
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
enableStrictMode()
AppInitializer.getInstance(this).apply { AppInitializer.getInstance(this).apply {
initializeComponent(CrashInitializer::class.java) initializeComponent(CrashInitializer::class.java)
initializeComponent(PlatformInitializer::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. // Remove once https://issuetracker.google.com/issues/477038695 is fixed.
isAnchoredDraggableComponentsStrictOffsetCheckEnabled = false 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()
)
}
} }
@@ -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 @Composable
private fun MainContent(appBindings: AppBindings) { private fun MainContent(appBindings: AppBindings) {
val migrationState = appBindings.migrationEntryPoint().present() val migrationState = appBindings.migrationEntryPoint().present()
@@ -11,11 +11,12 @@ package io.element.android.x.di
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Multibinds 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.AppStartupHook
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.lockscreen.api.LockScreenService 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.features.rageshake.api.reporter.BugReporter
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@@ -52,6 +53,13 @@ interface AppBindings {
fun buildMeta(): BuildMeta 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? fun sentrySdkDsn(): SentrySdkDsn?
@Multibinds(allowEmpty = true) @Multibinds(allowEmpty = true)
@@ -8,11 +8,14 @@
package io.element.android.x.initializer package io.element.android.x.initializer
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.system.Os import android.system.Os
import android.util.Log
import androidx.startup.Initializer import androidx.startup.Initializer
import io.element.android.features.rageshake.api.logs.createWriteToFilesConfiguration import io.element.android.features.rageshake.api.logs.createWriteToFilesConfiguration
import io.element.android.libraries.architecture.bindings 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.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.tracing.TracingConfiguration import io.element.android.libraries.matrix.api.tracing.TracingConfiguration
import io.element.android.x.di.AppBindings import io.element.android.x.di.AppBindings
@@ -24,6 +27,15 @@ private const val ELEMENT_X_TARGET = "elementx"
class PlatformInitializer : Initializer<Unit> { class PlatformInitializer : Initializer<Unit> {
override fun create(context: Context) { 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<AppBindings>() val appBindings = context.bindings<AppBindings>()
val tracingService = appBindings.tracingService() val tracingService = appBindings.tracingService()
val platformService = appBindings.platformService() val platformService = appBindings.platformService()
@@ -41,6 +53,8 @@ class PlatformInitializer : Initializer<Unit> {
sdkSentryDsn = appBindings.sentrySdkDsn()?.value?.takeIf { it.isNotBlank() }, sdkSentryDsn = appBindings.sentrySdkDsn()?.value?.takeIf { it.isNotBlank() },
) )
bugReporter.setCurrentTracingLogLevel(logLevel.name) bugReporter.setCurrentTracingLogLevel(logLevel.name)
// Init the tracing service with the configuration so we have logging enabled as soon as possible
platformService.init(tracingConfiguration) platformService.init(tracingConfiguration)
// Also set env variable for rust back trace // Also set env variable for rust back trace
Os.setenv("RUST_BACKTRACE", "1", true) Os.setenv("RUST_BACKTRACE", "1", true)
@@ -30,5 +30,384 @@
<domain includeSubdomains="true">home</domain> <domain includeSubdomains="true">home</domain>
<domain includeSubdomains="true">lan</domain> <domain includeSubdomains="true">lan</domain>
<domain includeSubdomains="true">localdomain</domain> <domain includeSubdomains="true">localdomain</domain>
<!-- Domains below are imported from rustls-platform-verifier 0.2.0 by tools/dependencies/update_rustls_platform_verifier.py -->
<!-- They are needed to fetch the certificate revocation lists over cleartext -->
<domain includeSubdomains="true">abitabdv.crl.certum.pl</domain>
<domain includeSubdomains="true">abitabev.crl.certum.pl</domain>
<domain includeSubdomains="true">abitabov.crl.certum.pl</domain>
<domain includeSubdomains="true">alpiro.crl.sectigo.com</domain>
<domain includeSubdomains="true">anticdv.crl.certum.pl</domain>
<domain includeSubdomains="true">antrustovsslg2r30ca.crl.certum.pl</domain>
<domain includeSubdomains="true">autontrustprodvtlsg2r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">bitcert.crl.sectigo.com</domain>
<domain includeSubdomains="true">btdvtlsr35g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">c.cf-b.ssl.com</domain>
<domain includeSubdomains="true">c.cf-i.ssl.com</domain>
<domain includeSubdomains="true">c.pki.goog</domain>
<domain includeSubdomains="true">ca1.agid.gov.it</domain>
<domain includeSubdomains="true">cdn.d-trust-cloudcrl.net</domain>
<domain includeSubdomains="true">cdp.disig.sk</domain>
<domain includeSubdomains="true">cdp.elektronicznypodpis.pl</domain>
<domain includeSubdomains="true">cdp.geotrust.com</domain>
<domain includeSubdomains="true">cdp.rapidssl.com</domain>
<domain includeSubdomains="true">cdp.thawte.com</domain>
<domain includeSubdomains="true">cdp1.disig.sk</domain>
<domain includeSubdomains="true">cdp1.pca.dfn.de</domain>
<domain includeSubdomains="true">cdpa.digitalcertvalidation.com</domain>
<domain includeSubdomains="true">cdpb.digitalcertvalidation.com</domain>
<domain includeSubdomains="true">cdpc.digitalcertvalidation.com</domain>
<domain includeSubdomains="true">cdpd.digitalcertvalidation.com</domain>
<domain includeSubdomains="true">cdpe.digitalcertvalidation.com</domain>
<domain includeSubdomains="true">cdpf.digitalcertvalidation.com</domain>
<domain includeSubdomains="true">cdpg.digitalcertvalidation.com</domain>
<domain includeSubdomains="true">cdph.digitalcertvalidation.com</domain>
<domain includeSubdomains="true">cdpi.digitalcertvalidation.com</domain>
<domain includeSubdomains="true">certcloud.crl.trust-provider.com</domain>
<domain includeSubdomains="true">certdata.crl.sectigo.com</domain>
<domain includeSubdomains="true">certera.crl.sectigo.com</domain>
<domain includeSubdomains="true">certificates.godaddy.com</domain>
<domain includeSubdomains="true">certum-crl.wt.trustasia.com</domain>
<domain includeSubdomains="true">certum.crl.sheca.com</domain>
<domain includeSubdomains="true">certumdvtlsg2e39ca.crl.certum.pl</domain>
<domain includeSubdomains="true">certumdvtlsg2r39ca.crl.certum.pl</domain>
<domain includeSubdomains="true">certumevtlsg2e39ca.crl.certum.pl</domain>
<domain includeSubdomains="true">certumevtlsg2r39ca.crl.certum.pl</domain>
<domain includeSubdomains="true">certumovtlsg2e39ca.crl.certum.pl</domain>
<domain includeSubdomains="true">certumovtlsg2r39ca.crl.certum.pl</domain>
<domain includeSubdomains="true">cfcadveccca.crl.certum.pl</domain>
<domain includeSubdomains="true">cfcadvrsaca.crl.certum.pl</domain>
<domain includeSubdomains="true">cfcaeveccca.crl.certum.pl</domain>
<domain includeSubdomains="true">cfcaevrsaca.crl.certum.pl</domain>
<domain includeSubdomains="true">cfcaoveccca.crl.certum.pl</domain>
<domain includeSubdomains="true">cfcaovrsaca.crl.certum.pl</domain>
<domain includeSubdomains="true">cnssldvtlsg2r36ca.crl.certum.pl</domain>
<domain includeSubdomains="true">cnsslovtlsg2r36ca.crl.certum.pl</domain>
<domain includeSubdomains="true">crl-01.r1.ica.amazontrust.com</domain>
<domain includeSubdomains="true">crl-01.r1.ica.amznts.eu</domain>
<domain includeSubdomains="true">crl-c.emsign.com</domain>
<domain includeSubdomains="true">crl-pro.litessl.com</domain>
<domain includeSubdomains="true">crl.anf.es</domain>
<domain includeSubdomains="true">crl.apple.com</domain>
<domain includeSubdomains="true">crl.businessid.telesec.de</domain>
<domain includeSubdomains="true">crl.buypass.no</domain>
<domain includeSubdomains="true">crl.catrust.oemssl.cn</domain>
<domain includeSubdomains="true">crl.certigna.fr</domain>
<domain includeSubdomains="true">crl.certsign.ro</domain>
<domain includeSubdomains="true">crl.certum.pl</domain>
<domain includeSubdomains="true">crl.cfca.com.cn</domain>
<domain includeSubdomains="true">crl.cntrus.oemssl.cn</domain>
<domain includeSubdomains="true">crl.cnwebtrust.oemssl.cn</domain>
<domain includeSubdomains="true">crl.comodoca.com</domain>
<domain includeSubdomains="true">crl.crlocsp.cn</domain>
<domain includeSubdomains="true">crl.cybertrust.ne.jp</domain>
<domain includeSubdomains="true">crl.d-trust.net</domain>
<domain includeSubdomains="true">crl.dhimyotis.com</domain>
<domain includeSubdomains="true">crl.digicert.cn</domain>
<domain includeSubdomains="true">crl.digicert.eu</domain>
<domain includeSubdomains="true">crl.e-szigno.hu</domain>
<domain includeSubdomains="true">crl.e2m01.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2m01.eu.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2m02.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2m03.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2m04.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2s14.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2s15.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2s16.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2s17.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2s18.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2s19.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2s20.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2s21.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3m01.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3m01.eu.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3m02.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3m03.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3m04.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3s22.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3s23.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3s24.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3s25.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3s26.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3s27.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3s28.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3s29.amazontrust.com</domain>
<domain includeSubdomains="true">crl.emsign.com</domain>
<domain includeSubdomains="true">crl.ensuredca.com</domain>
<domain includeSubdomains="true">crl.firmaprofesional.com</domain>
<domain includeSubdomains="true">crl.gdca.com.cn</domain>
<domain includeSubdomains="true">crl.global.sheca.com</domain>
<domain includeSubdomains="true">crl.globalsign.com</domain>
<domain includeSubdomains="true">crl.globalsign.net</domain>
<domain includeSubdomains="true">crl.godaddy.com</domain>
<domain includeSubdomains="true">crl.harica.gr</domain>
<domain includeSubdomains="true">crl.izenpe.com</domain>
<domain includeSubdomains="true">crl.litessl.com</domain>
<domain includeSubdomains="true">crl.netsolssl.com</domain>
<domain includeSubdomains="true">crl.oem.trustca.net</domain>
<domain includeSubdomains="true">crl.pcsp.telesec.de</domain>
<domain includeSubdomains="true">crl.pki.goog</domain>
<domain includeSubdomains="true">crl.quovadisglobal.com</domain>
<domain includeSubdomains="true">crl.r1001.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2001.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2m01.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2m01.eu.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2m02.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2m03.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2m04.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2s18.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2s19.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2s20.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2s21.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2s22.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2s23.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2s24.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2s25.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r3001.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r4001.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r4m01.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r4m02.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r4m03.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r4m04.amazontrust.com</domain>
<domain includeSubdomains="true">crl.root-e1.certainly.com</domain>
<domain includeSubdomains="true">crl.root-r1.certainly.com</domain>
<domain includeSubdomains="true">crl.rootca1.amazontrust.com</domain>
<domain includeSubdomains="true">crl.rootca2.amazontrust.com</domain>
<domain includeSubdomains="true">crl.rootca3.amazontrust.com</domain>
<domain includeSubdomains="true">crl.rootca4.amazontrust.com</domain>
<domain includeSubdomains="true">crl.rztrust.oemssl.cn</domain>
<domain includeSubdomains="true">crl.sbca.telesec.de</domain>
<domain includeSubdomains="true">crl.sectigo.com</domain>
<domain includeSubdomains="true">crl.sectigochina.com</domain>
<domain includeSubdomains="true">crl.serverid.telesec.de</domain>
<domain includeSubdomains="true">crl.starfieldtech.com</domain>
<domain includeSubdomains="true">crl.swisssign.ch</domain>
<domain includeSubdomains="true">crl.trust-provider.cn</domain>
<domain includeSubdomains="true">crl.trust-provider.com</domain>
<domain includeSubdomains="true">crl.tuntrust.tn</domain>
<domain includeSubdomains="true">crl.usertrust.com</domain>
<domain includeSubdomains="true">crl.wt.trustasia.com</domain>
<domain includeSubdomains="true">crl.xinchacha.oemssl.cn</domain>
<domain includeSubdomains="true">crl.zw.oemssl.cn</domain>
<domain includeSubdomains="true">crl05.actalis.it</domain>
<domain includeSubdomains="true">crl06.actalis.it</domain>
<domain includeSubdomains="true">crl09.actalis.it</domain>
<domain includeSubdomains="true">crl1.ecert.gov.hk</domain>
<domain includeSubdomains="true">crl13.actalis.it</domain>
<domain includeSubdomains="true">crl14.actalis.it</domain>
<domain includeSubdomains="true">crl15.actalis.it</domain>
<domain includeSubdomains="true">crl2.microsoft.com</domain>
<domain includeSubdomains="true">crl3.digicert.com</domain>
<domain includeSubdomains="true">crl4.digicert.com</domain>
<domain includeSubdomains="true">crls.certainly.com</domain>
<domain includeSubdomains="true">crls.ssl.com</domain>
<domain includeSubdomains="true">cyberfolks2.crl.certum.pl</domain>
<domain includeSubdomains="true">depo.kamusm.gov.tr</domain>
<domain includeSubdomains="true">digitaltrust.crl.sectigo.com</domain>
<domain includeSubdomains="true">dnencrypt.crl.sectigo.com</domain>
<domain includeSubdomains="true">dvtlsca2025-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">dvtlsca2026-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">e5.c.lencr.org</domain>
<domain includeSubdomains="true">e6.c.lencr.org</domain>
<domain includeSubdomains="true">e7.c.lencr.org</domain>
<domain includeSubdomains="true">e8.c.lencr.org</domain>
<domain includeSubdomains="true">ec2ca2017-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">ec2ca2017-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">ec2ca2017-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">ec2sslca2017-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">ec2sslca2017-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">ec2sslca2017-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">ec3ca2017-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">ec3ca2017-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">ec3ca2017-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">ec3sslca2017-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">ec3sslca2017-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">ec3sslca2017-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">eca.hinet.net</domain>
<domain includeSubdomains="true">edvtlsca2023-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">edvtlsca2025-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">edvtlsca2026-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">emudhra.crl.sectigo.com</domain>
<domain includeSubdomains="true">eosslca2017-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">eosslca2017-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">eosslca2017-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">eovtlsca2023-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">eovtlsca2025-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">eovtlsca2026-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">eqca2017-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">eqca2017-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">eqca2017-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">eqcpca2017-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">eqcpca2017-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">eqcpca2017-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">eqoca2017-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">eqoca2017-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">eqoca2017-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">eqtlsca2018-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">eqtlsca2018-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">eqtlsca2018-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">eqtlsca2023-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">eqtlsca2025-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">eqtlsca2026-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">esafer.crl.sectigo.com</domain>
<domain includeSubdomains="true">eue2m1.crl.root.amznts.eu</domain>
<domain includeSubdomains="true">eue3m1.crl.root.amznts.eu</domain>
<domain includeSubdomains="true">eur2m1.crl.root.amznts.eu</domain>
<domain includeSubdomains="true">eveccca.crl.certum.pl</domain>
<domain includeSubdomains="true">evrsaca.crl.certum.pl</domain>
<domain includeSubdomains="true">gdcadv.crl.certum.pl</domain>
<domain includeSubdomains="true">gdcaev.crl.certum.pl</domain>
<domain includeSubdomains="true">gdcaov.crl.certum.pl</domain>
<domain includeSubdomains="true">gdcatrustauthdvtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">gdcatrustauthevtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">gdcatrustauthovtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">geant.crl.sectigo.com</domain>
<domain includeSubdomains="true">genious.crl.sectigo.com</domain>
<domain includeSubdomains="true">geossldvtlsr34g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">geosslevtlsr34g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">geosslovtlsr34g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">globaltrust.crl.sectigo.com</domain>
<domain includeSubdomains="true">globessl.crl.sectigo.com</domain>
<domain includeSubdomains="true">gogetssldv.crl.certum.pl</domain>
<domain includeSubdomains="true">gogetsslev.crl.certum.pl</domain>
<domain includeSubdomains="true">gogetsslov.crl.certum.pl</domain>
<domain includeSubdomains="true">homepldvtlsg2r35ca.crl.certum.pl</domain>
<domain includeSubdomains="true">homepldvtlsg2r36ca.crl.certum.pl</domain>
<domain includeSubdomains="true">homeplovtlsg2r35ca.crl.certum.pl</domain>
<domain includeSubdomains="true">homeplovtlsg2r36ca.crl.certum.pl</domain>
<domain includeSubdomains="true">httpcrl.trust.telia.com</domain>
<domain includeSubdomains="true">ica.navercloudtrust.com</domain>
<domain includeSubdomains="true">ica.navercorp.com</domain>
<domain includeSubdomains="true">issauth.crl.sectigo.com</domain>
<domain includeSubdomains="true">itrusdv2.crl.certum.pl</domain>
<domain includeSubdomains="true">itrusdvtlsr35g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">itrusov2.crl.certum.pl</domain>
<domain includeSubdomains="true">itrusovtlsr35g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">itso.crl.sectigo.com</domain>
<domain includeSubdomains="true">joyssldvtlsg2r33ca.crl.certum.pl</domain>
<domain includeSubdomains="true">joysslovtlsg2r33ca.crl.certum.pl</domain>
<domain includeSubdomains="true">kingnettechdv.crl.certum.pl</domain>
<domain includeSubdomains="true">kingnettechev.crl.certum.pl</domain>
<domain includeSubdomains="true">kingnettechov.crl.certum.pl</domain>
<domain includeSubdomains="true">kr.crl.digicert.com</domain>
<domain includeSubdomains="true">lh2.crl.certum.pl</domain>
<domain includeSubdomains="true">namecheap.crl.sectigo.com</domain>
<domain includeSubdomains="true">nazwassldvtlsg2e29ca.crl.certum.pl</domain>
<domain includeSubdomains="true">nazwassldvtlsg2r29ca.crl.certum.pl</domain>
<domain includeSubdomains="true">netartcadv2.crl.certum.pl</domain>
<domain includeSubdomains="true">netartssldvtlsg2e31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">nijimo.crl.sectigo.com</domain>
<domain includeSubdomains="true">nyalabscadv.crl.certum.pl</domain>
<domain includeSubdomains="true">nyatworkdv.crl.certum.pl</domain>
<domain includeSubdomains="true">omitsecurity.crl.sectigo.com</domain>
<domain includeSubdomains="true">onesign.crl.sectigo.com</domain>
<domain includeSubdomains="true">osslca2016-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">osslca2016-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">osslca2016-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">ovtlsca2025-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">ovtlsca2026-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">pki-crl.atos.net</domain>
<domain includeSubdomains="true">pki.telesec.de</domain>
<domain includeSubdomains="true">pkipro.certsign.ro</domain>
<domain includeSubdomains="true">positiwise.crl.sectigo.com</domain>
<domain includeSubdomains="true">psw.crl.sectigo.com</domain>
<domain includeSubdomains="true">public.wisekey.com</domain>
<domain includeSubdomains="true">qiduodv.crl.certum.pl</domain>
<domain includeSubdomains="true">qtlsca2018-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">qtlsca2018-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">qtlsca2018-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">qtlsca2026-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">r10.c.lencr.org</domain>
<domain includeSubdomains="true">r11.c.lencr.org</domain>
<domain includeSubdomains="true">r12.c.lencr.org</domain>
<domain includeSubdomains="true">r13.c.lencr.org</domain>
<domain includeSubdomains="true">rca.navercloudtrust.com</domain>
<domain includeSubdomains="true">rca.navercorp.com</domain>
<domain includeSubdomains="true">repo.pubcert.jprs.jp</domain>
<domain includeSubdomains="true">repo1.secomtrust.net</domain>
<domain includeSubdomains="true">repository.secomtrust.net</domain>
<domain includeSubdomains="true">repository.tls.hinet.net</domain>
<domain includeSubdomains="true">rootca.twca.com.tw</domain>
<domain includeSubdomains="true">rootglobaldv.crl.certum.pl</domain>
<domain includeSubdomains="true">rootnetworksdv2.crl.certum.pl</domain>
<domain includeSubdomains="true">shenzhendv.crl.certum.pl</domain>
<domain includeSubdomains="true">shenzhenev.crl.certum.pl</domain>
<domain includeSubdomains="true">shenzhenov.crl.certum.pl</domain>
<domain includeSubdomains="true">shoperdvtlsg2r34ca.crl.certum.pl</domain>
<domain includeSubdomains="true">shuididv.crl.certum.pl</domain>
<domain includeSubdomains="true">shuidiev.crl.certum.pl</domain>
<domain includeSubdomains="true">shuidiov.crl.certum.pl</domain>
<domain includeSubdomains="true">soomadv.crl.certum.pl</domain>
<domain includeSubdomains="true">ssl2ca2016-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">ssl2ca2016-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">ssl2ca2016-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">sslca2014-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">sslca2014-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">sslca2014-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">sslcom.crl.certum.pl</domain>
<domain includeSubdomains="true">ssllimiteddv.crl.certum.pl</domain>
<domain includeSubdomains="true">sslserver.twca.com.tw</domain>
<domain includeSubdomains="true">subca.crl.certum.pl</domain>
<domain includeSubdomains="true">titrust.crl.sectigo.com</domain>
<domain includeSubdomains="true">tlcdvtlsr34g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">tlcevtlsr34g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">tlcovtlsr34g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">tlsrootca2025-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">trustasia.crl.certum.pl</domain>
<domain includeSubdomains="true">trustasiadv.crl.certum.pl</domain>
<domain includeSubdomains="true">trustasiadvtlsr35g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">trustasiaev.crl.certum.pl</domain>
<domain includeSubdomains="true">trustasiaevtlse35g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">trustasiaevtlsr35g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">trustasiaov.crl.certum.pl</domain>
<domain includeSubdomains="true">trustasiaovtlse35g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">trustasiaovtlsr35g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">trustocean.crl.certum.pl</domain>
<domain includeSubdomains="true">tstlser20.crl.telesec.de</domain>
<domain includeSubdomains="true">tstlsrr23.crl.telesec.de</domain>
<domain includeSubdomains="true">unitedtrustov.crl.certum.pl</domain>
<domain includeSubdomains="true">validation.identrust.com</domain>
<domain includeSubdomains="true">verokey.crl.sectigo.com</domain>
<domain includeSubdomains="true">vtrusdvtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">vtrusovtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">webnic.crl.sectigo.com</domain>
<domain includeSubdomains="true">whitessldvtlsg2e36ca.crl.certum.pl</domain>
<domain includeSubdomains="true">wosign.crl.certum.pl</domain>
<domain includeSubdomains="true">wotrus-dvca.crl.certum.pl</domain>
<domain includeSubdomains="true">wotrus-evca.crl.certum.pl</domain>
<domain includeSubdomains="true">wotrus-ovca.crl.certum.pl</domain>
<domain includeSubdomains="true">wotrus.crl.sectigo.com</domain>
<domain includeSubdomains="true">wtca-cafiles.itrus.com.cn</domain>
<domain includeSubdomains="true">wtca-crl.itrus.com.cn</domain>
<domain includeSubdomains="true">www.accv.es</domain>
<domain includeSubdomains="true">www.cert.fnmt.es</domain>
<domain includeSubdomains="true">www.d-trust.net</domain>
<domain includeSubdomains="true">www.microsoft.com</domain>
<domain includeSubdomains="true">x1.c.lencr.org</domain>
<domain includeSubdomains="true">x2.c.lencr.org</domain>
<domain includeSubdomains="true">xcctrustdvtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">xcctrustevtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">xcctrustovtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">xinchacha2dv.crl.certum.pl</domain>
<domain includeSubdomains="true">xinchacha2ov.crl.certum.pl</domain>
<domain includeSubdomains="true">xinchachatrustdvtlsg2r34ca.crl.certum.pl</domain>
<domain includeSubdomains="true">xinchachatrustevtlsg2r34ca.crl.certum.pl</domain>
<domain includeSubdomains="true">xinchachatrustovtlsg2r34ca.crl.certum.pl</domain>
<domain includeSubdomains="true">xinnetdvtlsr34g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">xinnetevtlsr34g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">xinnetovtlsr34g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">ye.c.lencr.org</domain>
<domain includeSubdomains="true">ye1.c.lencr.org</domain>
<domain includeSubdomains="true">ye2.c.lencr.org</domain>
<domain includeSubdomains="true">yektadv.crl.certum.pl</domain>
<domain includeSubdomains="true">yektaov.crl.certum.pl</domain>
<domain includeSubdomains="true">yr.c.lencr.org</domain>
<domain includeSubdomains="true">yr1.c.lencr.org</domain>
<domain includeSubdomains="true">yr2.c.lencr.org</domain>
<domain includeSubdomains="true">zerossl.crl.sectigo.com</domain>
<domain includeSubdomains="true">ziwit.crl.sectigo.com</domain>
<domain includeSubdomains="true">zycatrustdvtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">zycatrustevtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">zycatrustovtlsg3r31ca.crl.certum.pl</domain>
</domain-config> </domain-config>
</network-security-config> </network-security-config>
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<!-- Ref: https://developer.android.com/training/articles/security-config.html -->
<!-- By default, do not allow clearText traffic -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
<certificates
src="user"
tools:ignore="AcceptsUserCertificates" />
</trust-anchors>
</base-config>
<!-- Allow clearText traffic on some specified host -->
<domain-config cleartextTrafficPermitted="true">
<!-- Localhost -->
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<!-- Localhost for Android emulator -->
<domain includeSubdomains="true">10.0.2.2</domain>
<!-- Onion services -->
<domain includeSubdomains="true">onion</domain>
<!-- Domains that are used for LANs -->
<!-- These are IANA recognized special use domain names, see https://www.iana.org/assignments/special-use-domain-names/special-use-domain-names.xhtml -->
<domain includeSubdomains="true">home.arpa</domain>
<domain includeSubdomains="true">local</domain> <!-- Note this has been reserved for use with mDNS -->
<domain includeSubdomains="true">test</domain>
<!-- These are observed in the wild either by convention or RFCs that have not been accepted, and are not currently TLDs -->
<domain includeSubdomains="true">home</domain>
<domain includeSubdomains="true">lan</domain>
<domain includeSubdomains="true">localdomain</domain>
</domain-config>
</network-security-config>
-1
View File
@@ -48,7 +48,6 @@ android {
} }
dependencies { dependencies {
implementation(libs.coroutines.core)
implementation(libs.androidx.annotationjvm) implementation(libs.androidx.annotationjvm)
implementation(libs.androidx.corektx) implementation(libs.androidx.corektx)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
+1 -1
View File
@@ -44,12 +44,12 @@ dependencies {
implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.wellknown.api)
implementation(projects.features.login.api) implementation(projects.features.login.api)
implementation(libs.coil) implementation(libs.coil)
implementation(projects.features.announcement.api) implementation(projects.features.announcement.api)
implementation(projects.features.callnative.api)
implementation(projects.features.ftue.api) implementation(projects.features.ftue.api)
implementation(projects.features.linknewdevice.api) implementation(projects.features.linknewdevice.api)
implementation(projects.features.share.api) implementation(projects.features.share.api)
@@ -33,7 +33,7 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.matrix.api.MatrixClient 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 import kotlinx.parcelize.Parcelize
/** /**
@@ -8,28 +8,51 @@
package io.element.android.appnav 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.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage 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.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.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@Inject @AssistedInject
class LoggedInEventProcessor( class LoggedInEventProcessor(
private val snackbarDispatcher: SnackbarDispatcher, @Assisted private val snackbarDispatcher: SnackbarDispatcher,
private val roomMembershipObserver: RoomMembershipObserver, 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) { 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 } .filter { !it.isUserInRoom }
.distinctUntilChanged() .distinctUntilChanged()
.onEach { roomMemberShipUpdate -> .onEach { roomMemberShipUpdate ->
@@ -48,12 +71,20 @@ class LoggedInEventProcessor(
else -> Unit 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() { fun stopObserving() {
observingJob?.cancel() currentChildScope?.cancel()
observingJob = null currentChildScope = null
} }
private fun displayMessage(message: Int) { private fun displayMessage(message: Int) {
@@ -48,6 +48,7 @@ import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.compound.colors.SemanticColorsLightDark 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.createroom.api.CreateRoomEntryPoint
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.SessionEnterpriseService 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.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.JoinedRoom 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.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.matrix.api.verification.VerificationRequest
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
@@ -110,14 +110,10 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
import java.time.Duration
import java.time.Instant
import java.util.Optional import java.util.Optional
import java.util.UUID import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.toKotlinDuration
import im.vector.app.features.analytics.plan.JoinedRoom as JoinedRoomAnalyticsEvent 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. // 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 shareEntryPoint: ShareEntryPoint,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val sendingQueue: SendQueues, private val sendingQueue: SendQueues,
private val nativeCallHost: NativeCallHost,
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint, private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration, private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
private val sessionEnterpriseService: SessionEnterpriseService, private val sessionEnterpriseService: SessionEnterpriseService,
@@ -160,6 +157,7 @@ class LoggedInFlowNode(
private val createRoomEntryPoint: CreateRoomEntryPoint, private val createRoomEntryPoint: CreateRoomEntryPoint,
private val activeLiveLocationShareManager: ActiveLiveLocationShareManager, private val activeLiveLocationShareManager: ActiveLiveLocationShareManager,
private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
private val loggedInEventProcessorFactory: LoggedInEventProcessor.Factory,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>( ) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Placeholder, initialElement = NavTarget.Placeholder,
@@ -178,31 +176,13 @@ class LoggedInFlowNode(
} }
private val callback: Callback = callback() private val callback: Callback = callback()
private val loggedInFlowProcessor = LoggedInEventProcessor( private val loggedInFlowProcessor = loggedInEventProcessorFactory.create(snackbarDispatcher)
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = matrixClient.roomMembershipObserver,
)
private val verificationListener = object : SessionVerificationServiceListener { /**
override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) { * Display the incoming verification request, the app is expected to be in foreground.
// 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. fun onIncomingVerificationRequest(verificationRequest: VerificationRequest.Incoming) {
lifecycleScope.launch { 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
}
// Wait for the RoomList UI to be ready so the incoming verification screen can be displayed on top of it // 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 // Otherwise, the RoomList UI may be incorrectly displayed on top
withTimeout(5.seconds) { withTimeout(5.seconds) {
@@ -214,7 +194,6 @@ class LoggedInFlowNode(
backstack.singleTop(NavTarget.IncomingVerificationRequest(verificationRequest)) backstack.singleTop(NavTarget.IncomingVerificationRequest(verificationRequest))
} }
} }
}
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
@@ -227,7 +206,6 @@ class LoggedInFlowNode(
analyticsRoomListStateWatcher.start() analyticsRoomListStateWatcher.start()
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId) appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
loggedInFlowProcessor.observeEvents(sessionCoroutineScope) loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
matrixClient.sessionVerificationService.setListener(verificationListener)
mediaPreviewConfigMigration() mediaPreviewConfigMigration()
sessionCoroutineScope.launch { sessionCoroutineScope.launch {
// Wait for the network to be connected before pre-fetching the max file upload size // Wait for the network to be connected before pre-fetching the max file upload size
@@ -256,7 +234,6 @@ class LoggedInFlowNode(
onDestroy = { onDestroy = {
appNavigationStateService.onLeavingSession(id) appNavigationStateService.onLeavingSession(id)
loggedInFlowProcessor.stopObserving() loggedInFlowProcessor.stopObserving()
matrixClient.sessionVerificationService.setListener(null)
analyticsRoomListStateWatcher.stop() analyticsRoomListStateWatcher.stop()
} }
) )
@@ -382,6 +359,10 @@ class LoggedInFlowNode(
override fun navigateToBugReport() { override fun navigateToBugReport() {
callback.navigateToBugReport() callback.navigateToBugReport()
} }
override fun navigateToAddAccount() {
callback.navigateToAddAccount()
}
} }
homeEntryPoint.createNode( homeEntryPoint.createNode(
parentNode = this, parentNode = this,
@@ -701,9 +682,12 @@ class LoggedInFlowNode(
) { ) {
CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) { CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) {
val isOnline by syncService.isOnline.collectAsState() val isOnline by syncService.isOnline.collectAsState()
// 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( ConnectivityIndicatorContainer(
isOnline = isOnline, isOnline = isOnline,
modifier = modifier, modifier = callContentModifier,
) { contentModifier -> ) { contentModifier ->
Box(modifier = contentModifier) { Box(modifier = contentModifier) {
val ftueState by ftueService.state.collectAsState() val ftueState by ftueService.state.collectAsState()
@@ -716,6 +700,7 @@ class LoggedInFlowNode(
} }
} }
} }
}
} }
@Parcelize @Parcelize
@@ -13,6 +13,7 @@ package io.element.android.appnav
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi import coil3.annotation.DelicateCoilApi
import com.bumble.appyx.core.lifecycle.subscribe 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.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode 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.LoginEntryPoint
import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.LoginParams
import io.element.android.libraries.architecture.BackstackView 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.architecture.inputs
import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices
import io.element.android.libraries.designsystem.utils.ScreenOrientation 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 io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
@@ -45,6 +51,8 @@ class NotLoggedInFlowNode(
private val loginEntryPoint: LoginEntryPoint, private val loginEntryPoint: LoginEntryPoint,
private val imageLoaderHolder: ImageLoaderHolder, private val imageLoaderHolder: ImageLoaderHolder,
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
private val featureFlagService: FeatureFlagService,
private val announcementService: AnnouncementService,
) : BaseFlowNode<NotLoggedInFlowNode.NavTarget>( ) : BaseFlowNode<NotLoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, initialElement = NavTarget.Root,
@@ -68,6 +76,13 @@ class NotLoggedInFlowNode(
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
analyticsColdStartWatcher.whenLoggingIn() 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( lifecycle.subscribe(
onResume = { onResume = {
SingletonImageLoader.setUnsafe(imageLoaderHolder.get()) SingletonImageLoader.setUnsafe(imageLoaderHolder.get())
@@ -40,6 +40,9 @@ import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView import io.element.android.appnav.root.RootView
import io.element.android.appnav.session.MatrixSessionCache 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.announcement.api.AnnouncementService
import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl 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.appyx.rememberDelegateTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached 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.deeplink.api.DeeplinkData
import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService 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.asEventId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias 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.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.OAuthAction
import io.element.android.libraries.oauth.api.OAuthActionFlow import io.element.android.libraries.oauth.api.OAuthActionFlow
import io.element.android.libraries.sessionstorage.api.LoggedInState 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.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
@AssistedInject @AssistedInject
@@ -100,6 +110,7 @@ class RootFlowNode(
private val announcementService: AnnouncementService, private val announcementService: AnnouncementService,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
private val incomingVerificationRequestObserver: IncomingVerificationRequestObserver,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope, @AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : BaseFlowNode<RootFlowNode.NavTarget>( ) : BaseFlowNode<RootFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@@ -127,6 +138,8 @@ class RootFlowNode(
observeNavState(false) observeNavState(false)
} }
} }
observeIncomingVerificationRequests()
observeRemovedSessions()
super.onBuilt() super.onBuilt()
} }
@@ -203,6 +216,65 @@ class RootFlowNode(
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
pendingLoginParams = null pendingLoginParams = null
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId)) 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<LoggedInAppScopeFlowNode, NavTarget> { 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?) { private fun switchToNotLoggedInFlow(params: LoginParams?) {
@@ -294,6 +366,11 @@ class RootFlowNode(
) : NavTarget ) : NavTarget
@Parcelize data object BugReport : 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 { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -348,6 +425,23 @@ class RootFlowNode(
) )
} }
NavTarget.SplashScreen -> emptyNode(buildContext) 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<OtherSessionIncomingVerificationNode>(buildContext, plugins = listOf(inputs, callback))
}
NavTarget.BugReport -> { NavTarget.BugReport -> {
val callback = object : BugReportEntryPoint.Callback { val callback = object : BugReportEntryPoint.Callback {
override fun onDone() { override fun onDone() {
@@ -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.JoinedRoomLoadedFlowNode
import io.element.android.appnav.room.joined.LoadingRoomNodeView import io.element.android.appnav.room.joined.LoadingRoomNodeView
import io.element.android.features.joinroom.api.JoinRoomEntryPoint import io.element.android.features.joinroom.api.JoinRoomEntryPoint
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint.Params import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint.Params
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
@@ -6,12 +6,9 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.appnav.room.joined package io.element.android.appnav.room.joined
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -68,7 +67,6 @@ fun LoadingRoomNodeView(
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun LoadingRoomTopBar( private fun LoadingRoomTopBar(
onBackClick: () -> Unit, onBackClick: () -> Unit,
@@ -13,7 +13,7 @@ import com.bumble.appyx.core.state.SavedStateMap
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.appnav.session.MatrixSessionCache import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.features.preferences.api.CacheService 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.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -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.libraries.matrix.api.core.SessionId
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.AnalyticsUserData 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.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import timber.log.Timber import timber.log.Timber
@@ -41,6 +44,12 @@ class MatrixSessionCache(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : MatrixClientProvider { ) : MatrixClientProvider {
private val sessionIdsToMatrixSession = ConcurrentHashMap<SessionId, InMemoryMatrixSession>() private val sessionIdsToMatrixSession = ConcurrentHashMap<SessionId, InMemoryMatrixSession>()
private val _matrixClients = MutableStateFlow<List<MatrixClient>>(emptyList())
/**
* The [MatrixClient]s currently in the cache.
*/
val matrixClients: StateFlow<List<MatrixClient>> = _matrixClients.asStateFlow()
private val restoreMutex = Mutex() private val restoreMutex = Mutex()
init { init {
@@ -51,10 +60,12 @@ class MatrixSessionCache(
fun removeAll() { fun removeAll() {
sessionIdsToMatrixSession.clear() sessionIdsToMatrixSession.clear()
updateMatrixClients()
} }
fun remove(sessionId: SessionId) { fun remove(sessionId: SessionId) {
sessionIdsToMatrixSession.remove(sessionId) sessionIdsToMatrixSession.remove(sessionId)
updateMatrixClients()
} }
override fun getOrNull(sessionId: SessionId): MatrixClient? { override fun getOrNull(sessionId: SessionId): MatrixClient? {
@@ -122,8 +133,13 @@ class MatrixSessionCache(
matrixClient = matrixClient, matrixClient = matrixClient,
syncOrchestrator = syncOrchestrator, syncOrchestrator = syncOrchestrator,
) )
updateMatrixClients()
syncOrchestrator.start() syncOrchestrator.start()
} }
private fun updateMatrixClients() {
_matrixClients.value = sessionIdsToMatrixSession.values.map { it.matrixClient }
}
} }
private data class InMemoryMatrixSession( private data class InMemoryMatrixSession(
@@ -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<IncomingVerificationRequestData> = 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)
}
}
}
}
@@ -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<Plugin>,
sessionGraphFactory: SessionGraphFactory,
matrixClientProvider: MatrixClientProvider,
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
) : ParentNode<OtherSessionIncomingVerificationNode.NavTarget>(
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,
)
}
}
@@ -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(),
)
}
@@ -25,6 +25,8 @@ import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.session.FakeSyncOrchestratorFactory import io.element.android.appnav.session.FakeSyncOrchestratorFactory
import io.element.android.appnav.session.MatrixSessionCache 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.LoginEntryPoint
import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.test.FakeLoginEntryPoint 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.login.test.accesscontrol.FakeAccountProviderAccessControl
import io.element.android.features.preferences.test.FakeCacheService import io.element.android.features.preferences.test.FakeCacheService
import io.element.android.features.rageshake.test.FakeBugReportEntryPoint 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.share.test.FakeShareIntentHandler
import io.element.android.features.signedout.test.FakeSignedOutEntryPoint import io.element.android.features.signedout.test.FakeSignedOutEntryPoint
import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint 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.FakeSdkMetadata
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser 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.FakeOAuthActionFlow
import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver
import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory 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.FakeAnalyticsService
import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher
import io.element.android.services.apperror.test.FakeAppErrorStateService 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.FakeNodeFactoriesBindings
import io.element.android.tests.testutils.node.FakeParentNode import io.element.android.tests.testutils.node.FakeParentNode
import io.element.android.tests.testutils.presenter.NotUsedPresenter import io.element.android.tests.testutils.presenter.NotUsedPresenter
@@ -139,6 +141,8 @@ class RootFlowNodeTest : RobolectricTest() {
loginEntryPoint = loginEntryPoint, loginEntryPoint = loginEntryPoint,
imageLoaderHolder = FakeImageLoaderHolder(), imageLoaderHolder = FakeImageLoaderHolder(),
analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(), analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(),
featureFlagService = FakeFeatureFlagService(),
announcementService = FakeAnnouncementService(),
) )
} }
) )
@@ -183,6 +187,10 @@ class RootFlowNodeTest : RobolectricTest() {
announcementService = FakeAnnouncementService(), announcementService = FakeAnnouncementService(),
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(), analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(),
incomingVerificationRequestObserver = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = FakeAppForegroundStateService(),
),
appCoroutineScope = backgroundScope, appCoroutineScope = backgroundScope,
) )
} }
@@ -70,9 +70,11 @@ class MatrixSessionCacheTest {
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient) assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient) assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.matrixClients.value).containsExactly(fakeMatrixClient)
// Remove // Remove
matrixSessionCache.remove(A_SESSION_ID) matrixSessionCache.remove(A_SESSION_ID)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
assertThat(matrixSessionCache.matrixClients.value).isEmpty()
} }
@Test @Test
@@ -83,9 +85,11 @@ class MatrixSessionCacheTest {
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient) assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient) assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.matrixClients.value).containsExactly(fakeMatrixClient)
// Remove all // Remove all
matrixSessionCache.removeAll() matrixSessionCache.removeAll()
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
assertThat(matrixSessionCache.matrixClients.value).isEmpty()
} }
@Test @Test
@@ -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,
)
}
+89 -10
View File
@@ -101,10 +101,6 @@ allprojects {
// Uncomment to suppress Compose Kotlin compiler compatibility warning // Uncomment to suppress Compose Kotlin compiler compatibility warning
// freeCompilerArgs.addAll(listOf("-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true")) // 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 { allprojects {
tasks.withType<Test> { tasks.withType<Test> {
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) } val isScreenshotTest = project.gradle.startParameter.taskNames.any { it.contains("paparazzi", ignoreCase = true) }
if (isScreenshotTest) { if (isScreenshotTest) {
@@ -173,6 +175,9 @@ allprojects {
// Gradle parallelise by running many modules' test tasks concurrently instead. // Gradle parallelise by running many modules' test tasks concurrently instead.
maxParallelForks = 1 maxParallelForks = 1
// Increase heap size for screenshot tests
maxHeapSize = "2g"
// Disable screenshot tests by default // Disable screenshot tests by default
exclude("ui/*.class") exclude("ui/*.class")
exclude("translations/*.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()) 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 { subprojects {
val snapshotsDir = File("${project.projectDir}/src/test/snapshots") val snapshotsDir = File("${project.projectDir}/src/test/snapshots")
val removeOldScreenshotsTask = tasks.register("removeOldSnapshots") { tasks.register("removeOldSnapshots") {
onlyIf { snapshotsDir.exists() } onlyIf { snapshotsDir.exists() }
doFirst { doFirst {
println("Delete previous screenshots located at $snapshotsDir\n") println("Delete previous screenshots located at $snapshotsDir\n")
snapshotsDir.deleteRecursively() 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/<runName>.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 "<package>.<class>#<method>" and the screenshot file is named
// "<package>_<class>_<method>.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 { subprojects {
@@ -53,7 +53,7 @@ class ContributesNodeProcessor(
val annotatedSymbols = resolver.getSymbolsWithAnnotation(ContributesNode::class.qualifiedName!!) val annotatedSymbols = resolver.getSymbolsWithAnnotation(ContributesNode::class.qualifiedName!!)
.filterIsInstance<KSClassDeclaration>() .filterIsInstance<KSClassDeclaration>()
val (validSymbols, invalidSymbols) = annotatedSymbols.partition { it.validate() } val (validSymbols, invalidSymbols) = annotatedSymbols.partition { it.validate(enableNewFeatures = false) }
if (validSymbols.isEmpty()) return invalidSymbols if (validSymbols.isEmpty()) return invalidSymbols
@@ -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
-2
View File
@@ -16,6 +16,4 @@ android {
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.uiStrings)
} }
@@ -17,4 +17,6 @@ sealed interface Announcement {
} }
data object NewNotificationSound : Announcement data object NewNotificationSound : Announcement
data object MultiAccount : Announcement
} }
@@ -29,10 +29,11 @@ class DefaultAnnouncementService(
) : AnnouncementService { ) : AnnouncementService {
override suspend fun showAnnouncement(announcement: Announcement) { override suspend fun showAnnouncement(announcement: Announcement) {
when (announcement) { when (announcement) {
is Announcement.Fullscreen -> showFullscreenAnnouncement(announcement) is Announcement.Fullscreen -> showAnnouncementIfNeverShown(announcement)
Announcement.NewNotificationSound -> { Announcement.NewNotificationSound -> {
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
} }
Announcement.MultiAccount -> showAnnouncementIfNeverShown(announcement)
} }
} }
@@ -44,16 +45,22 @@ class DefaultAnnouncementService(
return combine( return combine(
flowOf(Unit), flowOf(Unit),
announcementStore.announcementStatusFlow(Announcement.NewNotificationSound), announcementStore.announcementStatusFlow(Announcement.NewNotificationSound),
) { _, newNotificationSoundStatus -> announcementStore.announcementStatusFlow(Announcement.MultiAccount),
) { _, newNotificationSoundStatus, multiAccountStatus ->
buildList { buildList {
if (newNotificationSoundStatus == AnnouncementStatus.Show) { if (newNotificationSoundStatus == AnnouncementStatus.Show) {
add(Announcement.NewNotificationSound) 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() val currentValue = announcementStore.announcementStatusFlow(announcement).first()
if (currentValue == AnnouncementStatus.NeverShown) { if (currentValue == AnnouncementStatus.NeverShown) {
announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Show) announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Show)
@@ -121,7 +121,6 @@ private fun FullscreenAnnouncementHeader(
subTitle = announcement.subtitle(), subTitle = announcement.subtitle(),
iconStyle = BigIcon.Style.Default( iconStyle = BigIcon.Style.Default(
vectorIcon = announcement.icon(), vectorIcon = announcement.icon(),
usePrimaryTint = true,
), ),
) )
} }
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.map
private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement") private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement")
private val newNotificationSoundKey = intPreferencesKey("newNotificationSound") private val newNotificationSoundKey = intPreferencesKey("newNotificationSound")
private val multiAccountKey = intPreferencesKey("multiAccountAnnouncement")
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultAnnouncementStore( class DefaultAnnouncementStore(
@@ -36,11 +37,15 @@ class DefaultAnnouncementStore(
override fun announcementStatusFlow(announcement: Announcement): Flow<AnnouncementStatus> { override fun announcementStatusFlow(announcement: Announcement): Flow<AnnouncementStatus> {
val key = announcement.toKey() val key = announcement.toKey()
// Default-status convention: Shown for one-shot intro/feature announcements that should // Default-status convention: Shown for one-shot intro/feature announcements that should
// NOT replay on first launch (Fullscreen.Space is disabled; NewNotificationSound is opted // NOT replay on first launch.
// in by AppMigration08). // - 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) { val defaultStatus = when (announcement) {
Announcement.Fullscreen.Space -> AnnouncementStatus.Shown Announcement.Fullscreen.Space -> AnnouncementStatus.Shown
Announcement.NewNotificationSound -> AnnouncementStatus.Shown Announcement.NewNotificationSound -> AnnouncementStatus.Shown
Announcement.MultiAccount -> AnnouncementStatus.NeverShown
} }
return store.data.map { prefs -> return store.data.map { prefs ->
val ordinal = prefs[key] ?: defaultStatus.ordinal val ordinal = prefs[key] ?: defaultStatus.ordinal
@@ -56,4 +61,5 @@ class DefaultAnnouncementStore(
private fun Announcement.toKey() = when (this) { private fun Announcement.toKey() = when (this) {
Announcement.Fullscreen.Space -> spaceAnnouncementKey Announcement.Fullscreen.Space -> spaceAnnouncementKey
Announcement.NewNotificationSound -> newNotificationSoundKey Announcement.NewNotificationSound -> newNotificationSoundKey
Announcement.MultiAccount -> multiAccountKey
} }
@@ -51,6 +51,22 @@ class DefaultAnnouncementServiceTest {
assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.Show) 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 @Test
fun `test announcementsToShowFlow`() = runTest { fun `test announcementsToShowFlow`() = runTest {
val announcementStore = InMemoryAnnouncementStore() val announcementStore = InMemoryAnnouncementStore()
@@ -58,10 +74,16 @@ class DefaultAnnouncementServiceTest {
announcementStore = announcementStore, announcementStore = announcementStore,
) )
sut.announcementsToShowFlow().test { sut.announcementsToShowFlow().test {
assertThat(awaitItem()).isEmpty() // MultiAccount is pending by default
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount)
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) 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) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown)
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount)
announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Shown)
assertThat(awaitItem()).isEmpty() assertThat(awaitItem()).isEmpty()
} }
} }
@@ -16,9 +16,11 @@ import kotlinx.coroutines.flow.asStateFlow
class InMemoryAnnouncementStore( class InMemoryAnnouncementStore(
initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialMultiAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
) : AnnouncementStore { ) : AnnouncementStore {
private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus) private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus)
private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus) private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus)
private val multiAccountAnnouncement = MutableStateFlow(initialMultiAccountAnnouncementStatus)
override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) { override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) {
announcement.toMutableStateFlow().value = status announcement.toMutableStateFlow().value = status
@@ -31,10 +33,12 @@ class InMemoryAnnouncementStore(
override suspend fun reset() { override suspend fun reset() {
fullScreenAnnouncement.value = AnnouncementStatus.NeverShown fullScreenAnnouncement.value = AnnouncementStatus.NeverShown
newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown
multiAccountAnnouncement.value = AnnouncementStatus.NeverShown
} }
private fun Announcement.toMutableStateFlow() = when (this) { private fun Announcement.toMutableStateFlow() = when (this) {
is Announcement.Fullscreen -> fullScreenAnnouncement is Announcement.Fullscreen -> fullScreenAnnouncement
Announcement.NewNotificationSound -> newNotificationSoundAnnouncement Announcement.NewNotificationSound -> newNotificationSoundAnnouncement
Announcement.MultiAccount -> multiAccountAnnouncement
} }
} }
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * 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.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
-1
View File
@@ -17,6 +17,5 @@ android {
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
} }
@@ -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()
}
@@ -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)
}
+1 -1
View File
@@ -86,10 +86,10 @@ dependencies {
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.serialization.json) implementation(libs.serialization.json)
implementation(libs.element.call.embedded) implementation(libs.element.call.embedded)
implementation(projects.features.callnative.api)
api(projects.features.call.api) api(projects.features.call.api)
testCommonDependencies(libs, true) testCommonDependencies(libs, true)
testImplementation(projects.features.call.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
@@ -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.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.IntentProvider 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.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.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultElementCallEntryPoint( class DefaultElementCallEntryPoint(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val activeCallManager: ActiveCallManager, private val activeCallManager: ActiveCallManager,
private val nativeCallEntryPoint: NativeCallEntryPoint,
private val featureFlagService: FeatureFlagService,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : ElementCallEntryPoint { ) : ElementCallEntryPoint {
companion object { companion object {
const val EXTRA_CALL_TYPE = "EXTRA_CALL_TYPE" const val EXTRA_CALL_TYPE = "EXTRA_CALL_TYPE"
@@ -31,8 +41,19 @@ class DefaultElementCallEntryPoint(
} }
override fun startCall(callData: CallData) { override fun startCall(callData: 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)) context.startActivity(IntentProvider.createIntent(context, callData))
} }
}
}
override suspend fun handleIncomingCall( override suspend fun handleIncomingCall(
callData: CallData, callData: CallData,
@@ -18,10 +18,8 @@ import androidx.core.app.PendingIntentCompat
import androidx.core.app.Person import androidx.core.app.Person
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.appconfig.ElementCallConfig 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.receivers.DeclineCallBroadcastReceiver
import io.element.android.features.call.impl.ui.IncomingCallActivity 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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.CommonDrawables 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.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 io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -55,6 +53,15 @@ class RingingCallNotificationCreator(
* Request code for the full screen intent. * Request code for the full screen intent.
*/ */
const val FULL_SCREEN_INTENT_REQUEST_CODE = 2 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( suspend fun createNotification(
@@ -89,14 +96,6 @@ class RingingCallNotificationCreator(
.setImportant(true) .setImportant(true)
.build() .build()
val answerIntent = IntentProvider.getPendingIntent(
context,
CallData(
sessionId = sessionId,
roomId = roomId,
isAudioCall = audioOnly,
),
)
val notificationData = CallNotificationData( val notificationData = CallNotificationData(
sessionId = sessionId, sessionId = sessionId,
roomId = roomId, roomId = roomId,
@@ -112,6 +111,21 @@ class RingingCallNotificationCreator(
audioOnly = audioOnly, 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( val declineIntent = PendingIntentCompat.getBroadcast(
context, context,
DECLINE_REQUEST_CODE, DECLINE_REQUEST_CODE,
@@ -8,6 +8,7 @@
package io.element.android.features.call.impl.ui package io.element.android.features.call.impl.ui
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@@ -47,6 +48,15 @@ class IncomingCallActivity : AppCompatActivity() {
* Extra key for the notification data. * Extra key for the notification data.
*/ */
const val EXTRA_NOTIFICATION_DATA = "EXTRA_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 @Inject
@@ -85,6 +95,7 @@ class IncomingCallActivity : AppCompatActivity() {
) )
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) } val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
if (answerImmediatelyIfAsked(intent)) return
if (notificationData != null) { if (notificationData != null) {
setContent { setContent {
val colors by remember { val colors by remember {
@@ -116,6 +127,37 @@ class IncomingCallActivity : AppCompatActivity() {
.launchIn(lifecycleScope) .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) { private fun onAnswer(notificationData: CallNotificationData) {
elementCallEntryPoint.startCall( elementCallEntryPoint.startCall(
CallData( CallData(
@@ -19,9 +19,11 @@ import coil3.annotation.DelicateCoilApi
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding
import io.element.android.appconfig.ElementCallConfig import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallData import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.CurrentCall 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.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.core.extensions.runCatchingExceptions 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.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId 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.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler
@@ -90,7 +92,8 @@ interface ActiveCallManager {
} }
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class, binding = binding<ActiveCallManager>())
@ContributesBinding(AppScope::class, binding = binding<RingingCallTracker>())
class DefaultActiveCallManager( class DefaultActiveCallManager(
@ApplicationContext context: Context, @ApplicationContext context: Context,
@AppCoroutineScope @AppCoroutineScope
@@ -103,7 +106,16 @@ class DefaultActiveCallManager(
private val appForegroundStateService: AppForegroundStateService, private val appForegroundStateService: AppForegroundStateService,
private val imageLoaderHolder: ImageLoaderHolder, private val imageLoaderHolder: ImageLoaderHolder,
private val systemClock: SystemClock, 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 val tag = "ActiveCallManager"
private var timedOutCallJob: Job? = null private var timedOutCallJob: Job? = null
@@ -11,20 +11,23 @@ package io.element.android.features.call.impl.utils
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn 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.CurrentCall
import io.element.android.features.call.api.CurrentCallService import io.element.android.features.call.api.CurrentCallService
import io.element.android.features.call.api.CurrentCallTracker
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class, binding = binding<CurrentCallService>())
class DefaultCurrentCallService : CurrentCallService { @ContributesBinding(AppScope::class, binding = binding<CurrentCallTracker>())
class DefaultCurrentCallService : CurrentCallService, CurrentCallTracker {
override val currentCall = MutableStateFlow<CurrentCall>(CurrentCall.None) override val currentCall = MutableStateFlow<CurrentCall>(CurrentCall.None)
fun onCallStarted(call: CurrentCall) { override fun onCallStarted(call: CurrentCall) {
currentCall.value = call currentCall.value = call
} }
fun onCallEnded() { override fun onCallEnded() {
currentCall.value = CurrentCall.None currentCall.value = CurrentCall.None
} }
} }
@@ -6,25 +6,30 @@
* Please see LICENSE files in the repository root for full details. * 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 android.content.Intent
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallData 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.notifications.CallNotificationData
import io.element.android.features.call.impl.ui.ElementCallActivity 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.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_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_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 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.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.robolectric.RuntimeEnvironment import org.robolectric.RuntimeEnvironment
@@ -32,10 +37,13 @@ import org.robolectric.Shadows.shadowOf
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class DefaultElementCallEntryPointTest : RobolectricTest() { class DefaultElementCallEntryPointTest : RobolectricTest() {
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest { fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest {
val entryPoint = createEntryPoint() val entryPoint = createEntryPoint()
entryPoint.startCall(CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = false)) 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 expectedIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ElementCallActivity::class.java)
val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity
@@ -43,6 +51,24 @@ class DefaultElementCallEntryPointTest : RobolectricTest() {
assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue() 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<CallData, Unit> {}
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) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() = runTest { fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() = runTest {
@@ -70,8 +96,15 @@ class DefaultElementCallEntryPointTest : RobolectricTest() {
private fun TestScope.createEntryPoint( private fun TestScope.createEntryPoint(
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(), activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
nativeCallEntryPoint: NativeCallEntryPoint = FakeNativeCallEntryPoint(),
isNativeCallEnabled: Boolean = false,
) = DefaultElementCallEntryPoint( ) = DefaultElementCallEntryPoint(
context = InstrumentationRegistry.getInstrumentation().targetContext, context = InstrumentationRegistry.getInstrumentation().targetContext,
activeCallManager = activeCallManager, activeCallManager = activeCallManager,
nativeCallEntryPoint = nativeCallEntryPoint,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.NativeCall.key to isNativeCallEnabled),
),
appCoroutineScope = this,
) )
} }
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * 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.Manifest
import android.webkit.PermissionRequest import android.webkit.PermissionRequest
@@ -6,13 +6,13 @@
* Please see LICENSE files in the repository root for full details. * 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.core.graphics.drawable.IconCompat
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import coil3.ImageLoader import coil3.ImageLoader
import com.google.common.truth.Truth.assertThat 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.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.test.AN_EVENT_ID 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_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.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider 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.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.robolectric.Shadows.shadowOf
class RingingCallNotificationCreatorTest : RobolectricTest() { class RingingCallNotificationCreatorTest : RobolectricTest() {
@Test @Test
@@ -89,6 +90,35 @@ class RingingCallNotificationCreatorTest : RobolectricTest() {
assertThat(acceptAction?.title?.toString()).isEqualTo("Answer") 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( private suspend fun RingingCallNotificationCreator.createTestNotification(audioOnly: Boolean = false) = createNotification(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * 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 com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallData import io.element.android.features.call.api.CallData
@@ -5,11 +5,9 @@
* Please see LICENSE files in the repository root for full details. * 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 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 import org.junit.Test
class CallScreenBackPressPolicyTest { class CallScreenBackPressPolicyTest {
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * 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.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
@@ -14,13 +14,10 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.features.call.api.CallData import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.ui.CallScreenEvent import io.element.android.features.call.impl.utils.FakeActiveCallManager
import io.element.android.features.call.impl.ui.CallScreenNavigator import io.element.android.features.call.impl.utils.FakeCallWidgetProvider
import io.element.android.features.call.impl.ui.CallScreenPresenter import io.element.android.features.call.impl.utils.FakeWidgetMessageInterceptor
import io.element.android.features.call.impl.utils.WidgetMessageSerializer 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.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * 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.view.KeyEvent
import android.webkit.WebView 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.PictureInPictureEvent
import io.element.android.features.call.impl.pip.PictureInPictureState 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.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.EventsRecorder
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.robolectric.RobolectricTest
@@ -6,9 +6,7 @@
* Please see LICENSE files in the repository root for full details. * 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 io.element.android.features.call.impl.ui.CallScreenNavigator
class FakeCallScreenNavigator : CallScreenNavigator { class FakeCallScreenNavigator : CallScreenNavigator {
var closeCalled = false var closeCalled = false
@@ -6,10 +6,9 @@
* Please see LICENSE files in the repository root for full details. * 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 androidx.compose.runtime.Composable
import io.element.android.features.call.impl.ui.LanguageTagProvider
class FakeLanguageTagProvider(private val languageTag: String?) : LanguageTagProvider { class FakeLanguageTagProvider(private val languageTag: String?) : LanguageTagProvider {
@Composable @Composable
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * 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 android.os.PowerManager
import androidx.core.app.NotificationManagerCompat 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.api.CallData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator 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.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.EventId
import io.element.android.libraries.matrix.api.core.RoomId 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.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.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo 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.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler
@@ -6,10 +6,9 @@
* Please see LICENSE files in the repository root for full details. * 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 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.MatrixClientProvider
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
@@ -6,12 +6,10 @@
* Please see LICENSE files in the repository root for full details. * 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.api.CallData
import io.element.android.features.call.impl.notifications.CallNotificationData 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 io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -6,9 +6,8 @@
* Please see LICENSE files in the repository root for full details. * 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
@@ -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)
}
@@ -6,9 +6,8 @@
* Please see LICENSE files in the repository root for full details. * 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 import kotlinx.coroutines.flow.MutableSharedFlow
class FakeWidgetMessageInterceptor : WidgetMessageInterceptor { class FakeWidgetMessageInterceptor : WidgetMessageInterceptor {
-1
View File
@@ -17,7 +17,6 @@ android {
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
api(projects.features.call.api) api(projects.features.call.api)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
@@ -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<CurrentCall>()
override fun onCallStarted(call: CurrentCall) {
calls += call
}
override fun onCallEnded() {
calls += CurrentCall.None
}
}
@@ -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<CallData>()
val ended = mutableListOf<CallData>()
override suspend fun onCallJoined(callData: CallData) {
joined += callData
}
override suspend fun onCallEnded(callData: CallData) {
ended += callData
}
}
+32
View File
@@ -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)
}
@@ -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
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
+52
View File
@@ -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)
}
@@ -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
}
@@ -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,
)
}
}
@@ -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)
}
}
@@ -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<ElementCallController?>
/**
* 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<Map<SessionId, ElementCallStack>>(emptyMap())
// One at a time across every session: two calls placed at once must not race into two stacks for
// the same session, because only one of them would ever be closed.
private val mutex = Mutex()
override suspend fun getOrBuild(sessionId: SessionId): ElementCallController? = mutex.withLock {
stacks.value[sessionId]?.let { return@withLock it.controller }
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull()
if (client == null) {
Timber.w("NativeCall: no client for $sessionId, cannot start a call")
return@withLock null
}
build(client).also { stack ->
stacks.update { it + (sessionId to stack) }
// The client's scope is the stack's lifetime: the component has no shutdown of its own, so
// cancelling this at logout is what stops the RTC core. Closing here only releases the
// component's own reference, so a dead stack is not left behind it.
client.sessionCoroutineScope.coroutineContext.job.invokeOnCompletion {
stacks.update { if (it[sessionId] === stack) it - sessionId else it }
stack.close()
}
}.controller
}
override fun controller(sessionId: SessionId): Flow<ElementCallController?> =
stacks.map { it[sessionId]?.controller }.distinctUntilChanged()
override fun withRunningCall(): ElementCallController? =
stacks.value.values.firstNotNullOfOrNull { stack ->
stack.controller.takeIf { it.state.value != null }
}
private suspend fun build(client: MatrixClient): ElementCallStack =
ElementCallStack.Builder(context, transportFactory.create(client))
.lifecycleListener(
ElementXCallLifecycleListener(
sessionId = client.sessionId,
currentCallTracker = currentCallTracker,
ringingCallTracker = ringingCallTracker,
sessionCoroutineScope = client.sessionCoroutineScope,
appForegroundStateService = appForegroundStateService,
)
)
.roomContext(ElementXRoomContextProvider(client))
.options(options())
.build(scope = client.sessionCoroutineScope)
private suspend fun options() = ElementCallOptions(
logging = loggingConfiguration(),
notification = ElementCallNotificationConfig(
// The same id the WebView call's foreground service uses, so that the two paths cannot
// leave two ongoing-call notifications in the shade.
notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.ONGOING_CALL),
),
)
/**
* Read here rather than in `PlatformInitializer`, where the Rust SDK's tracing is configured: the
* core applies this when it first loads, which is when a call is first placed, and this is the first
* place where both the preference and the flag are readable.
*/
private suspend fun loggingConfiguration(): MatrixRtcLoggingConfiguration {
val logLevel = appPreferencesStore.getTracingLogLevelFlow().first()
return MatrixRtcLoggingConfiguration(
// Floored at debug: turning the SDK level down to quiet the timeline and event-cache
// traces is exactly what you do while working on calls, and it must not also silence the
// RTC core. The core's own filter holds down the parts that log per frame.
logLevel = maxOf(logLevel, LogLevel.DEBUG).toMatrixRtcLogLevel(),
writesToLogcat = featureFlagService.isFeatureEnabled(FeatureFlags.PrintLogsToLogcat),
)
}
private fun LogLevel.toMatrixRtcLogLevel(): MatrixRtcLogLevel = when (this) {
LogLevel.ERROR -> MatrixRtcLogLevel.ERROR
LogLevel.WARN -> MatrixRtcLogLevel.WARN
LogLevel.INFO -> MatrixRtcLogLevel.INFO
LogLevel.DEBUG -> MatrixRtcLogLevel.DEBUG
LogLevel.TRACE -> MatrixRtcLogLevel.TRACE
}
}
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.callnative.impl
import io.element.android.call.api.ElementCallData
import io.element.android.call.api.ElementCallLifecycleListener
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.CurrentCallTracker
import io.element.android.features.call.api.RingingCallTracker
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
/**
* Reports a native call back to the parts of Element X that already track calls.
*
* The library knows it is in a call; Element X's room list, timeline and ringing machinery each
* already know how to react to one. This joins the two, so that a native call looks like any other
* call to the rest of the app rather than needing every reader to learn about it.
*/
class ElementXCallLifecycleListener(
private val sessionId: SessionId,
private val currentCallTracker: CurrentCallTracker,
private val ringingCallTracker: RingingCallTracker,
private val sessionCoroutineScope: CoroutineScope,
appForegroundStateService: AppForegroundStateService,
) : ElementCallLifecycleListener {
override val isAppInForeground: Flow<Boolean> = appForegroundStateService.isInForeground
override fun onCallStarted(callData: ElementCallData) {
currentCallTracker.onCallStarted(CurrentCall.RoomCall(callData.roomId()))
}
override fun onCallJoined(callData: ElementCallData) {
// The ringing side is suspending and these callbacks are not, so the work is handed to the
// session scope rather than blocking the controller's. It outlives the call on purpose: a
// ring still has to be taken down even if the call ends while this is in flight.
sessionCoroutineScope.launch { ringingCallTracker.onCallJoined(callData.toCallData()) }
}
override fun onCallEnded(callData: ElementCallData?) {
currentCallTracker.onCallEnded()
callData?.let { sessionCoroutineScope.launch { ringingCallTracker.onCallEnded(it.toCallData()) } }
}
private fun ElementCallData.roomId() = RoomId(roomId.value)
private fun ElementCallData.toCallData() = CallData(
sessionId = sessionId,
roomId = roomId(),
isAudioCall = isAudioCall,
)
}
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.callnative.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import io.element.android.call.ui.theme.ElementCallColors
import io.element.android.call.ui.theme.ElementCallIcons
import io.element.android.call.ui.theme.ElementCallStyle
import io.element.android.call.ui.theme.ElementCallTypography
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
/**
* How Element X dresses the native call.
*
* Deliberately almost nothing. The component's own defaults are already Compound - its colours are
* Compound's dark palette and its glyphs are Compound vectors - so re-deriving them from
* `ElementTheme` would buy no visual change and a lot of mapping to keep in step. The call also stays
* dark in a light-themed app on purpose: it is a video surface, as it is in Element Call on the web.
*
* What the component genuinely cannot do is turn an `mxc://` URL into a picture, because that needs a
* Matrix media loader. So the avatar is the one thing overridden here, and it is what makes faces
* appear on the tiles rather than initials.
*/
@Composable
fun rememberElementXCallStyle(): ElementCallStyle {
val icons = ElementCallIcons.default()
return remember(icons) {
ElementCallStyle(
colors = ElementCallColors.Dark,
typography = ElementCallTypography.Default,
icons = icons,
avatar = { data, size ->
Avatar(
avatarData = AvatarData(
id = data.id,
name = data.name,
url = data.url,
// Unused: forcedAvatarSize below is what decides the size, and the component
// asks in dp because it lays the tiles out itself.
size = AvatarSize.ActiveCallItem,
),
avatarType = AvatarType.User,
forcedAvatarSize = size,
)
},
)
}
}
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.callnative.impl
import io.element.android.call.api.ElementCallRoomContext
import io.element.android.call.api.ElementCallRoomContextProvider
import io.element.android.call.api.ElementCallRoomMember
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.roomMembers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import timber.log.Timber
import io.element.android.call.api.rtc.id.RoomId as ElementCallRoomId
import io.element.android.call.api.rtc.id.UserId as ElementCallUserId
import io.element.android.libraries.matrix.api.core.RoomId as MatrixRoomId
/**
* Tells the call what the room is called and who is in it.
*
* The RTC layer knows a participant only as a member id and a user id - it has no reason to know
* what anyone is called or what they look like. Element X does, so the join happens here rather than
* in the call UI, and it goes through Element X's room cache so that names and avatars are the same
* ones the rest of the app shows. The library ships a provider of its own that reads the SDK
* directly; it is not used, because it would miss exactly that cache.
*/
class ElementXRoomContextProvider(
private val matrixClient: MatrixClient,
) : ElementCallRoomContextProvider {
override fun roomContext(roomId: ElementCallRoomId): Flow<ElementCallRoomContext> = flow {
val room = matrixClient.getRoom(MatrixRoomId(roomId.value))
if (room == null) {
// Emits nothing rather than guessing: the call then shows user ids, which is better than
// a name we made up.
Timber.w("NativeCall: no room for ${roomId.value}, the call will show user ids")
return@flow
}
room.use {
coroutineScope {
// The member list is loaded lazily, and a call is exactly the moment it is needed:
// without this the tiles would show user ids until something else happened to ask.
// Launched rather than awaited so the room name still arrives immediately.
launch { room.updateMembers() }
emitAll(
combine(room.roomInfoFlow, room.membersStateFlow) { info, membersState ->
ElementCallRoomContext(
displayName = info.name,
isDm = info.isDm,
members = membersState.toElementCallMembers(),
)
}
)
}
}
}
private fun RoomMembersState.toElementCallMembers(): Map<ElementCallUserId, ElementCallRoomMember> =
roomMembers().orEmpty().associate { member ->
ElementCallUserId(member.userId.value) to ElementCallRoomMember(
userId = ElementCallUserId(member.userId.value),
displayName = member.displayName,
avatarUrl = member.avatarUrl,
)
}
}
@@ -0,0 +1,103 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.callnative.impl
import android.Manifest
import android.app.Application
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.call.test.FakeElementCallController
import io.element.android.features.call.api.CallData
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultNativeCallEntryPointTest : RobolectricTest() {
@Test
fun `starting a call answers the microphone permission the app already holds`() = runTest {
grantMicrophonePermission()
val controller = FakeElementCallController()
val entryPoint = createEntryPoint(controller)
entryPoint.startCall(A_CALL_DATA)
runCurrent()
assertThat(controller.startedCalls.map { it.roomId.value }).containsExactly(A_ROOM_ID.value)
// The call screen normally answers this, but it is not composed when a call is answered from
// a notification with the app in the background - and a call waiting on a permission nobody
// is there to grant sits unanswered and refuses every later attempt.
assertThat(controller.microphonePermissionAnswers).containsExactly(true)
}
@Test
fun `a call the user placed opens on the call screen, not in the minimized bar`() = runTest {
grantMicrophonePermission()
val controller = FakeElementCallController()
val entryPoint = createEntryPoint(controller)
entryPoint.startCall(A_CALL_DATA)
runCurrent()
// A snapshot starts maximized, which is what the user asked for by pressing call. Saying
// otherwise here is what a UI-less build had to do, and it would now dock a freshly placed
// call into the bar.
assertThat(controller.maximizedCalls).isEmpty()
}
@Test
fun `the call still starts without the microphone permission, so the call screen can ask`() = runTest {
shadowOf(RuntimeEnvironment.getApplication()).denyPermissions(Manifest.permission.RECORD_AUDIO)
val controller = FakeElementCallController()
val entryPoint = createEntryPoint(controller)
entryPoint.startCall(A_CALL_DATA)
runCurrent()
assertThat(controller.startedCalls).hasSize(1)
// Nothing to report, and nothing to report it from: only an Activity can ask, and the call
// screen does exactly that once it appears.
assertThat(controller.microphonePermissionAnswers).isEmpty()
}
@Test
fun `nothing happens for a session that cannot be restored`() = runTest {
grantMicrophonePermission()
val controller = FakeElementCallController()
val entryPoint = createEntryPoint(controller, sessionCanBeRestored = false)
entryPoint.startCall(A_CALL_DATA)
runCurrent()
assertThat(controller.startedCalls).isEmpty()
}
private fun grantMicrophonePermission() {
shadowOf(RuntimeEnvironment.getApplication()).grantPermissions(Manifest.permission.RECORD_AUDIO)
}
private fun TestScope.createEntryPoint(
controller: FakeElementCallController,
sessionCanBeRestored: Boolean = true,
) = DefaultNativeCallEntryPoint(
context = InstrumentationRegistry.getInstrumentation().targetContext as Application,
controllers = FakeElementCallControllers(controller.takeIf { sessionCanBeRestored }),
appCoroutineScope = this,
)
private companion object {
val A_CALL_DATA = CallData(sessionId = A_SESSION_ID, roomId = A_ROOM_ID, isAudioCall = true)
}
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.callnative.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.call.api.ElementCallData
import io.element.android.call.api.rtc.id.RoomId
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.test.FakeCurrentCallTracker
import io.element.android.features.call.test.FakeRingingCallTracker
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ElementXCallLifecycleListenerTest {
@Test
fun `a started call is reported as the current call, and cleared when it ends`() = runTest {
val currentCallTracker = FakeCurrentCallTracker()
val listener = createListener(currentCallTracker = currentCallTracker)
listener.onCallStarted(A_CALL)
listener.onCallEnded(A_CALL)
assertThat(currentCallTracker.calls).containsExactly(
CurrentCall.RoomCall(A_ROOM_ID),
CurrentCall.None,
).inOrder()
}
@Test
fun `joining a call stops the ring`() = runTest {
val ringingCallTracker = FakeRingingCallTracker()
val listener = createListener(ringingCallTracker = ringingCallTracker)
listener.onCallJoined(A_CALL)
runCurrent()
assertThat(ringingCallTracker.joined.map { it.roomId }).containsExactly(A_ROOM_ID)
assertThat(ringingCallTracker.ended).isEmpty()
}
@Test
fun `ending a call tells the ringing side, and survives a call that never had data`() = runTest {
val ringingCallTracker = FakeRingingCallTracker()
val currentCallTracker = FakeCurrentCallTracker()
val listener = createListener(
currentCallTracker = currentCallTracker,
ringingCallTracker = ringingCallTracker,
)
listener.onCallEnded(null)
runCurrent()
// The current call is always cleared; only the ring needs to know which call it was.
assertThat(currentCallTracker.calls).containsExactly(CurrentCall.None)
assertThat(ringingCallTracker.ended).isEmpty()
}
private fun TestScope.createListener(
currentCallTracker: FakeCurrentCallTracker = FakeCurrentCallTracker(),
ringingCallTracker: FakeRingingCallTracker = FakeRingingCallTracker(),
) = ElementXCallLifecycleListener(
sessionId = A_SESSION_ID,
currentCallTracker = currentCallTracker,
ringingCallTracker = ringingCallTracker,
sessionCoroutineScope = this,
appForegroundStateService = FakeAppForegroundStateService(),
)
private companion object {
val A_CALL = ElementCallData(roomId = RoomId(A_ROOM_ID.value), isAudioCall = true)
}
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.callnative.impl
import io.element.android.call.api.ElementCallController
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
/**
* @param controller what [getOrBuild] answers with, or null to stand for a session that cannot be
* restored - logged out, or an id from a stale notification.
*/
class FakeElementCallControllers(
private val controller: ElementCallController? = null,
) : ElementCallControllers {
val requestedSessions = mutableListOf<SessionId>()
override suspend fun getOrBuild(sessionId: SessionId): ElementCallController? {
requestedSessions += sessionId
return controller
}
override fun controller(sessionId: SessionId): Flow<ElementCallController?> = flowOf(controller)
override fun withRunningCall(): ElementCallController? =
controller?.takeIf { it.state.value != null }
}
@@ -8,16 +8,8 @@ android {
} }
dependencies { dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
} }
@@ -24,7 +24,6 @@ dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)

Some files were not shown because too many files have changed in this diff Show More