mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-07 20:36:05 +00:00
Merge branch 'develop' into renovate/com.github.matrix-org-matrix-analytics-events-0.x
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' }}
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
=============================
|
=============================
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+81
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -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
|
||||||
|
|||||||
+191
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
+1
-1
Submodule enterprise updated: 6b422f4c8e...819746a734
@@ -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
|
||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -17,4 +17,6 @@ sealed interface Announcement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data object NewNotificationSound : Announcement
|
data object NewNotificationSound : Announcement
|
||||||
|
|
||||||
|
data object MultiAccount : Announcement
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -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)
|
||||||
|
|||||||
-1
@@ -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,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-2
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-2
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+23
@@ -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()
|
||||||
|
}
|
||||||
+28
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
+21
@@ -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,
|
||||||
|
|||||||
+25
-11
@@ -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,
|
||||||
|
|||||||
+42
@@ -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(
|
||||||
|
|||||||
+15
-3
@@ -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
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-3
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+1
-1
@@ -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
|
||||||
+33
-3
@@ -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,
|
||||||
+1
-1
@@ -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
|
||||||
+1
-3
@@ -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 {
|
||||||
+4
-7
@@ -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
|
||||||
+1
-6
@@ -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
|
||||||
+1
-3
@@ -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
|
||||||
+1
-2
@@ -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
|
||||||
+2
-6
@@ -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
|
||||||
+1
-2
@@ -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
|
||||||
+1
-3
@@ -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
|
||||||
|
|
||||||
+1
-2
@@ -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
|
||||||
+17
@@ -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)
|
||||||
|
}
|
||||||
+1
-2
@@ -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 {
|
||||||
@@ -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)
|
||||||
|
|||||||
+24
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+26
@@ -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
|
||||||
|
}
|
||||||
+24
@@ -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)
|
||||||
|
}
|
||||||
+26
@@ -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)
|
||||||
|
}
|
||||||
+26
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+60
@@ -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
|
||||||
|
}
|
||||||
+68
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+180
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+57
@@ -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,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -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
Reference in New Issue
Block a user