Merge branch 'release/26.10.0'

This commit is contained in:
Benoit Marty
2026-10-16 11:54:56 +02:00
2175 changed files with 19039 additions and 8135 deletions
+2 -2
View File
@@ -13,8 +13,8 @@
"hostRules": [
{
"matchHost": "https://repo1.maven.org/maven2/",
"concurrentRequestLimit": 10,
"maxRequestsPerSecond": 10,
"concurrentRequestLimit": 5,
"maxRequestsPerSecond": 5,
"timeout": 10000,
}
],
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APKs
@@ -74,7 +74,7 @@ jobs:
run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug APKs
if: ${{ matrix.variant == 'debug' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: elementx-debug
path: |
+2 -2
View File
@@ -61,7 +61,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug Gplay Enterprise APK
@@ -79,7 +79,7 @@ jobs:
run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug Enterprise APKs
if: ${{ matrix.variant == 'debug' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: elementx-enterprise-debug
path: |
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
+16 -3
View File
@@ -53,7 +53,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK
@@ -63,7 +63,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- name: Upload APK as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: elementx-apk-maestro
path: |
@@ -124,8 +124,21 @@ jobs:
disk-size: 3G
script: |
.github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh app-gplay-x86_64-debug.apk
- name: Set up Python
if: always()
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: 3.14
- name: End all the remaining sessions of the Maestro account
if: always()
continue-on-error: true
env:
MAS_PASSWORD: ${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }}
run: |
pip install requests
python3 ./tools/mas/delete_all_sessions.py --homeserver matrix.org --username maestroelement
- name: Upload test results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: test-results
path: |
+6 -6
View File
@@ -43,22 +43,22 @@ jobs:
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
cache-read-only: false
- name: ⚙️ Run unit tests, debug and release
run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES
run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES --continue
- name: 📸 Run screenshot tests
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES --continue
- name: 📈 Generate kover report and verify coverage
run: ./gradlew :app:koverXmlReportGplayDebug :app:koverHtmlReportGplayDebug :app:koverVerifyAll $CI_GRADLE_ARG_PROPERTIES
- name: ✅ Upload kover report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: kover-results
path: |
@@ -85,14 +85,14 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis
run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES
- name: Upload dependency analysis
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: dependency-analysis
path: build/reports/dependency-check-report.html
+13 -13
View File
@@ -74,7 +74,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
@@ -113,14 +113,14 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Konsist tests
run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon
- name: Upload reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: konsist-report
path: |
@@ -154,7 +154,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run compose tests
@@ -188,7 +188,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build Gplay Debug
@@ -199,7 +199,7 @@ jobs:
run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue
- name: Upload reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: linting-report
path: |
@@ -233,14 +233,14 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Detekt
run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon
run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon --continue
- name: Upload reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: detekt-report
path: |
@@ -274,14 +274,14 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Ktlint check
run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES
run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES --continue
- name: Upload reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ktlint-report
path: |
@@ -336,7 +336,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3
- uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
upload_reports:
name: Project Check Suite
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
java-version: '21'
# Add gradle cache, this should speed up the process
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots
+8 -4
View File
@@ -37,13 +37,15 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check Matrix Rust SDK is not a nightly version
run: ./tools/release/check_no_nightly_sdk.sh
- name: Use JDK 21
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
- name: Create app bundle
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -57,7 +59,7 @@ jobs:
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload bundle as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: elementx-app-gplay-bundle-unsigned
path: |
@@ -87,13 +89,15 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check Matrix Rust SDK is not a nightly version
run: ./tools/release/check_no_nightly_sdk.sh
- name: Use JDK 21
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
- name: Create APKs
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -101,7 +105,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew assembleFdroidRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload apks as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: elementx-app-fdroid-apks-unsigned
path: |
@@ -51,9 +51,10 @@ if [[ -z ${REPO} ]]; then
exit 1
fi
echo "Deleting previous screenshots"
./gradlew removeOldSnapshots --stacktrace --warn $GRADLE_ARGS
# Note: the previous screenshots are deliberately kept, so that Paparazzi can leave the ones that only
# differ by less than `maxPercentDifference` untouched instead of rewriting their Git LFS blob. The
# screenshots of previews that no longer exist are pruned afterwards by `pruneObsoleteSnapshots`, which
# `recordPaparazziDebug` is finalized by.
echo "Record screenshots"
./gradlew recordPaparazziDebug --stacktrace $GRADLE_ARGS
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build debug code and test fixtures
@@ -0,0 +1,85 @@
name: Sync Enterprise submodule
on:
workflow_dispatch:
push:
branches: [ develop ]
paths: [ enterprise ]
schedule:
# At 06:00 UTC every day
- cron: '0 6 * * *'
permissions: {}
jobs:
sync-enterprise-submodule:
runs-on: ubuntu-latest
# Skip in forks
if: github.repository == 'element-hq/element-x-android'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Add SSH private keys for submodule repositories
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
- name: Check enterprise submodule reference
id: check
env:
EVENT_NAME: ${{ github.event_name }}
run: |
current=$(git ls-tree HEAD enterprise | awk '{print $3}')
# Do not init the submodule: the current commit may be on a deleted branch.
git init -q "$RUNNER_TEMP/enterprise"
git -C "$RUNNER_TEMP/enterprise" fetch -q git@github.com:element-hq/element-android-enterprise.git main
main=$(git -C "$RUNNER_TEMP/enterprise" rev-parse FETCH_HEAD)
if git -C "$RUNNER_TEMP/enterprise" merge-base --is-ancestor "$current" "$main" 2>/dev/null; then
on_main=true
else
on_main=false
fi
echo "Current: $current, main: $main, current is on main: $on_main"
if [ "$current" = "$main" ]; then
echo "Submodule is already up to date."
echo "update=false" >> "$GITHUB_OUTPUT"
elif [ "$EVENT_NAME" = "push" ] && [ "$on_main" = "true" ]; then
echo "Submodule targets a commit on main, nothing to do."
echo "update=false" >> "$GITHUB_OUTPUT"
else
echo "update=true" >> "$GITHUB_OUTPUT"
fi
{
echo "body<<EOF"
if [ "$on_main" = "true" ]; then
# Do not list the commit messages, the enterprise repository is private.
count=$(git -C "$RUNNER_TEMP/enterprise" rev-list --count "$current..$main")
echo "Update the \`enterprise\` submodule from \`$current\` to the latest commit of \`main\` (\`$main\`), $count new commit(s)."
else
echo "The \`enterprise\` submodule targets \`$current\`, which is not on the \`main\` branch."
echo
echo "Update it to the latest commit of \`main\` (\`$main\`)."
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
if [ "$current" != "$main" ]; then
# Update the submodule reference without cloning it.
git update-index --cacheinfo "160000,$main,enterprise"
git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \
commit -q -m "Update enterprise submodule to latest main" -m "Update to $main"
fi
- name: Create Pull Request to update the enterprise submodule
# On schedule and manual runs, also run when there is nothing to update, so that a stale PR gets closed.
if: ${{ steps.check.outputs.update == 'true' || github.event_name != 'push' }}
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
title: Update enterprise submodule to latest main
body: ${{ steps.check.outputs.body }}
branch: sync-enterprise-submodule
base: develop
delete-branch: true
labels: PR-Element-Pro
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
+6 -6
View File
@@ -68,22 +68,22 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: ⚙️ Run unit tests
run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --continue
- name: ⚙️ Run screenshot tests
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES --continue
- name: ⚙️ Generate kover coverage report
run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
- name: 🚫 Upload kover failed coverage reports
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: kover-error-report
path: |
@@ -95,7 +95,7 @@ jobs:
- name: 🚫 Upload test results on error
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: tests-and-screenshot-tests-results
path: |
@@ -113,7 +113,7 @@ jobs:
# https://github.com/codecov/codecov-action
- name: ☂️ Upload coverage reports to codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
+1 -1
View File
@@ -2,6 +2,6 @@
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.4.10" />
<option name="version" value="2.4.20" />
</component>
</project>
+10 -4
View File
@@ -2,17 +2,23 @@ appId: ${MAESTRO_APP_ID}
---
- tapOn:
id: "home_screen-settings"
- tapOn:
id: "settings-user_profile"
- assertVisible: "Your account"
- tapOn: "Remove this device"
- takeScreenshot: build/maestro/900-SignOutScreen
- back
- tapOn: "Remove this device"
# Ensure pressing back cancels
- back
- tapOn: "Remove this device"
# Ensure cancel cancels
- tapOn: "Cancel"
- tapOn: "Remove this device"
- tapOn:
id: "sign-out-submit"
# Cancel the dialog cancels the logout
- tapOn:
id: "dialog-negative"
- tapOn: "Remove this device"
- tapOn:
id: "sign-out-submit"
- tapOn:
id: "dialog-positive"
- runFlow: ../assertions/assertInitDisplayed.yaml
+1 -1
View File
@@ -37,7 +37,7 @@ PRs must meet these rules.
- Create previews for **all main states** of a Composable.
- Use `@PreviewsDayNight` for consistency.
- Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states.
- Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states. Always add new states to existing providers at the end of the sequence.
- Wrap previews in `ElementPreview { ... }`.
- When writing tests, never try to record the screenshots, the CI will do it.
+72
View File
@@ -1,3 +1,75 @@
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
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.09.3 -->
## What's Changed
### ✨ Features
* Integrate the new timeline renderer behind a feature flag by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7722
* Add headers (`h1-h6`) tag support for the new timeline renderer by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7754
### 🙌 Improvements
* Add smooth animation to voice messages play progress by @bxdxnn in https://github.com/element-hq/element-x-android/pull/7698
* Add linkification to the new timeline renderer by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7731
### 🐛 Bugfixes
* Make global search use a `Joined` filter for room results by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7695
* Fix flaky test in `TimelinePresenterTest` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7702
* Close the threaded timelines created to create a poll, share a location or send media by @bmarty in https://github.com/element-hq/element-x-android/pull/7705
* Start sync manually after completing the auth flow by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7734
* Fix forwarding an event not working in threads by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7743
* Fix blank screen at launch when the homeserver expires the sliding sync session by @bmarty in https://github.com/element-hq/element-x-android/pull/7692
* Fix unread notification count in room list by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7749
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7699
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7736
### 🧱 Build
* Reduce parallel JVMs spawned for tests by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7701
* Replace git flow with regular git commands in the release script V2 by @bmarty in https://github.com/element-hq/element-x-android/pull/7707
* Remove Roborazzi, use Paparazzi for Compound by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7735
* Fix regex for invalid placeholders by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7738
* Add a script to install and launch an APK given a PR URL by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7752
### 🚧 In development 🚧
* New timeline event renderer: add new modules by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7721
* Add recent search results to global search by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7742
### Dependency upgrades
* Update dependency io.sentry:sentry-android to v8.55.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7685
* Update coil to v3.6.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7693
* Update kotlinpoet to v2.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7709
* Update plugin sonarqube to v7.5.0.8588 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7671
* Update roborazzi to v1.74.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7712
* Update dependency io.element.android:element-call-embedded to v0.26.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7715
* Use patched Konsist version (forked, 0.18.0) by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7719
* Update dependency net.zetetic:sqlcipher-android to v4.19.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7716
* Update metro to v1.4.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7711
* Update actions/setup-java action to v6.0.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7728
* Update dependency androidx.compose:compose-bom to v2026.09.00 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7730
* Update dependency androidx.sqlite:sqlite-ktx to v2.7.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7729
* Update dependencyAnalysis to v3.19.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7755
* Update media3 to v1.11.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7756
* Upgrade SDK bindings to `v26.09.26` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7780
### Others
* Let the user give a reason when removing a message by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7538
* Iterate on client creation by @bmarty in https://github.com/element-hq/element-x-android/pull/7591
* Iteration on Acount provider by @bmarty in https://github.com/element-hq/element-x-android/pull/7703
* Do not report detekt LargeClass issues on test classes by @bmarty in https://github.com/element-hq/element-x-android/pull/7713
* Let the user give an optional reason when removing a message by @bmarty in https://github.com/element-hq/element-x-android/pull/7679
* Make NotificationResultProcessor.emit wait until the batch is processed by @Johennes in https://github.com/element-hq/element-x-android/pull/7640
* Settings iteration: move user status and update multi-account section by @bmarty in https://github.com/element-hq/element-x-android/pull/7741
* Update remove message confirmation dialog. by @bmarty in https://github.com/element-hq/element-x-android/pull/7757
* Add a script to end all the sessions of a Matrix account using MAS by @bmarty in https://github.com/element-hq/element-x-android/pull/7762
* Settings iteration: split account settings and application settings. by @bmarty in https://github.com/element-hq/element-x-android/pull/7750
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.09.2...v26.09.3
Changes in Element X v26.09.2
=============================
+12 -13
View File
@@ -21,6 +21,7 @@ import extension.allFeaturesImpl
import extension.allLibrariesImpl
import extension.allServicesImpl
import extension.buildConfigFieldStr
import extension.environmentOrGradleValue
import extension.locales
import extension.setupDependencyInjection
import extension.testCommonDependencies
@@ -88,13 +89,14 @@ android {
storePassword = "android"
}
register("nightly") {
keyAlias = System.getenv("ELEMENT_ANDROID_NIGHTLY_KEYID")
?: project.property("signing.element.nightly.keyId") as? String?
keyPassword = System.getenv("ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD")
?: project.property("signing.element.nightly.keyPassword") as? String?
storeFile = file("./signature/nightly.keystore")
storePassword = System.getenv("ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD")
?: project.property("signing.element.nightly.storePassword") as? String?
keyAlias = environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_KEYID", "signing.element.nightly.keyId")
keyPassword = environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD", "signing.element.nightly.keyPassword")
storeFile = file(
environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_STOREFILE", "signing.element.nightly.storeFile")
?.ifBlank { null }
?: "./signature/nightly.keystore"
)
storePassword = environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD", "signing.element.nightly.storePassword")
}
}
@@ -278,7 +280,6 @@ dependencies {
implementation(projects.features.migration.api)
implementation(projects.appnav)
implementation(projects.appconfig)
implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.compose)
if (ModulesConfig.pushProvidersConfig.includeFirebase) {
@@ -296,18 +297,13 @@ dependencies {
implementation(libs.androidx.splash)
implementation(libs.androidx.core)
implementation(libs.androidx.corektx)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.startup)
implementation(libs.androidx.preference)
implementation(libs.coil)
implementation(platform(libs.network.okhttp.bom))
implementation(libs.network.okhttp.logging)
implementation(libs.serialization.json)
implementation(libs.matrix.emojibase.bindings)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
@@ -340,6 +336,9 @@ licensee {
ignoreDependencies("com.github.matrix-org", "matrix-analytics-events")
// Ignore dependency that are not third-party licenses to us.
ignoreDependencies(groupId = "io.element.android")
// The rustls-platform-verifier dependency does not provide a license file in their maven repo, but there are MIT and Apache licenses in
// the source code repo: see https://github.com/rustls/rustls-platform-verifier
ignoreDependencies(groupId = "org.rustls", artifactId = "rustls-platform-verifier")
}
fun Project.configureLicensesTasks(reportingExtension: ReportingExtension) {
@@ -1,2 +1,9 @@
# Don't obfuscate anything for non-enterprise builds
-dontobfuscate
# Strip PostHog's Firebase push token integration. We don't use it and it breaks F-Droid.
# Removing the contents of this method will mark the Firebase-related classes as unused,
# which will allow R8 to remove them from the final build.
-assumevalues class com.posthog.android.PostHogAndroidConfig {
boolean getCapturePushNotificationSubscriptions() return false;
}
+2 -1
View File
@@ -53,9 +53,10 @@
-->
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode"
android:exported="true"
android:launchMode="singleTask"
android:supportsPictureInPicture="true"
android:theme="@style/Theme.ElementX.Splash"
android:windowSoftInputMode="adjustResize">
<intent-filter>
@@ -9,6 +9,7 @@
package io.element.android.x
import android.app.Application
import android.os.StrictMode
import androidx.compose.material3.ComposeMaterial3Flags.isAnchoredDraggableComponentsStrictOffsetCheckEnabled
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.startup.AppInitializer
@@ -38,6 +39,9 @@ class ElementXApplication : Application(), DependencyInjectionGraphOwner, Config
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate() {
super.onCreate()
enableStrictMode()
AppInitializer.getInstance(this).apply {
initializeComponent(CrashInitializer::class.java)
initializeComponent(PlatformInitializer::class.java)
@@ -51,4 +55,16 @@ class ElementXApplication : Application(), DependencyInjectionGraphOwner, Config
// Remove once https://issuetracker.google.com/issues/477038695 is fixed.
isAnchoredDraggableComponentsStrictOffsetCheckEnabled = false
}
private fun enableStrictMode() {
// Workaround to avoid NetworkOnMainThreadException when using the network on the main thread.
// This is a temporary solution to rustls doing the certificate verification using blocking network requests.
// It should be removed once the underlying issue is fixed.
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectNetwork()
.penaltyLog()
.build()
)
}
}
@@ -70,6 +70,19 @@ class MainActivity : NodeActivity() {
}
}
/**
* The pre-Android 12 way into picture-in-picture for a native call.
*
* Everything else about it is installed where the call is drawn, which knows whose call it is.
* This cannot be: an Activity override has no session in scope. From Android 12 the system is told
* up front that this Activity would like to shrink, and the call handles it without ever coming
* through here.
*/
override fun onUserLeaveHint() {
super.onUserLeaveHint()
appBindings.nativeCallPip().onUserLeaveHint(this)
}
@Composable
private fun MainContent(appBindings: AppBindings) {
val migrationState = appBindings.migrationEntryPoint().present()
@@ -11,11 +11,12 @@ package io.element.android.x.di
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Multibinds
import io.element.android.features.api.MigrationEntryPoint
import io.element.android.features.callnative.api.NativeCallPip
import io.element.android.features.enterprise.api.AppStartupHook
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.migration.api.MigrationEntryPoint
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@@ -52,6 +53,13 @@ interface AppBindings {
fun buildMeta(): BuildMeta
/**
* For picture-in-picture. Only an Activity is told it is being left, and only the call knows
* whether that is worth shrinking for, so `MainActivity` needs this much of the native call and
* no more.
*/
fun nativeCallPip(): NativeCallPip
fun sentrySdkDsn(): SentrySdkDsn?
@Multibinds(allowEmpty = true)
@@ -8,11 +8,14 @@
package io.element.android.x.initializer
import android.annotation.SuppressLint
import android.content.Context
import android.system.Os
import android.util.Log
import androidx.startup.Initializer
import io.element.android.features.rageshake.api.logs.createWriteToFilesConfiguration
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.tracing.TracingConfiguration
import io.element.android.x.di.AppBindings
@@ -24,6 +27,15 @@ private const val ELEMENT_X_TARGET = "elementx"
class PlatformInitializer : Initializer<Unit> {
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 tracingService = appBindings.tracingService()
val platformService = appBindings.platformService()
@@ -41,6 +53,8 @@ class PlatformInitializer : Initializer<Unit> {
sdkSentryDsn = appBindings.sentrySdkDsn()?.value?.takeIf { it.isNotBlank() },
)
bugReporter.setCurrentTracingLogLevel(logLevel.name)
// Init the tracing service with the configuration so we have logging enabled as soon as possible
platformService.init(tracingConfiguration)
// Also set env variable for rust back trace
Os.setenv("RUST_BACKTRACE", "1", true)
@@ -30,5 +30,384 @@
<domain includeSubdomains="true">home</domain>
<domain includeSubdomains="true">lan</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>
</network-security-config>
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<!-- Ref: https://developer.android.com/training/articles/security-config.html -->
<!-- By default, do not allow clearText traffic -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
<certificates
src="user"
tools:ignore="AcceptsUserCertificates" />
</trust-anchors>
</base-config>
<!-- Allow clearText traffic on some specified host -->
<domain-config cleartextTrafficPermitted="true">
<!-- Localhost -->
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<!-- Localhost for Android emulator -->
<domain includeSubdomains="true">10.0.2.2</domain>
<!-- Onion services -->
<domain includeSubdomains="true">onion</domain>
<!-- Domains that are used for LANs -->
<!-- These are IANA recognized special use domain names, see https://www.iana.org/assignments/special-use-domain-names/special-use-domain-names.xhtml -->
<domain includeSubdomains="true">home.arpa</domain>
<domain includeSubdomains="true">local</domain> <!-- Note this has been reserved for use with mDNS -->
<domain includeSubdomains="true">test</domain>
<!-- These are observed in the wild either by convention or RFCs that have not been accepted, and are not currently TLDs -->
<domain includeSubdomains="true">home</domain>
<domain includeSubdomains="true">lan</domain>
<domain includeSubdomains="true">localdomain</domain>
</domain-config>
</network-security-config>
-1
View File
@@ -48,7 +48,6 @@ android {
}
dependencies {
implementation(libs.coroutines.core)
implementation(libs.androidx.annotationjvm)
implementation(libs.androidx.corektx)
implementation(projects.libraries.matrix.api)
+1 -1
View File
@@ -44,12 +44,12 @@ dependencies {
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.wellknown.api)
implementation(projects.features.login.api)
implementation(libs.coil)
implementation(projects.features.announcement.api)
implementation(projects.features.callnative.api)
implementation(projects.features.ftue.api)
implementation(projects.features.linknewdevice.api)
implementation(projects.features.share.api)
@@ -8,9 +8,15 @@
package io.element.android.appnav
import com.bumble.appyx.core.navigation.NavKey
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.BackStackElement
import com.bumble.appyx.navmodel.backstack.BackStackElements
import com.bumble.appyx.navmodel.backstack.operation.BackStackOperation
import com.bumble.appyx.navmodel.backstack.operation.NewRoot
import com.bumble.appyx.navmodel.backstack.operation.Remove
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue
/**
* Don't process NewRoot if the nav target already exists in the stack.
@@ -32,3 +38,45 @@ fun <T : Any> BackStack<T>.removeLast(element: T) {
} ?: return
accept(Remove(lastExpectedNavElement.key))
}
/**
* Replace the root element of the backstack, and keep the elements on top of it, contrary to [safeRoot] which removes all the elements.
*/
fun <T : Any> BackStack<T>.replaceRoot(element: T) {
accept(ReplaceRoot(element))
}
/**
* Replace the root element of the backstack, keeping the elements on top of it.
*
* Operation:
*
* [A, B, C] + ReplaceRoot(D) = [D, B, C]
* [A] + ReplaceRoot(D) = [D]
*/
@Parcelize
internal data class ReplaceRoot<T : Any>(
private val element: @RawValue T
) : BackStackOperation<T> {
override fun isApplicable(elements: BackStackElements<T>): Boolean =
elements.rootElement()?.key?.navTarget != element
override fun invoke(elements: BackStackElements<T>): BackStackElements<T> {
val root = elements.rootElement() ?: return elements
if (root.targetState == BackStack.State.ACTIVE) {
// Nothing on top of the root, so this is the same as NewRoot
return NewRoot(element).invoke(elements)
}
// The root is stashed below other elements: drop it, as Remove does for a non active element,
// and add the new root, stashed, in place of it.
val newRoot = BackStackElement(
key = NavKey(element),
fromState = BackStack.State.STASHED,
targetState = BackStack.State.STASHED,
operation = this,
)
return elements.map { if (it == root) newRoot else it }
}
private fun BackStackElements<T>.rootElement() = firstOrNull { it.targetState != BackStack.State.DESTROYED }
}
@@ -33,7 +33,8 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import kotlinx.parcelize.Parcelize
/**
@@ -59,6 +60,7 @@ class LoggedInAppScopeFlowNode(
interface Callback : Plugin {
fun navigateToBugReport()
fun navigateToAddAccount()
fun navigateToSession(sessionId: SessionId)
}
private val callback: Callback = callback()
@@ -91,6 +93,10 @@ class LoggedInAppScopeFlowNode(
override fun navigateToAddAccount() {
callback.navigateToAddAccount()
}
override fun navigateToSession(sessionId: SessionId) {
callback.navigateToSession(sessionId)
}
}
return createNode<LoggedInFlowNode>(buildContext, listOf(callback))
}
@@ -8,28 +8,51 @@
package io.element.android.appnav
import dev.zacsweers.metro.Inject
import androidx.annotation.VisibleForTesting
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@Inject
@AssistedInject
class LoggedInEventProcessor(
private val snackbarDispatcher: SnackbarDispatcher,
@Assisted private val snackbarDispatcher: SnackbarDispatcher,
private val roomMembershipObserver: RoomMembershipObserver,
private val sessionId: SessionId,
private val syncService: SyncService,
private val notificationCleaner: NotificationCleaner,
private val dispatchers: CoroutineDispatchers
) {
private var observingJob: Job? = null
@AssistedFactory
interface Factory {
fun create(snackbarDispatcher: SnackbarDispatcher): LoggedInEventProcessor
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal var currentChildScope: CoroutineScope? = null
fun observeEvents(coroutineScope: CoroutineScope) {
observingJob = roomMembershipObserver.updates
if (currentChildScope != null) return
val childScope = coroutineScope.childScope(dispatchers.computation, "LoggedInEventProcessor")
.also { currentChildScope = it }
roomMembershipObserver.updates
.filter { !it.isUserInRoom }
.distinctUntilChanged()
.onEach { roomMemberShipUpdate ->
@@ -48,12 +71,19 @@ class LoggedInEventProcessor(
else -> Unit
}
}
.launchIn(coroutineScope)
.launchIn(childScope)
// Use the sync service heartbeat as a 'heartbeat' update to check existing notifications
syncService.heartBeat
.onEach {
notificationCleaner.clearReadRoomsNotifications(sessionId)
}
.launchIn(childScope)
}
fun stopObserving() {
observingJob?.cancel()
observingJob = null
currentChildScope?.cancel()
currentChildScope = null
}
private fun displayMessage(message: Int) {
@@ -14,7 +14,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
@@ -48,16 +47,17 @@ import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.callnative.api.NativeCallHost
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
import io.element.android.features.location.api.LocalMapTilerConfig
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
@@ -66,8 +66,6 @@ import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.share.api.ShareIntentData
import io.element.android.features.startchat.api.StartChatEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
@@ -87,12 +85,12 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
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.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
@@ -110,14 +108,10 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.time.Duration
import java.time.Instant
import java.util.Optional
import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toKotlinDuration
import im.vector.app.features.analytics.plan.JoinedRoom as JoinedRoomAnalyticsEvent
// The maximum number of room nodes that should be kept in the backstack at the same time.
@@ -141,9 +135,9 @@ class LoggedInFlowNode(
private val sessionCoroutineScope: CoroutineScope,
private val ftueService: FtueService,
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
private val shareEntryPoint: ShareEntryPoint,
private val matrixClient: MatrixClient,
private val sendingQueue: SendQueues,
private val nativeCallHost: NativeCallHost,
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
private val sessionEnterpriseService: SessionEnterpriseService,
@@ -159,7 +153,8 @@ class LoggedInFlowNode(
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val activeLiveLocationShareManager: ActiveLiveLocationShareManager,
private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
private val locationService: LocationService,
private val loggedInEventProcessorFactory: LoggedInEventProcessor.Factory,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Placeholder,
@@ -175,34 +170,17 @@ class LoggedInFlowNode(
interface Callback : Plugin {
fun navigateToBugReport()
fun navigateToAddAccount()
fun navigateToSession(sessionId: SessionId)
}
private val callback: Callback = callback()
private val loggedInFlowProcessor = LoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = matrixClient.roomMembershipObserver,
)
private val loggedInFlowProcessor = loggedInEventProcessorFactory.create(snackbarDispatcher)
private val verificationListener = object : SessionVerificationServiceListener {
override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) {
// Without this launch the rendering and actual state of this Appyx node's children gets out of sync, resulting in a crash.
// This might be because this method is called back from Rust in a background thread.
/**
* Display the incoming verification request, the app is expected to be in foreground.
*/
fun onIncomingVerificationRequest(verificationRequest: VerificationRequest.Incoming) {
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
// Otherwise, the RoomList UI may be incorrectly displayed on top
withTimeout(5.seconds) {
@@ -214,7 +192,6 @@ class LoggedInFlowNode(
backstack.singleTop(NavTarget.IncomingVerificationRequest(verificationRequest))
}
}
}
override fun onBuilt() {
super.onBuilt()
@@ -227,7 +204,6 @@ class LoggedInFlowNode(
analyticsRoomListStateWatcher.start()
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
matrixClient.sessionVerificationService.setListener(verificationListener)
mediaPreviewConfigMigration()
sessionCoroutineScope.launch {
// Wait for the network to be connected before pre-fetching the max file upload size
@@ -256,7 +232,6 @@ class LoggedInFlowNode(
onDestroy = {
appNavigationStateService.onLeavingSession(id)
loggedInFlowProcessor.stopObserving()
matrixClient.sessionVerificationService.setListener(null)
analyticsRoomListStateWatcher.stop()
}
)
@@ -317,9 +292,6 @@ class LoggedInFlowNode(
@Parcelize
data object RoomDirectory : NavTarget
@Parcelize
data class IncomingShare(val shareIntentData: ShareIntentData) : NavTarget
@Parcelize
data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget
}
@@ -382,6 +354,14 @@ class LoggedInFlowNode(
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
override fun navigateToAddAccount() {
callback.navigateToAddAccount()
}
override fun navigateToSession(sessionId: SessionId) {
callback.navigateToSession(sessionId)
}
}
homeEntryPoint.createNode(
parentNode = this,
@@ -474,6 +454,10 @@ class LoggedInFlowNode(
callback.navigateToAddAccount()
}
override fun navigateToSession(sessionId: SessionId) {
callback.navigateToSession(sessionId)
}
override fun navigateToLinkNewDevice() {
backstack.push(NavTarget.LinkNewDevice)
}
@@ -587,30 +571,6 @@ class LoggedInFlowNode(
},
)
}
is NavTarget.IncomingShare -> {
shareEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = ShareEntryPoint.Params(shareIntentData = navTarget.shareIntentData),
callback = object : ShareEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) {
// Remove the incoming share screen
backstack.pop()
// Navigate to the room if the text/media was shared to a single one
roomIds.singleOrNull()?.let { roomId ->
lifecycleScope.launch {
// Wait until the incoming share screen is removed
backstack.elements.first { it.lastOrNull()?.key?.navTarget !is NavTarget.IncomingShare }
// Then attach the room
attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false)
}
}
}
},
)
}
is NavTarget.IncomingVerificationRequest -> {
incomingVerificationEntryPoint.createNode(
parentNode = this,
@@ -626,6 +586,15 @@ class LoggedInFlowNode(
}
}
/**
* Wait for the home screen to be attached, i.e. for the FTUE to be complete.
*/
internal suspend fun waitForHome() {
waitForNavTargetAttached { navTarget ->
navTarget is NavTarget.Home
}
}
suspend fun attachRoom(
roomIdOrAlias: RoomIdOrAlias,
serverNames: List<String> = emptyList(),
@@ -670,27 +639,13 @@ class LoggedInFlowNode(
}
}
internal suspend fun attachIncomingShare(shareIntentData: ShareIntentData) {
waitForNavTargetAttached { navTarget ->
navTarget is NavTarget.Home
}
attachChild<Node> {
backstack.push(
NavTarget.IncomingShare(shareIntentData)
)
}
}
@Composable
override fun View(modifier: Modifier) {
val colors by remember {
enterpriseService.semanticColorsFlow(sessionId = matrixClient.sessionId)
}.collectAsState(SemanticColorsLightDark.default)
val currentMapTilerConfig = LocalMapTilerConfig.current
val updatedMapTilerConfig by produceState(currentMapTilerConfig) {
value = customMapTilerConfigProvider.get().getOrNull() ?: currentMapTilerConfig
}
val mapTilerConfig by locationService.mapTilerConfig.collectAsState()
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
@@ -699,11 +654,14 @@ class LoggedInFlowNode(
compoundDark = colors.dark,
buildMeta = buildMeta,
) {
CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) {
CompositionLocalProvider(LocalMapTilerConfig provides mapTilerConfig) {
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(
isOnline = isOnline,
modifier = modifier,
modifier = callContentModifier,
) { contentModifier ->
Box(modifier = contentModifier) {
val ftueState by ftueService.state.collectAsState()
@@ -717,6 +675,7 @@ class LoggedInFlowNode(
}
}
}
}
@Parcelize
private class AttachRoomOperation(
@@ -13,6 +13,7 @@ package io.element.android.appnav
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import com.bumble.appyx.core.lifecycle.subscribe
@@ -24,6 +25,8 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.api.LoginParams
import io.element.android.libraries.architecture.BackstackView
@@ -33,8 +36,11 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices
import io.element.android.libraries.designsystem.utils.ScreenOrientation
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
@@ -45,6 +51,8 @@ class NotLoggedInFlowNode(
private val loginEntryPoint: LoginEntryPoint,
private val imageLoaderHolder: ImageLoaderHolder,
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
private val featureFlagService: FeatureFlagService,
private val announcementService: AnnouncementService,
) : BaseFlowNode<NotLoggedInFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
@@ -68,6 +76,13 @@ class NotLoggedInFlowNode(
override fun onBuilt() {
super.onBuilt()
analyticsColdStartWatcher.whenLoggingIn()
lifecycleScope.launch {
// Users who enter the login flow while the feature is available, either for the first time or to add
// another account, do not need to be informed about the multi-account feature.
if (featureFlagService.isFeatureEnabled(FeatureFlags.MultiAccount)) {
announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
}
}
lifecycle.subscribe(
onResume = {
SingletonImageLoader.setUnsafe(imageLoaderHolder.get())
@@ -40,10 +40,15 @@ import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView
import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.appnav.verification.IncomingVerificationRequestData
import io.element.android.appnav.verification.IncomingVerificationRequestObserver
import io.element.android.appnav.verification.OtherSessionIncomingVerificationNode
import io.element.android.appnav.verification.OtherSessionVerificationNode
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.share.api.ShareIntentData
import io.element.android.features.signedout.api.SignedOutEntryPoint
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
@@ -52,17 +57,20 @@ import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.core.coroutine.withPreviousValue
import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.oauth.api.OAuthAction
import io.element.android.libraries.oauth.api.OAuthActionFlow
import io.element.android.libraries.sessionstorage.api.LoggedInState
@@ -75,11 +83,16 @@ import io.element.android.services.appnavstate.api.ROOM_OPENED_FROM_NOTIFICATION
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
@ContributesNode(AppScope::class)
@AssistedInject
@@ -100,6 +113,8 @@ class RootFlowNode(
private val announcementService: AnnouncementService,
private val analyticsService: AnalyticsService,
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
private val incomingVerificationRequestObserver: IncomingVerificationRequestObserver,
private val shareEntryPoint: ShareEntryPoint,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : BaseFlowNode<RootFlowNode.NavTarget>(
backstack = BackStack(
@@ -127,6 +142,8 @@ class RootFlowNode(
observeNavState(false)
}
}
observeIncomingVerificationRequests()
observeRemovedSessions()
super.onBuilt()
}
@@ -202,7 +219,68 @@ class RootFlowNode(
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
pendingLoginParams = null
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
setRoot(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 ->
// The verification of a removed session cannot continue, for instance if the user has signed out from it
backstack.removeLast(NavTarget.OtherAccountSessionVerification(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?) {
@@ -213,7 +291,20 @@ class RootFlowNode(
private fun switchToSignedOutFlow(sessionId: SessionId) {
pendingLoginParams = null
backstack.safeRoot(NavTarget.SignedOutFlow(sessionId))
setRoot(NavTarget.SignedOutFlow(sessionId))
}
/**
* Set the root of the backstack. If an incoming share is displayed, keep it on top of the new root, so that the user can
* finish sharing, for instance with another account if the current one has been signed out.
*/
private fun setRoot(navTarget: NavTarget) {
val hasIncomingShare = backstack.elements.value.any { it.key.navTarget is NavTarget.IncomingShare }
if (hasIncomingShare) {
backstack.replaceRoot(navTarget)
} else {
backstack.safeRoot(navTarget)
}
}
private suspend fun restoreSessionIfNeeded(
@@ -277,8 +368,7 @@ class RootFlowNode(
@Parcelize data class AccountSelect(
val currentSessionId: SessionId,
val shareIntentData: ShareIntentData?,
val permalinkData: PermalinkData?,
val permalinkData: PermalinkData,
) : NavTarget
@Parcelize data class NotLoggedInFlow(
@@ -294,6 +384,20 @@ class RootFlowNode(
) : NavTarget
@Parcelize data object BugReport : NavTarget
@Parcelize data class IncomingShare(
val sessionId: SessionId,
val shareIntentData: ShareIntentData,
) : NavTarget
@Parcelize data class OtherAccountIncomingVerificationRequest(
val sessionId: SessionId,
val verificationRequest: VerificationRequest.Incoming,
) : NavTarget
@Parcelize data class OtherAccountSessionVerification(
val sessionId: SessionId,
) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -312,6 +416,12 @@ class RootFlowNode(
override fun navigateToAddAccount() {
backstack.push(NavTarget.NotLoggedInFlow(null))
}
override fun navigateToSession(sessionId: SessionId) {
lifecycleScope.launch {
sessionStore.setLatestSession(sessionId.value)
}
}
}
val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap)
val buildContext = if (savedNavState != null) {
@@ -348,6 +458,73 @@ class RootFlowNode(
)
}
NavTarget.SplashScreen -> emptyNode(buildContext)
is NavTarget.OtherAccountIncomingVerificationRequest -> {
if (matrixSessionCache.getOrNull(navTarget.sessionId) == null) {
Timber.w("Couldn't find session ${navTarget.sessionId} for the incoming verification request")
lifecycleScope.launch { backstack.pop() }
return emptyNode(buildContext)
}
val inputs = OtherSessionIncomingVerificationNode.Inputs(
sessionId = navTarget.sessionId,
verificationRequest = navTarget.verificationRequest,
)
val callback = object : OtherSessionIncomingVerificationNode.Callback {
override fun onDone() {
backstack.pop()
}
}
createNode<OtherSessionIncomingVerificationNode>(buildContext, plugins = listOf(inputs, callback))
}
is NavTarget.OtherAccountSessionVerification -> {
if (matrixSessionCache.getOrNull(navTarget.sessionId) == null) {
Timber.w("Couldn't find session ${navTarget.sessionId} to verify")
lifecycleScope.launch { backstack.pop() }
return emptyNode(buildContext)
}
val inputs = OtherSessionVerificationNode.Inputs(sessionId = navTarget.sessionId)
val callback = object : OtherSessionVerificationNode.Callback {
override fun onDone() {
backstack.pop()
}
}
createNode<OtherSessionVerificationNode>(buildContext, plugins = listOf(inputs, callback))
}
is NavTarget.IncomingShare -> {
val callback = object : ShareEntryPoint.Callback {
override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) {
// Remove the incoming share screen
backstack.pop()
lifecycleScope.launch {
// Switch to the session which has been used to share the data, if necessary,
// and navigate to the room if the data was shared to a single one.
val loggedInFlowNode = attachSession(sessionId)
roomIds.singleOrNull()?.let { roomId ->
loggedInFlowNode.attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false)
}
}
}
override fun onCancel() {
backstack.pop()
}
override fun onSessionVerificationRequired(sessionId: SessionId) {
val navTarget = NavTarget.OtherAccountSessionVerification(sessionId)
if (backstack.elements.value.lastOrNull()?.key?.navTarget != navTarget) {
backstack.push(navTarget)
}
}
}
shareEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = ShareEntryPoint.Params(
sessionId = navTarget.sessionId,
shareIntentData = navTarget.shareIntentData,
),
callback = callback,
)
}
NavTarget.BugReport -> {
val callback = object : BugReportEntryPoint.Callback {
override fun onDone() {
@@ -369,13 +546,7 @@ class RootFlowNode(
// Do not pop when the account is changed to avoid a UI flicker.
backstack.pop()
}
attachSession(sessionId).apply {
if (navTarget.shareIntentData != null) {
attachIncomingShare(navTarget.shareIntentData)
} else if (navTarget.permalinkData != null) {
attachPermalinkData(navTarget.permalinkData)
}
}
attachSession(sessionId).attachPermalinkData(navTarget.permalinkData)
}
}
@@ -445,21 +616,12 @@ class RootFlowNode(
// No session, open login
switchToNotLoggedInFlow(null)
} else {
// wait for the current session to be restored
val loggedInFlowNode = attachSession(latestSessionId)
if (sessionStore.numberOfSessions() > 1) {
// Several accounts, let the user choose which one to use
backstack.push(
NavTarget.AccountSelect(
currentSessionId = latestSessionId,
shareIntentData = shareIntentData,
permalinkData = null,
)
)
} else {
// Only one account, directly attach the incoming share node.
loggedInFlowNode.attachIncomingShare(shareIntentData)
}
// Wait for the current session to be restored and for the FTUE to be complete, so that the session is verified,
// then display the incoming share on top of it.
// The incoming share does not belong to the current session: in case of multiple accounts, the user can switch account
// from the room select screen, and the share is kept if the current session is signed out in the meantime.
attachSession(latestSessionId).waitForHome()
backstack.push(NavTarget.IncomingShare(latestSessionId, shareIntentData))
}
}
@@ -482,7 +644,6 @@ class RootFlowNode(
backstack.push(
NavTarget.AccountSelect(
currentSessionId = latestSessionId,
shareIntentData = null,
permalinkData = permalinkData,
)
)
@@ -41,7 +41,7 @@ class SendQueues(
fun launchIn(coroutineScope: CoroutineScope) {
combine(
syncService.syncState,
matrixClient.sendQueueDisabledFlow(),
matrixClient.sendQueueDisabledFlow().onEach { Timber.tag("SendQueues").d("Send queue disabled for room: $it") },
) { syncState, _ -> syncState }
.debounce(SEND_QUEUES_RETRY_DELAY_MILLIS)
.onEach { syncState ->
@@ -28,8 +28,8 @@ import io.element.android.appnav.room.joined.JoinedRoomFlowNode
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.appnav.room.joined.LoadingRoomNodeView
import io.element.android.features.joinroom.api.JoinRoomEntryPoint
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint.Params
import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint
import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint.Params
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
@@ -6,12 +6,9 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.appnav.room.joined
import android.os.Parcelable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -201,7 +201,7 @@ class JoinedRoomLoadedFlowNode(
} else {
(graph as TimelineBindings).timelineProvider
}
val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider)
val params = ForwardEntryPoint.Params(listOf(navTarget.eventId), timelineProvider)
val callback = object : ForwardEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) {
backstack.pop()
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -68,7 +67,6 @@ fun LoadingRoomNodeView(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LoadingRoomTopBar(
onBackClick: () -> Unit,
@@ -13,7 +13,7 @@ import com.bumble.appyx.core.state.SavedStateMap
import dev.zacsweers.metro.Inject
import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.flow.Flow
@@ -21,6 +21,9 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.AnalyticsUserData
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
@@ -41,6 +44,12 @@ class MatrixSessionCache(
private val analyticsService: AnalyticsService,
) : MatrixClientProvider {
private val sessionIdsToMatrixSession = ConcurrentHashMap<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()
init {
@@ -51,10 +60,12 @@ class MatrixSessionCache(
fun removeAll() {
sessionIdsToMatrixSession.clear()
updateMatrixClients()
}
fun remove(sessionId: SessionId) {
sessionIdsToMatrixSession.remove(sessionId)
updateMatrixClients()
}
override fun getOrNull(sessionId: SessionId): MatrixClient? {
@@ -122,8 +133,13 @@ class MatrixSessionCache(
matrixClient = matrixClient,
syncOrchestrator = syncOrchestrator,
)
updateMatrixClients()
syncOrchestrator.start()
}
private fun updateMatrixClients() {
_matrixClients.value = sessionIdsToMatrixSession.values.map { it.matrixClient }
}
}
private data class InMemoryMatrixSession(
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.appnav.verification
import dev.zacsweers.metro.Inject
import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.libraries.core.coroutine.withPreviousValue
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
import kotlin.time.Duration.Companion.minutes
data class IncomingVerificationRequestData(
val sessionId: SessionId,
val verificationRequest: VerificationRequest.Incoming,
)
/**
* Listens to the incoming verification requests of all the sessions in [MatrixSessionCache], not only the current one.
*/
@Inject
class IncomingVerificationRequestObserver(
private val matrixSessionCache: MatrixSessionCache,
private val appForegroundStateService: AppForegroundStateService,
) {
/**
* Emits the incoming verification requests once the app is in foreground.
* Requests received while the app is in background for too long are discarded.
*/
fun incomingVerificationRequests(): Flow<IncomingVerificationRequestData> = channelFlow {
launch {
matrixSessionCache.matrixClients.withPreviousValue().collect { (previousMatrixClients, matrixClients) ->
val previousMatrixClientSet = previousMatrixClients.orEmpty().toSet()
// Remove the listener of the clients which are not in the cache anymore
(previousMatrixClientSet - matrixClients.toSet()).forEach { matrixClient ->
matrixClient.sessionVerificationService.setListener(null)
}
(matrixClients - previousMatrixClientSet).forEach { matrixClient ->
val sessionId = matrixClient.sessionId
matrixClient.sessionVerificationService.setListener(
object : SessionVerificationServiceListener {
override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) {
launch {
// Wait until the app is in foreground to display the incoming verification request
// TODO there should also be a timeout for > 10 minutes elapsed since the request was created,
// but the SDK doesn't expose that info yet
val isInForeground = withTimeoutOrNull(2.minutes) {
appForegroundStateService.isInForeground.first { it }
}
if (isInForeground == null) {
Timber.w("Incoming verification request ${verificationRequest.details.flowId} discarded due to timeout.")
} else {
send(IncomingVerificationRequestData(sessionId, verificationRequest))
}
}
}
}
)
}
}
}
awaitClose {
matrixSessionCache.matrixClients.value.forEach { matrixClient ->
matrixClient.sessionVerificationService.setListener(null)
}
}
}
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.appnav.verification
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.SessionGraphFactory
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import kotlinx.parcelize.Parcelize
/**
* Display an incoming verification request received by a session which is not the current one, without switching the current session.
* It sets up the Session graph of the session receiving the request.
*/
@ContributesNode(AppScope::class)
@AssistedInject
class OtherSessionIncomingVerificationNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
sessionGraphFactory: SessionGraphFactory,
matrixClientProvider: MatrixClientProvider,
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
) : ParentNode<OtherSessionIncomingVerificationNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
), DependencyInjectionGraphOwner {
interface Callback : Plugin {
fun onDone()
}
@Parcelize
object NavTarget : Parcelable
data class Inputs(
val sessionId: SessionId,
val verificationRequest: VerificationRequest.Incoming,
) : NodeInputs
private val callback: Callback = callback()
private val inputs: Inputs = inputs()
override val graph = sessionGraphFactory.create(
requireNotNull(matrixClientProvider.getOrNull(inputs.sessionId)) { "No MatrixClient for session ${inputs.sessionId}" }
)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return incomingVerificationEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = IncomingVerificationEntryPoint.Params(inputs.verificationRequest),
callback = object : IncomingVerificationEntryPoint.Callback {
override fun onDone() {
callback.onDone()
}
},
)
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = navModel,
modifier = modifier,
)
}
}
@@ -0,0 +1,89 @@
/*
* 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.ftue.api.SessionVerificationEntryPoint
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 kotlinx.parcelize.Parcelize
/**
* Display the verification of a session which is not the current one, without switching the current session.
* It sets up the Session graph of the session to verify.
*/
@ContributesNode(AppScope::class)
@AssistedInject
class OtherSessionVerificationNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
sessionGraphFactory: SessionGraphFactory,
matrixClientProvider: MatrixClientProvider,
private val sessionVerificationEntryPoint: SessionVerificationEntryPoint,
) : ParentNode<OtherSessionVerificationNode.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,
) : 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 sessionVerificationEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
callback = object : SessionVerificationEntryPoint.Callback {
override fun onDone() {
callback.onDone()
}
},
)
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = navModel,
modifier = modifier,
)
}
}
@@ -2,5 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Odhlásit se a upgradovat"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s již nepodporuje starý protokol. Odhlaste se a znovu přihlaste, abyste mohli pokračovat v používání aplikace."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Váš domovský server již nepodporuje starý protokol. Chcete-li pokračovat v používání aplikace, odhlaste se a znovu se přihlaste."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Váš poskytovatel účtu již nepodporuje starý protokol. Chcete-li aplikaci nadále používat, odhlaste se a znovu se přihlaste."</string>
</resources>
@@ -2,5 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Deconectați-vă și faceți upgrade"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s nu mai acceptă vechiul protocol. Vă rugăm să vă deconectați și să vă reconectați pentru a continua utilizarea aplicației."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Serverul dvs. de acasă nu mai acceptă vechiul protocol. Vă rugăm să vă deconectați și să vă conectați din nou pentru a continua să utilizați aplicația."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Furnizorul contului dvs. nu mai acceptă vechiul protocol. Vă rugăm să vă deconectați și să vă conectați din nou pentru a continua să utilizați aplicația."</string>
</resources>
@@ -0,0 +1,56 @@
/*
* 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.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class BackstackExtTest {
@Test
fun `replaceRoot without element on top replaces the root`() {
val backStack = BackStack(initialElement = "A", savedStateMap = null)
backStack.replaceRoot("D")
assertThat(backStack.navTargets()).containsExactly("D")
assertThat(backStack.activeTarget()).isEqualTo("D")
}
@Test
fun `replaceRoot with elements on top replaces the root and keeps the elements on top`() {
val backStack = BackStack(initialElement = "A", savedStateMap = null)
backStack.push("B")
backStack.push("C")
backStack.replaceRoot("D")
assertThat(backStack.navTargets()).containsExactly("D", "B", "C").inOrder()
assertThat(backStack.activeTarget()).isEqualTo("C")
// When the elements on top are removed, the new root is displayed
backStack.pop()
backStack.pop()
assertThat(backStack.activeTarget()).isEqualTo("D")
}
@Test
fun `replaceRoot with the current root does nothing`() {
val backStack = BackStack(initialElement = "A", savedStateMap = null)
backStack.push("B")
backStack.replaceRoot("A")
assertThat(backStack.navTargets()).containsExactly("A", "B").inOrder()
assertThat(backStack.activeTarget()).isEqualTo("B")
}
private fun BackStack<String>.navTargets() = elements.value
.filter { it.targetState != BackStack.State.DESTROYED }
.map { it.key.navTarget }
private fun BackStack<String>.activeTarget() = elements.value
.single { it.targetState == BackStack.State.ACTIVE }
.key
.navTarget
}
@@ -0,0 +1,196 @@
/*
* 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.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.sync.FakeSyncService
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 sync heartbeat is emitted`() = runTest {
val clearReadNotificationsLambda = lambdaRecorder { _: SessionId -> }
val snackbarDispatcher = SnackbarDispatcher()
val notificationCleaner = FakeNotificationCleaner(clearReadRoomsNotificationsLambda = clearReadNotificationsLambda)
val syncService = FakeSyncService()
val loggedInEventProcessor = createLoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
notificationCleaner = notificationCleaner,
syncService = syncService,
)
loggedInEventProcessor.observeEvents(backgroundScope)
testScheduler.runCurrent()
// Simulate a sync heartbeat
syncService.emitHeartBeat()
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,
syncService: FakeSyncService = FakeSyncService(),
notificationCleaner: FakeNotificationCleaner = FakeNotificationCleaner(),
) = LoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = roomMembershipObserver,
sessionId = sessionId,
syncService = syncService,
notificationCleaner = notificationCleaner,
dispatchers = testCoroutineDispatchers(),
)
}
@@ -14,6 +14,7 @@ import android.net.Uri
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.AncestryInfo
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.node
import com.bumble.appyx.navmodel.backstack.activeElement
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
@@ -25,6 +26,8 @@ import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.session.FakeSyncOrchestratorFactory
import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.appnav.verification.IncomingVerificationRequestObserver
import io.element.android.features.announcement.test.FakeAnnouncementService
import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.test.FakeLoginEntryPoint
@@ -32,7 +35,7 @@ import io.element.android.features.login.test.FakeLoginIntentResolver
import io.element.android.features.login.test.accesscontrol.FakeAccountProviderAccessControl
import io.element.android.features.preferences.test.FakeCacheService
import io.element.android.features.rageshake.test.FakeBugReportEntryPoint
import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.share.test.FakeShareIntentHandler
import io.element.android.features.signedout.test.FakeSignedOutEntryPoint
import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint
@@ -41,7 +44,7 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.FakeSdkMetadata
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder
import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder
import io.element.android.libraries.oauth.test.FakeOAuthActionFlow
import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver
import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory
@@ -50,6 +53,8 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher
import io.element.android.services.apperror.test.FakeAppErrorStateService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.FakeNodeFactoriesBindings
import io.element.android.tests.testutils.node.FakeParentNode
import io.element.android.tests.testutils.presenter.NotUsedPresenter
@@ -139,6 +144,8 @@ class RootFlowNodeTest : RobolectricTest() {
loginEntryPoint = loginEntryPoint,
imageLoaderHolder = FakeImageLoaderHolder(),
analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(),
featureFlagService = FakeFeatureFlagService(),
announcementService = FakeAnnouncementService(),
)
}
)
@@ -183,7 +190,19 @@ class RootFlowNodeTest : RobolectricTest() {
announcementService = FakeAnnouncementService(),
analyticsService = FakeAnalyticsService(),
analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(),
incomingVerificationRequestObserver = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = FakeAppForegroundStateService(),
),
appCoroutineScope = backgroundScope,
shareEntryPoint = object : ShareEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: ShareEntryPoint.Params,
callback: ShareEntryPoint.Callback,
): Node = lambdaError()
},
)
}
}
@@ -70,9 +70,11 @@ class MatrixSessionCacheTest {
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.matrixClients.value).containsExactly(fakeMatrixClient)
// Remove
matrixSessionCache.remove(A_SESSION_ID)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
assertThat(matrixSessionCache.matrixClients.value).isEmpty()
}
@Test
@@ -83,9 +85,11 @@ class MatrixSessionCacheTest {
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.matrixClients.value).containsExactly(fakeMatrixClient)
// Remove all
matrixSessionCache.removeAll()
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
assertThat(matrixSessionCache.matrixClients.value).isEmpty()
}
@Test
@@ -0,0 +1,191 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.appnav.verification
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.appnav.session.SyncOrchestrator
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class)
class IncomingVerificationRequestObserverTest {
private val sessionVerificationService1 = FakeSessionVerificationService()
private val sessionVerificationService2 = FakeSessionVerificationService()
@Test
fun `incoming verification requests of all the sessions are emitted with their session id`() = runTest {
val matrixSessionCache = createMatrixSessionCache()
val observer = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = FakeAppForegroundStateService(),
)
observer.incomingVerificationRequests().test {
matrixSessionCache.getOrRestore(A_SESSION_ID)
matrixSessionCache.getOrRestore(A_SESSION_ID_2)
runCurrent()
val request = anIncomingVerificationRequest()
sessionVerificationService2.listener!!.onIncomingSessionRequest(request)
assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID_2, request))
sessionVerificationService1.listener!!.onIncomingSessionRequest(request)
assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID, request))
}
// The listeners are removed when the flow is not collected anymore
assertThat(sessionVerificationService1.listener).isNull()
assertThat(sessionVerificationService2.listener).isNull()
}
@Test
fun `listener is removed when the session is removed from the cache`() = runTest {
val matrixSessionCache = createMatrixSessionCache()
val observer = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = FakeAppForegroundStateService(),
)
observer.incomingVerificationRequests().test {
matrixSessionCache.getOrRestore(A_SESSION_ID)
matrixSessionCache.getOrRestore(A_SESSION_ID_2)
runCurrent()
assertThat(sessionVerificationService1.listener).isNotNull()
assertThat(sessionVerificationService2.listener).isNotNull()
matrixSessionCache.remove(A_SESSION_ID)
runCurrent()
assertThat(sessionVerificationService1.listener).isNull()
assertThat(sessionVerificationService2.listener).isNotNull()
}
}
@Test
fun `incoming user verification request of another session is emitted with its session id`() = runTest {
val matrixSessionCache = createMatrixSessionCache()
val observer = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = FakeAppForegroundStateService(),
)
observer.incomingVerificationRequests().test {
matrixSessionCache.getOrRestore(A_SESSION_ID)
matrixSessionCache.getOrRestore(A_SESSION_ID_2)
runCurrent()
val request = anIncomingUserVerificationRequest()
sessionVerificationService2.listener!!.onIncomingSessionRequest(request)
assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID_2, request))
}
}
@Test
fun `incoming verification request is emitted when the app goes to foreground`() = runTest {
val matrixSessionCache = createMatrixSessionCache()
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = false)
val observer = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = appForegroundStateService,
)
observer.incomingVerificationRequests().test {
matrixSessionCache.getOrRestore(A_SESSION_ID)
runCurrent()
val request = anIncomingVerificationRequest()
sessionVerificationService1.listener!!.onIncomingSessionRequest(request)
advanceTimeBy(30.seconds)
expectNoEvents()
appForegroundStateService.givenIsInForeground(true)
assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID, request))
}
}
@Test
fun `incoming verification request is discarded if the app does not go to foreground in time`() = runTest {
val matrixSessionCache = createMatrixSessionCache()
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = false)
val observer = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = appForegroundStateService,
)
observer.incomingVerificationRequests().test {
matrixSessionCache.getOrRestore(A_SESSION_ID)
runCurrent()
sessionVerificationService1.listener!!.onIncomingSessionRequest(anIncomingVerificationRequest())
advanceTimeBy(3.minutes)
appForegroundStateService.givenIsInForeground(true)
runCurrent()
expectNoEvents()
}
}
private fun TestScope.createMatrixSessionCache(): MatrixSessionCache {
val matrixClient1 = FakeMatrixClient(
sessionId = A_SESSION_ID,
sessionCoroutineScope = backgroundScope,
sessionVerificationService = sessionVerificationService1,
userIdServerNameLambda = { "server" },
)
val matrixClient2 = FakeMatrixClient(
sessionId = A_SESSION_ID_2,
sessionCoroutineScope = backgroundScope,
sessionVerificationService = sessionVerificationService2,
userIdServerNameLambda = { "server" },
)
return MatrixSessionCache(
authenticationService = FakeMatrixAuthenticationService(
matrixClientResult = { sessionId ->
Result.success(if (sessionId == A_SESSION_ID) matrixClient1 else matrixClient2)
},
),
syncOrchestratorFactory = object : SyncOrchestrator.Factory {
override fun create(matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope) = SyncOrchestrator(
matrixClient = matrixClient,
sessionCoroutineScope = sessionCoroutineScope,
appForegroundStateService = FakeAppForegroundStateService(),
networkMonitor = FakeNetworkMonitor(),
dispatchers = testCoroutineDispatchers(),
analyticsService = FakeAnalyticsService(),
)
},
analyticsService = FakeAnalyticsService(),
)
}
private fun anIncomingVerificationRequest() = VerificationRequest.Incoming.OtherSession(
details = aSessionVerificationRequestDetails(senderProfile = MatrixUser(A_SESSION_ID)),
)
private fun anIncomingUserVerificationRequest() = VerificationRequest.Incoming.User(
details = aSessionVerificationRequestDetails(senderProfile = MatrixUser(A_USER_ID_3)),
)
private fun aSessionVerificationRequestDetails(senderProfile: MatrixUser) = SessionVerificationRequestDetails(
senderProfile = senderProfile,
flowId = FlowId("flowId"),
deviceId = DeviceId("deviceId"),
deviceDisplayName = "a device name",
firstSeenTimestamp = 0,
)
}
+89 -10
View File
@@ -101,10 +101,6 @@ allprojects {
// Uncomment to suppress Compose Kotlin compiler compatibility warning
// freeCompilerArgs.addAll(listOf("-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true"))
// Fix compilation warning for annotations
// See https://youtrack.jetbrains.com/issue/KT-73255/Change-defaulting-rule-for-annotations for more details
freeCompilerArgs.add("-Xannotation-default-target=first-only")
}
}
}
@@ -149,7 +145,13 @@ sonar {
allprojects {
tasks.withType<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) }
if (isScreenshotTest) {
@@ -173,6 +175,9 @@ allprojects {
// Gradle parallelise by running many modules' test tasks concurrently instead.
maxParallelForks = 1
// Increase heap size for screenshot tests
maxHeapSize = "2g"
// Disable screenshot tests by default
exclude("ui/*.class")
exclude("translations/*.class")
@@ -211,19 +216,93 @@ tasks.register("generateDocsToc", Exec::class.java) {
commandLine("python3", "tools/docs/generate_toc.py", *inputs.files.map { it.path }.toTypedArray())
}
// Make sure to delete old screenshots before recording new ones
// Delete all the screenshots. Not hooked to the record tasks on purpose, see `pruneObsoleteSnapshots`
// below. Run it by hand (or use Paparazzi's own `cleanRecordPaparazziDebug`) to record from scratch.
subprojects {
val snapshotsDir = File("${project.projectDir}/src/test/snapshots")
val removeOldScreenshotsTask = tasks.register("removeOldSnapshots") {
tasks.register("removeOldSnapshots") {
onlyIf { snapshotsDir.exists() }
doFirst {
println("Delete previous screenshots located at $snapshotsDir\n")
snapshotsDir.deleteRecursively()
}
}
tasks.findByName("recordPaparazzi")?.dependsOn(removeOldScreenshotsTask)
tasks.findByName("recordPaparazziDebug")?.dependsOn(removeOldScreenshotsTask)
tasks.findByName("recordPaparazziRelease")?.dependsOn(removeOldScreenshotsTask)
}
// Delete the screenshots of previews that no longer exist, *after* recording.
//
// Wiping the whole snapshots directory before recording used to be how stale screenshots were removed,
// but it also removes the reference image Paparazzi needs to honour
// `app.cash.paparazzi.overwriteOnMaxPercentDifference` (see gradle.properties): with no existing file to
// compare against, every screenshot is rewritten unconditionally, so renderer noise far below the
// threshold `verifyPaparazziDebug` accepts still rewrites the Git LFS blob of every affected screenshot.
//
// Instead, let the record task keep the existing screenshots, then prune the ones belonging to a
// preview that Paparazzi did not render at all.
//
// Pruning per preview, and not per screenshot, is deliberate: a preview that fails to render is absent
// from the run report in exactly the same way as a preview that was deleted, and LayoutLib does fail to
// render a preview now and then. Matching whole previews means such
// a failure leaves the other screenshots of that preview in place, so it cannot silently delete a
// screenshot that is still in use. The cost is that dropping one value from a PreviewParameterProvider
// leaves its last screenshot behind, since the preview itself still renders. Run `removeOldSnapshots`
// and record again to clean those up.
subprojects {
val projectDir = project.projectDir
val pruneObsoleteSnapshots = tasks.register("pruneObsoleteSnapshots") {
description = "Delete the recorded screenshots of previews that no longer exist"
val imagesDir = File(projectDir, "src/test/snapshots/images")
val reportsDir = File(projectDir, "build/reports/paparazzi")
onlyIf { imagesDir.exists() }
doLast {
// One `runs/<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 {
@@ -53,7 +53,7 @@ class ContributesNodeProcessor(
val annotatedSymbols = resolver.getSymbolsWithAnnotation(ContributesNode::class.qualifiedName!!)
.filterIsInstance<KSClassDeclaration>()
val (validSymbols, invalidSymbols) = annotatedSymbols.partition { it.validate() }
val (validSymbols, invalidSymbols) = annotatedSymbols.partition { it.validate(enableNewFeatures = false) }
if (validSymbols.isEmpty()) return invalidSymbols
+1
View File
@@ -27,6 +27,7 @@ Click on this link and follow the instruction: [https://appdistribution.firebase
Nightly build can be built manually from your computer. You will need to retrieved some secrets from Passbolt and add them to your file `~/.gradle/gradle.properties`:
```
signing.element.nightly.storeFile=LOCATION_OF_THE_KEYSTORE_FILE
signing.element.nightly.storePassword=VALUE_FROM_PASSBOLT
signing.element.nightly.keyId=VALUE_FROM_PASSBOLT
signing.element.nightly.keyPassword=VALUE_FROM_PASSBOLT
@@ -0,0 +1,2 @@
Main changes in this version: improved sign out screen, updated user status UI, accessibility improvements and bug fixes.
Full changelog: https://github.com/element-hq/element-x-android/releases
-2
View File
@@ -16,6 +16,4 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.uiStrings)
}
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_settings_help_us_improve">"Distribuiți date anonime de utilizare pentru a ne ajuta să identificăm probleme."</string>
<string name="screen_analytics_settings_help_us_improve">"Partajați date anonime de utilizare pentru a ne ajuta să identificăm probleme."</string>
<string name="screen_analytics_settings_read_terms">"Puteți citi toate condițiile noastre %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"aici"</string>
<string name="screen_analytics_settings_share_data">"Partajați datele analitice"</string>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"Nu vom înregistra și nu vom face profiluri cu privire la datele personale."</string>
<string name="screen_analytics_prompt_help_us_improve">"Distribuiți date anonime de utilizare pentru a ne ajuta să identificăm probleme."</string>
<string name="screen_analytics_prompt_help_us_improve">"Partajați date anonime de utilizare pentru a ne ajuta să identificăm probleme."</string>
<string name="screen_analytics_prompt_read_terms">"Puteți citi toate condițiile noastre %1$s."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"aici"</string>
<string name="screen_analytics_prompt_settings">"Puteți dezactiva această opțiune oricând din setări"</string>
@@ -17,4 +17,8 @@ sealed interface Announcement {
}
data object NewNotificationSound : Announcement
data object MultiAccount : Announcement
data object SwipeAvatarToSwitchAccount : Announcement
}
@@ -29,10 +29,12 @@ class DefaultAnnouncementService(
) : AnnouncementService {
override suspend fun showAnnouncement(announcement: Announcement) {
when (announcement) {
is Announcement.Fullscreen -> showFullscreenAnnouncement(announcement)
is Announcement.Fullscreen -> showAnnouncementIfNeverShown(announcement)
Announcement.NewNotificationSound -> {
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
}
Announcement.MultiAccount -> showAnnouncementIfNeverShown(announcement)
Announcement.SwipeAvatarToSwitchAccount -> showAnnouncementIfNeverShown(announcement)
}
}
@@ -44,16 +46,27 @@ class DefaultAnnouncementService(
return combine(
flowOf(Unit),
announcementStore.announcementStatusFlow(Announcement.NewNotificationSound),
) { _, newNotificationSoundStatus ->
announcementStore.announcementStatusFlow(Announcement.MultiAccount),
announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount),
) { _, newNotificationSoundStatus, multiAccountStatus, swipeAvatarToSwitchAccountStatus ->
buildList {
if (newNotificationSoundStatus == AnnouncementStatus.Show) {
add(Announcement.NewNotificationSound)
}
// The multi-account announcement is pending until it is dismissed. Users who were already
// logged in when the feature became available still have the default NeverShown status.
if (multiAccountStatus != AnnouncementStatus.Shown) {
add(Announcement.MultiAccount)
}
// The swipe avatar announcement is pending until it is dismissed, or the user switches account by swiping.
if (swipeAvatarToSwitchAccountStatus != AnnouncementStatus.Shown) {
add(Announcement.SwipeAvatarToSwitchAccount)
}
}
}
}
private suspend fun showFullscreenAnnouncement(announcement: Announcement.Fullscreen) {
private suspend fun showAnnouncementIfNeverShown(announcement: Announcement) {
val currentValue = announcementStore.announcementStatusFlow(announcement).first()
if (currentValue == AnnouncementStatus.NeverShown) {
announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Show)
@@ -121,7 +121,6 @@ private fun FullscreenAnnouncementHeader(
subTitle = announcement.subtitle(),
iconStyle = BigIcon.Style.Default(
vectorIcon = announcement.icon(),
usePrimaryTint = true,
),
)
}
@@ -19,6 +19,8 @@ import kotlinx.coroutines.flow.map
private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement")
private val newNotificationSoundKey = intPreferencesKey("newNotificationSound")
private val multiAccountKey = intPreferencesKey("multiAccountAnnouncement")
private val swipeAvatarToSwitchAccountKey = intPreferencesKey("swipeAvatarToSwitchAccountAnnouncement")
@ContributesBinding(AppScope::class)
class DefaultAnnouncementStore(
@@ -36,11 +38,18 @@ class DefaultAnnouncementStore(
override fun announcementStatusFlow(announcement: Announcement): Flow<AnnouncementStatus> {
val key = announcement.toKey()
// Default-status convention: Shown for one-shot intro/feature announcements that should
// NOT replay on first launch (Fullscreen.Space is disabled; NewNotificationSound is opted
// in by AppMigration08).
// NOT replay on first launch.
// - Fullscreen.Space is disabled
// - NewNotificationSound is opted in by AppMigration08
// - MultiAccount is NeverShown by default, which means pending. It is set to Shown when the user enters
// the login flow while the feature is enabled (see NotLoggedInFlowNode), or dismisses the announcement.
// - SwipeAvatarToSwitchAccount is NeverShown by default, which means pending. It is set to Shown when the user
// dismisses the banner, or switches account by swiping their avatar.
val defaultStatus = when (announcement) {
Announcement.Fullscreen.Space -> AnnouncementStatus.Shown
Announcement.NewNotificationSound -> AnnouncementStatus.Shown
Announcement.MultiAccount -> AnnouncementStatus.NeverShown
Announcement.SwipeAvatarToSwitchAccount -> AnnouncementStatus.NeverShown
}
return store.data.map { prefs ->
val ordinal = prefs[key] ?: defaultStatus.ordinal
@@ -56,4 +65,6 @@ class DefaultAnnouncementStore(
private fun Announcement.toKey() = when (this) {
Announcement.Fullscreen.Space -> spaceAnnouncementKey
Announcement.NewNotificationSound -> newNotificationSoundKey
Announcement.MultiAccount -> multiAccountKey
Announcement.SwipeAvatarToSwitchAccount -> swipeAvatarToSwitchAccountKey
}
@@ -51,6 +51,38 @@ class DefaultAnnouncementServiceTest {
assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.Show)
}
@Test
fun `when showing MultiAccount announcement, announcement is set to show only if it was never shown`() = runTest {
val announcementStore = InMemoryAnnouncementStore()
val sut = createDefaultAnnouncementService(
announcementStore = announcementStore,
)
assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.NeverShown)
sut.showAnnouncement(Announcement.MultiAccount)
assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Show)
// Simulate user close the announcement
sut.onAnnouncementDismissed(Announcement.MultiAccount)
// Calling again showAnnouncement should not change the value
sut.showAnnouncement(Announcement.MultiAccount)
assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Shown)
}
@Test
fun `when showing SwipeAvatarToSwitchAccount 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.SwipeAvatarToSwitchAccount).first()).isEqualTo(AnnouncementStatus.NeverShown)
sut.showAnnouncement(Announcement.SwipeAvatarToSwitchAccount)
assertThat(announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount).first()).isEqualTo(AnnouncementStatus.Show)
// Simulate user close the announcement
sut.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount)
// Calling again showAnnouncement should not change the value
sut.showAnnouncement(Announcement.SwipeAvatarToSwitchAccount)
assertThat(announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount).first()).isEqualTo(AnnouncementStatus.Shown)
}
@Test
fun `test announcementsToShowFlow`() = runTest {
val announcementStore = InMemoryAnnouncementStore()
@@ -58,10 +90,18 @@ class DefaultAnnouncementServiceTest {
announcementStore = announcementStore,
)
sut.announcementsToShowFlow().test {
assertThat(awaitItem()).isEmpty()
// MultiAccount and SwipeAvatarToSwitchAccount are pending by default
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound)
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Show)
// Still pending
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown)
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Shown)
assertThat(awaitItem()).containsExactly(Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.SwipeAvatarToSwitchAccount, AnnouncementStatus.Shown)
assertThat(awaitItem()).isEmpty()
}
}
@@ -16,9 +16,13 @@ import kotlinx.coroutines.flow.asStateFlow
class InMemoryAnnouncementStore(
initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialMultiAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialSwipeAvatarToSwitchAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
) : AnnouncementStore {
private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus)
private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus)
private val multiAccountAnnouncement = MutableStateFlow(initialMultiAccountAnnouncementStatus)
private val swipeAvatarToSwitchAccountAnnouncement = MutableStateFlow(initialSwipeAvatarToSwitchAccountAnnouncementStatus)
override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) {
announcement.toMutableStateFlow().value = status
@@ -31,10 +35,14 @@ class InMemoryAnnouncementStore(
override suspend fun reset() {
fullScreenAnnouncement.value = AnnouncementStatus.NeverShown
newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown
multiAccountAnnouncement.value = AnnouncementStatus.NeverShown
swipeAvatarToSwitchAccountAnnouncement.value = AnnouncementStatus.NeverShown
}
private fun Announcement.toMutableStateFlow() = when (this) {
is Announcement.Fullscreen -> fullScreenAnnouncement
Announcement.NewNotificationSound -> newNotificationSoundAnnouncement
Announcement.MultiAccount -> multiAccountAnnouncement
Announcement.SwipeAvatarToSwitchAccount -> swipeAvatarToSwitchAccountAnnouncement
}
}
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.test.logs
package io.element.android.features.announcement.test
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-1
View File
@@ -17,6 +17,5 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.api
/**
* The write side of [CurrentCallService], for whoever is actually running a call.
*
* Separate from [CurrentCallService] because the two have opposite audiences: everything in the app
* that wants to know whether we are in a call reads the service, while only a call implementation
* writes. The WebView path reaches the implementation directly through `ActiveCallManager`, both
* being in `features/call/impl`; the native path is in another module and would otherwise have to
* depend on that impl to report a call it is running itself.
*/
interface CurrentCallTracker {
fun onCallStarted(call: CurrentCall)
fun onCallEnded()
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.api
/**
* Tells the ringing machinery what became of the call it is ringing for.
*
* There is one ring per incoming call, and it is owned by `ActiveCallManager` in
* `features/call/impl` - but *whichever* call implementation answers has to stop it, cancel the
* missed-call timeout and release the wake lock. The WebView path reaches the manager directly,
* being in the same module; the native path is in another one, and this is the narrow slice of it
* that anyone answering a call needs.
*/
interface RingingCallTracker {
/**
* The user is now in the call: stop ringing, take down the incoming-call UI, and cancel the
* timeout that would otherwise post a missed call.
*/
suspend fun onCallJoined(callData: CallData)
/** The call is over. Clears the active call, and stops a ring that is still in progress. */
suspend fun onCallEnded(callData: CallData)
}
+1 -1
View File
@@ -86,10 +86,10 @@ dependencies {
implementation(libs.coil.compose)
implementation(libs.serialization.json)
implementation(libs.element.call.embedded)
implementation(projects.features.callnative.api)
api(projects.features.call.api)
testCommonDependencies(libs, true)
testImplementation(projects.features.call.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.matrix.test)
@@ -16,14 +16,24 @@ import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.IntentProvider
import io.element.android.features.callnative.api.NativeCallEntryPoint
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
@ContributesBinding(AppScope::class)
class DefaultElementCallEntryPoint(
@ApplicationContext private val context: Context,
private val activeCallManager: ActiveCallManager,
private val nativeCallEntryPoint: NativeCallEntryPoint,
private val featureFlagService: FeatureFlagService,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : ElementCallEntryPoint {
companion object {
const val EXTRA_CALL_TYPE = "EXTRA_CALL_TYPE"
@@ -31,8 +41,19 @@ class DefaultElementCallEntryPoint(
}
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))
}
}
}
override suspend fun handleIncomingCall(
callData: CallData,
@@ -18,10 +18,8 @@ import androidx.core.app.PendingIntentCompat
import androidx.core.app.Person
import dev.zacsweers.metro.Inject
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver
import io.element.android.features.call.impl.ui.IncomingCallActivity
import io.element.android.features.call.impl.utils.IntentProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.CommonDrawables
@@ -31,7 +29,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import kotlin.time.Duration.Companion.seconds
@@ -55,6 +53,15 @@ class RingingCallNotificationCreator(
* Request code for the full screen intent.
*/
const val FULL_SCREEN_INTENT_REQUEST_CODE = 2
/**
* Request code for the answer action.
*
* Distinct from [FULL_SCREEN_INTENT_REQUEST_CODE] even though both now target
* `IncomingCallActivity`: they carry different extras, and a shared request code would let
* `FLAG_CANCEL_CURRENT` on one replace the other.
*/
const val ANSWER_REQUEST_CODE = 3
}
suspend fun createNotification(
@@ -89,14 +96,6 @@ class RingingCallNotificationCreator(
.setImportant(true)
.build()
val answerIntent = IntentProvider.getPendingIntent(
context,
CallData(
sessionId = sessionId,
roomId = roomId,
isAudioCall = audioOnly,
),
)
val notificationData = CallNotificationData(
sessionId = sessionId,
roomId = roomId,
@@ -112,6 +111,21 @@ class RingingCallNotificationCreator(
audioOnly = audioOnly,
)
// Through IncomingCallActivity rather than straight to ElementCallActivity, so that
// answering goes through ElementCallEntryPoint and lands in whichever call stack the
// NativeCall flag selects. Naming the WebView activity here meant the notification's Answer
// button always opened the WebView, even with native calls turned on.
val answerIntent = PendingIntentCompat.getActivity(
context,
ANSWER_REQUEST_CODE,
Intent(context, IncomingCallActivity::class.java).apply {
putExtra(IncomingCallActivity.EXTRA_NOTIFICATION_DATA, notificationData)
putExtra(IncomingCallActivity.EXTRA_ANSWER_IMMEDIATELY, true)
},
PendingIntent.FLAG_CANCEL_CURRENT,
false,
)!!
val declineIntent = PendingIntentCompat.getBroadcast(
context,
DECLINE_REQUEST_CODE,
@@ -8,6 +8,7 @@
package io.element.android.features.call.impl.ui
import android.content.Intent
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.setContent
@@ -47,6 +48,15 @@ class IncomingCallActivity : AppCompatActivity() {
* Extra key for the notification data.
*/
const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA"
/**
* Answer straight away rather than showing the incoming-call screen.
*
* Set by the notification's Answer action, which has already asked the user - showing them
* the same question again would be the bug. The screen is for the full-screen intent, where
* the user has not answered anything yet.
*/
const val EXTRA_ANSWER_IMMEDIATELY = "EXTRA_ANSWER_IMMEDIATELY"
}
@Inject
@@ -85,6 +95,7 @@ class IncomingCallActivity : AppCompatActivity() {
)
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
if (answerImmediatelyIfAsked(intent)) return
if (notificationData != null) {
setContent {
val colors by remember {
@@ -116,6 +127,37 @@ class IncomingCallActivity : AppCompatActivity() {
.launchIn(lifecycleScope)
}
/**
* The Answer action can arrive while this activity is already showing the ringing screen, because
* the full screen intent put it there and it is `singleTask`. That delivers a new intent instead
* of recreating the activity, so the answer has to be handled here as well as in [onCreate] - and
* [setIntent] has to run first, or everything reading `intent` afterwards would still see the one
* the activity was created with.
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
answerImmediatelyIfAsked(intent)
}
/**
* Answers and finishes when the intent is the notification's Answer action, and reports whether
* it did.
*
* Answering goes through this activity rather than straight to a call screen so that it goes
* through `ElementCallEntryPoint`, which is what decides between the WebView and the native
* stack. The notification action used to name `ElementCallActivity` outright, so answering from
* the notification always opened the WebView however the flag was set.
*/
private fun answerImmediatelyIfAsked(intent: Intent?): Boolean {
if (intent == null || !intent.getBooleanExtra(EXTRA_ANSWER_IMMEDIATELY, false)) return false
val notificationData = IntentCompat.getParcelableExtra(intent, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java)
?: return false
onAnswer(notificationData)
finish()
return true
}
private fun onAnswer(notificationData: CallNotificationData) {
elementCallEntryPoint.startCall(
CallData(
@@ -19,9 +19,11 @@ import coil3.annotation.DelicateCoilApi
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.RingingCallTracker
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.core.extensions.runCatchingExceptions
@@ -29,7 +31,7 @@ import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler
@@ -90,7 +92,8 @@ interface ActiveCallManager {
}
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
@ContributesBinding(AppScope::class, binding = binding<ActiveCallManager>())
@ContributesBinding(AppScope::class, binding = binding<RingingCallTracker>())
class DefaultActiveCallManager(
@ApplicationContext context: Context,
@AppCoroutineScope
@@ -103,7 +106,16 @@ class DefaultActiveCallManager(
private val appForegroundStateService: AppForegroundStateService,
private val imageLoaderHolder: ImageLoaderHolder,
private val systemClock: SystemClock,
) : ActiveCallManager {
) : ActiveCallManager, RingingCallTracker {
/**
* The two [RingingCallTracker] entry points are the existing methods under the names a caller
* outside this module can reach. Deliberately not new behaviour: answering has always had to do
* exactly this, and the native path simply could not say so from another module.
*/
override suspend fun onCallJoined(callData: CallData) = joinedCall(callData)
override suspend fun onCallEnded(callData: CallData) = hangUpCall(callData)
private val tag = "ActiveCallManager"
private var timedOutCallJob: Job? = null
@@ -11,20 +11,23 @@ package io.element.android.features.call.impl.utils
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.CurrentCallService
import io.element.android.features.call.api.CurrentCallTracker
import kotlinx.coroutines.flow.MutableStateFlow
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultCurrentCallService : CurrentCallService {
@ContributesBinding(AppScope::class, binding = binding<CurrentCallService>())
@ContributesBinding(AppScope::class, binding = binding<CurrentCallTracker>())
class DefaultCurrentCallService : CurrentCallService, CurrentCallTracker {
override val currentCall = MutableStateFlow<CurrentCall>(CurrentCall.None)
fun onCallStarted(call: CurrentCall) {
override fun onCallStarted(call: CurrentCall) {
currentCall.value = call
}
fun onCallEnded() {
override fun onCallEnded() {
currentCall.value = CurrentCall.None
}
}
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Apel în curs"</string>
<string name="call_foreground_service_message_android">"Atingeți pentru a reveni la apel."</string>
<string name="call_foreground_service_message_android">"Atingeți pentru a reveni la apel"</string>
<string name="call_foreground_service_title_android">"☎️ Apel în curs"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call nu permite utilizarea dispozitivelor audio Bluetooth în această versiune Android. Vă rugăm să selectați un alt dispozitiv audio."</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call nu permite utilizarea dispozitivelor audio Bluetooth în această versiune Android. Selectați un alt dispozitiv audio."</string>
<string name="screen_incoming_call_subtitle_android">"Primiți un apel Element Call"</string>
</resources>
@@ -6,25 +6,30 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call
package io.element.android.features.call.impl
import android.content.Intent
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.ui.ElementCallActivity
import io.element.android.features.call.utils.FakeActiveCallManager
import io.element.android.features.call.impl.utils.FakeActiveCallManager
import io.element.android.features.call.impl.utils.FakeNativeCallEntryPoint
import io.element.android.features.callnative.api.NativeCallEntryPoint
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.robolectric.RuntimeEnvironment
@@ -32,10 +37,13 @@ import org.robolectric.Shadows.shadowOf
import kotlin.time.Duration.Companion.seconds
class DefaultElementCallEntryPointTest : RobolectricTest() {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest {
val entryPoint = createEntryPoint()
entryPoint.startCall(CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = false))
// The flag is read asynchronously before routing.
advanceUntilIdle()
val expectedIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ElementCallActivity::class.java)
val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity
@@ -43,6 +51,24 @@ class DefaultElementCallEntryPointTest : RobolectricTest() {
assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `startCall - routes to the native call stack when the feature flag is on`() = runTest {
val startNativeCallLambda = lambdaRecorder<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)
@Test
fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() = runTest {
@@ -70,8 +96,15 @@ class DefaultElementCallEntryPointTest : RobolectricTest() {
private fun TestScope.createEntryPoint(
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
nativeCallEntryPoint: NativeCallEntryPoint = FakeNativeCallEntryPoint(),
isNativeCallEnabled: Boolean = false,
) = DefaultElementCallEntryPoint(
context = InstrumentationRegistry.getInstrumentation().targetContext,
activeCallManager = activeCallManager,
nativeCallEntryPoint = nativeCallEntryPoint,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.NativeCall.key to isNativeCallEnabled),
),
appCoroutineScope = this,
)
}
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call
package io.element.android.features.call.impl
import android.Manifest
import android.webkit.PermissionRequest
@@ -6,13 +6,13 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.notifications
package io.element.android.features.call.impl.notifications
import androidx.core.graphics.drawable.IconCompat
import androidx.test.platform.app.InstrumentationRegistry
import coil3.ImageLoader
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.features.call.impl.ui.IncomingCallActivity
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
@@ -20,12 +20,13 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder
import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.robolectric.Shadows.shadowOf
class RingingCallNotificationCreatorTest : RobolectricTest() {
@Test
@@ -89,6 +90,35 @@ class RingingCallNotificationCreatorTest : RobolectricTest() {
assertThat(acceptAction?.title?.toString()).isEqualTo("Answer")
}
@Test
fun `createNotification - the answer action goes through IncomingCallActivity, asking it to answer straight away`() = runTest {
val notificationCreator = createRingingCallNotificationCreator(
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }),
)
val notification = notificationCreator.createTestNotification()
// Not ElementCallActivity: naming the WebView activity here made the Answer button open the
// WebView call whatever FeatureFlags.NativeCall was set to.
val answerIntent = shadowOf(notification?.actions?.get(1)?.actionIntent).savedIntent
assertThat(answerIntent.component?.className).isEqualTo(IncomingCallActivity::class.java.name)
assertThat(answerIntent.getBooleanExtra(IncomingCallActivity.EXTRA_ANSWER_IMMEDIATELY, false)).isTrue()
assertThat(answerIntent.hasExtra(IncomingCallActivity.EXTRA_NOTIFICATION_DATA)).isTrue()
}
@Test
fun `createNotification - tapping the notification body answers too`() = runTest {
val notificationCreator = createRingingCallNotificationCreator(
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }),
)
val notification = notificationCreator.createTestNotification()
val contentIntent = shadowOf(notification?.contentIntent).savedIntent
assertThat(contentIntent.component?.className).isEqualTo(IncomingCallActivity::class.java.name)
assertThat(contentIntent.getBooleanExtra(IncomingCallActivity.EXTRA_ANSWER_IMMEDIATELY, false)).isTrue()
}
private suspend fun RingingCallNotificationCreator.createTestNotification(audioOnly: Boolean = false) = createNotification(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallData
@@ -5,11 +5,9 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.ui.CallScreenBackPressAction
import io.element.android.features.call.impl.ui.CallScreenBackPressPolicy
import org.junit.Test
class CallScreenBackPressPolicyTest {
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
@@ -14,13 +14,10 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.ui.CallScreenEvent
import io.element.android.features.call.impl.ui.CallScreenNavigator
import io.element.android.features.call.impl.ui.CallScreenPresenter
import io.element.android.features.call.impl.utils.FakeActiveCallManager
import io.element.android.features.call.impl.utils.FakeCallWidgetProvider
import io.element.android.features.call.impl.utils.FakeWidgetMessageInterceptor
import io.element.android.features.call.impl.utils.WidgetMessageSerializer
import io.element.android.features.call.utils.FakeActiveCallManager
import io.element.android.features.call.utils.FakeCallWidgetProvider
import io.element.android.features.call.utils.FakeWidgetMessageInterceptor
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import android.view.KeyEvent
import android.webkit.WebView
@@ -18,11 +18,6 @@ import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.call.impl.pip.PictureInPictureEvent
import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.aPictureInPictureState
import io.element.android.features.call.impl.ui.CallScreenEvent
import io.element.android.features.call.impl.ui.CallScreenState
import io.element.android.features.call.impl.ui.CallScreenView
import io.element.android.features.call.impl.ui.JavascriptBackHandlerBridge
import io.element.android.features.call.impl.ui.aCallScreenState
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest
@@ -6,9 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
import io.element.android.features.call.impl.ui.CallScreenNavigator
package io.element.android.features.call.impl.ui
class FakeCallScreenNavigator : CallScreenNavigator {
var closeCalled = false
@@ -6,10 +6,9 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import androidx.compose.runtime.Composable
import io.element.android.features.call.impl.ui.LanguageTagProvider
class FakeLanguageTagProvider(private val languageTag: String?) : LanguageTagProvider {
@Composable
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import android.os.PowerManager
import androidx.core.app.NotificationManagerCompat
@@ -16,10 +16,6 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.features.call.impl.notifications.aCallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.CallState
import io.element.android.features.call.impl.utils.DefaultActiveCallManager
import io.element.android.features.call.impl.utils.DefaultCurrentCallService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
@@ -34,7 +30,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder
import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler
@@ -6,10 +6,9 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.utils.DefaultCallWidgetProvider
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.A_ROOM_ID
@@ -6,12 +6,10 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.MutableStateFlow
@@ -6,9 +6,8 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import io.element.android.features.call.impl.utils.CallWidgetProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import io.element.android.features.call.api.CallData
import io.element.android.features.callnative.api.NativeCallEntryPoint
class FakeNativeCallEntryPoint(
private val startCallLambda: (CallData) -> Unit = {},
) : NativeCallEntryPoint {
override fun startCall(callData: CallData) = startCallLambda(callData)
}
@@ -6,9 +6,8 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
import kotlinx.coroutines.flow.MutableSharedFlow
class FakeWidgetMessageInterceptor : WidgetMessageInterceptor {
-1
View File
@@ -17,7 +17,6 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
api(projects.features.call.api)
implementation(projects.libraries.matrix.api)
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.test
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.CurrentCallTracker
class FakeCurrentCallTracker : CurrentCallTracker {
/** Every transition in order, so a test can assert the call was reported before it was cleared. */
val calls = mutableListOf<CurrentCall>()
override fun onCallStarted(call: CurrentCall) {
calls += call
}
override fun onCallEnded() {
calls += CurrentCall.None
}
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.test
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.RingingCallTracker
class FakeRingingCallTracker : RingingCallTracker {
val joined = mutableListOf<CallData>()
val ended = mutableListOf<CallData>()
override suspend fun onCallJoined(callData: CallData) {
joined += callData
}
override suspend fun onCallEnded(callData: CallData) {
ended += callData
}
}
+32
View File
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
// NativeCallHost renders the call, so this module carries a composable.
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.callnative.api"
}
// Temporary: `-PelementCallLocalVersion` points this at a locally published build of
// element-call-android. Goes with the repositories in settings.gradle.kts.
val elementCallVersion: String = providers.gradleProperty("elementCallLocalVersion")
.getOrElse(libs.versions.element.call.get())
dependencies {
// api, not implementation: ElementCallMatrixTransport is part of ElementCallTransportFactory's
// signature, and CallData of NativeCallEntryPoint's.
// api, so that consumers of this module inherit the versions the BOM pins - the artifact
// below is declared without one.
api(platform("io.element.android:element-call-bom:$elementCallVersion"))
api(libs.element.call.api)
api(projects.features.call.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.callnative.api
import io.element.android.call.api.matrix.ElementCallMatrixTransport
import io.element.android.libraries.matrix.api.MatrixClient
/**
* Makes the native call's view of Matrix for a session.
*
* The component's turnkey transport is built over a raw Rust SDK client, and only
* `libraries/matrix/impl` is compiled against the SDK. This is the seam that keeps it that way: the
* implementation lives there, everything else takes a [MatrixClient] and asks.
*
* A factory rather than a binding for the transport itself, because the call is started from app scope
* - a call can be placed for an account that is not the one on screen - and the session it belongs to
* is known only as an id until a client is fetched for it.
*/
fun interface ElementCallTransportFactory {
fun create(client: MatrixClient): ElementCallMatrixTransport
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.callnative.api
import io.element.android.features.call.api.CallData
/**
* Starts a call on the native MatrixRTC stack instead of the Element Call WebView.
*
* Experimental: gated behind `FeatureFlags.NativeCall` and reached only through
* [io.element.android.features.call.api.ElementCallEntryPoint], so nothing else in the app has to know
* it exists.
*
* There is nothing behind this seam yet - the default implementation does nothing and says so. The
* native stack that implements it is a separate library, added in a follow-up.
*/
fun interface NativeCallEntryPoint {
fun startCall(callData: CallData)
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.callnative.api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* Where a native call is drawn, wrapped around the logged-in content.
*
* A wrapper rather than an overlay, because the two states of a call need different things from the
* layout: minimized, the call takes a strip of height above the content; maximized, it covers it. The
* [Modifier] handed back to [content] has consumed whatever window insets the call has already taken,
* so the screens underneath do not pad for the status bar a second time.
*
* With no call running this renders [content] and nothing else.
*/
interface NativeCallHost {
@Composable
fun Render(modifier: Modifier, content: @Composable (Modifier) -> Unit)
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.callnative.api
import android.app.Activity
/**
* The one part of picture-in-picture that an Activity has to do itself.
*
* Everything else is installed where the call is drawn, which is inside the logged-in content and so
* knows which session's call it is. This does not: `onUserLeaveHint` is an Activity override, there is
* no session in scope at that point, and it applies to whichever call happens to be running.
*
* Only Android 11 and below reach this. From Android 12 the system is told up front that the Activity
* would like to shrink, which covers every way out of the app including the home gesture - the one
* people actually use, and the one this hook never sees.
*/
interface NativeCallPip {
/** Call from the host Activity's `onUserLeaveHint()` override. */
fun onUserLeaveHint(activity: Activity)
}

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