Merge develop into feature/account-provider-selection

This commit is contained in:
Jenna Vassar
2026-08-14 07:51:17 -07:00
1632 changed files with 11623 additions and 5960 deletions
+2 -2
View File
@@ -51,12 +51,12 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APKs - name: Assemble debug APKs
+2 -2
View File
@@ -56,12 +56,12 @@ jobs:
- name: Clone submodules - name: Clone submodules
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug Gplay Enterprise APK - name: Assemble debug Gplay Enterprise APK
+2 -2
View File
@@ -19,12 +19,12 @@ jobs:
- name: ⏬ Checkout with LFS - name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
name: Use JDK 21 name: Use JDK 21
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
+3 -3
View File
@@ -44,13 +44,13 @@ jobs:
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
ref: ${{ github.ref }} ref: ${{ github.ref }}
persist-credentials: false persist-credentials: false
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
name: Use JDK 21 name: Use JDK 21
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK - name: Assemble debug APK
@@ -98,7 +98,7 @@ jobs:
run: curl -fsSL "https://get.maestro.mobile.dev" | bash run: curl -fsSL "https://get.maestro.mobile.dev" | bash
- name: Run Maestro tests in emulator - name: Run Maestro tests in emulator
id: maestro_test id: maestro_test
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0 uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
continue-on-error: true continue-on-error: true
env: env:
MAESTRO_USERNAME: maestroelement MAESTRO_USERNAME: maestroelement
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+4 -4
View File
@@ -37,13 +37,13 @@ jobs:
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: false cache-read-only: false
@@ -80,12 +80,12 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis - name: Dependency analysis
+13 -13
View File
@@ -69,12 +69,12 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12
@@ -108,12 +108,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Konsist tests - name: Run Konsist tests
@@ -149,12 +149,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run compose tests - name: Run compose tests
@@ -183,12 +183,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build Gplay Debug - name: Build Gplay Debug
@@ -228,12 +228,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Detekt - name: Run Detekt
@@ -269,12 +269,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Ktlint check - name: Run Ktlint check
@@ -336,7 +336,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 - uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
upload_reports: upload_reports:
name: Project Check Suite name: Project Check Suite
+2 -2
View File
@@ -54,13 +54,13 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: ☕️ Use JDK 21 - name: ☕️ Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
# Add gradle cache, this should speed up the process # Add gradle cache, this should speed up the process
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots - name: Record screenshots
+6 -6
View File
@@ -38,12 +38,12 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: Create app bundle - name: Create app bundle
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -82,12 +82,12 @@ jobs:
- name: Clone submodules - name: Clone submodules
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: Create Enterprise app bundle - name: Create Enterprise app bundle
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -126,12 +126,12 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: Create APKs - name: Create APKs
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -78,6 +78,10 @@ else
git config --local user.name "${INPUT_AUTHOR_EMAIL}" git config --local user.name "${INPUT_AUTHOR_EMAIL}"
fi fi
git add -A git add -A
if git diff --cached --quiet; then
echo "Screenshots are already up to date, nothing to commit."
exit 0
fi
git commit -m "Update screenshots" git commit -m "Update screenshots"
GITHUB_REPO="https://$GITHUB_ACTOR:$TOKEN@github.com/$REPO.git" GITHUB_REPO="https://$GITHUB_ACTOR:$TOKEN@github.com/$REPO.git"
+2 -2
View File
@@ -45,12 +45,12 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build debug code and test fixtures - name: Build debug code and test fixtures
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
permissions: permissions:
issues: write issues: write
steps: steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with: with:
only-labels: "X-Needs-Info" only-labels: "X-Needs-Info"
days-before-issue-stale: 30 days-before-issue-stale: 30
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12
+2 -2
View File
@@ -63,12 +63,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: ☕️ Use JDK 21 - name: ☕️ Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
+92
View File
@@ -1,3 +1,95 @@
Changes in Element X v26.08.1
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.08.1 -->
## What's Changed
### 🙌 Improvements
* Render push notifications for live location share starts (MSC4505) by @ara4n in https://github.com/element-hq/element-x-android/pull/7203
* [Task] Show active call participants in the people list #7199 by @Eklavya47 in https://github.com/element-hq/element-x-android/pull/7300
### 🐛 Bugfixes
* Fix overlap issue. by @bmarty in https://github.com/element-hq/element-x-android/pull/7331
* Announce checked/unchecked state for user rows in invite screen by @bmarty in https://github.com/element-hq/element-x-android/pull/7136
* Display 'add reactions' menu in the threads screen by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7388
* Expand the emoji picker to full height when the keyboard is shown by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7382
* UI fixes by @bmarty in https://github.com/element-hq/element-x-android/pull/7409
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7389
### 🧱 Build
* Don't check enterprise previews in `KonsistPreviewTest` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7297
* CI: split unit tests, screenshots and coverage in `tests.yml` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7296
* Update Gradle Wrapper from 9.6.1 to 9.7.0 by @ElementBot in https://github.com/element-hq/element-x-android/pull/7335
### 🚧 In development 🚧
* QR code login iteration by @bmarty in https://github.com/element-hq/element-x-android/pull/6719
* User status last pieces by @ganfra in https://github.com/element-hq/element-x-android/pull/7328
* [Link new device] Add timeout by @bmarty in https://github.com/element-hq/element-x-android/pull/7338
### Dependency upgrades
* Update dependency org.maplibre.compose:maplibre-compose to v0.13.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7291
* Update android.gradle.plugin to v9.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7248
* Update dependency io.sentry:sentry-android to v8.50.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7298
* Update zizmorcore/zizmor-action action to v0.6.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7306
* Update android.gradle.plugin to v9.3.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7304
* Update dependency com.posthog:posthog-android to v3.56.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7311
* Update dependencyAnalysis to v3.18.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7318
* Update actions/stale action to v11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7317
* Update dependency io.github.zxing-cpp:android to v3.1.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7322
* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.05 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7324
* Update dependency androidx.constraintlayout:constraintlayout to v2.2.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7329
* Update reactivecircus/android-emulator-runner action to v2.38.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7189
* Update dependency io.sentry:sentry-android to v8.51.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7301
* Update dependency org.maplibre.gl:android-sdk to v13.4.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7305
* Update dependency io.element.android:element-call-embedded to v0.23.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7323
* Update metro to v1.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7334
* Update dependency com.google.firebase:firebase-bom to v34.17.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7333
* Update dependency androidx.compose.material3:material3 to v1.5.0-alpha23 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7175
* Update dependency org.jsoup:jsoup to v1.23.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7332
* Update dependency androidx.constraintlayout:constraintlayout-compose to v1.1.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7330
* Update zizmorcore/zizmor-action action to v0.6.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7383
* Update actions/setup-java action to v5.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7339
* Update gradle/actions action to v6.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7384
* Update plugin dependencycheck to v13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7386
* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7402
### 💼 Element Pro
* Add support for checking the ESS config in Element Pro by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7302
### Others
* Add extra vertical padding to `ReplyToInvalidContent` and `ReplyToNotFoundContent` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7288
* Index messages locally in an encrypted on-device store so encrypted rooms can be searched at all by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7249
* Bump enterprise module to the latest commit by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7307
* Bump enterprise module to the latest commit by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7309
* Move deviceId and git details to developer settings by @bmarty in https://github.com/element-hq/element-x-android/pull/7241
* Show a Message not found placeholder when a replied to message cannot be loaded by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7376
* Update in reply to message when message is not found. by @bmarty in https://github.com/element-hq/element-x-android/pull/7387
* Add default `OAUTH_CLIENT_URL_PATH` alternative by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7385
* Fix the feature module template writing the impl build script into the api module by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7374
* Let users enter their full Matrix user ID when searching for a homeserver by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7375
* Keep manually revealed media visible when re-entering a room by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7378
* Copy a blocked user's Matrix ID by long pressing them by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7372
* Do not draw the media loading progress bar under the status bar by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7366
* Do not notify about an event sent by another account on the same device by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7360
* Keep the opened image on screen when the media gallery fails to load by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7362
* Do not linkify the server part of a fediverse handle as an email address by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7356
* Expose global account data get and set on MatrixClient by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7373
* Show the alias or the direct message user's Matrix ID in the room picker by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7371
* Use a dedicated string for the Mute room notification mode by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7367
* Keep playing a voice message when the screen turns off by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7365
* Let a voice message without a waveform be seeked by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7363
* Ignore a second voice recording start while one is already running by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7346
* Do not offer to start a call in a DM with no other member by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7352
* Do not notify about a redaction whose target event is unknown by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7350
* Ignore a room list filter that is incompatible with an already selected one by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7342
* Stop a right to left sender name from reordering a room list preview by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7348
* Do not append the application MapTiler key to a custom map style url by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7340
* Hide space children you have left that can only be joined by invitation by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7369
* Never re-encode a video at a higher bitrate than the source by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7351
* Display push notifications for knocks by @ara4n in https://github.com/element-hq/element-x-android/pull/7207
## New Contributors
* @hayaksi1 made their first contribution in https://github.com/element-hq/element-x-android/pull/7249
* @ara4n made their first contribution in https://github.com/element-hq/element-x-android/pull/7203
* @Eklavya47 made their first contribution in https://github.com/element-hq/element-x-android/pull/7300
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.08.0...v26.08.1
Changes in Element X v26.08.0 Changes in Element X v26.08.0
============================= =============================
@@ -35,4 +35,11 @@ object RageshakeConfig {
* The maximum number of log lines a rageshake can contain. * The maximum number of log lines a rageshake can contain.
*/ */
const val MAX_LOG_LINES_SIZE = 1_000_000 const val MAX_LOG_LINES_SIZE = 1_000_000
/**
* The maximum size of the description. The bug report server copies it verbatim into a GitHub issue body,
* which GitHub rejects above 65536 characters. The remaining budget is left to the other reported fields,
* mainly the list of the omitted log file names.
*/
const val MAX_DESCRIPTION_SIZE = 40 * 1024
} }
+2
View File
@@ -44,6 +44,7 @@ dependencies {
implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.wellknown.api)
implementation(projects.features.login.api) implementation(projects.features.login.api)
implementation(libs.coil) implementation(libs.coil)
@@ -60,6 +61,7 @@ dependencies {
testCommonDependencies(libs) testCommonDependencies(libs)
testImplementation(projects.features.login.test) testImplementation(projects.features.login.test)
testImplementation(projects.features.share.test) testImplementation(projects.features.share.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oauth.test) testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
@@ -11,8 +11,10 @@ package io.element.android.appnav
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -49,11 +51,13 @@ import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.SessionEnterpriseService import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.features.ftue.api.FtueEntryPoint 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.FtueService
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
import io.element.android.features.location.api.LocalMapTilerConfig
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
@@ -153,6 +157,7 @@ class LoggedInFlowNode(
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher, private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
private val createRoomEntryPoint: CreateRoomEntryPoint, private val createRoomEntryPoint: CreateRoomEntryPoint,
private val activeLiveLocationShareManager: ActiveLiveLocationShareManager, private val activeLiveLocationShareManager: ActiveLiveLocationShareManager,
private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>( ) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Placeholder, initialElement = NavTarget.Placeholder,
@@ -677,6 +682,12 @@ class LoggedInFlowNode(
val colors by remember { val colors by remember {
enterpriseService.semanticColorsFlow(sessionId = matrixClient.sessionId) enterpriseService.semanticColorsFlow(sessionId = matrixClient.sessionId)
}.collectAsState(SemanticColorsLightDark.default) }.collectAsState(SemanticColorsLightDark.default)
val currentMapTilerConfig = LocalMapTilerConfig.current
val updatedMapTilerConfig by produceState(currentMapTilerConfig) {
value = remoteEnterpriseConfigProvider.get(matrixClient.sessionId).dataOrNull()?.mapTilerConfig ?: currentMapTilerConfig
}
ElementThemeApp( ElementThemeApp(
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
@@ -684,16 +695,18 @@ class LoggedInFlowNode(
compoundDark = colors.dark, compoundDark = colors.dark,
buildMeta = buildMeta, buildMeta = buildMeta,
) { ) {
val isOnline by syncService.isOnline.collectAsState() CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) {
ConnectivityIndicatorContainer( val isOnline by syncService.isOnline.collectAsState()
isOnline = isOnline, ConnectivityIndicatorContainer(
modifier = modifier, isOnline = isOnline,
) { contentModifier -> modifier = modifier,
Box(modifier = contentModifier) { ) { contentModifier ->
val ftueState by ftueService.state.collectAsState() Box(modifier = contentModifier) {
BackstackView(transitionHandler = rememberLoggedInFlowTransitionHandler(backstack)) val ftueState by ftueService.state.collectAsState()
if (ftueState is FtueState.Complete) { BackstackView(transitionHandler = rememberLoggedInFlowTransitionHandler(backstack))
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent) if (ftueState is FtueState.Complete) {
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
}
} }
} }
} }
@@ -30,6 +30,8 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
@@ -65,6 +67,7 @@ class LoggedInPresenter(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor, private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
private val featureFlagService: FeatureFlagService,
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<LoggedInState> { ) : Presenter<LoggedInState> {
private val localNetworkPermissionsPresenter: PermissionsPresenter = private val localNetworkPermissionsPresenter: PermissionsPresenter =
@@ -120,6 +123,16 @@ class LoggedInPresenter(
}.launchIn(this) }.launchIn(this)
} }
LaunchedEffect(Unit) {
// Keep automatic call status (m.call) in sync with the feature flag and homeserver support.
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus)
.onEach { isUserStatusEnabled ->
val enabled = isUserStatusEnabled && matrixClient.isUserStatusSupported().getOrDefault(false)
matrixClient.enableAutomaticCallStatus(enabled)
}
.launchIn(this)
}
val networkConnectivity by networkMonitor.connectivity.collectAsState() val networkConnectivity by networkMonitor.connectivity.collectAsState()
LaunchedEffect(networkConnectivity) { LaunchedEffect(networkConnectivity) {
if (networkConnectivity == NetworkStatus.Connected) { if (networkConnectivity == NetworkStatus.Connected) {
@@ -17,6 +17,9 @@ import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -48,6 +51,7 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
@@ -127,6 +131,7 @@ class LoggedInPresenterTest {
buildMeta = buildMeta, buildMeta = buildMeta,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(), localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
featureFlagService = FakeFeatureFlagService(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(), permissionsPresenterFactory = FakePermissionsPresenterFactory(),
).test { ).test {
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN) encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
@@ -346,6 +351,57 @@ class LoggedInPresenterTest {
} }
} }
@Test
fun `present - does not enable automatic call status when server does not support it`() = runTest {
val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { }
val matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
enableAutomaticCallStatusLambda = enableAutomaticCallStatusLambda,
).apply {
isUserStatusSupportedResult = Result.success(false)
}
createLoggedInPresenter(
matrixClient = matrixClient,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.UserStatus.key to true),
),
).test {
cancelAndConsumeRemainingEvents()
assert(enableAutomaticCallStatusLambda)
.isCalledOnce()
.with(value(false))
}
}
@Test
fun `present - reacts to user status flag being toggled at runtime`() = runTest {
val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { }
val matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
enableAutomaticCallStatusLambda = enableAutomaticCallStatusLambda,
).apply {
isUserStatusSupportedResult = Result.success(true)
}
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.UserStatus.key to false),
)
createLoggedInPresenter(
matrixClient = matrixClient,
featureFlagService = featureFlagService,
).test {
awaitItem()
// Turning the flag on at runtime enables it.
featureFlagService.setFeatureEnabled(FeatureFlags.UserStatus, true)
cancelAndConsumeRemainingEvents()
assert(enableAutomaticCallStatusLambda)
.isCalledExactly(2)
.withSequence(
listOf(value(false)),
listOf(value(true)),
)
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T { private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1) skipItems(1)
return awaitItem() return awaitItem()
@@ -362,6 +418,7 @@ class LoggedInPresenterTest {
), ),
buildMeta: BuildMeta = aBuildMeta(), buildMeta: BuildMeta = aBuildMeta(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): LoggedInPresenter { ): LoggedInPresenter {
return LoggedInPresenter( return LoggedInPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
@@ -373,6 +430,7 @@ class LoggedInPresenterTest {
buildMeta = buildMeta, buildMeta = buildMeta,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(), localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
featureFlagService = featureFlagService,
permissionsPresenterFactory = FakePermissionsPresenterFactory(), permissionsPresenterFactory = FakePermissionsPresenterFactory(),
) )
} }
+8
View File
@@ -1,3 +1,5 @@
import org.sonarqube.gradle.SonarResolverTask
/* /*
* Copyright (c) 2025 Element Creations Ltd. * Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd. * Copyright 2022-2025 New Vector Ltd.
@@ -18,3 +20,9 @@ dependencies {
implementation(libs.ksp.plugin) implementation(libs.ksp.plugin)
implementation(libs.kotlinpoet.ksp) implementation(libs.kotlinpoet.ksp)
} }
// Ensure that the processResources task is executed before the SonarResolverTask to avoid issues with missing resources during SonarQube analysis.
// This *should not be necesssary* but is a workaround for a known issue with the SonarQube Gradle plugin.
tasks.withType<SonarResolverTask>().configureEach {
dependsOn("processResources")
}
+6 -2
View File
@@ -303,6 +303,8 @@ Follow these steps to install and configure the plugin and templates:
- Click on OK - Click on OK
4. Configure generate-module-from-template plugin : 4. Configure generate-module-from-template plugin :
- Navigate to AS/Settings/Tools/Module Template Settings - Navigate to AS/Settings/Tools/Module Template Settings
- If a `FeatureModule` template is already listed, select it and remove it, since the plugin keeps its own copy of the
imported template and will not pick up changes made to `FeatureModule.json` on its own
- Click on + / Import From File - Click on + / Import From File
- Pick the `tools/templates/FeatureModule.json` - Pick the `tools/templates/FeatureModule.json`
@@ -321,8 +323,10 @@ Example for a new feature called RoomDetails:
5. The modules api/impl should be created under `features/roomdetails` directory. 5. The modules api/impl should be created under `features/roomdetails` directory.
6. Sync project with Gradle so the modules are recognized (no need to add them to settings.gradle). 6. Sync project with Gradle so the modules are recognized (no need to add them to settings.gradle).
7. You can now add more Presentation classes (Events, State, StateProvider, View, Presenter) in the impl module with the `Template Presentation Classes`. 7. You can now add more Presentation classes (Events, State, StateProvider, View, Presenter) in the impl module with the `Template Presentation Classes`.
To use it, just right click on the package where you want to generate classes, and click on `Template Presentation Classes`. To use it, just right click on the package where you want to generate classes, and click on `New` / `Template Presentation Classes`.
Fill the text field with the base name of the classes, ie `RootRoomDetails` in the `root` package. Fill the text field with the base name of the classes: the generated files are `<BaseName>Event.kt`, `<BaseName>State.kt`,
`<BaseName>StateProvider.kt`, `<BaseName>Presenter.kt`, `<BaseName>Node.kt` and `<BaseName>View.kt`. For instance
`PreferencesRoot` in the `features/preferences/impl/root` package.
Note that naming of files and classes is important, since those names are used to set up code coverage rules. For instance, presenters MUST have a Note that naming of files and classes is important, since those names are used to set up code coverage rules. For instance, presenters MUST have a
@@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases
@@ -39,7 +39,7 @@ fun AnalyticsPreferencesView(
) )
Column(modifier) { Column(modifier) {
ListItem( ListItem(
headlineContent = { content = {
Text(stringResource(id = R.string.screen_analytics_settings_share_data)) Text(stringResource(id = R.string.screen_analytics_settings_share_data))
}, },
supportingContent = { supportingContent = {
@@ -12,15 +12,34 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
/**
* Queues the full-screen announcements shown to the user once, such as the notice about a new feature.
*
* A dismissed announcement is remembered so it is not shown again.
*/
interface AnnouncementService { interface AnnouncementService {
/**
* Queues an announcement, unless the user has already dismissed it.
*
* @param announcement the announcement to show.
*/
suspend fun showAnnouncement(announcement: Announcement) suspend fun showAnnouncement(announcement: Announcement)
/**
* Records that the user dismissed an announcement, so it is not queued again.
*
* @param announcement the announcement that was dismissed.
*/
suspend fun onAnnouncementDismissed(announcement: Announcement) suspend fun onAnnouncementDismissed(announcement: Announcement)
/** The announcements still waiting to be shown, in the order they should appear. */
fun announcementsToShowFlow(): Flow<List<Announcement>> fun announcementsToShowFlow(): Flow<List<Announcement>>
/** /**
* Use this composable to render the announcement UI in Fullscreen. * Use this composable to render the announcement UI in Fullscreen.
* Draws nothing while there is no announcement to show.
*
* @param modifier layout modifier for the container.
*/ */
@Composable @Composable
fun Render( fun Render(
@@ -8,6 +8,9 @@
package io.element.android.features.cachecleaner.api package io.element.android.features.cachecleaner.api
/**
* Removes the temporary files holding decrypted content, so plaintext media does not linger on disk.
*/
interface CacheCleaner { interface CacheCleaner {
/** /**
* Clear the cache subdirs holding temporarily decrypted content (such as media and voice messages). * Clear the cache subdirs holding temporarily decrypted content (such as media and voice messages).
@@ -10,6 +10,9 @@ package io.element.android.features.call.api
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
/**
* Tracks the call the user is currently in on this device, so unrelated parts of the app can react to it.
*/
interface CurrentCallService { interface CurrentCallService {
/** /**
* The current call state flow, which will be updated when the active call changes. * The current call state flow, which will be updated when the active call changes.
@@ -18,5 +18,11 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentVal
* This will only process media when a [ContentScanner] is provided when building the client in Pro. * This will only process media when a [ContentScanner] is provided when building the client in Pro.
*/ */
fun interface ContentScannerService { fun interface ContentScannerService {
/**
* Starts scanning the given media, reporting the verdict of each one through [contentValidationState] rather than by returning.
*
* @param mediaSources the media to scan.
* @param contentValidationState where the outcome of each scan is published for the UI to observe.
*/
fun scan(mediaSources: List<MediaSource>, contentValidationState: ContentValidationState) fun scan(mediaSources: List<MediaSource>, contentValidationState: ContentValidationState)
} }
@@ -15,9 +15,21 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
interface CreateRoomEntryPoint : FeatureEntryPoint { interface CreateRoomEntryPoint : FeatureEntryPoint {
/**
* Configures the create-room flow before it is started, since it serves rooms and spaces from the same screens.
*/
interface Builder { interface Builder {
/**
* @param isSpace true to create a space rather than a room.
*/
fun setIsSpace(isSpace: Boolean): Builder fun setIsSpace(isSpace: Boolean): Builder
/**
* @param parentSpaceId the space the new room should be added to once created.
*/
fun setParentSpace(parentSpaceId: RoomId): Builder fun setParentSpace(parentSpaceId: RoomId): Builder
/** Builds the node with the options configured so far. */
fun build(): Node fun build(): Node
} }
@@ -312,7 +312,7 @@ private fun RoomJoinRuleOptions(
backgroundTint = Color.Transparent, backgroundTint = Color.Transparent,
) )
}, },
headlineContent = { content = {
val title = when (item) { val title = when (item) {
JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_title) JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_title)
is JoinRuleItem.PrivateVisibility.Restricted -> stringResource(R.string.screen_create_room_room_access_section_restricted_option_title) is JoinRuleItem.PrivateVisibility.Restricted -> stringResource(R.string.screen_create_room_room_access_section_restricted_option_title)
@@ -58,7 +58,7 @@ internal fun SelectParentSpaceOptions(
modifier = modifier modifier = modifier
) { ) {
ListItem( ListItem(
headlineContent = { content = {
Text( Text(
text = selectedSpace?.displayName text = selectedSpace?.displayName
?: stringResource(R.string.screen_create_room_space_selection_no_space_title), ?: stringResource(R.string.screen_create_room_space_selection_no_space_title),
@@ -126,7 +126,7 @@ private fun SelectParentSpaceBottomSheet(
LazyColumn(modifier = Modifier.fillMaxWidth()) { LazyColumn(modifier = Modifier.fillMaxWidth()) {
item { item {
ListItem( ListItem(
headlineContent = { content = {
Text( Text(
text = stringResource(R.string.screen_create_room_space_selection_no_space_option), text = stringResource(R.string.screen_create_room_space_selection_no_space_option),
maxLines = 1 maxLines = 1
@@ -141,7 +141,7 @@ private fun SelectParentSpaceBottomSheet(
for (space in spaces) { for (space in spaces) {
item { item {
ListItem( ListItem(
headlineContent = { content = {
Text( Text(
text = space.displayName, text = space.displayName,
maxLines = 1 maxLines = 1
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"請確認您想要停用您的帳號。此動作無法還原。"</string> <string name="screen_deactivate_account_confirmation_dialog_content">"請確認您想要刪除您的帳號。此動作無法還原。"</string>
<string name="screen_deactivate_account_delete_all_messages">"刪除我所有的訊息"</string> <string name="screen_deactivate_account_delete_all_messages">"刪除我所有的訊息"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"警告:未來的使用者可能會看到不完整的對話。"</string> <string name="screen_deactivate_account_delete_all_messages_notice">"警告:未來的使用者可能會看到不完整的對話。"</string>
<string name="screen_deactivate_account_description">"停用您的帳號為 %1$s,它將:"</string> <string name="screen_deactivate_account_description">"刪除您的帳號為 %1$s,它將:"</string>
<string name="screen_deactivate_account_description_bold_part">"不可逆"</string> <string name="screen_deactivate_account_description_bold_part">"不可逆"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s 您的帳號(您將無法重新登入,也無法重用您的 ID)。"</string> <string name="screen_deactivate_account_list_item_1">"%1$s 您的帳號(您將無法重新登入,也無法重用您的 ID)。"</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"永久停用"</string> <string name="screen_deactivate_account_list_item_1_bold_part">"永久停用"</string>
<string name="screen_deactivate_account_list_item_2">"將您從所有聊天室移除。"</string> <string name="screen_deactivate_account_list_item_2">"將您從所有聊天室移除。"</string>
<string name="screen_deactivate_account_list_item_3">"從我們的身份伺服器將您的帳號資訊刪除。"</string> <string name="screen_deactivate_account_list_item_3">"從我們的身份伺服器將您的帳號資訊刪除。"</string>
<string name="screen_deactivate_account_list_item_4">"註冊使用者仍可看到您的訊息,但如果您選擇刪除,新使用者與未註冊的使用者將看不到它們。"</string> <string name="screen_deactivate_account_list_item_4">"註冊使用者仍可看到您的訊息,但如果您選擇刪除,新使用者與未註冊的使用者將看不到它們。"</string>
<string name="screen_deactivate_account_title">"停用帳號"</string> <string name="screen_deactivate_account_title">"刪除帳號"</string>
</resources> </resources>
@@ -10,15 +10,46 @@ package io.element.android.features.enterprise.api
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
/**
* Exposes the restrictions and customisations an enterprise deployment can impose: allowed homeservers, branding, push gateways and more.
*
* On a standard build this reports permissive defaults, so callers do not need to branch on the build type themselves.
*/
interface EnterpriseService { interface EnterpriseService {
/** Whether this build is an enterprise one; `false` for the public app. */
val isEnterpriseBuild: Boolean val isEnterpriseBuild: Boolean
/**
* Whether the given session belongs to an enterprise deployment, which can be true even on a standard build.
*
* @param sessionId the session to check.
*/
suspend fun isEnterpriseUser(sessionId: SessionId): Boolean suspend fun isEnterpriseUser(sessionId: SessionId): Boolean
suspend fun tweakMasUrl(url: String, homeserver: String): String
/**
* Rewrites the authentication server URL when the deployment requires a different one from the one advertised.
*
* @param url the URL to rewrite.
* @param homeserver the homeserver the URL belongs to.
* @param urlContentFetcher used to read the deployment configuration; a client that is not authenticated yet also works.
*/
suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String
/**
* The homeservers the user is allowed to sign in to; an empty list, or one containing [ANY_ACCOUNT_PROVIDER], means no restriction.
*/
fun defaultHomeserverList(): List<String> fun defaultHomeserverList(): List<String>
/**
* Whether the user is allowed to sign in to a given homeserver, according to [defaultHomeserverList].
*
* @param homeserverUrl the server the user is trying to use.
*/
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean
/** /**
@@ -28,17 +59,38 @@ interface EnterpriseService {
*/ */
suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?)
/**
* The brand color to apply, or `null` when the default theme color should be used.
*
* @param sessionId the session to read the color of, or `null` for the color used when no session is active.
*/
fun brandColorsFlow(sessionId: SessionId?): Flow<Color?> fun brandColorsFlow(sessionId: SessionId?): Flow<Color?>
/**
* The full light and dark color palettes derived from the brand color.
*
* @param sessionId the session to read the colors of, or `null` for the colors used when no session is active.
*/
fun semanticColorsFlow(sessionId: SessionId?): Flow<SemanticColorsLightDark> fun semanticColorsFlow(sessionId: SessionId?): Flow<SemanticColorsLightDark>
/** The push gateway to register with when using Firebase, or `null` to use the app default. */
fun firebasePushGateway(): String? fun firebasePushGateway(): String?
/** The push gateway to register with when using UnifiedPush, or `null` to use the app default. */
fun unifiedPushDefaultPushGateway(): String? fun unifiedPushDefaultPushGateway(): String?
/**
* Where bug reports should be submitted, which an enterprise deployment can redirect or disable entirely.
*
* @param sessionId the session to read the setting of, or `null` for the setting used when no session is active.
*/
fun bugReportUrlFlow(sessionId: SessionId?): Flow<BugReportUrl> fun bugReportUrlFlow(sessionId: SessionId?): Flow<BugReportUrl>
/** /**
* Gets Notification Channel to use for the noisy notifications of the provided session. * Gets Notification Channel to use for the noisy notifications of the provided session.
* Returns `null` when the session has no dedicated channel and the app-wide one should be used.
*
* @param sessionId the session whose channel is requested.
*/ */
fun getNoisyNotificationChannelId(sessionId: SessionId): String? fun getNoisyNotificationChannelId(sessionId: SessionId): String?
@@ -47,6 +99,14 @@ interface EnterpriseService {
*/ */
fun overriddenElementWellKnown(): ElementWellKnown? fun overriddenElementWellKnown(): ElementWellKnown?
/**
* Gets the Element Server Suite (ESS) config endpoint URL for the given domain.
* Returns `null` when this build does not read its configuration from an ESS deployment.
*
* @param domain the server whose configuration endpoint is requested.
*/
fun essConfigEndpointUrl(domain: String): String?
companion object { companion object {
const val ANY_ACCOUNT_PROVIDER = "*" const val ANY_ACCOUNT_PROVIDER = "*"
} }
@@ -8,11 +8,25 @@
package io.element.android.features.enterprise.api package io.element.android.features.enterprise.api
/**
* The session-scoped counterpart of [EnterpriseService], answering the same kind of questions for the session it belongs to.
*
* [init] must have run before the other members return meaningful answers.
*/
interface SessionEnterpriseService { interface SessionEnterpriseService {
/** Whether Element Call may be used on this session, which a deployment can turn off. */
suspend fun isElementCallAvailable(): Boolean suspend fun isElementCallAvailable(): Boolean
/**
* Rewrites the authentication server URL for this session; see [EnterpriseService.tweakMasUrl].
*
* @param url the URL to rewrite.
*/
suspend fun tweakMasUrl(url: String): String suspend fun tweakMasUrl(url: String): String
/** Whether the homeserver forbids creating encrypted rooms, in which case the app must not offer the option. */
suspend fun isEncryptionDisabledByHomeserver(): Boolean suspend fun isEncryptionDisabledByHomeserver(): Boolean
/** Loads the deployment configuration of this session; to be called once when the session starts. */
suspend fun init() suspend fun init()
} }
@@ -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.enterprise.api.remoteconfig
import io.element.android.libraries.wellknown.api.ElementWellKnown
/**
* Typealias for the well-known configuration used for enterprise features.
*
* This will allow for easier refactoring in the future when we'll want to move as much of its logic as possible to this module.
*/
typealias RemoteEnterpriseConfig = ElementWellKnown
@@ -0,0 +1,35 @@
/*
* 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.enterprise.api.remoteconfig
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
/**
* Wrapper to fetch the remote enterprise configuration for a given [SessionId], either from a local cache or from a remote source.
*/
interface RemoteEnterpriseConfigProvider {
/**
* Returns the configuration of a session, served from the cache when possible.
* A stale entry is reported as outdated while a refresh runs in the background, so this rarely waits on the network.
*
* @param sessionId the session whose configuration is requested.
*/
suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig>
/**
* Creates a provider, which needs a fetcher because the configuration is read over HTTP.
*/
interface Factory {
/**
* @param urlContentFetcher used to perform the request; a client that is not authenticated yet also works.
*/
fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider
}
}
@@ -14,6 +14,7 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -24,7 +25,7 @@ class DefaultEnterpriseService : EnterpriseService {
override val isEnterpriseBuild = false override val isEnterpriseBuild = false
override suspend fun isEnterpriseUser(sessionId: SessionId) = false override suspend fun isEnterpriseUser(sessionId: SessionId) = false
override suspend fun tweakMasUrl(url: String, homeserver: String) = url override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher) = url
override fun defaultHomeserverList(): List<String> = emptyList() override fun defaultHomeserverList(): List<String> = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
@@ -48,4 +49,6 @@ class DefaultEnterpriseService : EnterpriseService {
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null
override fun overriddenElementWellKnown(): ElementWellKnown? = null override fun overriddenElementWellKnown(): ElementWellKnown? = null
override fun essConfigEndpointUrl(domain: String): String? = null
} }
@@ -0,0 +1,30 @@
/*
* 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.enterprise.impl.remoteconfig
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
@ContributesBinding(AppScope::class)
class DefaultRemoteEnterpriseConfigProvider : RemoteEnterpriseConfigProvider {
override suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig> {
return WellknownRetrieverResult.NotFound
}
@ContributesBinding(AppScope::class)
class Factory : RemoteEnterpriseConfigProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider {
return DefaultRemoteEnterpriseConfigProvider()
}
}
}
@@ -5,10 +5,12 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.libraries.matrix.impl.scanner package io.element.android.features.enterprise.impl.scanner
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
/** /**
@@ -16,5 +18,12 @@ import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
*/ */
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultContentScannerUrlProvider : ContentScannerUrlProvider { class DefaultContentScannerUrlProvider : ContentScannerUrlProvider {
override suspend fun getContentScannerUrl(homeserver: String): Result<String?> = Result.success(null) override suspend fun getContentScannerUrl(sessionId: SessionId): Result<String?> = Result.success(null)
@ContributesBinding(AppScope::class)
class Factory : ContentScannerUrlProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider {
return DefaultContentScannerUrlProvider()
}
}
} }
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
@@ -31,8 +32,9 @@ class FakeEnterpriseService(
private val firebasePushGatewayResult: () -> String? = { lambdaError() }, private val firebasePushGatewayResult: () -> String? = { lambdaError() },
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() }, private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() }, private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() },
private val tweakMasUrlResult: (String, String) -> String = { _, _ -> lambdaError() }, private val tweakMasUrlResult: (String, String, UrlContentFetcher) -> String = { _, _, _ -> lambdaError() },
private val overrideWellKnownResult: () -> ElementWellKnown? = { lambdaError() }, private val overrideWellKnownResult: () -> ElementWellKnown? = { lambdaError() },
private val essConfigEndpointUrlResult: (String) -> String = { lambdaError() },
) : EnterpriseService { ) : EnterpriseService {
private val brandColorState = MutableStateFlow(initialBrandColor) private val brandColorState = MutableStateFlow(initialBrandColor)
private val semanticColorsState = MutableStateFlow(initialSemanticColors) private val semanticColorsState = MutableStateFlow(initialSemanticColors)
@@ -41,8 +43,8 @@ class FakeEnterpriseService(
isEnterpriseUserResult(sessionId) isEnterpriseUserResult(sessionId)
} }
override suspend fun tweakMasUrl(url: String, homeserver: String): String = simulateLongTask { override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String = simulateLongTask {
tweakMasUrlResult(url, homeserver) tweakMasUrlResult(url, homeserver, urlContentFetcher)
} }
override fun defaultHomeserverList(): List<String> { override fun defaultHomeserverList(): List<String> {
@@ -85,4 +87,8 @@ class FakeEnterpriseService(
override fun overriddenElementWellKnown(): ElementWellKnown? { override fun overriddenElementWellKnown(): ElementWellKnown? {
return overrideWellKnownResult() return overrideWellKnownResult()
} }
override fun essConfigEndpointUrl(domain: String): String {
return essConfigEndpointUrlResult(domain)
}
} }
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.enterprise.test.remoteconfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
class FakeRemoteEnterpriseConfigProvider(
private val getResult: (SessionId) -> WellknownRetrieverResult<RemoteEnterpriseConfig> = { WellknownRetrieverResult.NotFound }
) : RemoteEnterpriseConfigProvider {
override suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig> {
return getResult(sessionId)
}
}
@@ -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.enterprise.test.remoteconfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.UrlContentFetcher
class FakeRemoteEnterpriseConfigProviderFactory(
private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(),
) : RemoteEnterpriseConfigProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider
}
@@ -14,12 +14,15 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -204,6 +207,9 @@ private fun HomeScaffold(
floatingActionButton = { floatingActionButton = {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
HomeBottomBar( HomeBottomBar(
// The Scaffold uses top-only insets so the scrollable content can go edge-to-edge behind the
// navigation bar, so the floating toolbar has to apply the bottom inset itself to avoid overlapping it.
modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
currentHomeNavigationBarItem = state.currentHomeNavigationBarItem, currentHomeNavigationBarItem = state.currentHomeNavigationBarItem,
onItemClick = { item -> onItemClick = { item ->
// scroll to top if selecting the same item // scroll to top if selecting the same item
@@ -8,6 +8,8 @@
package io.element.android.features.home.impl.components package io.element.android.features.home.impl.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
@@ -15,9 +17,11 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
@@ -25,8 +29,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -37,10 +44,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
@@ -71,10 +88,13 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toEmojiText
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -151,7 +171,7 @@ fun HomeTopBar(
}, },
// We want a 16dp left padding for the navigationIcon : // We want a 16dp left padding for the navigationIcon :
// 4dp from default TopAppBarHorizontalPadding // 4dp from default TopAppBarHorizontalPadding
// 8dp from AccountIcon default padding (because of IconButton) // 8dp from AccountIcon default padding (48dp touch target around 32dp avatar)
// 4dp extra padding using left insets // 4dp extra padding using left insets
windowInsets = WindowInsets(left = 4.dp), windowInsets = WindowInsets(left = 4.dp),
) )
@@ -316,19 +336,36 @@ private fun AccountIcon(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
IconButton( val interactionSource = remember { MutableInteractionSource() }
modifier = modifier.then(testTag), Box(
onClick = onClick, modifier = modifier
.then(testTag)
.minimumInteractiveComponentSize()
.clickable(
interactionSource = interactionSource,
onClick = onClick,
indication = ripple(bounded = false),
),
contentAlignment = Alignment.Center,
) { ) {
Box {
val avatarData by remember(matrixUser) { val avatarData by remember(matrixUser) {
derivedStateOf { derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar) matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
} }
} }
val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
val avatarModifier = if (statusEmoji != null) {
Modifier.eraseStatusEmojiBackground(
parentSize = AvatarSize.CurrentUserTopBar.dp,
layoutDirection = LocalLayoutDirection.current,
)
} else {
Modifier
}
Avatar( Avatar(
avatarData = avatarData, avatarData = avatarData,
avatarType = AvatarType.User, avatarType = AvatarType.User,
modifier = avatarModifier,
contentDescription = if (isCurrentAccount) { contentDescription = if (isCurrentAccount) {
if (showAvatarIndicator) { if (showAvatarIndicator) {
stringResource(CommonStrings.a11y_settings_with_required_action) stringResource(CommonStrings.a11y_settings_with_required_action)
@@ -339,11 +376,69 @@ private fun AccountIcon(
null null
}, },
) )
if (statusEmoji != null) {
StatusEmojiBadge(
emoji = statusEmoji,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
if (showAvatarIndicator) { if (showAvatarIndicator) {
RedIndicatorAtom( RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd) modifier = Modifier.align(Alignment.TopEnd)
) )
} }
}
}
private val statusEmojiBadgeSize = 20.dp
private val statusEmojiBadgeRadius = statusEmojiBadgeSize / 2
private val statusEmojiBadgeOffset = 8.dp
private fun Modifier.eraseStatusEmojiBackground(
parentSize: Dp,
layoutDirection: LayoutDirection,
): Modifier = this
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
drawCircle(
color = Color.Black,
center = Offset(
x = if (layoutDirection == LayoutDirection.Ltr) {
(parentSize - statusEmojiBadgeRadius + statusEmojiBadgeOffset).toPx()
} else {
(statusEmojiBadgeRadius - statusEmojiBadgeOffset).toPx()
},
y = size.height - statusEmojiBadgeRadius.toPx(),
),
radius = statusEmojiBadgeRadius.toPx(),
blendMode = BlendMode.Clear,
)
}
@Composable
private fun StatusEmojiBadge(
emoji: String,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
// Ensure the emoji size is not affected by the user's font scale settings
val fixedFontScaleDensity = remember(density.density) {
Density(density = density.density, fontScale = 1f)
}
Box(
modifier = modifier
.offset(x = statusEmojiBadgeOffset)
.size(statusEmojiBadgeSize),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) {
Text(
text = emoji,
style = ElementTheme.typography.fontBodyMdRegular,
)
} }
} }
} }
@@ -432,6 +527,33 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
internal fun HomeTopBarWithStatusPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
currentUserAndNeighbors = persistentListOf(
aMatrixUser(
id = "@id:domain",
displayName = USER_NAME_ALICE,
displayedStatus = DisplayedStatus.UserSet(UserStatus(emoji = "🌴", text = "Away")),
)
),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
spaceFiltersState = anUnselectedSpaceFiltersState(),
onMenuActionClick = {},
)
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
@@ -22,6 +22,7 @@ class DefaultFilterSelectionStrategy : FilterSelectionStrategy {
override val filterSelectionStates = MutableStateFlow(buildFilters()) override val filterSelectionStates = MutableStateFlow(buildFilters())
override fun select(filter: RoomListFilter) { override fun select(filter: RoomListFilter) {
if (selectedFilters.any { it in filter.incompatibleFilters }) return
selectedFilters.add(filter) selectedFilters.add(filter)
filterSelectionStates.value = buildFilters() filterSelectionStates.value = buildFilters()
} }
@@ -94,7 +94,7 @@ private fun RoomListModalBottomSheetContent(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
ListItem( ListItem(
headlineContent = { content = {
Text( Text(
text = contextMenu.roomName ?: stringResource(id = CommonStrings.common_no_room_name), text = contextMenu.roomName ?: stringResource(id = CommonStrings.common_no_room_name),
style = ElementTheme.typography.fontBodyLgMedium, style = ElementTheme.typography.fontBodyLgMedium,
@@ -104,7 +104,7 @@ private fun RoomListModalBottomSheetContent(
) )
if (contextMenu.hasNewContent) { if (contextMenu.hasNewContent) {
ListItem( ListItem(
headlineContent = { content = {
Text( Text(
text = stringResource(id = CommonStrings.action_mark_as_read), text = stringResource(id = CommonStrings.action_mark_as_read),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
@@ -117,7 +117,7 @@ private fun RoomListModalBottomSheetContent(
) )
} else { } else {
ListItem( ListItem(
headlineContent = { content = {
Text( Text(
text = stringResource(id = R.string.screen_roomlist_mark_as_unread), text = stringResource(id = R.string.screen_roomlist_mark_as_unread),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
@@ -135,7 +135,7 @@ private fun RoomListModalBottomSheetContent(
CommonStrings.common_favourite to CompoundIcons.Favourite() CommonStrings.common_favourite to CompoundIcons.Favourite()
} }
ListItem( ListItem(
headlineContent = { content = {
Text( Text(
text = stringResource(id = textResId), text = stringResource(id = textResId),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
@@ -154,7 +154,7 @@ private fun RoomListModalBottomSheetContent(
}, },
) )
ListItem( ListItem(
headlineContent = { content = {
Text( Text(
text = stringResource(id = CommonStrings.common_settings), text = stringResource(id = CommonStrings.common_settings),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
@@ -169,7 +169,7 @@ private fun RoomListModalBottomSheetContent(
) )
if (canReportRoom) { if (canReportRoom) {
ListItem( ListItem(
headlineContent = { content = {
Text(text = stringResource(CommonStrings.action_report_room)) Text(text = stringResource(CommonStrings.action_report_room))
}, },
modifier = Modifier.clickable { onReportRoomClick() }, modifier = Modifier.clickable { onReportRoomClick() },
@@ -182,7 +182,7 @@ private fun RoomListModalBottomSheetContent(
) )
} }
ListItem( ListItem(
headlineContent = { content = {
Text(text = stringResource(CommonStrings.action_leave_room)) Text(text = stringResource(CommonStrings.action_leave_room))
}, },
modifier = Modifier.clickable { onLeaveRoomClick() }, modifier = Modifier.clickable { onLeaveRoomClick() },
@@ -5,7 +5,7 @@
<string name="banner_battery_optimization_title_android">"Powiadomienia nie dochodzą?"</string> <string name="banner_battery_optimization_title_android">"Powiadomienia nie dochodzą?"</string>
<string name="banner_new_sound_message">"Sygnał powiadomień został zaktualizowany — jest wyraźniejszy, szybszy i mniej uciążliwy."</string> <string name="banner_new_sound_message">"Sygnał powiadomień został zaktualizowany — jest wyraźniejszy, szybszy i mniej uciążliwy."</string>
<string name="banner_new_sound_title">"Odświeżyliśmy Twoje dźwięki"</string> <string name="banner_new_sound_title">"Odświeżyliśmy Twoje dźwięki"</string>
<string name="banner_set_up_recovery_content">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić tę kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string> <string name="banner_set_up_recovery_content">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
<string name="banner_set_up_recovery_submit">"Uzyskaj klucz przywracania"</string> <string name="banner_set_up_recovery_submit">"Uzyskaj klucz przywracania"</string>
<string name="banner_set_up_recovery_title">"Utwórz kopię zapasową swoich czatów"</string> <string name="banner_set_up_recovery_title">"Utwórz kopię zapasową swoich czatów"</string>
<string name="confirm_recovery_key_banner_message">"Potwierdź klucz przywracania, aby zachować dostęp do magazynu kluczy i historii wiadomości."</string> <string name="confirm_recovery_key_banner_message">"Potwierdź klucz przywracania, aby zachować dostęp do magazynu kluczy i historii wiadomości."</string>
@@ -5,7 +5,7 @@
<string name="banner_battery_optimization_title_android">"Nu primiți notificări?"</string> <string name="banner_battery_optimization_title_android">"Nu primiți notificări?"</string>
<string name="banner_new_sound_message">"Sunetul pentru notificări a fost actualizat — mai clar, mai rapid și mai puțin perturbatoar."</string> <string name="banner_new_sound_message">"Sunetul pentru notificări a fost actualizat — mai clar, mai rapid și mai puțin perturbatoar."</string>
<string name="banner_new_sound_title">"Am reîmprospătat sunetele"</string> <string name="banner_new_sound_title">"Am reîmprospătat sunetele"</string>
<string name="banner_set_up_recovery_content">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura această copie de rezervă și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string> <string name="banner_set_up_recovery_content">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura aceast backup și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
<string name="banner_set_up_recovery_submit">"Obțineți cheia de recuperare"</string> <string name="banner_set_up_recovery_submit">"Obțineți cheia de recuperare"</string>
<string name="banner_set_up_recovery_title">"Faceți un backup al mesajelor"</string> <string name="banner_set_up_recovery_title">"Faceți un backup al mesajelor"</string>
<string name="confirm_recovery_key_banner_message">"Backup-ul pentru chat nu este sincronizat. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup."</string> <string name="confirm_recovery_key_banner_message">"Backup-ul pentru chat nu este sincronizat. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup."</string>
@@ -72,6 +72,49 @@ class RoomListFiltersPresenterTest {
} }
} }
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - toggling two incompatible filters in the same frame keeps the first one`() = runTest {
val presenter = createRoomListFiltersPresenter()
presenter.test {
awaitItem().let { state ->
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Favourites))
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Invites))
}
advanceUntilIdle()
awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters()).containsExactly(RoomListFilter.Favourites)
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Favourites, true),
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.People, false),
filterSelectionState(RoomListFilter.Rooms, false),
).inOrder()
}
}
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - toggling two incompatible categories in the same frame keeps the first one`() = runTest {
val presenter = createRoomListFiltersPresenter()
presenter.test {
awaitItem().let { state ->
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.People))
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
}
advanceUntilIdle()
awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters()).containsExactly(RoomListFilter.People)
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.People, true),
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.Favourites, false),
).inOrder()
}
}
}
@Test @Test
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun `present - clear filters event`() = runTest { fun `present - clear filters event`() = runTest {
@@ -11,6 +11,9 @@ package io.element.android.features.invite.api
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
/**
* Remembers which invitations the user has already looked at, so that only genuinely new ones are highlighted in the room list.
*/
interface SeenInvitesStore { interface SeenInvitesStore {
/** /**
* Returns a flow of seen room IDs of invitation. * Returns a flow of seen room IDs of invitation.
@@ -32,7 +35,7 @@ interface SeenInvitesStore {
suspend fun markAsUnSeen(roomId: RoomId) suspend fun markAsUnSeen(roomId: RoomId)
/** /**
* Delete the store. * Delete the store, so every invitation counts as unseen again.
*/ */
suspend fun clear() suspend fun clear()
} }
@@ -10,6 +10,9 @@ package io.element.android.features.invite.api.acceptdecline
import io.element.android.features.invite.api.InviteData import io.element.android.features.invite.api.InviteData
/**
* Events sent to the accept/decline invite presenter, from whichever screen is showing the invitation.
*/
interface AcceptDeclineInviteEvents { interface AcceptDeclineInviteEvents {
data class AcceptInvite(val invite: InviteData) : AcceptDeclineInviteEvents data class AcceptInvite(val invite: InviteData) : AcceptDeclineInviteEvents
data class DeclineInvite(val invite: InviteData, val blockUser: Boolean, val shouldConfirm: Boolean) : AcceptDeclineInviteEvents data class DeclineInvite(val invite: InviteData, val blockUser: Boolean, val shouldConfirm: Boolean) : AcceptDeclineInviteEvents
@@ -12,7 +12,20 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
/**
* Renders the confirmation dialogs and error states of accepting or declining an invitation; it draws nothing until such an event is sent.
*
* Embedded by every screen that can act on an invitation, so the behaviour stays identical between the invite list and a room preview.
*/
fun interface AcceptDeclineInviteView { fun interface AcceptDeclineInviteView {
/**
* Draws whichever dialog the current state calls for.
*
* @param state the state produced by the accept/decline presenter.
* @param onAcceptInviteSuccess called once the room has been joined, so the host screen can navigate into it.
* @param onDeclineInviteSuccess called once the invitation has been declined.
* @param modifier layout modifier for the container.
*/
@Composable @Composable
fun Render( fun Render(
state: AcceptDeclineInviteState, state: AcceptDeclineInviteState,
@@ -81,7 +81,7 @@ fun DeclineAndBlockView(
) { ) {
ListItem( ListItem(
modifier = Modifier.padding(end = 8.dp), modifier = Modifier.padding(end = 8.dp),
headlineContent = { content = {
Text(text = stringResource(R.string.screen_decline_and_block_block_user_option_title)) Text(text = stringResource(R.string.screen_decline_and_block_block_user_option_title))
}, },
supportingContent = { supportingContent = {
@@ -96,7 +96,7 @@ fun DeclineAndBlockView(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
ListItem( ListItem(
modifier = Modifier.padding(end = 8.dp), modifier = Modifier.padding(end = 8.dp),
headlineContent = { content = {
Text(text = stringResource(CommonStrings.action_report_room)) Text(text = stringResource(CommonStrings.action_report_room))
}, },
supportingContent = { supportingContent = {
@@ -8,6 +8,9 @@
package io.element.android.features.invitepeople.api package io.element.android.features.invitepeople.api
/**
* Events the invite people UI sends to its presenter through [InvitePeopleState.eventSink].
*/
interface InvitePeopleEvents { interface InvitePeopleEvents {
data object SendInvites : InvitePeopleEvents data object SendInvites : InvitePeopleEvents
data object CloseSearch : InvitePeopleEvents data object CloseSearch : InvitePeopleEvents
@@ -13,7 +13,14 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
interface InvitePeoplePresenter : Presenter<InvitePeopleState> { interface InvitePeoplePresenter : Presenter<InvitePeopleState> {
/**
* Creates a presenter for one invite flow.
*/
interface Factory { interface Factory {
/**
* @param joinedRoom the room to invite into, or `null` when the room does not exist yet, as when inviting from a direct message.
* @param roomId the id of the room to invite into.
*/
fun create( fun create(
joinedRoom: JoinedRoom?, joinedRoom: JoinedRoom?,
roomId: RoomId, roomId: RoomId,
@@ -11,7 +11,16 @@ package io.element.android.features.invitepeople.api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
/**
* Renders the invite people UI, so that other screens can embed it without depending on its implementation.
*/
interface InvitePeopleRenderer { interface InvitePeopleRenderer {
/**
* Draws the invite list and its search field.
*
* @param state the state produced by [InvitePeoplePresenter].
* @param modifier layout modifier for the container.
*/
@Composable @Composable
fun Render( fun Render(
state: InvitePeopleState, state: InvitePeopleState,
@@ -11,10 +11,22 @@ package io.element.android.features.invitepeople.api
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
/**
* State of the invite people UI, produced by an [InvitePeoplePresenter] and consumed by an [InvitePeopleRenderer].
*/
interface InvitePeopleState { interface InvitePeopleState {
/** Whether the send button should be enabled, i.e. at least one user is selected and the user has the permission. */
val canInvite: Boolean val canInvite: Boolean
/** Whether the search field currently has focus, which the host screen uses to adapt its own layout. */
val isSearchActive: Boolean val isSearchActive: Boolean
/** Progress of the invite request, so the UI can show a loader and surface failures. */
val sendInvitesAction: AsyncAction<Unit> val sendInvitesAction: AsyncAction<Unit>
/** Progress of turning a direct message into a room, which happens when inviting a third person into a DM. */
val createRoomFromDmAction: AsyncAction<RoomId> val createRoomFromDmAction: AsyncAction<RoomId>
/** Where the UI sends its events. */
val eventSink: (InvitePeopleEvents) -> Unit val eventSink: (InvitePeopleEvents) -> Unit
} }
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Bereits Mitglied"</string> <string name="screen_invite_users_already_a_member">"Bereits Mitglied"</string>
<string name="screen_invite_users_already_invited">"Bereits eingeladen"</string> <string name="screen_invite_users_already_invited">"Bereits eingeladen"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Du hast derzeit keine Chats mit diesen Kontakten. Bestätige, dass du sie in diesen Chat einladen möchtest, bevor du fortfährst."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Du hast derzeit keine Chats mit diesem Kontakt. Bestätige, dass du ihn in diesen Chat einladen möchtest, bevor du fortfährst."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Möchtest du neue Kontakte in diesen Chat einladen?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Willst du einen neuen Kontakt in diesen Chat einladen?"</string>
</resources> </resources>
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"已是成員"</string> <string name="screen_invite_users_already_a_member">"已是成員"</string>
<string name="screen_invite_users_already_invited">"已邀請"</string> <string name="screen_invite_users_already_invited">"已邀請"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"您目前與這些聯絡人沒有任何聊天紀錄。請確認是否要邀請他們加入此聊天室,然後再繼續。"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"您目前與此聯絡人沒有任何聊天紀錄。請確認是否要邀請對方加入此聊天室,然後再繼續。"</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"要邀請新聯絡人加入此聊天室嗎?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"要邀請新聯絡人加入此聊天室嗎?"</string>
</resources> </resources>
@@ -11,7 +11,16 @@ package io.element.android.features.knockrequests.api.banner
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
/**
* Renders the banner shown at the top of a room when people are waiting to join it.
*/
interface KnockRequestsBannerRenderer { interface KnockRequestsBannerRenderer {
/**
* Draws the banner, or nothing when there is no pending request or the user cannot moderate the room.
*
* @param modifier layout modifier for the container.
* @param onViewRequestsClick called when the user asks to see the full list of requests.
*/
@Composable @Composable
fun View(modifier: Modifier, onViewRequestsClick: () -> Unit) fun View(modifier: Modifier, onViewRequestsClick: () -> Unit)
} }
@@ -10,6 +10,9 @@ package io.element.android.features.leaveroom.api
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
/**
* Events sent to the leave room presenter through [LeaveRoomState.eventSink].
*/
interface LeaveRoomEvent { interface LeaveRoomEvent {
data class LeaveRoom(val roomId: RoomId, val needsConfirmation: Boolean) : LeaveRoomEvent data class LeaveRoom(val roomId: RoomId, val needsConfirmation: Boolean) : LeaveRoomEvent
} }
@@ -12,7 +12,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
/**
* Renders the confirmation dialogs of the leave room flow; it draws nothing until the host screen sends a leave event.
*/
fun interface LeaveRoomRenderer { fun interface LeaveRoomRenderer {
/**
* Draws whichever dialog the current state calls for.
*
* @param state the state produced by the leave room presenter.
* @param onSelectNewOwners called when the user is the last owner of a room that still has other members, and must promote someone before leaving.
* @param modifier layout modifier for the container.
*/
@Composable @Composable
fun Render( fun Render(
state: LeaveRoomState, state: LeaveRoomState,
@@ -10,7 +10,11 @@ package io.element.android.features.leaveroom.api
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
/**
* State of the leave room flow, which is only the confirmation dialogs: the screen it is hosted in owns the rest of the UI.
*/
@Immutable @Immutable
interface LeaveRoomState { interface LeaveRoomState {
/** Where the host screen sends its events to start or confirm leaving a room. */
val eventSink: (LeaveRoomEvent) -> Unit val eventSink: (LeaveRoomEvent) -> Unit
} }
@@ -62,7 +62,7 @@ fun DependenciesDetailsView(
} }
} }
ListItem( ListItem(
headlineContent = { content = {
ClickableLinkText( ClickableLinkText(
text = text, text = text,
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
@@ -100,7 +100,7 @@ fun DependencyLicensesListView(
} }
is AsyncData.Success -> items(state.licenses.data) { license -> is AsyncData.Success -> items(state.licenses.data) { license ->
ListItem( ListItem(
headlineContent = { Text(license.safeName) }, content = { Text(license.safeName) },
supportingContent = { supportingContent = {
Text( Text(
buildString { buildString {
@@ -12,10 +12,12 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
import io.element.android.libraries.matrix.api.logs.LoggerTags import io.element.android.libraries.matrix.api.logs.LoggerTags
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.minutes
private val loggerTag = LoggerTag("LinkNewDesktopHandler", LoggerTags.linkNewDevice) private val loggerTag = LoggerTag("LinkNewDesktopHandler", LoggerTags.linkNewDevice)
@@ -40,25 +43,34 @@ class LinkNewDesktopHandler(
get() = linkDesktopStepFlow.asStateFlow() get() = linkDesktopStepFlow.asStateFlow()
private var currentJob: Job? = null private var currentJob: Job? = null
private var timerJob: Job? = null
private var handler: LinkDesktopHandler? = null private var handler: LinkDesktopHandler? = null
fun createNewHandler() { private fun resetJobs() {
currentJob?.cancel() currentJob?.cancel()
currentJob = null currentJob = null
timerJob?.cancel()
timerJob = null
}
fun createNewHandler() {
resetJobs()
handler = matrixClient.createLinkDesktopHandler().getOrNull() handler = matrixClient.createLinkDesktopHandler().getOrNull()
timerJob = sessionScope.launch {
delay(2.minutes)
linkDesktopStepFlow.emit(LinkDesktopStep.Error(ErrorType.Expired("Scanning QrCode took too long.")))
}
} }
fun reset() { fun reset() {
currentJob?.cancel() resetJobs()
currentJob = null
sessionScope.launch { sessionScope.launch {
linkDesktopStepFlow.emit(LinkDesktopStep.Uninitialized) linkDesktopStepFlow.emit(LinkDesktopStep.Uninitialized)
} }
} }
fun onScannedCode(data: ByteArray) { fun onScannedCode(data: ByteArray) {
currentJob?.cancel() resetJobs()
currentJob = null
val currentHandler = handler val currentHandler = handler
if (currentHandler == null) { if (currentHandler == null) {
Timber.tag(loggerTag.value).e("onScannedCode: Handler is not initialized. Call createNewHandler() first.") Timber.tag(loggerTag.value).e("onScannedCode: Handler is not initialized. Call createNewHandler() first.")
@@ -56,6 +56,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
@@ -102,6 +103,7 @@ class LinkNewDeviceFlowNode(
@Suppress("AssignedValueIsNeverRead") @Suppress("AssignedValueIsNeverRead")
linkDesktopHandlerJob = observeLinkNewDesktopHandler() linkDesktopHandlerJob = observeLinkNewDesktopHandler()
}, },
onResume = ::onResume,
onDestroy = { onDestroy = {
linkMobileHandlerJob?.cancel() linkMobileHandlerJob?.cancel()
linkDesktopHandlerJob?.cancel() linkDesktopHandlerJob?.cancel()
@@ -209,6 +211,18 @@ class LinkNewDeviceFlowNode(
.launchIn(sessionCoroutineScope) .launchIn(sessionCoroutineScope)
} }
private fun onResume() = sessionCoroutineScope.launch {
// Application is resumed, if the step is waiting for auth, send the confirmation
(linkNewMobileHandler.stepFlow.value as? LinkMobileStep.WaitingForAuth)?.let {
Timber.tag(tag.value).d("Resuming while waiting for auth on mobile, sending confirmation")
it.continuationMessageSender.confirm()
}
(linkNewDesktopHandler.stepFlow.value as? LinkDesktopStep.WaitingForAuth)?.let {
Timber.tag(tag.value).d("Resuming while waiting for auth on desktop, sending confirmation")
it.continuationMessageSender.confirm()
}
}
private fun navigateToError(errorType: ErrorType) { private fun navigateToError(errorType: ErrorType) {
// Map the error to an error screen // Map the error to an error screen
val error = when (errorType) { val error = when (errorType) {
@@ -20,6 +20,13 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
/**
* Max number of QrCode rotation.
* When QrCode is rotated this number of time, a TimeOut error is emitted.
* Set to 0 to disable QrCode rotation.
*/
private const val MAX_QR_CODE_ROTATION = 0
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@AssistedInject @AssistedInject
class ShowQrCodeNode( class ShowQrCodeNode(
@@ -39,6 +46,7 @@ class ShowQrCodeNode(
private val callback: Callback = callback() private val callback: Callback = callback()
private val showQrCodePresenter: ShowQrCodePresenter = showQrCodePresenterFactory.create( private val showQrCodePresenter: ShowQrCodePresenter = showQrCodePresenterFactory.create(
initialData = inputs.data, initialData = inputs.data,
maxQrCodeRotation = MAX_QR_CODE_ROTATION,
) )
@Composable @Composable
@@ -26,24 +26,29 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice) private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice)
@AssistedInject @AssistedInject
class ShowQrCodePresenter( class ShowQrCodePresenter(
@Assisted private val initialData: String, @Assisted private val initialData: String,
@Assisted private val maxQrCodeRotation: Int,
private val linkNewMobileHandler: LinkNewMobileHandler, private val linkNewMobileHandler: LinkNewMobileHandler,
) : Presenter<ShowQrCodeState> { ) : Presenter<ShowQrCodeState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(initialData: String): ShowQrCodePresenter fun create(
initialData: String,
maxQrCodeRotation: Int,
): ShowQrCodePresenter
} }
private var loadingJob: Job? = null private var loadingJob: Job? = null
@Composable @Composable
override fun present(): ShowQrCodeState { override fun present(): ShowQrCodeState {
var qrCodeRotationCounter by remember { mutableIntStateOf(MAX_QR_CODE_ROTATION) } var qrCodeRotationCounter by remember { mutableIntStateOf(maxQrCodeRotation) }
val state by produceState( val state by produceState(
initialValue = ShowQrCodeState( initialValue = ShowQrCodeState(
data = AsyncData.Success(initialData), data = AsyncData.Success(initialData),
@@ -63,7 +68,7 @@ class ShowQrCodePresenter(
linkNewMobileHandler.rotateQrCode() linkNewMobileHandler.rotateQrCode()
// Ensure that outdated data is not rendered too long while rotating QR code // Ensure that outdated data is not rendered too long while rotating QR code
loadingJob = launch { loadingJob = launch {
delay(1000) delay(1.seconds)
value = ShowQrCodeState( value = ShowQrCodeState(
data = AsyncData.Loading(), data = AsyncData.Loading(),
) )
@@ -80,8 +85,4 @@ class ShowQrCodePresenter(
return state return state
} }
companion object {
const val MAX_QR_CODE_ROTATION = 10
}
} }
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Skaneeri QR-koodi selle seadmega"</string> <string name="screen_link_new_device_desktop_step3">"Skaneeri QR-koodi selle seadmega"</string>
<string name="screen_link_new_device_desktop_submit">"Skaneerimiseks valmis"</string> <string name="screen_link_new_device_desktop_submit">"Skaneerimiseks valmis"</string>
<string name="screen_link_new_device_desktop_title">"QR-koodi laadimiseks ava %1$s süle- või lauaarvutis"</string> <string name="screen_link_new_device_desktop_title">"QR-koodi laadimiseks ava %1$s süle- või lauaarvutis"</string>
<string name="screen_link_new_device_desktop_warning">"Skaneeri QR-koodi peale järgnevate sammude läbitegemist oma seadmes. Ära iialgi skaneeri QR-koodi, mille on saatnud keegi teine."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numbrid ei klapi"</string> <string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numbrid ei klapi"</string>
<string name="screen_link_new_device_enter_number_notice">"Sisesta kahekohaline kood"</string> <string name="screen_link_new_device_enter_number_notice">"Sisesta kahekohaline kood"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Sellega verifitseerime, et ühendus sinu teise seadmega on turvaline."</string> <string name="screen_link_new_device_enter_number_subtitle">"Sellega verifitseerime, et ühendus sinu teise seadmega on turvaline."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Skenirajte QR kod ovim uređajem"</string> <string name="screen_link_new_device_desktop_step3">"Skenirajte QR kod ovim uređajem"</string>
<string name="screen_link_new_device_desktop_submit">"Spremno za skeniranje"</string> <string name="screen_link_new_device_desktop_submit">"Spremno za skeniranje"</string>
<string name="screen_link_new_device_desktop_title">"Otvorite %1$s na stolnom računalu kako biste dobili QR kod"</string> <string name="screen_link_new_device_desktop_title">"Otvorite %1$s na stolnom računalu kako biste dobili QR kod"</string>
<string name="screen_link_new_device_desktop_warning">"QR kod skenirajte tek nakon što ste na vlastitom uređaju slijedili gore navedene korake. Nikada ne skenirajte kod koji vam je poslala druga osoba."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Brojevi se ne podudaraju"</string> <string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Brojevi se ne podudaraju"</string>
<string name="screen_link_new_device_enter_number_notice">"Unesite dvoznamenkasti kod"</string> <string name="screen_link_new_device_enter_number_notice">"Unesite dvoznamenkasti kod"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Time ćete potvrditi da je veza s vašim drugim uređajem sigurna."</string> <string name="screen_link_new_device_enter_number_subtitle">"Time ćete potvrditi da je veza s vašim drugim uređajem sigurna."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Olvassa be a QR-kódot ezzel az eszközzel"</string> <string name="screen_link_new_device_desktop_step3">"Olvassa be a QR-kódot ezzel az eszközzel"</string>
<string name="screen_link_new_device_desktop_submit">"Készen áll a beolvasásra"</string> <string name="screen_link_new_device_desktop_submit">"Készen áll a beolvasásra"</string>
<string name="screen_link_new_device_desktop_title">"Nyissa meg az asztali %1$s alkalmazást, hogy megkapja a QR-kódot"</string> <string name="screen_link_new_device_desktop_title">"Nyissa meg az asztali %1$s alkalmazást, hogy megkapja a QR-kódot"</string>
<string name="screen_link_new_device_desktop_warning">"Csak a fenti lépések végrehajtása után olvass be QR-kódot a saját eszközén. Soha ne olvasson be más által küldött kódot."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"A számok nem egyeznek"</string> <string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"A számok nem egyeznek"</string>
<string name="screen_link_new_device_enter_number_notice">"Írja be a kétjegyű kódot"</string> <string name="screen_link_new_device_enter_number_notice">"Írja be a kétjegyű kódot"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Ezzel ellenőrizni fogja, hogy a másik eszközzel való kapcsolat biztonságos-e."</string> <string name="screen_link_new_device_enter_number_subtitle">"Ezzel ellenőrizni fogja, hogy a másik eszközzel való kapcsolat biztonságos-e."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Scanați codul QR cu acest dispozitiv"</string> <string name="screen_link_new_device_desktop_step3">"Scanați codul QR cu acest dispozitiv"</string>
<string name="screen_link_new_device_desktop_submit">"Gata de scanare"</string> <string name="screen_link_new_device_desktop_submit">"Gata de scanare"</string>
<string name="screen_link_new_device_desktop_title">"Deschide %1$s pe un computer desktop pentru a obține codul QR"</string> <string name="screen_link_new_device_desktop_title">"Deschide %1$s pe un computer desktop pentru a obține codul QR"</string>
<string name="screen_link_new_device_desktop_warning">"Scanați un cod QR doar după ce urmați pașii de mai sus pe propriul dispozitiv. Nu scanați niciodată un cod trimis de o altă persoană."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numerele nu se potrivesc"</string> <string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numerele nu se potrivesc"</string>
<string name="screen_link_new_device_enter_number_notice">"Introduceți codul de 2 cifre"</string> <string name="screen_link_new_device_enter_number_notice">"Introduceți codul de 2 cifre"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Aceasta va verifica dacă conexiunea cu celălalt dispozitiv este sigură."</string> <string name="screen_link_new_device_enter_number_subtitle">"Aceasta va verifica dacă conexiunea cu celălalt dispozitiv este sigură."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"使用此裝置掃描 QR code"</string> <string name="screen_link_new_device_desktop_step3">"使用此裝置掃描 QR code"</string>
<string name="screen_link_new_device_desktop_submit">"準備掃描"</string> <string name="screen_link_new_device_desktop_submit">"準備掃描"</string>
<string name="screen_link_new_device_desktop_title">"在桌上型電腦上開啟 %1$s 以取得 QR code"</string> <string name="screen_link_new_device_desktop_title">"在桌上型電腦上開啟 %1$s 以取得 QR code"</string>
<string name="screen_link_new_device_desktop_warning">"請務必先在您的裝置上完成上述步驟,之後才掃描 QR code。切勿掃描他人傳送的 QR code。"</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"數字不符"</string> <string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"數字不符"</string>
<string name="screen_link_new_device_enter_number_notice">"輸入兩位數代碼"</string> <string name="screen_link_new_device_enter_number_notice">"輸入兩位數代碼"</string>
<string name="screen_link_new_device_enter_number_subtitle">"這將確認您與另一台裝置之間的連線是否安全。"</string> <string name="screen_link_new_device_enter_number_subtitle">"這將確認您與另一台裝置之間的連線是否安全。"</string>
@@ -91,10 +91,36 @@ class ShowQrCodePresenterTest {
} }
} }
@Test
fun `present - when handler emits QrRotating, the presenter does not emit anything when rotation is disabled`() = runTest {
val linkMobileHandler = FakeLinkMobileHandler(
startResult = {},
)
val matrixClient = FakeMatrixClient(
sessionCoroutineScope = backgroundScope,
createLinkMobileHandlerResult = { Result.success(linkMobileHandler) },
)
val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
linkNewMobileHandler.createAndStartNewHandler()
createPresenter(
linkNewMobileHandler = linkNewMobileHandler,
maxQrCodeRotation = 0,
).test {
awaitItem()
linkMobileHandler.emitStep(
LinkMobileStep.QrRotating
)
runCurrent()
expectNoEvents()
}
}
private fun createPresenter( private fun createPresenter(
linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()), linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()),
maxQrCodeRotation: Int = 10,
) = ShowQrCodePresenter( ) = ShowQrCodePresenter(
initialData = "DATA", initialData = "DATA",
maxQrCodeRotation = maxQrCodeRotation,
linkNewMobileHandler = linkNewMobileHandler, linkNewMobileHandler = linkNewMobileHandler,
) )
} }
+1
View File
@@ -70,6 +70,7 @@ dependencies {
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.wellknown.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.datetime) implementation(libs.datetime)
@@ -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.location.api
import androidx.compose.runtime.staticCompositionLocalOf
import io.element.android.libraries.wellknown.api.MapTilerConfig
/**
* Provides a [MapTilerConfig] for the MapLibre maps.
*/
val LocalMapTilerConfig = staticCompositionLocalOf {
MapTilerConfig(
apiKey = BuildConfig.MAPTILER_API_KEY,
lightStyleId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
darkStyleId = BuildConfig.MAPTILER_DARK_MAP_ID,
baseUrl = BuildConfig.MAPTILER_BASE_URL
)
}
@@ -8,6 +8,10 @@
package io.element.android.features.location.api package io.element.android.features.location.api
/**
* Tells whether the location features can be used at all, which depends on the build having a map provider configured.
*/
interface LocationService { interface LocationService {
/** Whether location sharing and map rendering are available; `false` disables the whole feature in the UI. */
fun isServiceAvailable(): Boolean fun isServiceAvailable(): Boolean
} }
@@ -38,13 +38,14 @@ import coil3.request.ImageRequest
import coil3.request.SuccessResult import coil3.request.SuccessResult
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.api.internal.StaticMapPlaceholder import io.element.android.features.location.api.internal.StaticMapPlaceholder
import io.element.android.features.location.api.internal.StaticMapUrlBuilder
import io.element.android.features.location.api.internal.centerBottomEdge import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.features.location.api.internal.rememberStaticMapBuilder
import io.element.android.libraries.designsystem.components.LocationPin import io.element.android.libraries.designsystem.components.LocationPin
import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.wellknown.api.MapTilerConfig
/** /**
* Shows a static map image downloaded via a third party service's static maps API. * Shows a static map image downloaded via a third party service's static maps API.
@@ -57,6 +58,7 @@ import io.element.android.libraries.designsystem.utils.CommonDrawables
*/ */
@Composable @Composable
fun StaticMapView( fun StaticMapView(
mapTilerConfig: MapTilerConfig?,
location: Location?, location: Location?,
zoom: Double, zoom: Double,
pinVariant: PinVariant, pinVariant: PinVariant,
@@ -94,6 +96,7 @@ fun StaticMapView(
} }
// Cases 3 & 4: Non-null location - fetch map // Cases 3 & 4: Non-null location - fetch map
else -> LoadableMapContent( else -> LoadableMapContent(
mapTilerConfig = mapTilerConfig,
location = location, location = location,
zoom = zoom, zoom = zoom,
pinVariant = pinVariant, pinVariant = pinVariant,
@@ -124,6 +127,7 @@ private fun BoxWithConstraintsScope.StaleMapContent(
@Composable @Composable
private fun BoxWithConstraintsScope.LoadableMapContent( private fun BoxWithConstraintsScope.LoadableMapContent(
mapTilerConfig: MapTilerConfig?,
location: Location, location: Location,
zoom: Double, zoom: Double,
pinVariant: PinVariant, pinVariant: PinVariant,
@@ -132,7 +136,7 @@ private fun BoxWithConstraintsScope.LoadableMapContent(
) { ) {
val context = LocalContext.current val context = LocalContext.current
var retryHash by remember { mutableIntStateOf(0) } var retryHash by remember { mutableIntStateOf(0) }
val builder = remember { StaticMapUrlBuilder() } val builder = rememberStaticMapBuilder(mapTilerConfig)
val (painter, state, contentScale) = if (LocalInspectionMode.current) { val (painter, state, contentScale) = if (LocalInspectionMode.current) {
val painter = painterResource(CommonDrawables.sample_map) val painter = painterResource(CommonDrawables.sample_map)
@@ -203,6 +207,7 @@ private fun BoxWithConstraintsScope.LoadableMapContent(
@Composable @Composable
internal fun StaticMapViewPreview() = ElementPreview { internal fun StaticMapViewPreview() = ElementPreview {
StaticMapView( StaticMapView(
mapTilerConfig = null,
location = Location(0.0, 0.0), location = Location(0.0, 0.0),
zoom = 0.0, zoom = 0.0,
contentDescription = null, contentDescription = null,
@@ -9,6 +9,7 @@
package io.element.android.features.location.api.internal package io.element.android.features.location.api.internal
import io.element.android.features.location.api.BuildConfig import io.element.android.features.location.api.BuildConfig
import io.element.android.libraries.wellknown.api.MapTilerConfig
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
@@ -29,6 +30,13 @@ internal class MapTilerStaticMapUrlBuilder(
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID, darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
) )
constructor(mapTilerConfig: MapTilerConfig) : this(
baseUrl = (mapTilerConfig.baseUrl ?: BuildConfig.MAPTILER_BASE_URL).removeSuffix("/"),
apiKey = mapTilerConfig.apiKey.takeIf { it.isNotBlank() } ?: BuildConfig.MAPTILER_API_KEY,
lightMapId = mapTilerConfig.lightStyleId ?: BuildConfig.MAPTILER_LIGHT_MAP_ID,
darkMapId = mapTilerConfig.darkStyleId ?: BuildConfig.MAPTILER_DARK_MAP_ID,
)
override fun build( override fun build(
lat: Double, lat: Double,
lon: Double, lon: Double,
@@ -11,6 +11,7 @@
package io.element.android.features.location.api.internal package io.element.android.features.location.api.internal
import io.element.android.features.location.api.BuildConfig import io.element.android.features.location.api.BuildConfig
import io.element.android.libraries.wellknown.api.MapTilerConfig
internal class MapTilerTileServerStyleUriBuilder( internal class MapTilerTileServerStyleUriBuilder(
private val baseUrl: String, private val baseUrl: String,
@@ -26,17 +27,17 @@ internal class MapTilerTileServerStyleUriBuilder(
) )
override fun build( override fun build(
customMapStyleUrl: String?, customMapTilerConfig: MapTilerConfig?,
darkMode: Boolean, darkMode: Boolean,
): String { ): String {
return buildString { val baseUrl = customMapTilerConfig?.baseUrl.takeIf { !it.isNullOrBlank() } ?: baseUrl
if (customMapStyleUrl.isNullOrBlank()) { val apiKey = customMapTilerConfig?.apiKey ?: apiKey
val mapId = if (darkMode) darkMapId else lightMapId val mapId = if (darkMode) {
append("$baseUrl/$mapId/style.json") customMapTilerConfig?.darkStyleId ?: darkMapId
} else { } else {
append(customMapStyleUrl) customMapTilerConfig?.lightStyleId ?: lightMapId
}
append("?key=$apiKey")
} }
return "$baseUrl/$mapId/style.json?key=$apiKey"
} }
} }
@@ -8,10 +8,25 @@
package io.element.android.features.location.api.internal package io.element.android.features.location.api.internal
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import io.element.android.libraries.wellknown.api.MapTilerConfig
/** /**
* Builds an URL for a 3rd party service provider static maps API. * Builds an URL for a 3rd party service provider static maps API.
*/ */
interface StaticMapUrlBuilder { interface StaticMapUrlBuilder {
/**
* Builds the URL of a map image centred on the given coordinates.
*
* @param lat latitude of the centre of the map.
* @param lon longitude of the centre of the map.
* @param zoom zoom level to render at.
* @param darkMode whether to request the dark map style.
* @param width width of the image in density independent pixels.
* @param height height of the image in density independent pixels.
* @param density screen density, used to request a matching pixel size.
*/
fun build( fun build(
lat: Double, lat: Double,
lon: Double, lon: Double,
@@ -22,7 +37,17 @@ interface StaticMapUrlBuilder {
density: Float, density: Float,
): String ): String
/** Whether a map provider is configured for this build; the URLs are unusable when it is not. */
fun isServiceAvailable(): Boolean fun isServiceAvailable(): Boolean
} }
fun StaticMapUrlBuilder(): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder() @Composable
fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder {
return remember(mapTilerConfig) {
if (mapTilerConfig != null) {
MapTilerStaticMapUrlBuilder(mapTilerConfig)
} else {
MapTilerStaticMapUrlBuilder()
}
}
}
@@ -11,6 +11,7 @@ package io.element.android.features.location.api.internal
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.wellknown.api.MapTilerConfig
/** /**
* Builds a style URI for a MapLibre compatible tile server. * Builds a style URI for a MapLibre compatible tile server.
@@ -18,8 +19,14 @@ import io.element.android.compound.theme.ElementTheme
* Used for rendering dynamic maps. * Used for rendering dynamic maps.
*/ */
interface TileServerStyleUriBuilder { interface TileServerStyleUriBuilder {
/**
* Builds the style URI the map view loads its tiles from.
*
* @param customMapStyleUrl the style advertised by the homeserver, or `null` to use the one built into the app.
* @param darkMode whether to request the dark map style.
*/
fun build( fun build(
customMapStyleUrl: String?, customMapTilerConfig: MapTilerConfig?,
darkMode: Boolean, darkMode: Boolean,
): String ): String
} }
@@ -31,12 +38,12 @@ interface TileServerStyleUriBuilder {
*/ */
@Composable @Composable
fun rememberTileStyleUrl( fun rememberTileStyleUrl(
customMapStyleUrl: String?, customMapTileConfig: MapTilerConfig?,
): String { ): String {
val darkMode = !ElementTheme.isLightTheme val darkMode = !ElementTheme.isLightTheme
return remember(darkMode, customMapStyleUrl) { return remember(darkMode, customMapTileConfig) {
MapTilerTileServerStyleUriBuilder().build( MapTilerTileServerStyleUriBuilder().build(
customMapStyleUrl = customMapStyleUrl, customMapTilerConfig = customMapTileConfig,
darkMode = darkMode, darkMode = darkMode,
) )
} }
@@ -12,6 +12,9 @@ import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlin.time.Duration import kotlin.time.Duration
/**
* Owns the live location shares running on this device, across every room, and the foreground service that feeds them GPS updates.
*/
interface ActiveLiveLocationShareManager { interface ActiveLiveLocationShareManager {
/** All rooms currently sharing live location on this device. */ /** All rooms currently sharing live location on this device. */
val sharingRoomIds: StateFlow<Set<RoomId>> val sharingRoomIds: StateFlow<Set<RoomId>>
@@ -27,6 +30,9 @@ interface ActiveLiveLocationShareManager {
* Starts live location sharing in the given room. * Starts live location sharing in the given room.
* Calls room.startLiveLocationShare() on the SDK, registers the share, * Calls room.startLiveLocationShare() on the SDK, registers the share,
* and starts the foreground GPS service if not already running. * and starts the foreground GPS service if not already running.
*
* @param roomId the room to start sharing the location in.
* @param duration how long the share should last before it expires on its own.
*/ */
suspend fun startShare(roomId: RoomId, duration: Duration): Result<Unit> suspend fun startShare(roomId: RoomId, duration: Duration): Result<Unit>
@@ -34,6 +40,8 @@ interface ActiveLiveLocationShareManager {
* Stops live location sharing in the given room. * Stops live location sharing in the given room.
* Calls room.stopLiveLocationShare() on the SDK, removes the share, * Calls room.stopLiveLocationShare() on the SDK, removes the share,
* and stops the foreground service if no shares remain. * and stops the foreground service if no shares remain.
*
* @param roomId the room to stop sharing the location in.
*/ */
suspend fun stopShare(roomId: RoomId): Result<Unit> suspend fun stopShare(roomId: RoomId): Result<Unit>
} }
@@ -9,6 +9,7 @@
package io.element.android.features.location.api.internal package io.element.android.features.location.api.internal
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.wellknown.api.MapTilerConfig
import org.junit.Test import org.junit.Test
class MapTilerTileServerStyleUriBuilderTest { class MapTilerTileServerStyleUriBuilderTest {
@@ -23,7 +24,7 @@ class MapTilerTileServerStyleUriBuilderTest {
fun `light map uri`() { fun `light map uri`() {
assertThat( assertThat(
builder.build( builder.build(
customMapStyleUrl = null, customMapTilerConfig = null,
darkMode = false, darkMode = false,
) )
).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey") ).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey")
@@ -33,7 +34,7 @@ class MapTilerTileServerStyleUriBuilderTest {
fun `dark map uri`() { fun `dark map uri`() {
assertThat( assertThat(
builder.build( builder.build(
customMapStyleUrl = null, customMapTilerConfig = null,
darkMode = true, darkMode = true,
) )
).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey") ).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey")
@@ -43,19 +44,44 @@ class MapTilerTileServerStyleUriBuilderTest {
fun `custom map uri light`() { fun `custom map uri light`() {
assertThat( assertThat(
builder.build( builder.build(
customMapStyleUrl = "https://custom.url/style.json", customMapTilerConfig = MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = "https://custom.url/style.json"
),
darkMode = false, darkMode = false,
) )
).isEqualTo("https://custom.url/style.json?key=anApiKey") ).isEqualTo("https://custom.url/style.json/light/style.json?key=API_KEY")
} }
@Test @Test
fun `custom map uri dark`() { fun `custom map uri dark`() {
assertThat( assertThat(
builder.build( builder.build(
customMapStyleUrl = "https://custom.url/style.json", customMapTilerConfig = MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = "https://custom.url/style.json"
),
darkMode = true, darkMode = true,
) )
).isEqualTo("https://custom.url/style.json?key=anApiKey") ).isEqualTo("https://custom.url/style.json/dark/style.json?key=API_KEY")
}
@Test
fun `blank custom map uri falls back to the built-in style`() {
assertThat(
builder.build(
customMapTilerConfig = MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = " "
),
darkMode = false,
)
).isEqualTo("https://base.url/light/style.json?key=API_KEY")
} }
} }
+4
View File
@@ -26,6 +26,7 @@ setupDependencyInjection()
dependencies { dependencies {
api(projects.features.location.api) api(projects.features.location.api)
implementation(projects.features.enterprise.api)
implementation(projects.features.messages.api) implementation(projects.features.messages.api)
implementation(libs.maplibre.compose) implementation(libs.maplibre.compose)
implementation(libs.coil) implementation(libs.coil)
@@ -45,6 +46,7 @@ dependencies {
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api) implementation(projects.libraries.push.api)
implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.wellknown.api)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.datetime) implementation(libs.datetime)
@@ -58,5 +60,7 @@ dependencies {
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.wellknown.test)
testImplementation(projects.features.location.test) testImplementation(projects.features.location.test)
testImplementation(projects.features.enterprise.test)
} }
@@ -50,6 +50,7 @@ import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.wellknown.api.MapTilerConfig
import org.maplibre.compose.camera.CameraState import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MapOptions
@@ -66,7 +67,7 @@ import kotlin.math.roundToInt
* - Updating camera position padding based on sheet height * - Updating camera position padding based on sheet height
* - Rendering the MaplibreMap with proper ornament positioning * - Rendering the MaplibreMap with proper ornament positioning
* *
* @param customMapStyleUrl Optional custom style URL for the map * @param customMapTilerConfig Optional custom style URL for the map
* @param modifier Modifier for the root layout * @param modifier Modifier for the root layout
* @param scaffoldState State for the bottom sheet scaffold * @param scaffoldState State for the bottom sheet scaffold
* @param cameraState The camera state for the map * @param cameraState The camera state for the map
@@ -83,7 +84,7 @@ import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MapBottomSheetScaffold( fun MapBottomSheetScaffold(
customMapStyleUrl: AsyncData<String?>, customMapTilerConfig: AsyncData<MapTilerConfig?>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState( scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberBottomSheetState( bottomSheetState = rememberBottomSheetState(
@@ -137,11 +138,11 @@ fun MapBottomSheetScaffold(
val ornamentOptions = mapOptions.ornamentOptions.copy(padding = sheetPadding) val ornamentOptions = mapOptions.ornamentOptions.copy(padding = sheetPadding)
val mapOptions = mapOptions.copy(ornamentOptions = ornamentOptions) val mapOptions = mapOptions.copy(ornamentOptions = ornamentOptions)
Box { Box {
when (customMapStyleUrl) { when (customMapTilerConfig) {
is AsyncData.Success -> { is AsyncData.Success -> {
MaplibreMap( MaplibreMap(
options = mapOptions, options = mapOptions,
baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapStyleUrl.data)), baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapTilerConfig.data)),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
cameraState = cameraState, cameraState = cameraState,
content = mapContent, content = mapContent,
@@ -22,6 +22,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.location.impl.common.LocationConstraintsCheck import io.element.android.features.location.impl.common.LocationConstraintsCheck
import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.MapDefaults
@@ -72,6 +73,7 @@ class ShareLocationPresenter(
private val liveLocationShareManager: ActiveLiveLocationShareManager, private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val liveLocationStore: LiveLocationStore, private val liveLocationStore: LiveLocationStore,
private val userLocationStateFactory: UserLocationState.Factory, private val userLocationStateFactory: UserLocationState.Factory,
private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider,
) : Presenter<ShareLocationState> { ) : Presenter<ShareLocationState> {
@AssistedFactory @AssistedFactory
fun interface Factory { fun interface Factory {
@@ -92,9 +94,9 @@ class ShareLocationPresenter(
var pendingLiveLocationShare by remember { mutableStateOf(false) } var pendingLiveLocationShare by remember { mutableStateOf(false) }
val startLiveLocationAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) } val startLiveLocationAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val currentUser by client.userProfile.collectAsState() val currentUser by client.userProfile.collectAsState()
val customMapStyleUrl by produceState(AsyncData.Loading()) { val customMapConfig by produceState(AsyncData.Loading()) {
// Ignore errors // Ignore errors
value = AsyncData.Success(client.getMapStyleUrl().getOrNull()) value = AsyncData.Success(remoteEnterpriseConfigProvider.get(client.sessionId).dataOrNull()?.mapTilerConfig)
} }
val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms -> val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms ->
perms.sendLiveLocationPermissions() perms.sendLiveLocationPermissions()
@@ -199,7 +201,7 @@ class ShareLocationPresenter(
} }
return ShareLocationState( return ShareLocationState(
customMapStyleUrl = customMapStyleUrl, customMapTilerConfig = customMapConfig,
currentUser = currentUser, currentUser = currentUser,
dialogState = dialogState, dialogState = dialogState,
trackUserLocation = trackUserPosition, trackUserLocation = trackUserPosition,
@@ -13,10 +13,11 @@ import io.element.android.features.location.impl.common.userlocation.UserLocatio
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.wellknown.api.MapTilerConfig
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class ShareLocationState( data class ShareLocationState(
val customMapStyleUrl: AsyncData<String?>, val customMapTilerConfig: AsyncData<MapTilerConfig?>,
val currentUser: MatrixUser, val currentUser: MatrixUser,
val dialogState: Dialog, val dialogState: Dialog,
val trackUserLocation: Boolean, val trackUserLocation: Boolean,
@@ -15,6 +15,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.wellknown.api.MapTilerConfig
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
@@ -70,13 +71,13 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
canShareLiveLocation = true, canShareLiveLocation = true,
), ),
aShareLocationState( aShareLocationState(
customMapStyleUrl = AsyncData.Loading(), customMapTilerConfig = AsyncData.Loading(),
), ),
) )
} }
fun aShareLocationState( fun aShareLocationState(
customMapStyleUrl: AsyncData<String?> = AsyncData.Success(null), customMapTilerConfig: AsyncData<MapTilerConfig?> = AsyncData.Success(null),
currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")), currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")),
dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None, dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None,
trackUserPosition: Boolean = false, trackUserPosition: Boolean = false,
@@ -87,7 +88,7 @@ fun aShareLocationState(
eventSink: (ShareLocationEvent) -> Unit = {}, eventSink: (ShareLocationEvent) -> Unit = {},
): ShareLocationState { ): ShareLocationState {
return ShareLocationState( return ShareLocationState(
customMapStyleUrl = customMapStyleUrl, customMapTilerConfig = customMapTilerConfig,
currentUser = currentUser, currentUser = currentUser,
dialogState = dialogState, dialogState = dialogState,
trackUserLocation = trackUserPosition, trackUserLocation = trackUserPosition,
@@ -95,6 +96,6 @@ fun aShareLocationState(
canShareLiveLocation = canShareLiveLocation, canShareLiveLocation = canShareLiveLocation,
appName = appName, appName = appName,
startLiveLocationAction = startLiveLocationAction, startLiveLocationAction = startLiveLocationAction,
eventSink = eventSink eventSink = eventSink,
) )
} }
@@ -118,7 +118,7 @@ fun ShareLocationView(
} }
MapBottomSheetScaffold( MapBottomSheetScaffold(
customMapStyleUrl = state.customMapStyleUrl, customMapTilerConfig = state.customMapTilerConfig,
cameraState = cameraState, cameraState = cameraState,
modifier = modifier, modifier = modifier,
scaffoldState = scaffoldState, scaffoldState = scaffoldState,
@@ -261,7 +261,7 @@ private fun ShareCurrentLocationItem(
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
ListItem( ListItem(
headlineContent = { content = {
Text(stringResource(CommonStrings.screen_share_my_location_action)) Text(stringResource(CommonStrings.screen_share_my_location_action))
}, },
onClick = onClick, onClick = onClick,
@@ -276,7 +276,7 @@ private fun SharePinLocationItem(
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
ListItem( ListItem(
headlineContent = { content = {
Text(stringResource(CommonStrings.screen_share_this_location_action)) Text(stringResource(CommonStrings.screen_share_this_location_action))
}, },
onClick = onClick, onClick = onClick,
@@ -291,7 +291,7 @@ private fun ShareLiveLocationItem(
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
ListItem( ListItem(
headlineContent = { content = {
Text(stringResource(CommonStrings.action_share_live_location)) Text(stringResource(CommonStrings.action_share_live_location))
}, },
onClick = onClick, onClick = onClick,
@@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
@@ -46,7 +47,7 @@ import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.getBestName import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.matrix.api.room.joinedRoomMembers import io.element.android.libraries.matrix.api.room.joinedRoomMembers
@@ -64,10 +65,11 @@ import kotlinx.coroutines.launch
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val dateFormatter: DateFormatter, private val dateFormatter: DateFormatter,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val client: MatrixClient, private val sessionId: SessionId,
private val joinedRoom: JoinedRoom, private val joinedRoom: JoinedRoom,
private val liveLocationShareManager: ActiveLiveLocationShareManager, private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val userLocationStateFactory: UserLocationState.Factory, private val userLocationStateFactory: UserLocationState.Factory,
private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider,
) : Presenter<ShowLocationState> { ) : Presenter<ShowLocationState> {
@AssistedFactory fun interface Factory { @AssistedFactory fun interface Factory {
fun create(mode: ShowLocationMode): ShowLocationPresenter fun create(mode: ShowLocationMode): ShowLocationPresenter
@@ -85,9 +87,9 @@ import kotlinx.coroutines.launch
mutableStateOf(LocationConstraintsDialogState.None) mutableStateOf(LocationConstraintsDialogState.None)
} }
val customMapStyleUrl by produceState(AsyncData.Loading()) { val customMapTilerConfig by produceState(AsyncData.Loading()) {
// Ignore errors // Ignore errors
value = AsyncData.Success(client.getMapStyleUrl().getOrNull()) value = AsyncData.Success(remoteEnterpriseConfigProvider.get(sessionId).dataOrNull()?.mapTilerConfig)
} }
fun checkLocationConstraints() { fun checkLocationConstraints() {
@@ -220,7 +222,7 @@ import kotlinx.coroutines.launch
userLocationStateFactory.create(hasLocationPermission = permissionsState.isAnyGranted) userLocationStateFactory.create(hasLocationPermission = permissionsState.isAnyGranted)
} }
return ShowLocationState( return ShowLocationState(
customMapStyleUrl = customMapStyleUrl, customMapTilerConfig = customMapTilerConfig,
dialogState = dialogState, dialogState = dialogState,
locationShares = locationShares, locationShares = locationShares,
focusedLocation = focusedLocation, focusedLocation = focusedLocation,
@@ -17,10 +17,11 @@ import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.wellknown.api.MapTilerConfig
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class ShowLocationState( data class ShowLocationState(
val customMapStyleUrl: AsyncData<String?>, val customMapTilerConfig: AsyncData<MapTilerConfig?>,
val isLive: Boolean, val isLive: Boolean,
val dialogState: LocationConstraintsDialogState, val dialogState: LocationConstraintsDialogState,
val locationShares: ImmutableList<LocationShareItem>, val locationShares: ImmutableList<LocationShareItem>,
@@ -18,6 +18,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.wellknown.api.MapTilerConfig
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> { class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
@@ -36,14 +37,14 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
constraintsDialogState = LocationConstraintsDialogState.LocationServiceDisabled, constraintsDialogState = LocationConstraintsDialogState.LocationServiceDisabled,
), ),
aShowLocationState(isTrackMyLocation = true), aShowLocationState(isTrackMyLocation = true),
aShowLocationState(customMapStyleUrl = AsyncData.Loading()), aShowLocationState(customMapTilerConfig = AsyncData.Loading()),
) )
} }
private const val APP_NAME = "ApplicationName" private const val APP_NAME = "ApplicationName"
fun aShowLocationState( fun aShowLocationState(
customMapStyleUrl: AsyncData<String?> = AsyncData.Success(null), customMapTilerConfig: AsyncData<MapTilerConfig?> = AsyncData.Success(null),
isLive: Boolean = false, isLive: Boolean = false,
constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None, constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None,
locationShares: List<LocationShareItem> = listOf(aLocationShareItem(isLive = isLive)), locationShares: List<LocationShareItem> = listOf(aLocationShareItem(isLive = isLive)),
@@ -55,7 +56,7 @@ fun aShowLocationState(
eventSink: (ShowLocationEvent) -> Unit = {}, eventSink: (ShowLocationEvent) -> Unit = {},
): ShowLocationState { ): ShowLocationState {
return ShowLocationState( return ShowLocationState(
customMapStyleUrl = customMapStyleUrl, customMapTilerConfig = customMapTilerConfig,
dialogState = constraintsDialogState, dialogState = constraintsDialogState,
locationShares = locationShares.toImmutableList(), locationShares = locationShares.toImmutableList(),
focusedLocation = focusedLocation, focusedLocation = focusedLocation,
@@ -102,7 +102,7 @@ fun ShowLocationView(
} }
} }
MapBottomSheetScaffold( MapBottomSheetScaffold(
customMapStyleUrl = state.customMapStyleUrl, customMapTilerConfig = state.customMapTilerConfig,
sheetDragHandle = if (state.isSheetDraggable) { sheetDragHandle = if (state.isSheetDraggable) {
{ BottomSheetDefaults.DragHandle() } { BottomSheetDefaults.DragHandle() }
} else { } else {
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Der Verlauf deines Live-Standorts wird im Chat gespeichert und bleibt für die Mitglieder sichtbar, auch nachdem du die Freigabe beendet hast."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Wie lange soll der Live-Standort geteilt werden?"</string> <string name="screen_share_location_live_location_duration_picker_title">"Wie lange soll der Live-Standort geteilt werden?"</string>
</resources> </resources>
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"您的即時位置歷史將儲存於聊天室中,並在工作階段結束後對其他成員可見。"</string> <string name="screen_share_location_live_location_disclaimer_title">"您的即時位置歷史將儲存於聊天室中,並在工作階段結束後對其他成員可見。"</string>
<string name="screen_share_location_live_location_duration_picker_title">"選擇分享即時位置的時間長度。"</string> <string name="screen_share_location_live_location_duration_picker_title">"選擇分享即時位置的時間長度。"</string>
<string name="screen_share_location_live_location_missing_permissions">"您無權在此聊天室分享您的即時位置"</string>
</resources> </resources>
@@ -11,6 +11,7 @@ package io.element.android.features.location.impl.share
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider
import io.element.android.features.location.impl.common.FakeUserLocationStateFactory import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
@@ -58,6 +59,7 @@ class DefaultShareLocationEntryPointTest {
sessionId = room.sessionId, sessionId = room.sessionId,
), ),
userLocationStateFactory = FakeUserLocationStateFactory(), userLocationStateFactory = FakeUserLocationStateFactory(),
remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(),
) )
}, },
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),

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