diff --git a/.github/renovate.json b/.github/renovate.json
index ca74a436e84..00447475c95 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -1,29 +1,29 @@
{
- "$schema" : "https://docs.renovatebot.com/renovate-schema.json",
- "extends" : [
- "config:base"
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "extends": [
+ "config:recommended"
],
- "labels" : [
+ "labels": [
"PR-Dependencies"
],
- "ignoreDeps" : [
+ "ignoreDeps": [
"string:app_name",
"gradle"
],
- "packageRules" : [
+ "packageRules": [
{
- "matchPackagePatterns" : [
- "^org.jetbrains.kotlin",
- "^com.google.devtools.ksp",
- "^androidx.compose.compiler"
- ],
- "groupName" : "kotlin"
+ "groupName": "kotlin",
+ "matchPackageNames": [
+ "/^org.jetbrains.kotlin/",
+ "/^com.google.devtools.ksp/",
+ "/^androidx.compose.compiler/"
+ ]
},
{
- "matchPackagePatterns" : [
- "^org.maplibre"
- ],
- "versioning" : "semver"
+ "versioning": "semver",
+ "matchPackageNames": [
+ "/^org.maplibre/"
+ ]
}
]
}
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 5ce7acf8809..1668cd2b1fb 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -46,6 +46,10 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
+ ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
+ ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
+ ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
+ ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug APKs
if: ${{ matrix.variant == 'debug' }}
@@ -65,7 +69,7 @@ jobs:
retention-days: 5
overwrite: true
if-no-files-found: error
- - uses: rnkdsh/action-upload-diawi@v1.5.6
+ - uses: rnkdsh/action-upload-diawi@605adbad0db6c000eee26adfd8fc128d7df8f7ab # v1.5.9
id: diawi
# Do not fail the whole build if Diawi upload fails
continue-on-error: true
@@ -77,7 +81,7 @@ jobs:
file: app/build/outputs/apk/gplay/debug/app-gplay-arm64-v8a-debug.apk
- name: Add or update PR comment with QR Code to download APK.
if: ${{ matrix.variant == 'debug' && github.event_name == 'pull_request' && steps.diawi.conclusion == 'success' }}
- uses: NejcZdovc/comment-pr@v2
+ uses: NejcZdovc/comment-pr@a423635d183a8259308e80593c96fecf31539c26 # v2.1.0
with:
message: |
:iphone: Scan the QR code below to install the build (arm64 only) for this PR.
@@ -89,7 +93,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Compile release sources
if: ${{ matrix.variant == 'release' }}
- run: ./gradlew compileReleaseSources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
+ run: ./gradlew bundleGplayRelease -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Compile nightly sources
if: ${{ matrix.variant == 'nightly' }}
run: ./gradlew compileGplayNightlySources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml
index 9182a9af3c7..a1ec5063c0d 100644
--- a/.github/workflows/build_enterprise.yml
+++ b/.github/workflows/build_enterprise.yml
@@ -33,7 +33,7 @@ jobs:
# https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Add SSH private keys for submodule repositories
- uses: webfactory/ssh-agent@v0.9.0
+ uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
- name: Clone submodules
@@ -54,6 +54,10 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
+ ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
+ ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
+ ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
+ ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug Enterprise APKs
if: ${{ matrix.variant == 'debug' }}
diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml
index 6b6ac8243e3..9de62e104c1 100644
--- a/.github/workflows/danger.yml
+++ b/.github/workflows/danger.yml
@@ -11,7 +11,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Add SSH private keys for submodule repositories
- uses: webfactory/ssh-agent@v0.9.0
+ uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
- name: Clone submodules
@@ -20,7 +20,7 @@ jobs:
- run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger
- uses: danger/danger-js@12.3.3
+ uses: danger/danger-js@bdccecb77e0144055fbaea9224f10cf8b1229b68 # 13.0.4
with:
args: "--dangerfile ./tools/danger/dangerfile.js"
env:
diff --git a/.github/workflows/generate_github_pages.yml b/.github/workflows/generate_github_pages.yml
index e63cb52cc89..1f5551ca8ca 100644
--- a/.github/workflows/generate_github_pages.yml
+++ b/.github/workflows/generate_github_pages.yml
@@ -12,7 +12,7 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps:
- name: ⏬ Checkout with LFS
- uses: nschloe/action-cached-lfs-checkout@v1.2.3
+ uses: nschloe/action-cached-lfs-checkout@f46300cd8952454b9f0a21a3d133d4bd5684cfc2 # v1.2.3
- name: Use JDK 21
uses: actions/setup-java@v4
with:
@@ -32,7 +32,7 @@ jobs:
mkdir -p screenshots/en
cp tests/uitests/src/test/snapshots/images/* screenshots/en
- name: Deploy GitHub Pages
- uses: peaceiris/actions-gh-pages@v4
+ uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./screenshots
diff --git a/.github/workflows/gradle-wrapper-update.yml b/.github/workflows/gradle-wrapper-update.yml
index f5a5b3c94cb..dcc7bc5f0a3 100644
--- a/.github/workflows/gradle-wrapper-update.yml
+++ b/.github/workflows/gradle-wrapper-update.yml
@@ -19,7 +19,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Update Gradle Wrapper
- uses: gradle-update/update-gradle-wrapper-action@v2
+ uses: gradle-update/update-gradle-wrapper-action@512b1875f3b6270828abfe77b247d5895a2da1e5 # v2.1.0
with:
repo-token: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
target-branch: develop
diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml
index b07eb196fdd..cfb743934f2 100644
--- a/.github/workflows/maestro-local.yml
+++ b/.github/workflows/maestro-local.yml
@@ -11,7 +11,7 @@ env:
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
ARCH: x86_64
DEVICE: pixel_7_pro
- API_LEVEL: 35
+ API_LEVEL: 33
TARGET: google_apis
jobs:
@@ -56,7 +56,7 @@ jobs:
maestro-cloud:
name: Maestro test suite
runs-on: ubuntu-latest
- needs: [build-apk]
+ needs: [ build-apk ]
# Allow one per PR.
concurrency:
group: ${{ format('maestro-{0}', github.ref) }}
@@ -80,6 +80,7 @@ jobs:
- name: Install maestro
run: curl -fsSL "https://get.maestro.mobile.dev" | bash
- name: Run Maestro tests in emulator
+ id: maestro_test
uses: reactivecircus/android-emulator-runner@v2
continue-on-error: true
env:
@@ -109,3 +110,8 @@ jobs:
retention-days: 5
overwrite: true
if-no-files-found: error
+ - name: Fail the workflow in case of error in test
+ if: steps.maestro_test.outcome != 'success'
+ run: |
+ echo "Maestro tests failed. Please check the logs."
+ exit 1
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 465a95bd087..d82129e18d2 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -30,6 +30,10 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
+ ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
+ ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
+ ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
+ ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}
diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml
index 55293f972f6..7cc4f766c21 100644
--- a/.github/workflows/nightlyReports.yml
+++ b/.github/workflows/nightlyReports.yml
@@ -18,7 +18,7 @@ jobs:
if: ${{ github.repository == 'element-hq/element-x-android' }}
steps:
- name: ⏬ Checkout with LFS
- uses: nschloe/action-cached-lfs-checkout@v1.2.3
+ uses: nschloe/action-cached-lfs-checkout@f46300cd8952454b9f0a21a3d133d4bd5684cfc2 # v1.2.3
- name: Use JDK 21
uses: actions/setup-java@v4
diff --git a/.github/workflows/nightly_enterprise.yml b/.github/workflows/nightly_enterprise.yml
deleted file mode 100644
index c7700ed56b3..00000000000
--- a/.github/workflows/nightly_enterprise.yml
+++ /dev/null
@@ -1,49 +0,0 @@
-name: Build and release Enterprise nightly application
-
-on:
- workflow_dispatch:
- schedule:
- # Every nights at 4
- - cron: "0 4 * * *"
-
-env:
- GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx9g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
- CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
-
-jobs:
- nightly:
- name: Build and publish Enterprise nightly bundle to Firebase
- runs-on: ubuntu-latest
- if: ${{ github.repository == 'element-hq/element-x-android' }}
- steps:
- - uses: actions/checkout@v4
- - name: Add SSH private keys for submodule repositories
- uses: webfactory/ssh-agent@v0.9.0
- with:
- ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
- - name: Clone submodules
- run: git submodule update --init --recursive
- - name: Use JDK 21
- uses: actions/setup-java@v4
- with:
- distribution: 'temurin' # See 'Supported distributions' for available options
- java-version: '21'
- - name: Build and upload Nightly application
- run: |
- ./gradlew assembleGplayNightly appDistributionUploadGplayNightly $CI_GRADLE_ARG_PROPERTIES
- env:
- ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
- ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
- ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
- ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
- ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
- ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}
- FIREBASE_TOKEN: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_FIREBASE_TOKEN }}
- - name: Additionally upload Nightly APK to browserstack for testing
- continue-on-error: true # don't block anything by this upload failing (for now)
- run: |
- curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_PASSWORD" -X POST "https://api-cloud.browserstack.com/app-automate/upload" -F "file=@app/build/outputs/apk/gplay/nightly/app-gplay-universal-nightly.apk" -F "custom_id=element-x-android-nightly"
- env:
- BROWSERSTACK_USERNAME: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_USERNAME }}
- BROWSERSTACK_PASSWORD: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_ACCESS_KEY }}
diff --git a/.github/workflows/post-release.yml b/.github/workflows/post-release.yml
new file mode 100644
index 00000000000..56d40d0cf6c
--- /dev/null
+++ b/.github/workflows/post-release.yml
@@ -0,0 +1,28 @@
+name: Post-release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ post-release:
+ runs-on: ubuntu-latest
+ # Skip in forks
+ if: github.repository == 'element-hq/element-x-android'
+
+ steps:
+ - name: Trigger pipeline
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ secrets.ENTERPRISE_ACTIONS_TOKEN }}
+ script: |
+ const tag = context.ref.replace('refs/tags/', '');
+ const inputs = { git_tag: tag };
+ await github.rest.actions.createWorkflowDispatch({
+ owner: 'element-hq',
+ repo: 'element-enterprise',
+ workflow_id: 'pipeline-android.yml',
+ ref: 'main',
+ inputs: inputs
+ });
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
new file mode 100644
index 00000000000..929bcb5dcd0
--- /dev/null
+++ b/.github/workflows/pull_request.yml
@@ -0,0 +1,77 @@
+name: Pull Request
+on:
+ pull_request_target:
+ types: [ opened, edited, labeled, unlabeled, synchronize ]
+ workflow_call:
+ secrets:
+ ELEMENT_BOT_TOKEN:
+ required: true
+
+jobs:
+ prevent-blocked:
+ name: Prevent blocked
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: read
+ steps:
+ - name: Add notice
+ uses: actions/github-script@v7
+ if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
+ with:
+ script: |
+ core.setFailed("PR has been labeled with X-Blocked; it cannot be merged.");
+
+ community-prs:
+ name: Label Community PRs
+ runs-on: ubuntu-latest
+ if: github.event.action == 'opened'
+ permissions:
+ pull-requests: write
+ steps:
+ - name: Check membership
+ if: github.event.pull_request.user.login != 'renovate[bot]'
+ uses: tspascoal/get-user-teams-membership@57e9f42acd78f4d0f496b3be4368fc5f62696662 # v3
+ id: teams
+ with:
+ username: ${{ github.event.pull_request.user.login }}
+ organization: element-hq
+ team: Vector Core
+ GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN_READ_ORG }}
+ - name: Add label
+ if: steps.teams.outputs.isTeamMember == 'false'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ github.rest.issues.addLabels({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ labels: ['Z-Community-PR']
+ });
+
+ close-if-fork-develop:
+ name: Forbid develop branch fork contributions
+ runs-on: ubuntu-latest
+ if: >
+ github.event.action == 'opened' &&
+ github.event.pull_request.head.ref == 'develop' &&
+ github.event.pull_request.head.repo.full_name != github.repository
+ steps:
+ - name: Close pull request
+ uses: actions/github-script@v7
+ with:
+ script: |
+ github.rest.issues.createComment({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: "Thanks for opening this pull request, unfortunately we do not accept contributions from the main" +
+ " branch of your fork, please re-open once you switch to an alternative branch for everyone's sanity.",
+ });
+
+ github.rest.pulls.update({
+ pull_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ state: 'closed'
+ });
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index b69c693f197..bb6bbe87d1b 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -19,7 +19,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Add SSH private keys for submodule repositories
- uses: webfactory/ssh-agent@v0.9.0
+ uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
@@ -29,7 +29,7 @@ jobs:
- name: Run code quality check suite
run: ./tools/check/check_code_quality.sh
- checkScreesnhot:
+ checkScreenshot:
name: Search for invalid screenshot files
runs-on: ubuntu-latest
steps:
@@ -77,7 +77,7 @@ jobs:
# https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Add SSH private keys for submodule repositories
- uses: webfactory/ssh-agent@v0.9.0
+ uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
@@ -117,7 +117,7 @@ jobs:
# https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Add SSH private keys for submodule repositories
- uses: webfactory/ssh-agent@v0.9.0
+ uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
@@ -138,7 +138,7 @@ jobs:
- name: Build Fdroid Debug
run: ./gradlew :app:compileFdroidDebugKotlin $CI_GRADLE_ARG_PROPERTIES
- name: Run lint
- run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug $CI_GRADLE_ARG_PROPERTIES
+ run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue
- name: Upload reports
if: always()
uses: actions/upload-artifact@v4
@@ -161,7 +161,7 @@ jobs:
# https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Add SSH private keys for submodule repositories
- uses: webfactory/ssh-agent@v0.9.0
+ uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
@@ -201,7 +201,7 @@ jobs:
# https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Add SSH private keys for submodule repositories
- uses: webfactory/ssh-agent@v0.9.0
+ uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
@@ -241,7 +241,7 @@ jobs:
# https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Add SSH private keys for submodule repositories
- uses: webfactory/ssh-agent@v0.9.0
+ uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
@@ -270,7 +270,6 @@ jobs:
- name: Run shellcheck
uses: ludeeus/action-shellcheck@2.0.0
with:
- scandir: ./tools
severity: warning
upload_reports:
@@ -294,7 +293,7 @@ jobs:
yarn add danger-plugin-lint-report --dev
- name: Danger lint
if: always()
- uses: danger/danger-js@12.3.3
+ uses: danger/danger-js@bdccecb77e0144055fbaea9224f10cf8b1229b68 # 13.0.4
with:
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
env:
diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml
index 17012282e3f..a5172188ee0 100644
--- a/.github/workflows/recordScreenshots.yml
+++ b/.github/workflows/recordScreenshots.yml
@@ -19,18 +19,18 @@ jobs:
steps:
- name: Remove Record-Screenshots label
if: github.event.label.name == 'Record-Screenshots'
- uses: actions-ecosystem/action-remove-labels@v1
+ uses: actions-ecosystem/action-remove-labels@2ce5d41b4b6aa8503e285553f75ed56e0a40bae0 # v1.3.0
with:
labels: Record-Screenshots
- name: ⏬ Checkout with LFS (PR)
if: github.event.label.name == 'Record-Screenshots'
- uses: nschloe/action-cached-lfs-checkout@v1.2.3
+ uses: nschloe/action-cached-lfs-checkout@f46300cd8952454b9f0a21a3d133d4bd5684cfc2 # v1.2.3
with:
persist-credentials: false
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
- name: ⏬ Checkout with LFS (Branch)
if: github.event_name == 'workflow_dispatch'
- uses: nschloe/action-cached-lfs-checkout@v1.2.3
+ uses: nschloe/action-cached-lfs-checkout@f46300cd8952454b9f0a21a3d133d4bd5684cfc2 # v1.2.3
with:
persist-credentials: false
- name: ☕️ Use JDK 21
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e9de267d3f1..704802258ee 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -32,6 +32,10 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
+ ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
+ ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
+ ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
+ ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload bundle as artifact
uses: actions/upload-artifact@v4
@@ -50,7 +54,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Add SSH private keys for submodule repositories
- uses: webfactory/ssh-agent@v0.9.0
+ uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
diff --git a/.github/workflows/scripts/maestro/local-recording.sh b/.github/workflows/scripts/maestro/local-recording.sh
index 4a800dc5e71..6534d2feca3 100755
--- a/.github/workflows/scripts/maestro/local-recording.sh
+++ b/.github/workflows/scripts/maestro/local-recording.sh
@@ -12,8 +12,8 @@ mkdir -p /data/local/tmp/recordings;
FILENAME=/data/local/tmp/recordings/testRecording$COUNT.mp4
while true
do
- ((COUNT++))
+ COUNT=$((COUNT+1))
FILENAME=/data/local/tmp/recordings/testRecording$COUNT.mp4
- echo "\nRecording video file #$COUNT"
+ printf "\nRecording video file #%d\n" $COUNT
screenrecord --bugreport --bit-rate=16m --size 720x1280 $FILENAME
done
diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml
index dc688cb5a47..3e9e4692a43 100644
--- a/.github/workflows/sync-localazy.yml
+++ b/.github/workflows/sync-localazy.yml
@@ -36,7 +36,7 @@ jobs:
./tools/localazy/importSupportedLocalesFromLocalazy.py
./tools/test/generateAllScreenshots.py
- name: Create Pull Request for Strings
- uses: peter-evans/create-pull-request@v7
+ uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
commit-message: Sync Strings from Localazy
diff --git a/.github/workflows/sync-sas-strings.yml b/.github/workflows/sync-sas-strings.yml
index 632eb5d8ec8..f9224853fd5 100644
--- a/.github/workflows/sync-sas-strings.yml
+++ b/.github/workflows/sync-sas-strings.yml
@@ -23,7 +23,7 @@ jobs:
- name: Run SAS String script
run: ./tools/sas/import_sas_strings.py
- name: Create Pull Request for SAS Strings
- uses: peter-evans/create-pull-request@v7
+ uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
commit-message: Sync SAS Strings
title: Sync SAS Strings
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index ef7a8843c27..a0ffe74f6f1 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -33,13 +33,13 @@ jobs:
sudo swapon /mnt/swapfile
sudo swapon --show
- name: ⏬ Checkout with LFS
- uses: nschloe/action-cached-lfs-checkout@v1.2.3
+ uses: nschloe/action-cached-lfs-checkout@f46300cd8952454b9f0a21a3d133d4bd5684cfc2 # v1.2.3
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Add SSH private keys for submodule repositories
- uses: webfactory/ssh-agent@v0.9.0
+ uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
@@ -82,7 +82,7 @@ jobs:
# https://github.com/codecov/codecov-action
- name: ☂️ Upload coverage reports to codecov
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/triage-incoming.yml b/.github/workflows/triage-incoming.yml
index 109b8cc82d9..34c9767da31 100644
--- a/.github/workflows/triage-incoming.yml
+++ b/.github/workflows/triage-incoming.yml
@@ -8,7 +8,7 @@ jobs:
triage-new-issues:
runs-on: ubuntu-latest
steps:
- - uses: actions/add-to-project@main
+ - uses: actions/add-to-project@v1.0.2
with:
project-url: https://github.com/orgs/element-hq/projects/91
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
diff --git a/.github/workflows/triage-labelled.yml b/.github/workflows/triage-labelled.yml
index f6a9de700c9..b1c776c6ead 100644
--- a/.github/workflows/triage-labelled.yml
+++ b/.github/workflows/triage-labelled.yml
@@ -12,7 +12,7 @@ jobs:
if: >
github.repository == 'element-hq/element-x-android'
steps:
- - uses: actions/add-to-project@main
+ - uses: actions/add-to-project@v1.0.2
with:
project-url: https://github.com/orgs/element-hq/projects/43
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
@@ -21,7 +21,7 @@ jobs:
name: Move triaged needs info issues on board
runs-on: ubuntu-latest
steps:
- - uses: actions/add-to-project@main
+ - uses: actions/add-to-project@v1.0.2
id: addItem
with:
project-url: https://github.com/orgs/element-hq/projects/91
@@ -29,7 +29,7 @@ jobs:
labeled: X-Needs-Info
- name: Print itemId
run: echo ${{ steps.addItem.outputs.itemId }}
- - uses: kalgurn/update-project-item-status@main
+ - uses: kalgurn/update-project-item-status@31e54df46a2cdaef4f85c31ac839fbcd2fd7c3a2 # 0.0.3
if: ${{ steps.addItem.outputs.itemId }}
with:
project-url: https://github.com/orgs/element-hq/projects/91
@@ -43,7 +43,7 @@ jobs:
if: >
contains(github.event.issue.labels.*.name, 'Team: Element X Feature')
steps:
- - uses: actions/add-to-project@main
+ - uses: actions/add-to-project@v1.0.2
with:
project-url: https://github.com/orgs/element-hq/projects/73
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
@@ -54,7 +54,7 @@ jobs:
if: >
contains(github.event.issue.labels.*.name, 'Team: Verticals Feature')
steps:
- - uses: actions/add-to-project@main
+ - uses: actions/add-to-project@v1.0.2
with:
project-url: https://github.com/orgs/element-hq/projects/57
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
@@ -66,7 +66,7 @@ jobs:
contains(github.event.issue.labels.*.name, 'Team: QA') ||
contains(github.event.issue.labels.*.name, 'X-Needs-Signoff')
steps:
- - uses: actions/add-to-project@main
+ - uses: actions/add-to-project@v1.0.2
with:
project-url: https://github.com/orgs/element-hq/projects/69
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
@@ -77,7 +77,7 @@ jobs:
if: >
contains(github.event.issue.labels.*.name, 'X-Needs-Signoff')
steps:
- - uses: actions/add-to-project@main
+ - uses: actions/add-to-project@v1.0.2
with:
project-url: https://github.com/orgs/element-hq/projects/89
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
diff --git a/.github/workflows/validate-lfs.yml b/.github/workflows/validate-lfs.yml
index dd6cc5e64f8..3246cbd1a8f 100644
--- a/.github/workflows/validate-lfs.yml
+++ b/.github/workflows/validate-lfs.yml
@@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-latest
name: Validate
steps:
- - uses: nschloe/action-cached-lfs-checkout@v1.2.3
+ - uses: nschloe/action-cached-lfs-checkout@f46300cd8952454b9f0a21a3d133d4bd5684cfc2 # v1.2.3
- run: |
./tools/git/validate_lfs.sh
diff --git a/.gitignore b/.gitignore
index 12bdb4eda81..d4bdf725fc4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,6 +43,7 @@ captures/
.idea/.name
.idea/androidTestResultsUserPreferences.xml
.idea/assetWizardSettings.xml
+.idea/AndroidProjectSystem.xml
.idea/compiler.xml
.idea/deploymentTargetDropDown.xml
.idea/deploymentTargetSelector.xml
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index c22b6fa9ee9..03fcfb7bea5 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/.maestro/allTests.yaml b/.maestro/allTests.yaml
index 927a1cb0e54..ecbde4dab9d 100644
--- a/.maestro/allTests.yaml
+++ b/.maestro/allTests.yaml
@@ -1,4 +1,5 @@
appId: ${MAESTRO_APP_ID}
+androidWebViewHierarchy: devtools
---
## Check that all env variables required in the whole test suite are declared (to fail faster)
- runScript: ./scripts/checkEnv.js
diff --git a/.maestro/tests/account/login.yaml b/.maestro/tests/account/login.yaml
index 18054297d62..f3f584ef767 100644
--- a/.maestro/tests/account/login.yaml
+++ b/.maestro/tests/account/login.yaml
@@ -7,22 +7,39 @@ appId: ${MAESTRO_APP_ID}
- runFlow: ../assertions/assertLoginDisplayed.yaml
- tapOn:
id: "login-continue"
+## MAS page
+## Conditional workflow to pass the Chrome first launch welcome page.
+- runFlow:
+ when:
+ visible: 'Use without an account'
+ commands:
+ - tapOn: "Use without an account"
+## For older chrome versions
+- runFlow:
+ when:
+ visible: 'Accept & continue'
+ commands:
+ - tapOn: "Accept & continue"
+- runFlow:
+ when:
+ visible: 'No thanks'
+ commands:
+ - tapOn: "No thanks"
+## Working when running Maestro locally, but not on the CI yet.
+- extendedWaitUntil:
+ visible:
+ id: "form-1"
+ timeout: 10000
- tapOn:
- id: "login-email_username"
+ id: "form-1"
- inputText: ${MAESTRO_USERNAME}
- pressKey: Enter
- tapOn:
- id: "login-password"
-- inputText: "wrong-password"
-- pressKey: Enter
-- tapOn: "Continue"
-- tapOn: "OK"
-- tapOn:
- id: "login-password"
-- eraseText: 20
+ id: "form-3"
- inputText: ${MAESTRO_PASSWORD}
- pressKey: Enter
- tapOn: "Continue"
+## Back to native world
- runFlow: ../assertions/assertSessionVerificationDisplayed.yaml
- runFlow: ./verifySession.yaml
- runFlow: ../assertions/assertAnalyticsDisplayed.yaml
diff --git a/.maestro/tests/account/verifySession.yaml b/.maestro/tests/account/verifySession.yaml
index a16322543f9..f1f45527095 100644
--- a/.maestro/tests/account/verifySession.yaml
+++ b/.maestro/tests/account/verifySession.yaml
@@ -8,6 +8,6 @@ appId: ${MAESTRO_APP_ID}
- hideKeyboard
- tapOn: "Continue"
- extendedWaitUntil:
- visible: "Device verified"
+ visible: "Verification complete"
timeout: 30000
- tapOn: "Continue"
diff --git a/.maestro/tests/roomList/createAndDeleteDM.yaml b/.maestro/tests/roomList/createAndDeleteDM.yaml
index 6e0d55ab26e..7e33fd1d15a 100644
--- a/.maestro/tests/roomList/createAndDeleteDM.yaml
+++ b/.maestro/tests/roomList/createAndDeleteDM.yaml
@@ -7,8 +7,9 @@ appId: ${MAESTRO_APP_ID}
- tapOn:
text: ${MAESTRO_INVITEE1_MXID}
index: 1
+- tapOn: "Send invite"
- takeScreenshot: build/maestro/330-createAndDeleteDM
- tapOn: "maestroelement2"
- scroll
-- tapOn: "Leave conversation"
+- tapOn: "Leave room"
- tapOn: "Leave"
diff --git a/.maestro/tests/roomList/timeline/call/call.yaml b/.maestro/tests/roomList/timeline/call/call.yaml
new file mode 100644
index 00000000000..e390d0a3a10
--- /dev/null
+++ b/.maestro/tests/roomList/timeline/call/call.yaml
@@ -0,0 +1,13 @@
+appId: ${MAESTRO_APP_ID}
+---
+- tapOn: "Start a call"
+- takeScreenshot: build/maestro/700-Call
+- extendedWaitUntil:
+ visible: "maestroelement"
+ timeout: 10000
+- takeScreenshot: build/maestro/710-Call
+# Hangup
+- tapOn: "End call"
+- extendedWaitUntil:
+ visible: "MyRoom"
+ timeout: 10000
diff --git a/.maestro/tests/roomList/timeline/timeline.yaml b/.maestro/tests/roomList/timeline/timeline.yaml
index 5f85366e9ec..d82c5ea1ff4 100644
--- a/.maestro/tests/roomList/timeline/timeline.yaml
+++ b/.maestro/tests/roomList/timeline/timeline.yaml
@@ -6,5 +6,6 @@ appId: ${MAESTRO_APP_ID}
- runFlow: messages/text.yaml
- runFlow: messages/location.yaml
- runFlow: messages/poll.yaml
+- runFlow: call/call.yaml
- back
- runFlow: ../../assertions/assertHomeDisplayed.yaml
diff --git a/CHANGES.md b/CHANGES.md
index 60fcfe0046f..4b0b22eb038 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,763 @@
+Changes in Element X v25.06.3
+=============================
+
+## What's Changed
+### ✨ Features
+* Feature : room version upgrade by @ganfra in https://github.com/element-hq/element-x-android/pull/4862
+* Add a developer option for history sharing on invite by @richvdh in https://github.com/element-hq/element-x-android/pull/4821
+### 🙌 Improvements
+* Change : add tombstoned room decoration by @ganfra in https://github.com/element-hq/element-x-android/pull/4891
+* Show generic notification when Event cannot be resolved by @bmarty in https://github.com/element-hq/element-x-android/pull/4889
+### 🐛 Bugfixes
+* [a11y] Improve screen reader on polls by @bmarty in https://github.com/element-hq/element-x-android/pull/4875
+* fix (event action): allow to edit only if permission to send message by @ganfra in https://github.com/element-hq/element-x-android/pull/4895
+* fix (room upgrade) : room predecessor banner on DM room by @ganfra in https://github.com/element-hq/element-x-android/pull/4896
+* fix (join room) : do not navigate up when join is successful by @ganfra in https://github.com/element-hq/element-x-android/pull/4899
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4842
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4881
+### Dependency upgrades
+* chore(deps): update plugin dependencycheck to v12.1.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4856
+* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.10.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4858
+* fix(deps): update kotlin to v2.1.21-2.0.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4850
+* fix(deps): update dependency app.cash.turbine:turbine to v1.2.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4865
+* Update dependency com.posthog:posthog-android to v3.18.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4873
+* Update dependency org.maplibre.gl:android-sdk to v11.10.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4879
+* fix(deps): update dependency com.posthog:posthog-android to v3.19.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4882
+* fix(deps): update dependency io.sentry:sentry-android to v8.13.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4870
+* fix(deps): update showkase to v1.0.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4878
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.6.18 by @renovate in https://github.com/element-hq/element-x-android/pull/4894
+### Others
+* Annotate Composable functions with `@ReadOnlyComposable` where it's possible by @bmarty in https://github.com/element-hq/element-x-android/pull/4859
+* Add documentation on WebViewPipController by @bmarty in https://github.com/element-hq/element-x-android/pull/4861
+* Small cleanup around log tag. by @bmarty in https://github.com/element-hq/element-x-android/pull/4860
+* Another cleanup by @bmarty in https://github.com/element-hq/element-x-android/pull/4869
+* Disable BT audio devices for Element Call on Android < 12 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4876
+* Add a banner to ask the user to disable battery optimization when Event cannot be resolved from Push by @bmarty in https://github.com/element-hq/element-x-android/pull/4845
+* a11y: improve accessibility on rich text editor options. by @bmarty in https://github.com/element-hq/element-x-android/pull/4886
+* A11Y: improve accessibility on event reactions. by @bmarty in https://github.com/element-hq/element-x-android/pull/4877
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.06.2...v25.06.3
+
+Changes in Element X v25.06.2
+=============================
+
+## What's Changed
+### 🐛 Bugfixes
+* Fix crash when using Element Call on API <= 30 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4847
+* Element Call: add delay before selecting the default audio device by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4854
+* Fix for message composer losing focus in Compose 1.8.0 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4853
+### Dependency upgrades
+* chore(deps): update plugin dependencycheck to v12.1.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4840
+* deps (matrix rust sdk) : bump version to 25.06.10 by @ganfra in https://github.com/element-hq/element-x-android/pull/4855
+### Others
+* feat: Support matrix: links by @ShadowRZ in https://github.com/element-hq/element-x-android/pull/4839
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.06.1...v25.06.2
+
+## What's Changed
+### ✨ Features
+* Enable support for Android Auto. by @bmarty in https://github.com/element-hq/element-x-android/pull/4818
+* Element Call: Add audio output selector handled by Android by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4663
+### 🙌 Improvements
+* Oidc: Fallback to external browser instead of using Webview by @bmarty in https://github.com/element-hq/element-x-android/pull/4808
+* change (room member moderation) : update icon to match figma by @ganfra in https://github.com/element-hq/element-x-android/pull/4837
+### 🐛 Bugfixes
+* Fix login flow by @bmarty in https://github.com/element-hq/element-x-android/pull/4813
+* fix: When sending media as files use the `octet-stream` type by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4815
+* fix: Make `Client.findDM` return a `Result` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4816
+* Mark room as fully read when user goes back to the room list. by @bmarty in https://github.com/element-hq/element-x-android/pull/2687
+* fix (identity change) : RoomMemberIdentityStateChange in non encrypted room by @ganfra in https://github.com/element-hq/element-x-android/pull/4824
+* Fix room and user avatar downloaded with a `.bin` extension. by @bmarty in https://github.com/element-hq/element-x-android/pull/4830
+* Log the push resolving failure reason if available by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4835
+### 🧱 Build
+* Update Gradle Wrapper from 8.14.1 to 8.14.2 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4831
+### Dependency upgrades
+* fix(deps): update dependency androidx.compose:compose-bom to v2025.04.01 by @renovate in https://github.com/element-hq/element-x-android/pull/4631
+* fix(deps): update dependency androidx.compose:compose-bom to v2025.05.01 by @renovate in https://github.com/element-hq/element-x-android/pull/4814
+* fix(deps): update dependency io.sentry:sentry-android to v8.13.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4780
+* fix(deps): update appyx to v1.7.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4672
+* fix(deps): update telephoto to v0.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4749
+* fix(deps): update coil to v3.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4712
+* fix(deps): update dependency androidx.webkit:webkit to v1.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4823
+* fix(deps): update dependency com.posthog:posthog-android to v3.17.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4827
+* fix(deps): update dependency io.element.android:element-call-embedded to v0.12.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4832
+* fix(deps): update dependency com.google.firebase:firebase-bom to v33.15.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4833
+* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.10.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4825
+* fix(deps): update lifecycle to v2.9.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4822
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.6.6 by @renovate in https://github.com/element-hq/element-x-android/pull/4834
+* fix(deps): update dependency io.element.android:opusencoder to v1.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4836
+### Others
+* Add `catchingExceptions` method to replace `runCatching` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4797
+* Rename classes overriding classes from the FFI layer. by @bmarty in https://github.com/element-hq/element-x-android/pull/4817
+* Fix coroutine scope by @bmarty in https://github.com/element-hq/element-x-android/pull/4820
+* Add extra logs the 'send call notification' flow by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4819
+* misc (matrix) : use innerClient.subscribeToRoomInfo sdk method by @ganfra in https://github.com/element-hq/element-x-android/pull/4838
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.06.0...v25.06.1
+
+Changes in Element X v25.06.0
+=============================
+
+Rust SDK: https://github.com/matrix-org/matrix-rust-sdk/releases/tag/matrix-sdk-ffi%2F20250603
+
+## What's Changed
+### ✨ Features
+* Add support for login link by @bmarty in https://github.com/element-hq/element-x-android/pull/4752
+### 🙌 Improvements
+* On boarding flow: add a screen to select account provider among a fixed list by @bmarty in https://github.com/element-hq/element-x-android/pull/4769
+* Change : RoomMember moderation by @ganfra in https://github.com/element-hq/element-x-android/pull/4779
+### 🐛 Bugfixes
+* Fix left room membership change by @ganfra in https://github.com/element-hq/element-x-android/pull/4765
+* fix: exclude more domains from being backed up by the system by @lucasmz-dev in https://github.com/element-hq/element-x-android/pull/4773
+* Make sure HeaderFooterPage contents can be scrolled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4704
+* Fix mobile link by @bmarty in https://github.com/element-hq/element-x-android/pull/4805
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4775
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4804
+### 🧱 Build
+* Maestro: fix MAS and EC breaking the tests by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4762
+* Update Gradle Wrapper from 8.14 to 8.14.1 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4766
+* Stronger lambda error by @bmarty in https://github.com/element-hq/element-x-android/pull/4771
+* Use Localazy's `langAliases` for Indonesian language by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4801
+### Dependency upgrades
+* fix(deps): update datastore to v1.1.7 by @renovate in https://github.com/element-hq/element-x-android/pull/4754
+* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.8 by @renovate in https://github.com/element-hq/element-x-android/pull/4721
+* chore(deps): update plugin ktlint to v12.3.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4767
+* fix(deps): update dependency com.google.firebase:firebase-bom to v33.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4755
+* Update UnifiedPush library by @bmarty in https://github.com/element-hq/element-x-android/pull/4358
+* fix(deps): update sqldelight to v2.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4735
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.5.26 by @renovate in https://github.com/element-hq/element-x-android/pull/4781
+* fix(deps): update dependency com.posthog:posthog-android to v3.15.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4787
+* fix(deps): update dependency com.posthog:posthog-android to v3.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4789
+* fix(deps): update dependency io.element.android:element-call-embedded to v0.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4743
+* fix(deps): update dependencyanalysis to v2.18.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4796
+* fix(deps): update android.gradle.plugin to v8.10.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4795
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.5.29 by @renovate in https://github.com/element-hq/element-x-android/pull/4799
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.6.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4810
+### Others
+* fix(deps): update media3 to v1.7.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4733
+* fix: Ignore global proxy settings if system thinks there's none by @ShadowRZ in https://github.com/element-hq/element-x-android/pull/4744
+* Add `ActiveRoomHolder` to manage the active room for a session by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4758
+* Notification events resolving and rendering in batches by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4722
+* Hide Element Call entry point if Element Call service is not available. by @bmarty in https://github.com/element-hq/element-x-android/pull/4783
+* Fix dependencies on test by @bmarty in https://github.com/element-hq/element-x-android/pull/4790
+* Update _developer_onboarding.md by @lex-neufeld in https://github.com/element-hq/element-x-android/pull/4570
+
+## New Contributors
+* @lucasmz-dev made their first contribution in https://github.com/element-hq/element-x-android/pull/4773
+* @lex-neufeld made their first contribution in https://github.com/element-hq/element-x-android/pull/4570
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.05.4...v25.06.0
+
+
+
+Changes in Element X v25.05.4
+=============================
+
+Rust SDK: https://github.com/matrix-org/matrix-rust-sdk/releases/tag/matrix-sdk-ffi%2F20250521
+
+## What's Changed
+### 🙌 Improvements
+* Change (report room) : check if server supports the report room api by @ganfra in https://github.com/element-hq/element-x-android/pull/4718
+### 🐛 Bugfixes
+* Improve audio focus management by @bmarty in https://github.com/element-hq/element-x-android/pull/4707
+* When transcoding a video fails, send it as a file by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4257
+* Disable mutliple click (parallel or serial) on a room by @bmarty in https://github.com/element-hq/element-x-android/pull/4683
+* Fix generic mime type used when externally sharing several files by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4715
+* Fix issues on JoinedRoom / BaseRoom by @bmarty in https://github.com/element-hq/element-x-android/pull/4724
+* Use the right live timeline instance in `RustRoomFactory` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4745
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4739
+### 🧱 Build
+* Ensure the CI is marked as failed when Maestro test is failing by @bmarty in https://github.com/element-hq/element-x-android/pull/4700
+* Trigger pipeline build when a release tag is pushed by @bmarty in https://github.com/element-hq/element-x-android/pull/4741
+* Fix compilation issues. by @bmarty in https://github.com/element-hq/element-x-android/pull/4750
+### 📄 Documentation
+* README.md: fix broken link by @richvdh in https://github.com/element-hq/element-x-android/pull/4728
+### Dependency upgrades
+* chore(config): migrate renovate config by @renovate in https://github.com/element-hq/element-x-android/pull/4688
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.5.13 by @renovate in https://github.com/element-hq/element-x-android/pull/4716
+* fix(deps): update dependency io.sentry:sentry-android to v8.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4717
+* chore(deps): update plugin sonarqube to v6.2.0.5505 by @renovate in https://github.com/element-hq/element-x-android/pull/4725
+* fix(deps): update dependency com.posthog:posthog-android to v3.15.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4723
+* fix(deps): update dependency com.squareup.retrofit2:retrofit-bom to v2.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4727
+* chore(deps): update codecov/codecov-action action to v5.4.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4730
+* fix(deps): update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4713
+* fix(deps): update dependency com.squareup.retrofit2:retrofit-bom to v3 by @renovate in https://github.com/element-hq/element-x-android/pull/4729
+* fix(deps): update kotlinpoet to v2.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4732
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.5.21 by @renovate in https://github.com/element-hq/element-x-android/pull/4759
+### Others
+* Remove event cache feature flag by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4719
+* Check homeserver when login using qr code by @bmarty in https://github.com/element-hq/element-x-android/pull/4708
+* Merge on boarding module to login module by @bmarty in https://github.com/element-hq/element-x-android/pull/4746
+* Allow configuration to provide multiple account providers. by @bmarty in https://github.com/element-hq/element-x-android/pull/4742
+* Reduce API of JoinedRoom, caller must use the Timeline API from liveTimeline instead by @bmarty in https://github.com/element-hq/element-x-android/pull/4731
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.05.3...v25.05.4
+
+Changes in Element X v25.05.3
+=============================
+
+Version 25.05.2 was skipped.
+
+## What's Changed
+### 🐛 Bugfixes
+* Disable Continue button when the login field is cleared. by @bmarty in https://github.com/element-hq/element-x-android/pull/4699
+* Revert "fix(deps): update dependency io.element.android:element-call-embedded to v0.10.0", which caused an issue with to-device events in the latest version by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4706
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4703
+### 🧱 Build
+* Update Gradle Wrapper from 8.13 to 8.14 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4645
+### Dependency upgrades
+* fix(deps): update datastore to v1.1.6 by @renovate in https://github.com/element-hq/element-x-android/pull/4630
+* fix(deps): update lifecycle to v2.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4693
+* fix(deps): update dependency androidx.sqlite:sqlite-ktx to v2.5.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4692
+### Others
+* Update "Learn more" link by @bmarty in https://github.com/element-hq/element-x-android/pull/4686
+* Keep call notification ringing while a call is present in the room by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4634
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.05.1...v25.05.3
+
+Changes in Element X v25.05.1
+=============================
+
+## What's Changed
+### 🐛 Bugfixes
+* Fix broken Element Call in 25.05.0 by @bmarty in https://github.com/element-hq/element-x-android/pull/4694
+### Dependency upgrades
+* fix(deps): update android.gradle.plugin to v8.10.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4687
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.5.8 by @renovate in https://github.com/element-hq/element-x-android/pull/4696
+### Others
+* Let EnterpriseService prevent usage of homeserver by @bmarty in https://github.com/element-hq/element-x-android/pull/4682
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.05.0...v25.05.1
+
+Changes in Element X v25.05.0
+=============================
+
+## What's Changed
+### ✨ Features
+* Feature : Report room by @ganfra in https://github.com/element-hq/element-x-android/pull/4654
+### 🙌 Improvements
+* Render kick and ban reason in the timeline when available by @bmarty in https://github.com/element-hq/element-x-android/pull/4642
+### 🐛 Bugfixes
+* Accessibility: improve behavior of list items by @bmarty in https://github.com/element-hq/element-x-android/pull/4626
+* Render caller avatar on Incoming call screen by @bmarty in https://github.com/element-hq/element-x-android/pull/4635
+* Fix `Client.getJoinedRoom` crash when a room doesn't exist locally by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4656
+* Fix wrong member count in join room screen for invitation by @bmarty in https://github.com/element-hq/element-x-android/pull/4651
+* Make sure any `JoinedRustRoom` is destroyed after being used by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4678
+* Fix read receipt behavior when the timeline is opened. by @bmarty in https://github.com/element-hq/element-x-android/pull/4679
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4648
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4677
+### 🧱 Build
+* OIDC configuration by @bmarty in https://github.com/element-hq/element-x-android/pull/4623
+* Pin commit sha on GitHub actions by @bmarty in https://github.com/element-hq/element-x-android/pull/4653
+### Dependency upgrades
+* fix(deps): update dependency io.sentry:sentry-android to v8.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4624
+* fix(deps): update dependency com.posthog:posthog-android to v3.14.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4628
+* fix(deps): update dependency androidx.exifinterface:exifinterface to v1.4.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4632
+* fix(deps): update dependencyanalysis to v2.17.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4638
+* fix(deps): update dependency com.google.firebase:firebase-bom to v33.13.0 - autoclosed by @renovate in https://github.com/element-hq/element-x-android/pull/4637
+* fix(deps): update dependency io.sentry:sentry-android to v8.10.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4644
+* fix(deps): update dependency org.jsoup:jsoup to v1.20.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4655
+* fix(deps): update dependency com.google.accompanist:accompanist-permissions to v0.37.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4649
+* fix(deps): update dependency io.sentry:sentry-android to v8.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4660
+* fix(deps): update dependency io.mockk:mockk to v1.14.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4658
+* fix(deps): update dependency io.github.sergio-sastre.composablepreviewscanner:android to v0.6.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4647
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.4.30 by @renovate in https://github.com/element-hq/element-x-android/pull/4665
+* fix(deps): update kotlin to v2.1.20-2.0.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4670
+* fix(deps): update dependency io.sentry:sentry-android to v8.11.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4668
+* fix(deps): update dependency io.element.android:element-call-embedded to v0.10.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4667
+* chore(deps): update rnkdsh/action-upload-diawi action to v1.5.9 by @renovate in https://github.com/element-hq/element-x-android/pull/4674
+* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.7 by @renovate in https://github.com/element-hq/element-x-android/pull/4673
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.5.6 by @renovate in https://github.com/element-hq/element-x-android/pull/4681
+### Others
+* Split `MatrixRoom` into `MatrixRoom` and `JoinedMatrixRoom` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4561
+* Cleanup element call and UI by @bmarty in https://github.com/element-hq/element-x-android/pull/4641
+* Take change of screen_change_server_error_no_sliding_sync_message into account by @bmarty in https://github.com/element-hq/element-x-android/pull/4650
+* Improve the callback uri format and customization. by @bmarty in https://github.com/element-hq/element-x-android/pull/4664
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.04.3...v25.05.0
+
+Changes in Element X v25.04.3
+=============================
+
+### 🙌 Improvements
+* Use PreferenceDropdown for appearance by @ganfra in https://github.com/element-hq/element-x-android/pull/4581
+### 🐛 Bugfixes
+* Use in-call volume and mode for EC by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4481
+* Send SVG images as files by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4595
+* Fetch the initial ignored user list manually when subscribing by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4598
+* Fix audio output selection for Element Call by @bmarty in https://github.com/element-hq/element-x-android/pull/4602
+* [a11y] Make more items focusable by @bmarty in https://github.com/element-hq/element-x-android/pull/4605
+* Fix ringing calls not stopping when the other user cancels the call by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4613
+* Ensure that pinning an event makes the pinned messages banner appear by @bmarty in https://github.com/element-hq/element-x-android/pull/4606
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4590
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4612
+### 📄 Documentation
+* Improve onboarding docs: by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4578
+### Dependency upgrades
+* Upgrade Rust bindings to `v25.04.11` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4580
+* fix(deps): update dependency androidx.sqlite:sqlite-ktx to v2.5.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4568
+* fix(deps): update dependency app.cash.molecule:molecule-runtime to v2.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4585
+* fix(deps): update core to v1.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4564
+* Upate datastore to 1.1.4 by @bmarty in https://github.com/element-hq/element-x-android/pull/4551
+* fix(deps): update media3 to v1.6.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4592
+* chore(deps): update danger/danger-js action to v13 by @renovate in https://github.com/element-hq/element-x-android/pull/4596
+* fix(deps): update dependency io.element.android:emojibase-bindings to v1.4.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4591
+* fix(deps): update dagger to v2.56.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4603
+* fix(deps): update dependency io.sentry:sentry-android to v8.8.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4557
+* fix(deps): update dependency androidx.compose:compose-bom to v2025.04.00 - autoclosed by @renovate in https://github.com/element-hq/element-x-android/pull/4565
+* fix(deps): update dependency com.posthog:posthog-android to v3.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4616
+* fix(deps): update android.gradle.plugin to v8.9.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4615
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.4.22 by @renovate in https://github.com/element-hq/element-x-android/pull/4622
+### Others
+* Improve accessibility of the timeline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4579
+* Push: improve Push history screen, log and stored data by @bmarty in https://github.com/element-hq/element-x-android/pull/4601
+* Push gateway config by @bmarty in https://github.com/element-hq/element-x-android/pull/4608
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.04.2...v25.04.3
+
+Changes in Element X v25.04.2
+=============================
+
+Security fixes 🔐
+-----------------
+- Fix for [GHSA-m5px-pwq3-4p5m](https://github.com/element-hq/element-x-android/security/advisories/GHSA-m5px-pwq3-4p5m) / [CVE-2025-27599](https://www.cve.org/CVERecord?id=CVE-2025-27599)
+
+Changes in Element X v25.04.1
+=============================
+
+
+
+## What's Changed
+### ✨ Features
+* Introduce PushHistoryService to store data about the received push by @bmarty in https://github.com/element-hq/element-x-android/pull/4573
+### 🙌 Improvements
+* change (preferences) : new moderation and safety settings by @ganfra in https://github.com/element-hq/element-x-android/pull/4574
+### 🐛 Bugfixes
+* Ensure that we have only one single instance of SeenInviteStore per session by @bmarty in https://github.com/element-hq/element-x-android/pull/4577
+### Dependency upgrades
+* fix(deps): update dependencyanalysis to v2.15.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4558
+* fix(deps): update dependency io.mockk:mockk to v1.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4562
+* fix(deps): update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4552
+* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.6 by @renovate in https://github.com/element-hq/element-x-android/pull/4567
+* fix(deps): update dependencyanalysis to v2.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4575
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.04.0...v25.04.1
+
+Changes in Element X v25.04.0
+=============================
+
+
+
+## What's Changed
+### ✨ Features
+* Enable Rust trace log packs by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4514
+* Allow using a hardware keyboard to unlock the app using a pin code by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4530
+### 🙌 Improvements
+* Change (mention span) : rework and add more cases by @ganfra in https://github.com/element-hq/element-x-android/pull/4476
+* Add kick (remove) confirmation and reason by @bmarty in https://github.com/element-hq/element-x-android/pull/4507
+* Remove the green badge on a pending invite after a first preview by @bmarty in https://github.com/element-hq/element-x-android/pull/4532
+### 🐛 Bugfixes
+* Improve touch indicators for the user info UI in the timeline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4482
+* Limit the text length in the 'in reply to' preview by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4491
+* Timeline header: ensure that the decoration is clickable by @bmarty in https://github.com/element-hq/element-x-android/pull/4495
+* Add video autoplay to media gallery by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4499
+* Add `WakeLock` to dismiss ringing call screen when call is cancelled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4478
+* Make sure the live timeline is destroyed before clearing a room's cache by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4515
+* Fix bullet points not having leading margin on timeline items by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4536
+* Fix the share location URI by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4544
+* Add a inderminate progress bar when loging out and in Waiting state. by @bmarty in https://github.com/element-hq/element-x-android/pull/4538
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4506
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4543
+### 🧱 Build
+* Element config by @bmarty in https://github.com/element-hq/element-x-android/pull/4471
+* Check if Manifest.permission.REQUEST_INSTALL_PACKAGES is in the manifest by @bmarty in https://github.com/element-hq/element-x-android/pull/4490
+* Remove nightly_enterprise.yml. by @bmarty in https://github.com/element-hq/element-x-android/pull/4492
+* Log the packageId which is currently built. by @bmarty in https://github.com/element-hq/element-x-android/pull/4494
+* Use handy buildConfigFieldStr. by @bmarty in https://github.com/element-hq/element-x-android/pull/4501
+* Fix warnings in InMemoryAppPreferencesStore by @bmarty in https://github.com/element-hq/element-x-android/pull/4523
+### Dependency upgrades
+* fix(deps): update camera to v1.4.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4483
+* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.5 by @renovate in https://github.com/element-hq/element-x-android/pull/4487
+* fix(deps): update dependency com.posthog:posthog-android to v3.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4469
+* fix(deps): update dependency androidx.compose:compose-bom to v2025.03.01 by @renovate in https://github.com/element-hq/element-x-android/pull/4484
+* fix(deps): update dependencyanalysis to v2.13.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4493
+* fix(deps): update media3 to v1.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4488
+* fix(deps): update dependency io.element.android:element-call-embedded to v0.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4498
+* fix(deps): update dependency com.google.firebase:firebase-bom to v33.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4508
+* fix(deps): update dependency com.posthog:posthog-android to v3.13.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4516
+* fix(deps): update dependency io.sentry:sentry-android to v8.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4509
+* fix(deps): update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4444
+* fix(deps): update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4522
+* fix(deps): update dependencyanalysis to v2.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4527
+* fix(deps): update dependency io.element.android:compound-android to v25.4.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4537
+* chore(deps): update plugin dependencycheck to v12.1.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4540
+* fix(deps): update appyx to v1.7.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4547
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.4.7 by @renovate in https://github.com/element-hq/element-x-android/pull/4548
+### Others
+* Update screenshots by @bmarty in https://github.com/element-hq/element-x-android/pull/4497
+* Update store description. by @bmarty in https://github.com/element-hq/element-x-android/pull/4496
+* Improve TextFieldDialog by @bmarty in https://github.com/element-hq/element-x-android/pull/4512
+* Make `RustMatrixClient.close` asynchronous by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4513
+* Replace OutlinedTextField by our TextField by @bmarty in https://github.com/element-hq/element-x-android/pull/4521
+* Remove alias from room invite item by @bmarty in https://github.com/element-hq/element-x-android/pull/4531
+* Remember flows by @bmarty in https://github.com/element-hq/element-x-android/pull/4533
+* Use colors from compound for badges by @bmarty in https://github.com/element-hq/element-x-android/pull/4545
+* Update app icon by @bmarty in https://github.com/element-hq/element-x-android/pull/4534
+* Click on userId / room alias to copy value to clipboard. by @bmarty in https://github.com/element-hq/element-x-android/pull/4549
+* Run the 'prevent blocked' workflow even if PR has conflicts by @robintown in https://github.com/element-hq/element-x-android/pull/4432
+* Update wording for push provider support test. (#4079) by @bmarty in https://github.com/element-hq/element-x-android/pull/4553
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.4...v25.04.0
+
+Changes in Element X v25.03.4
+=============================
+
+
+
+## What's Changed
+### 🙌 Improvements
+* Change : composer suggestions by @ganfra in https://github.com/element-hq/element-x-android/pull/4485
+### 🧱 Build
+* Fix flaky incoming verification tests by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4479
+### Dependency upgrades
+* fix(deps): update dagger to v2.56.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4472
+* fix(deps): update dependencyanalysis to v2.13.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4473
+* Upgrade embedded EC version to `v0.9.0-rc.4` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4489
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.3...v25.03.4
+
+Changes in Element X v25.03.3
+=============================
+
+
+
+## What's Changed
+### ✨ Features
+* Add 'unencrypted room' badges and labels by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4445
+* Use embedded version of Element Call by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4470
+### 🐛 Bugfixes
+* Fix 'unverified session' flow displayed when creating account by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4467
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4461
+### 🧱 Build
+* Let element enterprise be able to configure id for mapTiler. by @bmarty in https://github.com/element-hq/element-x-android/pull/4446
+### Dependency upgrades
+* chore(deps): update rnkdsh/action-upload-diawi action to v1.5.8 by @renovate in https://github.com/element-hq/element-x-android/pull/4457
+* chore(deps): update plugin licensee to v1.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4447
+* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4450
+* fix(deps): update dependency com.google.firebase:firebase-bom to v33.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4448
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.3.24 by @renovate in https://github.com/element-hq/element-x-android/pull/4394
+* fix(deps): update dependencyanalysis to v2.13.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4464
+* chore(deps): update plugin sonarqube to v6.1.0.5360 by @renovate in https://github.com/element-hq/element-x-android/pull/4468
+* fix(deps): update android.gradle.plugin to v8.9.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4465
+### Others
+* Sync Strings - tweaks to identity change messages by @andybalaam in https://github.com/element-hq/element-x-android/pull/4454
+* Check link click by @bmarty in https://github.com/element-hq/element-x-android/pull/4463
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.2...v25.03.3
+
+Changes in Element X v25.03.2
+=============================
+
+
+
+## What's Changed
+### ✨ Features
+* Implement user verification by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4294
+* Add user verification and verification state violation badges by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4392
+* Open txt document inside the application by @bmarty in https://github.com/element-hq/element-x-android/pull/4414
+* Add timeline item prefetching by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4399
+### 🐛 Bugfixes
+* fix(read receipt): track read receipts for focused timeline by @ganfra in https://github.com/element-hq/element-x-android/pull/4374
+* Discard timed out verification requests by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4385
+* Ensure the snackbar "No more media to show" is not rendered when opening the media viewer. by @bmarty in https://github.com/element-hq/element-x-android/pull/4397
+* Disable click effect on Stickers by @bmarty in https://github.com/element-hq/element-x-android/pull/4401
+* Ensure that a click on a media open the correct media. by @bmarty in https://github.com/element-hq/element-x-android/pull/4413
+* Display user verification violation icon in DM rooms too by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4423
+* Add a filter to avoid stack overflow when pressing the back button several times. by @bmarty in https://github.com/element-hq/element-x-android/pull/4430
+* Make verification screens scrollable and emoji labels multiline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4449
+### 🗣 Translations
+* Sync Strings - New translations in Basque by @ElementBot in https://github.com/element-hq/element-x-android/pull/4381
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4421
+### 🧱 Build
+* More PR checks by @bmarty in https://github.com/element-hq/element-x-android/pull/4384
+* "Core Team" is a team of matrix-org. Use team "Vector Core" instead. by @bmarty in https://github.com/element-hq/element-x-android/pull/4393
+* Fix warnings in tests for push provider modules by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4398
+* Update Gradle Wrapper from 8.12.1 to 8.13 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4308
+* Revert agp to 8.8.1 by @bmarty in https://github.com/element-hq/element-x-android/pull/4451
+### Dependency upgrades
+* Update rnkdsh/action-upload-diawi action to v1.5.7 by @renovate in https://github.com/element-hq/element-x-android/pull/4354
+* fix(deps): update dependency com.posthog:posthog-android to v3.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4387
+* fix(deps): update dependencyanalysis to v2.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4395
+* fix(deps): update dependency androidx.compose:compose-bom to v2025.03.00 by @renovate in https://github.com/element-hq/element-x-android/pull/4407
+* fix(deps): update dependency androidx.webkit:webkit to v1.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4408
+* fix(deps): update dependency net.java.dev.jna:jna to v5.17.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4419
+* fix(deps): update dependencyanalysis to v2.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4409
+* Add Google Tink dependency, replacing `androidx.security.crypto` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4405
+* fix(deps): update dependency io.sentry:sentry-android to v8.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4411
+* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4427
+* chore(deps): update webfactory/ssh-agent action to v0.9.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4426
+* fix(deps): update android.gradle.plugin to v8.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4320
+* Update SDK version to `25.03.13` and fix breaking changes by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4406
+* Update dagger to v2.56 by @renovate in https://github.com/element-hq/element-x-android/pull/4440
+* Update dependency io.sentry:sentry-android to v8.5.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4433
+* Update dependencyAnalysis to v2.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4442
+* Update dependency com.google.crypto.tink:tink-android to v1.17.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4422
+* deps(rust sdk) : update to 25.03.20 and fix api change by @ganfra in https://github.com/element-hq/element-x-android/pull/4452
+### Others
+* Migrate some icons to Compound icon by @bmarty in https://github.com/element-hq/element-x-android/pull/4375
+* Long press link to copy URL to clipboard by @ShadowRZ in https://github.com/element-hq/element-x-android/pull/4376
+* Use public icon from Compound by @bmarty in https://github.com/element-hq/element-x-android/pull/4386
+* Be able to correctly render the UI with other colors. by @bmarty in https://github.com/element-hq/element-x-android/pull/4378
+* Let EnterpriseService provides push gateways by @bmarty in https://github.com/element-hq/element-x-android/pull/4400
+* Add feature flag to let the application prints logs to logcat in release builds. by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4402
+* Hide "unencrypted" lock for redacted messages by @Xant3s in https://github.com/element-hq/element-x-android/pull/4410
+* Hide unencrypted lock for redacted msgs by @bmarty in https://github.com/element-hq/element-x-android/pull/4429
+* Clear SDK cache properly by @bmarty in https://github.com/element-hq/element-x-android/pull/4396
+
+## New Contributors
+* @ShadowRZ made their first contribution in https://github.com/element-hq/element-x-android/pull/4376
+* @Xant3s made their first contribution in https://github.com/element-hq/element-x-android/pull/4410
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.1...v25.03.2
+
+Changes in Element X v25.03.1
+=============================
+
+
+
+## What's Changed
+### ✨ Features
+* Enable the Event cache by default. by @bmarty in https://github.com/element-hq/element-x-android/pull/4373
+### 🙌 Improvements
+* change(create room) : use history visibility "invited" by @ganfra in https://github.com/element-hq/element-x-android/pull/4335
+* change(room directory) : move the the room directory entry by @ganfra in https://github.com/element-hq/element-x-android/pull/4348
+* [Change] Invited state room preview by @ganfra in https://github.com/element-hq/element-x-android/pull/4353
+* change(left room snackbar) : manage cancel knock and decline invite by @ganfra in https://github.com/element-hq/element-x-android/pull/4360
+### 🐛 Bugfixes
+* Restore manual `Client` cleanup on session logout by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4333
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4346
+### 🧱 Build
+* Fix typo on job name. by @bmarty in https://github.com/element-hq/element-x-android/pull/4352
+### Dependency upgrades
+* chore(deps): update plugin ktlint to v12.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4338
+* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4340
+* fix(deps): update dependency io.mockk:mockk to v1.13.17 by @renovate in https://github.com/element-hq/element-x-android/pull/4334
+* fix(deps): update kotlin to v2.1.10-1.0.31 by @renovate in https://github.com/element-hq/element-x-android/pull/4337
+* fix(deps): update dependency com.google.firebase:firebase-bom to v33.10.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4339
+* Migrate to coil3 by @bmarty in https://github.com/element-hq/element-x-android/pull/4347
+* fix(deps): update dependency org.jsoup:jsoup to v1.19.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4351
+* deps(rust sdk) : update to 25.03.05 by @ganfra in https://github.com/element-hq/element-x-android/pull/4370
+* Update dependency org.matrix.rustcomponents:sdk-android to v25.3.6 by @renovate in https://github.com/element-hq/element-x-android/pull/4371
+### Others
+* Prevent PRs with the X-Blocked label from being merged by @robintown in https://github.com/element-hq/element-x-android/pull/4350
+* Fix some icon colors by @bmarty in https://github.com/element-hq/element-x-android/pull/4365
+* Remove PreferenceText, replace by ListItem. by @bmarty in https://github.com/element-hq/element-x-android/pull/4369
+* Show error screens in group calls by @robintown in https://github.com/element-hq/element-x-android/pull/4297
+
+## New Contributors
+* @robintown made their first contribution in https://github.com/element-hq/element-x-android/pull/4350
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.0...v25.03.1
+
+Changes in Element X v25.03.0
+=============================
+
+
+
+## What's Changed
+### ✨ Features
+* Create `SyncOrchestrator` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4176
+* feature(crypto): verification violation handling and block sending by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/4126
+* Update Matrix Room API and allow media swipe on pinned event only. by @bmarty in https://github.com/element-hq/element-x-android/pull/4274
+* Feature : join room by address by @ganfra in https://github.com/element-hq/element-x-android/pull/4302
+### 🙌 Improvements
+* Change : Room Preview by @ganfra in https://github.com/element-hq/element-x-android/pull/4250
+### 🐛 Bugfixes
+* SyncOrchestrator: restore the initial sync step by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4242
+* When an emoji is used as the 'initial' for an avatar, use the whole emoji by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4277
+* Try avoiding trailing punctuation inside linkified URLs by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4214
+* Preload account urls by @bmarty in https://github.com/element-hq/element-x-android/pull/4301
+* Fix issues due to multiple ntfy applications with the same name. by @bmarty in https://github.com/element-hq/element-x-android/pull/4312
+* Use `Settings.System.DEFAULT_RINGTONE_URI` for ringing notifications by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4310
+### 🗣 Translations
+* Sync Strings - New translations to turkish by @ElementBot in https://github.com/element-hq/element-x-android/pull/4253
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4298
+### 🧱 Build
+* Fix nightly reports by @bmarty in https://github.com/element-hq/element-x-android/pull/4235
+* Fix nightly reports - next step by @bmarty in https://github.com/element-hq/element-x-android/pull/4239
+* Prepare application for being configurable by @bmarty in https://github.com/element-hq/element-x-android/pull/4285
+* runQualityChecks task shouldn't fail fast by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4309
+* Get library ComposablePreviewScanner from maven and update to the latest version by @bmarty in https://github.com/element-hq/element-x-android/pull/4327
+### Dependency upgrades
+* Update dependency com.posthog:posthog-android to v3.11.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4230
+* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.78 by @renovate in https://github.com/element-hq/element-x-android/pull/4234
+* Update dependency org.maplibre.gl:android-sdk to v11.8.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4245
+* fix(deps): update dependency org.jetbrains.kotlinx:kotlinx-datetime to v0.6.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4258
+* fix(deps): update dependency io.sentry:sentry-android to v8.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4262
+* fix(deps): update telephoto to v0.15.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4270
+* fix(deps): update dependency com.google.firebase:firebase-bom to v33.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4249
+* chore(deps): update danger/danger-js action to v12.3.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4259
+* fix(deps): update android.gradle.plugin to v8.8.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4263
+* chore(deps): update plugin dependencycheck to v12.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4272
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25 by @renovate in https://github.com/element-hq/element-x-android/pull/4273
+* fix(deps): update dependency androidx.compose:compose-bom to v2025.02.00 by @renovate in https://github.com/element-hq/element-x-android/pull/4261
+* fix(deps): update kotlin to v2.1.10-1.0.30 by @renovate in https://github.com/element-hq/element-x-android/pull/4265
+* fix(deps): update dependency io.github.zxing-cpp:android to v2.3.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4282
+* fix(deps): update firebaseappdistribution to v5.1.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4246
+* fix(deps): update dependencyanalysis to v2.8.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4251
+* fix(deps): update dependency com.google.accompanist:accompanist-permissions to v0.37.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4283
+* fix(deps): update dependency com.google.accompanist:accompanist-permissions to v0.37.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4287
+* fix(deps): update dependencyanalysis to v2.10.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4288
+* fix(deps): update dependencyanalysis to v2.10.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4295
+* Upgrade SDK version to 25.02.26 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4305
+* fix(deps): update kotlinpoet to v2.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4304
+* Update compound by @bmarty in https://github.com/element-hq/element-x-android/pull/4319
+* fix(deps): update dependency androidx.constraintlayout:constraintlayout-compose to v1.1.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4324
+* fix(deps): update activity to v1.10.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4321
+* fix(deps): update dependency androidx.exifinterface:exifinterface to v1.4.0 - autoclosed by @renovate in https://github.com/element-hq/element-x-android/pull/4325
+* fix(deps): update dependency androidx.constraintlayout:constraintlayout to v2.2.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4322
+* fix(deps): update dependency io.sentry:sentry-android to v8.3.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4316
+* fix(deps): update dependency com.posthog:posthog-android to v3.11.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4313
+* fix(deps): update dependency com.android.tools:desugar_jdk_libs to v2.1.5 by @renovate in https://github.com/element-hq/element-x-android/pull/4299
+* chore(deps): update plugin detekt to v1.23.8 by @renovate in https://github.com/element-hq/element-x-android/pull/4292
+### Others
+* Update incoming call notification content to "📹 Incoming call" by @bmarty in https://github.com/element-hq/element-x-android/pull/4231
+* Display a bottom sheet to let user confirm the DM creation by @bmarty in https://github.com/element-hq/element-x-android/pull/4233
+* Open chat links in regular browser tabs by @cbs228 in https://github.com/element-hq/element-x-android/pull/4198
+* Theme override by @bmarty in https://github.com/element-hq/element-x-android/pull/4226
+* Allow user certificate in production builds. by @bmarty in https://github.com/element-hq/element-x-android/pull/4275
+* Replace Material icons with Compound icons wherever it's possible by @bmarty in https://github.com/element-hq/element-x-android/pull/4323
+
+## New Contributors
+* @cbs228 made their first contribution in https://github.com/element-hq/element-x-android/pull/4198
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.02.0...v25.03.0
+
+Changes in Element X v25.02.0 (2025-02-04)
+==========================================
+
+
+
+## What's Changed
+### ✨ Features
+* Media navigation with swipe gesture by @bmarty in https://github.com/element-hq/element-x-android/pull/4161
+* Add ability to swipe between media when opened from the timeline. by @bmarty in https://github.com/element-hq/element-x-android/pull/4205
+### 🙌 Improvements
+* change(design) : use ElementTheme.typography.fontBodyLgMedium by @ganfra in https://github.com/element-hq/element-x-android/pull/4145
+* change(design) : New component Announcement by @ganfra in https://github.com/element-hq/element-x-android/pull/4140
+* update rust sdk 0.2.75 by @ganfra in https://github.com/element-hq/element-x-android/pull/4158
+### 🐛 Bugfixes
+* Fix dm avatar rtl by @bmarty in https://github.com/element-hq/element-x-android/pull/4103
+* Unified push gateway resolver improvement by @bmarty in https://github.com/element-hq/element-x-android/pull/4101
+* Close the media preview screen ASAP with sending queue enabled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4089
+* fix(coroutine) : make sure to switch coroutine context by @ganfra in https://github.com/element-hq/element-x-android/pull/4146
+* Fix snack bar not displayed in MediaViewer by @bmarty in https://github.com/element-hq/element-x-android/pull/4195
+* Let the SDK provide the "network is available information" by @bmarty in https://github.com/element-hq/element-x-android/pull/4215
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4088
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4100
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4114
+* Fix import of en-US translations. by @bmarty in https://github.com/element-hq/element-x-android/pull/4135
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4139
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4172
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4199
+* Sync Strings - new (partial) language: Norwegian by @ElementBot in https://github.com/element-hq/element-x-android/pull/4227
+### 🧱 Build
+* Update Gradle Wrapper from 8.11.1 to 8.12 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4085
+* Test using Maestro CLI + emulator instead of Cloud by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4092
+* Make Maestro run for each PR push by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4121
+* Migrate to CalVer like versioning by @bmarty in https://github.com/element-hq/element-x-android/pull/4187
+* Kover: include back :libraries:matrix:impl module. by @bmarty in https://github.com/element-hq/element-x-android/pull/4193
+* Update Gradle Wrapper from 8.12 to 8.12.1 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4196
+* Use secret Sentry DSN value by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4210
+* Use Sentry breadcrumbs instead of logging new events by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4223
+### 🚧 In development 🚧
+* Media Viewer: show snackbar when reaching end of timeline. by @bmarty in https://github.com/element-hq/element-x-android/pull/4201
+* Feature : room settings - security and privacy by @ganfra in https://github.com/element-hq/element-x-android/pull/4212
+### Dependency upgrades
+* Update dependency io.mockk:mockk to v1.13.14 by @renovate in https://github.com/element-hq/element-x-android/pull/4083
+* Update dependency net.java.dev.jna:jna to v5.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4087
+* Update kotlin to v1.10.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4073
+* Update dagger to v2.54 by @renovate in https://github.com/element-hq/element-x-android/pull/4084
+* Update dependency io.sentry:sentry-android to v7.19.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4090
+* Update dependency com.android.tools:desugar_jdk_libs to v2.1.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4077
+* Update dependency com.posthog:posthog-android to v3.10.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4120
+* Update appyx to v1.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4129
+* Update dagger to v2.55 by @renovate in https://github.com/element-hq/element-x-android/pull/4131
+* Update android.gradle.plugin to v8.8.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4130
+* Update dependency org.maplibre.gl:android-sdk to v11.8.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4132
+* Update dependency io.mockk:mockk to v1.13.16 by @renovate in https://github.com/element-hq/element-x-android/pull/4134
+* Update dependencyAnalysis to v2.7.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4136
+* Update anvil to v0.4.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4144
+* Update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4117
+* Update plugin dependencycheck to v12 by @renovate in https://github.com/element-hq/element-x-android/pull/4137
+* Update dependency io.sentry:sentry-android to v7.20.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4107
+* Update wysiwyg to v2.38.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4104
+* Update dependency androidx.recyclerview:recyclerview to v1.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4154
+* Update activity to v1.10.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4152
+* Update firebaseAppDistribution to v5.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4159
+* Update dependency com.google.firebase:firebase-bom to v33.8.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4160
+* Update dependency androidx.compose:compose-bom to v2025 by @renovate in https://github.com/element-hq/element-x-android/pull/4155
+* Update dependency io.sentry:sentry-android to v7.20.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4178
+* Update dependency io.sentry:sentry-android to v8 by @renovate in https://github.com/element-hq/element-x-android/pull/4180
+* Update wysiwyg to v2.38.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4177
+* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.76 by @renovate in https://github.com/element-hq/element-x-android/pull/4183
+* Update wysiwyg to v2.38.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4186
+* Update dependency com.posthog:posthog-android to v3.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4204
+* Update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4200
+* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.77 by @renovate in https://github.com/element-hq/element-x-android/pull/4228
+* Update dependency com.posthog:posthog-android to v3.11.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4222
+* Update dependency io.element.android:emojibase-bindings to v1.3.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4213
+* Update dependencyAnalysis to v2.8.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4218
+* Update dependency androidx.compose:compose-bom to v2025.01.01 by @renovate in https://github.com/element-hq/element-x-android/pull/4217
+* Update dependency io.sentry:sentry-android to v8.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4221
+* Update rnkdsh/action-upload-diawi action to v1.5.6 by @renovate in https://github.com/element-hq/element-x-android/pull/4173
+* Update plugin dependencycheck to v12.0.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4170
+### Others
+* Improve gallery loading state by @bmarty in https://github.com/element-hq/element-x-android/pull/4080
+* Show more detail about the error when pusher registration fails. by @bmarty in https://github.com/element-hq/element-x-android/pull/4081
+* Update pull request template and CI automation by @bmarty in https://github.com/element-hq/element-x-android/pull/4037
+* Add a log function for handling complex values to the WebView client. by @Half-Shot in https://github.com/element-hq/element-x-android/pull/4098
+* design : CounterAtom by @ganfra in https://github.com/element-hq/element-x-android/pull/4108
+* Change sticker mimetype fallback to image by @surakin in https://github.com/element-hq/element-x-android/pull/4111
+* Dual licensing: AGPL + Element Commercial by @bmarty in https://github.com/element-hq/element-x-android/pull/4118
+* Replace the InfoListOrganism default bg color by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4091
+* Ignore dependency that are not third-party licenses to us. by @bmarty in https://github.com/element-hq/element-x-android/pull/4122
+* misc(send queue) : do not disable send queue when Network is Offline by @ganfra in https://github.com/element-hq/element-x-android/pull/4105
+* Remove or replace unnecessary `BackHandler` calls by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4148
+* Replace our firstIfSingle extension with singleOrNull from the Kotlin library by @bmarty in https://github.com/element-hq/element-x-android/pull/4184
+* Remove log. by @bmarty in https://github.com/element-hq/element-x-android/pull/4203
+* Remove unused types / code. by @bmarty in https://github.com/element-hq/element-x-android/pull/4185
+* Consider that the topic of a room has been removed when it's blank. by @bmarty in https://github.com/element-hq/element-x-android/pull/4209
+* CalVer: use 2 digits for the year and 2 digits for the month. by @bmarty in https://github.com/element-hq/element-x-android/pull/4192
+* Always display encryption badge by @bmarty in https://github.com/element-hq/element-x-android/pull/4219
+
+## New Contributors
+* @Half-Shot made their first contribution in https://github.com/element-hq/element-x-android/pull/4098
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v0.7.6...v25.02.0
+
Changes in Element X v0.7.6 (2024-12-20)
========================================
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bc4acfd5aa6..4e0c9b94463 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,8 +2,8 @@
-* [Contributing code to Matrix](#contributing-code-to-matrix)
* [Developer onboarding](#developer-onboarding)
+* [Contributing code to Matrix](#contributing-code-to-matrix)
* [Android Studio settings](#android-studio-settings)
* [Compilation](#compilation)
* [Strings](#strings)
@@ -28,17 +28,17 @@
-## Contributing code to Matrix
+## Developer onboarding
-Please read https://github.com/matrix-org/synapse/blob/master/CONTRIBUTING.md
+For a detailed overview of the project, see [Developer Onboarding](./docs/_developer_onboarding.md).
-Element X Android support can be found in this room: [](https://matrix.to/#/#element-x-android:matrix.org).
+## Contributing code to Matrix
-The rest of the document contains specific rules for Matrix Android projects
+If instead of contributing to the Element X Android project, you want to contribute to Synapse, the homeserver implementation, please read the [Synapse contribution guide](https://element-hq.github.io/synapse/latest/development/contributing_guide.html).
-## Developer onboarding
+Element X Android support can be found in this room: [](https://matrix.to/#/#element-x-android:matrix.org).
-For a detailed overview of the project, see [Developer Onboarding](./docs/_developer_onboarding.md).
+The rest of the document contains specific rules for Matrix Android projects.
## Android Studio settings
diff --git a/README.md b/README.md
index 37cfc58e9b7..20ac1c0536e 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,7 @@ Learn more about why we are building Element X in our blog post: [https://elemen
* [Translations](#translations)
* [Rust SDK](#rust-sdk)
* [Status](#status)
+* [Minimum SDK version](#minimum-sdk-version)
* [Contributing](#contributing)
* [Build instructions](#build-instructions)
* [Support](#support)
@@ -73,6 +74,12 @@ We're doing this as a way to share code between platforms and while we've seen p
This project is in an early rollout and migration phase.
+## Minimum SDK version
+
+Element X Android requires a minimum SDK version of 24 (Android 7.0, Nougat). We aim to support devices running Android 7.0 and above, which covers a wide range of devices still in use today.
+
+Element Android Enterprise requires a minimum SDK version of 33 (Android 13, Tiramisu). For Element Enterprise, we support only devices that still receive security updates, which means devices running Android 13 and above. Android does not have a documented support policy, but some information can be found at [https://endoflife.date/android](https://endoflife.date/android).
+
## Contributing
Want to get actively involved in the project? You're more than welcome! A good way to start is to check the issues that are labelled with the [good first issue](https://github.com/element-hq/element-x-android/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) label. Let us know by commenting the issue that you're starting working on it.
@@ -87,7 +94,7 @@ Just clone the project and open it in Android Studio. Make sure to select the
`app` configuration when building (as we also have sample apps in the project).
To build against a local copy of the Rust SDK, see the [Developer
-onboarding](docs/_developer_onboarding.md#build-the-sdk-locally) instructions.
+onboarding](docs/_developer_onboarding.md#building-the-sdk-locally) instructions.
## Support
diff --git a/anvilcodegen/src/main/kotlin/io/element/android/anvilcodegen/ContributesNodeProcessorProvider.kt b/anvilcodegen/src/main/kotlin/io/element/android/anvilcodegen/ContributesNodeProcessorProvider.kt
index 80c552428f7..ec6ad5958e3 100644
--- a/anvilcodegen/src/main/kotlin/io/element/android/anvilcodegen/ContributesNodeProcessorProvider.kt
+++ b/anvilcodegen/src/main/kotlin/io/element/android/anvilcodegen/ContributesNodeProcessorProvider.kt
@@ -13,7 +13,7 @@ import com.google.devtools.ksp.processing.SymbolProcessorProvider
class ContributesNodeProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
- val enableLogging = environment.options["enableLogging"]?.toBoolean() ?: false
+ val enableLogging = environment.options["enableLogging"]?.toBoolean() == true
return ContributesNodeProcessor(
logger = environment.logger,
codeGenerator = environment.codeGenerator,
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 9e5bb50d268..e170c3de6fb 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -10,6 +10,8 @@
import com.android.build.api.variant.FilterConfiguration.FilterType.ABI
import com.android.build.gradle.internal.tasks.factory.dependsOn
import com.android.build.gradle.tasks.GenerateBuildConfig
+import com.google.firebase.appdistribution.gradle.firebaseAppDistribution
+import config.BuildTimeConfig
import extension.AssetCopyTask
import extension.ComponentMergingStrategy
import extension.GitBranchNameValueSource
@@ -18,6 +20,7 @@ import extension.allEnterpriseImpl
import extension.allFeaturesImpl
import extension.allLibrariesImpl
import extension.allServicesImpl
+import extension.buildConfigFieldStr
import extension.koverDependencies
import extension.locales
import extension.setupAnvil
@@ -43,11 +46,7 @@ android {
namespace = "io.element.android.x"
defaultConfig {
- applicationId = if (isEnterpriseBuild) {
- "org.therevivalists.app"
- } else {
- "org.therevivalists.app"
- }
+ applicationId = BuildTimeConfig.APPLICATION_ID
targetSdk = Versions.TARGET_SDK
versionCode = Versions.VERSION_CODE
versionName = Versions.VERSION_NAME
@@ -61,21 +60,27 @@ android {
splits {
// Configures multiple APKs based on ABI.
abi {
- // Enables building multiple APKs per ABI.
- isEnable = true
+ val buildingAppBundle = gradle.startParameter.taskNames.any { it.contains("bundle") }
+
+ // Enables building multiple APKs per ABI. This should be disabled when building an AAB.
+ isEnable = !buildingAppBundle
+
// By default all ABIs are included, so use reset() and include to specify that we only
// want APKs for armeabi-v7a, x86, arm64-v8a and x86_64.
// Resets the list of ABIs that Gradle should create APKs for to none.
reset()
- // Specifies a list of ABIs that Gradle should create APKs for.
- include("armeabi-v7a", "x86", "arm64-v8a", "x86_64")
- // Generate a universal APK that includes all ABIs, so user who installs from CI tool can use this one by default.
- isUniversalApk = true
+
+ if (!buildingAppBundle) {
+ // Specifies a list of ABIs that Gradle should create APKs for.
+ include("armeabi-v7a", "x86", "arm64-v8a", "x86_64")
+ // Generate a universal APK that includes all ABIs, so user who installs from CI tool can use this one by default.
+ isUniversalApk = true
+ }
}
}
- defaultConfig {
- resourceConfigurations += locales
+ androidResources {
+ localeFilters += locales
}
}
@@ -96,23 +101,30 @@ android {
?: project.property("signing.element.nightly.storePassword") as? String?
}
}
-
- val baseAppName = if (isEnterpriseBuild) {
- "The Revivalists"
- } else {
- "The Revivalists"
- }
- logger.warnInBox("Building $baseAppName")
+
+ val baseAppName = BuildTimeConfig.APPLICATION_NAME
+ logger.warnInBox("Building ${defaultConfig.applicationId} ($baseAppName)")
buildTypes {
+ val oidcRedirectSchemeBase = BuildTimeConfig.METADATA_HOST_REVERSED ?: "io.element.android"
getByName("debug") {
resValue("string", "app_name", "$baseAppName dbg")
+ resValue(
+ "string",
+ "login_redirect_scheme",
+ "$oidcRedirectSchemeBase.debug",
+ )
applicationIdSuffix = ".debug"
signingConfig = signingConfigs.getByName("debug")
}
getByName("release") {
resValue("string", "app_name", baseAppName)
+ resValue(
+ "string",
+ "login_redirect_scheme",
+ oidcRedirectSchemeBase,
+ )
signingConfig = signingConfigs.getByName("debug")
postprocessing {
@@ -130,6 +142,11 @@ android {
applicationIdSuffix = ".nightly"
versionNameSuffix = "-nightly"
resValue("string", "app_name", "$baseAppName nightly")
+ resValue(
+ "string",
+ "login_redirect_scheme",
+ "$oidcRedirectSchemeBase.nightly",
+ )
matchingFallbacks += listOf("release")
signingConfig = signingConfigs.getByName("nightly")
@@ -170,13 +187,13 @@ android {
create("gplay") {
dimension = "store"
isDefault = true
- buildConfigField("String", "SHORT_FLAVOR_DESCRIPTION", "\"G\"")
- buildConfigField("String", "FLAVOR_DESCRIPTION", "\"GooglePlay\"")
+ buildConfigFieldStr("SHORT_FLAVOR_DESCRIPTION", "G")
+ buildConfigFieldStr("FLAVOR_DESCRIPTION", "GooglePlay")
}
create("fdroid") {
dimension = "store"
- buildConfigField("String", "SHORT_FLAVOR_DESCRIPTION", "\"F\"")
- buildConfigField("String", "FLAVOR_DESCRIPTION", "\"FDroid\"")
+ buildConfigFieldStr("SHORT_FLAVOR_DESCRIPTION", "F")
+ buildConfigFieldStr("FLAVOR_DESCRIPTION", "FDroid")
}
}
}
@@ -283,6 +300,7 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.services.toolbox.test)
koverDependencies()
}
@@ -291,14 +309,15 @@ tasks.withType().configureEach {
outputs.upToDateWhen { false }
val gitRevision = providers.of(GitRevisionValueSource::class.java) {}.get()
val gitBranchName = providers.of(GitBranchNameValueSource::class.java) {}.get()
- android.defaultConfig.buildConfigField("String", "GIT_REVISION", "\"$gitRevision\"")
- android.defaultConfig.buildConfigField("String", "GIT_BRANCH_NAME", "\"$gitBranchName\"")
+ android.defaultConfig.buildConfigFieldStr("GIT_REVISION", gitRevision)
+ android.defaultConfig.buildConfigFieldStr("GIT_BRANCH_NAME", gitBranchName)
}
licensee {
allow("Apache-2.0")
allow("MIT")
allow("BSD-2-Clause")
+ allow("BSD-3-Clause")
allowUrl("https://opensource.org/licenses/MIT")
allowUrl("https://developer.android.com/studio/terms.html")
allowUrl("https://www.zetetic.net/sqlcipher/license/")
@@ -335,3 +354,12 @@ fun Project.configureLicensesTasks(reportingExtension: ReportingExtension) {
}
}
}
+
+configurations.all {
+ resolutionStrategy {
+ dependencySubstitution {
+ val tink = libs.google.tink.get()
+ substitute(module("com.google.crypto.tink:tink")).using(module("${tink.group}:${tink.name}:${tink.version}"))
+ }
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2f354128213..d7de07e34b4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -21,8 +21,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ElementX"
- tools:targetApi="33">
-
+ tools:targetApi="33">
+
@@ -52,13 +57,16 @@
android:host="open"
android:scheme="elementx" />
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt
index 4f4e360f4fb..30bef76339e 100644
--- a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt
+++ b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt
@@ -9,13 +9,15 @@ package io.element.android.x
import android.app.Application
import androidx.startup.AppInitializer
+import io.element.android.appconfig.RageshakeConfig
+import io.element.android.appconfig.isEnabled
import io.element.android.features.cachecleaner.api.CacheCleanerInitializer
import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.x.di.AppComponent
import io.element.android.x.di.DaggerAppComponent
import io.element.android.x.info.logApplicationInfo
import io.element.android.x.initializer.CrashInitializer
-import io.element.android.x.initializer.TracingInitializer
+import io.element.android.x.initializer.PlatformInitializer
class ElementXApplication : Application(), DaggerComponentOwner {
override val daggerComponent: AppComponent = DaggerAppComponent.factory().create(this)
@@ -23,8 +25,10 @@ class ElementXApplication : Application(), DaggerComponentOwner {
override fun onCreate() {
super.onCreate()
AppInitializer.getInstance(this).apply {
- initializeComponent(CrashInitializer::class.java)
- initializeComponent(TracingInitializer::class.java)
+ if (RageshakeConfig.isEnabled) {
+ initializeComponent(CrashInitializer::class.java)
+ }
+ initializeComponent(PlatformInitializer::class.java)
initializeComponent(CacheCleanerInitializer::class.java)
}
logApplicationInfo(this)
diff --git a/app/src/main/kotlin/io/element/android/x/MainActivity.kt b/app/src/main/kotlin/io/element/android/x/MainActivity.kt
index 2d2b645f3fb..f80db878a76 100644
--- a/app/src/main/kotlin/io/element/android/x/MainActivity.kt
+++ b/app/src/main/kotlin/io/element/android/x/MainActivity.kt
@@ -14,7 +14,6 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
@@ -26,6 +25,7 @@ import androidx.lifecycle.repeatOnLifecycle
import com.bumble.appyx.core.integration.NodeHost
import com.bumble.appyx.core.integrationpoint.NodeActivity
import com.bumble.appyx.core.plugin.NodeReadyObserver
+import io.element.android.compound.theme.ElementTheme
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService
@@ -61,7 +61,11 @@ class MainActivity : NodeActivity() {
@Composable
private fun MainContent(appBindings: AppBindings) {
val migrationState = appBindings.migrationEntryPoint().present()
- ElementThemeApp(appBindings.preferencesStore()) {
+ ElementThemeApp(
+ appPreferencesStore = appBindings.preferencesStore(),
+ enterpriseService = appBindings.enterpriseService(),
+ buildMeta = appBindings.buildMeta()
+ ) {
CompositionLocalProvider(
LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(),
LocalUriHandler provides SafeUriHandler(this),
@@ -69,8 +73,8 @@ class MainActivity : NodeActivity() {
) {
Box(
modifier = Modifier
- .fillMaxSize()
- .background(MaterialTheme.colorScheme.background),
+ .fillMaxSize()
+ .background(ElementTheme.colors.bgCanvasDefault),
) {
if (migrationState.migrationAction.isSuccess()) {
MainNodeHost()
diff --git a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt
index e24fdbb9fb2..8525f6356b2 100644
--- a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt
+++ b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt
@@ -9,11 +9,15 @@ package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.api.MigrationEntryPoint
+import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.rageshake.api.reporter.BugReporter
+import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.matrix.api.platform.InitPlatformService
import io.element.android.libraries.matrix.api.tracing.TracingService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.services.analytics.api.AnalyticsService
@@ -24,6 +28,8 @@ interface AppBindings {
fun tracingService(): TracingService
+ fun platformService(): InitPlatformService
+
fun bugReporter(): BugReporter
fun lockScreenService(): LockScreenService
@@ -35,4 +41,10 @@ interface AppBindings {
fun lockScreenEntryPoint(): LockScreenEntryPoint
fun analyticsService(): AnalyticsService
+
+ fun enterpriseService(): EnterpriseService
+
+ fun featureFlagService(): FeatureFlagService
+
+ fun buildMeta(): BuildMeta
}
diff --git a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt
index d682cb75248..c5bc3f4598b 100644
--- a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt
+++ b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt
@@ -27,6 +27,7 @@ import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.x.BuildConfig
import io.element.android.x.R
import kotlinx.coroutines.CoroutineName
@@ -56,6 +57,7 @@ object AppModule {
}
@Provides
+ @AppCoroutineScope
@SingleIn(AppScope::class)
fun providesAppCoroutineScope(): CoroutineScope {
return MainScope() + CoroutineName("ElementX Scope")
@@ -73,23 +75,26 @@ object AppModule {
@ApplicationContext context: Context,
buildType: BuildType,
enterpriseService: EnterpriseService,
- ) = BuildMeta(
- isDebuggable = BuildConfig.DEBUG,
- buildType = buildType,
- applicationName = ApplicationConfig.APPLICATION_NAME.takeIf { it.isNotEmpty() } ?: context.getString(R.string.app_name),
- productionApplicationName = ApplicationConfig.PRODUCTION_APPLICATION_NAME,
- desktopApplicationName = ApplicationConfig.DESKTOP_APPLICATION_NAME,
- applicationId = BuildConfig.APPLICATION_ID,
- isEnterpriseBuild = enterpriseService.isEnterpriseBuild,
- // TODO EAx Config.LOW_PRIVACY_LOG_ENABLE,
- lowPrivacyLoggingEnabled = false,
- versionName = BuildConfig.VERSION_NAME,
- versionCode = context.getVersionCodeFromManifest(),
- gitRevision = BuildConfig.GIT_REVISION,
- gitBranchName = BuildConfig.GIT_BRANCH_NAME,
- flavorDescription = BuildConfig.FLAVOR_DESCRIPTION,
- flavorShortDescription = BuildConfig.SHORT_FLAVOR_DESCRIPTION,
- )
+ ): BuildMeta {
+ val applicationName = ApplicationConfig.APPLICATION_NAME.takeIf { it.isNotEmpty() } ?: context.getString(R.string.app_name)
+ return BuildMeta(
+ isDebuggable = BuildConfig.DEBUG,
+ buildType = buildType,
+ applicationName = applicationName,
+ productionApplicationName = if (enterpriseService.isEnterpriseBuild) applicationName else ApplicationConfig.PRODUCTION_APPLICATION_NAME,
+ desktopApplicationName = if (enterpriseService.isEnterpriseBuild) applicationName else ApplicationConfig.DESKTOP_APPLICATION_NAME,
+ applicationId = BuildConfig.APPLICATION_ID,
+ isEnterpriseBuild = enterpriseService.isEnterpriseBuild,
+ // TODO EAx Config.LOW_PRIVACY_LOG_ENABLE,
+ lowPrivacyLoggingEnabled = false,
+ versionName = BuildConfig.VERSION_NAME,
+ versionCode = context.getVersionCodeFromManifest(),
+ gitRevision = BuildConfig.GIT_REVISION,
+ gitBranchName = BuildConfig.GIT_BRANCH_NAME,
+ flavorDescription = BuildConfig.FLAVOR_DESCRIPTION,
+ flavorShortDescription = BuildConfig.SHORT_FLAVOR_DESCRIPTION,
+ )
+ }
@Provides
@SingleIn(AppScope::class)
diff --git a/app/src/main/kotlin/io/element/android/x/di/DefaultRoomComponentFactory.kt b/app/src/main/kotlin/io/element/android/x/di/DefaultRoomComponentFactory.kt
index 5ed8e89e24d..fb7a24ae8a1 100644
--- a/app/src/main/kotlin/io/element/android/x/di/DefaultRoomComponentFactory.kt
+++ b/app/src/main/kotlin/io/element/android/x/di/DefaultRoomComponentFactory.kt
@@ -10,14 +10,17 @@ package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appnav.di.RoomComponentFactory
import io.element.android.libraries.di.SessionScope
-import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.JoinedRoom
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultRoomComponentFactory @Inject constructor(
private val roomComponentBuilder: RoomComponent.Builder
) : RoomComponentFactory {
- override fun create(room: MatrixRoom): Any {
- return roomComponentBuilder.room(room).build()
+ override fun create(room: JoinedRoom): Any {
+ return roomComponentBuilder
+ .joinedRoom(room)
+ .baseRoom(room)
+ .build()
}
}
diff --git a/app/src/main/kotlin/io/element/android/x/di/RoomComponent.kt b/app/src/main/kotlin/io/element/android/x/di/RoomComponent.kt
index e5958180516..ac126ce0f03 100644
--- a/app/src/main/kotlin/io/element/android/x/di/RoomComponent.kt
+++ b/app/src/main/kotlin/io/element/android/x/di/RoomComponent.kt
@@ -14,7 +14,8 @@ import io.element.android.libraries.architecture.NodeFactoriesBindings
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
-import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.BaseRoom
+import io.element.android.libraries.matrix.api.room.JoinedRoom
@SingleIn(RoomScope::class)
@MergeSubcomponent(RoomScope::class)
@@ -22,7 +23,11 @@ interface RoomComponent : NodeFactoriesBindings {
@MergeSubcomponent.Builder
interface Builder {
@BindsInstance
- fun room(room: MatrixRoom): Builder
+ fun joinedRoom(room: JoinedRoom): Builder
+
+ @BindsInstance
+ fun baseRoom(room: BaseRoom): Builder
+
fun build(): RoomComponent
}
diff --git a/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt
new file mode 100644
index 00000000000..6dd0d69dc04
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2022-2024 New Vector 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.x.initializer
+
+import android.content.Context
+import android.system.Os
+import androidx.startup.Initializer
+import io.element.android.features.rageshake.api.reporter.BugReporter
+import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.matrix.api.tracing.TracingConfiguration
+import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration
+import io.element.android.x.di.AppBindings
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import timber.log.Timber
+
+private const val ELEMENT_X_TARGET = "elementx"
+
+class PlatformInitializer : Initializer {
+ override fun create(context: Context) {
+ val appBindings = context.bindings()
+ val tracingService = appBindings.tracingService()
+ val platformService = appBindings.platformService()
+ val bugReporter = appBindings.bugReporter()
+ Timber.plant(tracingService.createTimberTree(ELEMENT_X_TARGET))
+ val preferencesStore = appBindings.preferencesStore()
+ val featureFlagService = appBindings.featureFlagService()
+ val logLevel = runBlocking { preferencesStore.getTracingLogLevelFlow().first() }
+ val tracingConfiguration = TracingConfiguration(
+ writesToLogcat = runBlocking { featureFlagService.isFeatureEnabled(FeatureFlags.PrintLogsToLogcat) },
+ writesToFilesConfiguration = defaultWriteToDiskConfiguration(bugReporter),
+ logLevel = logLevel,
+ extraTargets = listOf(ELEMENT_X_TARGET),
+ traceLogPacks = runBlocking { preferencesStore.getTracingLogPacksFlow().first() },
+ )
+ bugReporter.setCurrentTracingLogLevel(logLevel.name)
+ platformService.init(tracingConfiguration)
+ // Also set env variable for rust back trace
+ Os.setenv("RUST_BACKTRACE", "1", true)
+ }
+
+ private fun defaultWriteToDiskConfiguration(bugReporter: BugReporter): WriteToFilesConfiguration.Enabled {
+ return WriteToFilesConfiguration.Enabled(
+ directory = bugReporter.logDirectory().absolutePath,
+ filenamePrefix = "logs",
+ // Keep a maximum of 1 week of log files.
+ numberOfFiles = 7 * 24,
+ )
+ }
+
+ override fun dependencies(): List>> = mutableListOf()
+}
diff --git a/app/src/main/kotlin/io/element/android/x/initializer/TracingInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/TracingInitializer.kt
deleted file mode 100644
index 5c53968ece7..00000000000
--- a/app/src/main/kotlin/io/element/android/x/initializer/TracingInitializer.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * Copyright 2022-2024 New Vector 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.x.initializer
-
-import android.content.Context
-import android.system.Os
-import androidx.startup.Initializer
-import io.element.android.features.rageshake.api.reporter.BugReporter
-import io.element.android.libraries.architecture.bindings
-import io.element.android.libraries.matrix.api.tracing.TracingConfiguration
-import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration
-import io.element.android.x.BuildConfig
-import io.element.android.x.di.AppBindings
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.runBlocking
-import timber.log.Timber
-
-private const val ELEMENT_X_TARGET = "elementx"
-
-class TracingInitializer : Initializer {
- override fun create(context: Context) {
- val appBindings = context.bindings()
- val tracingService = appBindings.tracingService()
- val bugReporter = appBindings.bugReporter()
- Timber.plant(tracingService.createTimberTree(ELEMENT_X_TARGET))
- val preferencesStore = appBindings.preferencesStore()
- val logLevel = runBlocking { preferencesStore.getTracingLogLevelFlow().first() }
- val tracingConfiguration = TracingConfiguration(
- writesToLogcat = BuildConfig.DEBUG,
- writesToFilesConfiguration = defaultWriteToDiskConfiguration(bugReporter),
- logLevel = logLevel,
- extraTargets = listOf(ELEMENT_X_TARGET),
- )
- bugReporter.setCurrentTracingLogLevel(logLevel.name)
- tracingService.setupTracing(tracingConfiguration)
- // Also set env variable for rust back trace
- Os.setenv("RUST_BACKTRACE", "1", true)
- }
-
- private fun defaultWriteToDiskConfiguration(bugReporter: BugReporter): WriteToFilesConfiguration.Enabled {
- return WriteToFilesConfiguration.Enabled(
- directory = bugReporter.logDirectory().absolutePath,
- filenamePrefix = "logs",
- // Keep a maximum of 1 week of log files.
- numberOfFiles = 7 * 24,
- )
- }
-
- override fun dependencies(): List>> = mutableListOf()
-}
diff --git a/app/src/main/kotlin/io/element/android/x/oidc/DefaultOidcRedirectUrlProvider.kt b/app/src/main/kotlin/io/element/android/x/oidc/DefaultOidcRedirectUrlProvider.kt
new file mode 100644
index 00000000000..4a103b4daa1
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/oidc/DefaultOidcRedirectUrlProvider.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2025 New Vector 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.x.oidc
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.matrix.api.auth.OidcRedirectUrlProvider
+import io.element.android.services.toolbox.api.strings.StringProvider
+import io.element.android.x.R
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultOidcRedirectUrlProvider @Inject constructor(
+ private val stringProvider: StringProvider,
+) : OidcRedirectUrlProvider {
+ override fun provide() = buildString {
+ append(stringProvider.getString(R.string.login_redirect_scheme))
+ append(":/")
+ }
+}
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
index d73e1f0c3d0..3602e38d2db 100644
--- a/app/src/main/res/xml/backup_rules.xml
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -9,4 +9,8 @@
-->
+
+
+
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
index ae76014f3b5..d11b4203feb 100644
--- a/app/src/main/res/xml/data_extraction_rules.xml
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -9,9 +9,18 @@
-->
-
+
+
+
+
+
+
-
+
+
+
+
+
-
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
index a89ce28bfd3..73a2ed405cf 100644
--- a/app/src/main/res/xml/locales_config.xml
+++ b/app/src/main/res/xml/locales_config.xml
@@ -3,12 +3,15 @@
+
+
+
@@ -16,6 +19,7 @@
+
@@ -25,7 +29,9 @@
+
+
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
index b9f3d039863..96add8d148c 100644
--- a/app/src/main/res/xml/network_security_config.xml
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -1,8 +1,15 @@
-
+
-
+
+
+
+
+
+
@@ -24,12 +31,4 @@
lan
localdomain
-
-
-
-
-
-
-
-
diff --git a/app/src/test/kotlin/io/element/android/x/oidc/DefaultOidcRedirectUrlProviderTest.kt b/app/src/test/kotlin/io/element/android/x/oidc/DefaultOidcRedirectUrlProviderTest.kt
new file mode 100644
index 00000000000..5a0df57bdad
--- /dev/null
+++ b/app/src/test/kotlin/io/element/android/x/oidc/DefaultOidcRedirectUrlProviderTest.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2025 New Vector 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.x.oidc
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.services.toolbox.test.strings.FakeStringProvider
+import io.element.android.x.R
+import org.junit.Test
+
+class DefaultOidcRedirectUrlProviderTest {
+ @Test
+ fun `test provide`() {
+ val stringProvider = FakeStringProvider(
+ defaultResult = "str"
+ )
+ val sut = DefaultOidcRedirectUrlProvider(
+ stringProvider = stringProvider,
+ )
+ val result = sut.provide()
+ assertThat(result).isEqualTo("str:/")
+ assertThat(stringProvider.lastResIdParam).isEqualTo(R.string.login_redirect_scheme)
+ }
+}
diff --git a/appconfig/build.gradle.kts b/appconfig/build.gradle.kts
index 99c7e1defaa..ecfed881d0b 100644
--- a/appconfig/build.gradle.kts
+++ b/appconfig/build.gradle.kts
@@ -1,3 +1,6 @@
+import config.BuildTimeConfig
+import extension.buildConfigFieldStr
+
/*
* Copyright 2022-2024 New Vector Ltd.
*
@@ -10,6 +13,29 @@ plugins {
android {
namespace = "io.element.android.appconfig"
+
+ buildFeatures {
+ buildConfig = true
+ }
+
+ defaultConfig {
+ buildConfigFieldStr(
+ name = "URL_POLICY",
+ value = if (isEnterpriseBuild) {
+ BuildTimeConfig.URL_POLICY ?: ""
+ } else {
+ "https://element.io/cookie-policy"
+ },
+ )
+ buildConfigFieldStr(
+ name = "BUG_REPORT_URL",
+ value = BuildTimeConfig.BUG_REPORT_URL,
+ )
+ buildConfigFieldStr(
+ name = "BUG_REPORT_APP_NAME",
+ value = BuildTimeConfig.BUG_REPORT_APP_NAME,
+ )
+ }
}
dependencies {
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/AnalyticsConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/AnalyticsConfig.kt
index 4b213db6371..346fce47254 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/AnalyticsConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/AnalyticsConfig.kt
@@ -8,5 +8,5 @@
package io.element.android.appconfig
object AnalyticsConfig {
- const val POLICY_LINK = "https://element.io/cookie-policy"
+ const val POLICY_LINK = BuildConfig.URL_POLICY
}
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt
index a2918a8515d..7bea78f3b8b 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt
@@ -8,12 +8,8 @@
package io.element.android.appconfig
object AuthenticationConfig {
- const val MATRIX_ORG_URL = "https://chat.the-revivalists.org"
- /**
- * Default homeserver url to sign in with, unless the user selects a different one.
- */
- const val DEFAULT_HOMESERVER_URL = MATRIX_ORG_URL
+ const val MATRIX_ORG_URL = "https://chat.the-revivalists.org"
/**
* URL with some docs that explain what's sliding sync and how to add it to your home server.
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt
index c9b7557c87d..ca9a6391f2a 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt
@@ -8,11 +8,6 @@
package io.element.android.appconfig
object ElementCallConfig {
- /**
- * The default base URL for the Element Call service.
- */
- const val DEFAULT_BASE_URL = "https://call.element.io"
-
/**
* The default duration of a ringing call in seconds before it's automatically dismissed.
*/
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt
index 9c1cd82def9..9dee73fc94d 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt
@@ -9,6 +9,7 @@ package io.element.android.appconfig
object LearnMoreConfig {
const val ENCRYPTION_URL: String = "https://element.io/help#encryption"
+ const val DEVICE_VERIFICATION_URL: String = "https://element.io/help#encryption-device-verification"
const val SECURE_BACKUP_URL: String = "https://element.io/help#encryption5"
const val IDENTITY_CHANGE_URL: String = "https://element.io/help#encryption18"
}
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt
index fb8d24973c6..8c836bc8a2e 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt
@@ -11,17 +11,23 @@ object RageshakeConfig {
/**
* The URL to submit bug reports to.
*/
- const val BUG_REPORT_URL = "https://rageshake.the-revivalists.org/api/submit"
+ const val BUG_REPORT_URL = BuildConfig.BUG_REPORT_URL
/**
* As per https://github.com/matrix-org/rageshake:
* Identifier for the application (eg 'riot-web').
* Should correspond to a mapping configured in the configuration file for github issue reporting to work.
*/
- const val BUG_REPORT_APP_NAME = "element-x-android"
+ const val BUG_REPORT_APP_NAME = BuildConfig.BUG_REPORT_APP_NAME
/**
* The maximum size of the upload request. Default value is just below CloudFlare's max request size.
*/
const val MAX_LOG_UPLOAD_SIZE = 50 * 1024 * 1024L
}
+
+/**
+ * Whether the rageshake feature is enabled.
+ */
+val RageshakeConfig.isEnabled: Boolean
+ get() = BUG_REPORT_URL.isNotEmpty() && BUG_REPORT_APP_NAME.isNotEmpty()
diff --git a/appicon/element/src/main/ic_launcher-playstore.png b/appicon/element/src/main/ic_launcher-playstore.png
index 62af9cf4b27..325bf570f53 100644
Binary files a/appicon/element/src/main/ic_launcher-playstore.png and b/appicon/element/src/main/ic_launcher-playstore.png differ
diff --git a/appicon/element/src/main/kotlin/io/element/android/appicon/element/IconPreview.kt b/appicon/element/src/main/kotlin/io/element/android/appicon/element/IconPreview.kt
index f55c6f23e80..c25675bffcb 100644
--- a/appicon/element/src/main/kotlin/io/element/android/appicon/element/IconPreview.kt
+++ b/appicon/element/src/main/kotlin/io/element/android/appicon/element/IconPreview.kt
@@ -10,25 +10,28 @@ package io.element.android.appicon.element
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
@Preview
@Composable
internal fun IconPreview() {
Box {
- Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
- Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
+ Image(
+ modifier = Modifier.matchParentSize(),
+ painter = painterResource(id = R.drawable.ic_launcher_background),
+ contentDescription = null,
+ )
+ Image(
+ painter = painterResource(id = R.mipmap.ic_launcher_foreground),
+ contentDescription = null,
+ )
}
}
@@ -36,8 +39,15 @@ internal fun IconPreview() {
@Composable
internal fun RoundIconPreview() {
Box(modifier = Modifier.clip(shape = CircleShape)) {
- Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
- Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
+ Image(
+ modifier = Modifier.matchParentSize(),
+ painter = painterResource(id = R.drawable.ic_launcher_background),
+ contentDescription = null,
+ )
+ Image(
+ painter = painterResource(id = R.mipmap.ic_launcher_foreground),
+ contentDescription = null,
+ )
}
}
@@ -46,10 +56,7 @@ internal fun RoundIconPreview() {
internal fun MonochromeIconPreview() {
Box(
modifier = Modifier
- .size(108.dp)
- .background(Color(0xFF2F3133))
- .clip(shape = RoundedCornerShape(32.dp)),
- contentAlignment = Alignment.Center
+ .background(Color(0xFF2F3133)),
) {
Image(
painter = painterResource(id = R.mipmap.ic_launcher_monochrome),
diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp
index 793d5ca60d6..2ae0da8d0fe 100644
Binary files a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_background.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_background.webp
deleted file mode 100644
index f051ae3c818..00000000000
Binary files a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_background.webp and /dev/null differ
diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
index d1ff05833e1..e40370b86f6 100644
Binary files a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ
diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp
index 78a93b86f18..8ad6b749012 100644
Binary files a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp
index e8f321ff178..d4e1b90f22f 100644
Binary files a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_background.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_background.webp
deleted file mode 100644
index 27d9d1db19f..00000000000
Binary files a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_background.webp and /dev/null differ
diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
index f411c1016c8..ac2361f8b03 100644
Binary files a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ
diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp
index 5380a9e8617..3cd52b2182f 100644
Binary files a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp
index b31de825854..527b23880ad 100644
Binary files a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_background.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_background.webp
deleted file mode 100644
index 4dbc6db0662..00000000000
Binary files a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_background.webp and /dev/null differ
diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
index 5e6654b50c3..f8c5c5f218a 100644
Binary files a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
index a368522d59d..1c98f35c9f5 100644
Binary files a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp
index 889388eab63..ed524b893ca 100644
Binary files a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp
deleted file mode 100644
index b635d5cbb56..00000000000
Binary files a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp and /dev/null differ
diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
index 9aebd17d213..bb401bcb377 100644
Binary files a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
index af593824176..a6b0547ed07 100644
Binary files a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
index 97afc844cb5..359e3921a11 100644
Binary files a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp
deleted file mode 100644
index b5cb68c7bb2..00000000000
Binary files a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp and /dev/null differ
diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
index 92e763d12f9..f0f9a633249 100644
Binary files a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
index d71ab178fe6..36125792fe6 100644
Binary files a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/appicon/element/src/release/res/drawable/ic_launcher_background.xml b/appicon/element/src/release/res/drawable/ic_launcher_background.xml
index 6ff3e595430..1cbabacb4fa 100644
--- a/appicon/element/src/release/res/drawable/ic_launcher_background.xml
+++ b/appicon/element/src/release/res/drawable/ic_launcher_background.xml
@@ -1,2 +1,10 @@
-
+
+
+
diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts
index 8b2257b4a3b..09b0a634a44 100644
--- a/appnav/build.gradle.kts
+++ b/appnav/build.gradle.kts
@@ -53,16 +53,15 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
+ testImplementation(projects.features.login.test)
testImplementation(projects.libraries.matrix.test)
- testImplementation(projects.libraries.oidc.impl)
+ testImplementation(projects.libraries.oidc.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.features.networkmonitor.test)
- testImplementation(projects.features.login.impl)
testImplementation(projects.tests.testutils)
testImplementation(projects.features.rageshake.test)
- testImplementation(projects.features.rageshake.impl)
testImplementation(projects.features.share.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.analytics.test)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt
index 99876d4ff57..ae169fcd347 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt
@@ -5,12 +5,15 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(DelicateCoilApi::class)
+
package io.element.android.appnav
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import coil.Coil
+import coil3.SingletonImageLoader
+import coil3.annotation.DelicateCoilApi
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
@@ -69,7 +72,7 @@ class LoggedInAppScopeFlowNode @AssistedInject constructor(
super.onBuilt()
lifecycle.subscribe(
onCreate = {
- Coil.setImageLoader(imageLoaderHolder.get(inputs.matrixClient))
+ SingletonImageLoader.setUnsafe(imageLoaderHolder.get(inputs.matrixClient))
},
)
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt
index 2759e49f70a..65980e34275 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt
@@ -10,34 +10,33 @@ package io.element.android.appnav
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
+import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.launch
import javax.inject.Inject
class LoggedInEventProcessor @Inject constructor(
private val snackbarDispatcher: SnackbarDispatcher,
- roomMembershipObserver: RoomMembershipObserver,
+ private val roomMembershipObserver: RoomMembershipObserver,
) {
private var observingJob: Job? = null
- private val displayLeftRoomMessage = roomMembershipObserver.updates
- .map { !it.isUserInRoom }
-
fun observeEvents(coroutineScope: CoroutineScope) {
- observingJob = coroutineScope.launch {
- displayLeftRoomMessage
- .filter { it }
- .onEach {
- displayMessage(CommonStrings.common_current_user_left_room)
+ observingJob = roomMembershipObserver.updates
+ .filter { !it.isUserInRoom }
+ .onEach {
+ when (it.change) {
+ MembershipChange.LEFT -> displayMessage(CommonStrings.common_current_user_left_room)
+ MembershipChange.INVITATION_REJECTED -> displayMessage(CommonStrings.common_current_user_rejected_invite)
+ MembershipChange.KNOCK_RETRACTED -> displayMessage(CommonStrings.common_current_user_canceled_knock)
+ else -> Unit
}
- .launchIn(this)
- }
+ }
+ .launchIn(coroutineScope)
}
fun stopObserving() {
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
index ced5d0033a7..043dc75a92d 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
@@ -14,9 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
-import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.repeatOnLifecycle
import com.bumble.appyx.core.composable.PermanentChild
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
@@ -43,6 +41,7 @@ import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.loggedin.LoggedInNode
+import io.element.android.appnav.loggedin.MediaPreviewConfigMigration
import io.element.android.appnav.loggedin.SendQueues
import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomNavigationTarget
@@ -51,13 +50,11 @@ import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
+import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.logout.api.LogoutEntryPoint
-import io.element.android.features.networkmonitor.api.NetworkMonitor
-import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
-import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint
@@ -69,45 +66,57 @@ import io.element.android.libraries.architecture.waitForNavTargetAttached
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
+import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
-import io.element.android.libraries.matrix.api.sync.SyncState
-import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
-import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
+import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeout
import kotlinx.parcelize.Parcelize
import timber.log.Timber
+import java.time.Duration
+import java.time.Instant
import java.util.Optional
import java.util.UUID
+import kotlin.collections.List
+import kotlin.collections.any
+import kotlin.collections.emptyList
+import kotlin.collections.first
+import kotlin.collections.forEach
+import kotlin.collections.listOf
+import kotlin.collections.mapNotNull
+import kotlin.collections.plus
+import kotlin.collections.setOf
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.toKotlinDuration
@ContributesNode(SessionScope::class)
class LoggedInFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
- private val roomListEntryPoint: RoomListEntryPoint,
+ private val homeEntryPoint: HomeEntryPoint,
private val preferencesEntryPoint: PreferencesEntryPoint,
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
private val secureBackupEntryPoint: SecureBackupEntryPoint,
private val userProfileEntryPoint: UserProfileEntryPoint,
private val ftueEntryPoint: FtueEntryPoint,
- private val coroutineScope: CoroutineScope,
- private val networkMonitor: NetworkMonitor,
+ @SessionCoroutineScope
+ private val sessionCoroutineScope: CoroutineScope,
private val ftueService: FtueService,
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
private val shareEntryPoint: ShareEntryPoint,
@@ -115,7 +124,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val sendingQueue: SendQueues,
private val logoutEntryPoint: LogoutEntryPoint,
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
- private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase,
+ private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
snackbarDispatcher: SnackbarDispatcher,
) : BaseFlowNode(
backstack = BackStack(
@@ -133,44 +142,66 @@ class LoggedInFlowNode @AssistedInject constructor(
fun onOpenBugReport()
}
- private val syncService = matrixClient.syncService()
private val loggedInFlowProcessor = LoggedInEventProcessor(
snackbarDispatcher,
matrixClient.roomMembershipObserver(),
)
private val verificationListener = object : SessionVerificationServiceListener {
- override fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails) {
- backstack.singleTop(NavTarget.IncomingVerificationRequest(sessionVerificationRequestDetails))
+ override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) {
+ // Without this launch the rendering and actual state of this Appyx node's children gets out of sync, resulting in a crash.
+ // This might be because this method is called back from Rust in a background thread.
+ lifecycleScope.launch {
+ val receivedAt = Instant.now()
+
+ // Wait until the app is in foreground to display the incoming verification request
+ appNavigationStateService.appNavigationState.first { it.isInForeground }
+
+ // TODO there should also be a timeout for > 10 minutes elapsed since the request was created, but the SDK doesn't expose that info yet
+ val now = Instant.now()
+ val elapsedTimeSinceReceived = Duration.between(receivedAt, now).toKotlinDuration()
+
+ // Discard the incoming verification request if it has timed out
+ if (elapsedTimeSinceReceived > 2.minutes) {
+ Timber.w("Incoming verification request ${verificationRequest.details.flowId} discarded due to timeout.")
+ return@launch
+ }
+
+ // Wait for the RoomList UI to be ready so the incoming verification screen can be displayed on top of it
+ // Otherwise, the RoomList UI may be incorrectly displayed on top
+ withTimeout(5.seconds) {
+ backstack.elements.first { elements ->
+ elements.any { it.key.navTarget == NavTarget.Home }
+ }
+ }
+
+ backstack.singleTop(NavTarget.IncomingVerificationRequest(verificationRequest))
+ }
}
}
override fun onBuilt() {
super.onBuilt()
+
lifecycle.subscribe(
onCreate = {
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
// TODO We do not support Space yet, so directly navigate to main space
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
- loggedInFlowProcessor.observeEvents(coroutineScope)
+ loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
matrixClient.sessionVerificationService().setListener(verificationListener)
+ mediaPreviewConfigMigration()
ftueService.state
.onEach { ftueState ->
when (ftueState) {
is FtueState.Unknown -> Unit // Nothing to do
is FtueState.Incomplete -> backstack.safeRoot(NavTarget.Ftue)
- is FtueState.Complete -> backstack.safeRoot(NavTarget.RoomList)
+ is FtueState.Complete -> backstack.safeRoot(NavTarget.Home)
}
}
.launchIn(lifecycleScope)
},
- onStop = {
- coroutineScope.launch {
- // Counterpart startSync is done in observeSyncStateAndNetworkStatus method.
- syncService.stopSync()
- }
- },
onDestroy = {
appNavigationStateService.onLeavingSpace(id)
appNavigationStateService.onLeavingSession(id)
@@ -178,7 +209,6 @@ class LoggedInFlowNode @AssistedInject constructor(
matrixClient.sessionVerificationService().setListener(null)
}
)
- observeSyncStateAndNetworkStatus()
setupSendingQueue()
}
@@ -186,31 +216,6 @@ class LoggedInFlowNode @AssistedInject constructor(
sendingQueue.launchIn(lifecycleScope)
}
- @OptIn(FlowPreview::class)
- private fun observeSyncStateAndNetworkStatus() {
- lifecycleScope.launch {
- repeatOnLifecycle(Lifecycle.State.STARTED) {
- combine(
- // small debounce to avoid spamming startSync when the state is changing quickly in case of error.
- syncService.syncState.debounce(100),
- networkMonitor.connectivity
- ) { syncState, networkStatus ->
- Pair(syncState, networkStatus)
- }
- .onStart {
- // Temporary fix to ensure that the sync is started even if the networkStatus is offline.
- syncService.startSync()
- }
- .collect { (syncState, networkStatus) ->
- Timber.d("Sync state: $syncState, network status: $networkStatus")
- if (syncState != SyncState.Running && networkStatus == NetworkStatus.Online) {
- syncService.startSync()
- }
- }
- }
- }
- }
-
sealed interface NavTarget : Parcelable {
@Parcelize
data object Placeholder : NavTarget
@@ -219,7 +224,7 @@ class LoggedInFlowNode @AssistedInject constructor(
data object LoggedInPermanent : NavTarget
@Parcelize
- data object RoomList : NavTarget
+ data object Home : NavTarget
@Parcelize
data class Room(
@@ -262,7 +267,7 @@ class LoggedInFlowNode @AssistedInject constructor(
data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget
@Parcelize
- data class IncomingVerificationRequest(val data: SessionVerificationRequestDetails) : NavTarget
+ data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -276,8 +281,8 @@ class LoggedInFlowNode @AssistedInject constructor(
}
createNode(buildContext, listOf(callback))
}
- NavTarget.RoomList -> {
- val callback = object : RoomListEntryPoint.Callback {
+ NavTarget.Home -> {
+ val callback = object : HomeEntryPoint.Callback {
override fun onRoomClick(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
}
@@ -306,15 +311,11 @@ class LoggedInFlowNode @AssistedInject constructor(
plugins().forEach { it.onOpenBugReport() }
}
- override fun onRoomDirectorySearchClick() {
- backstack.push(NavTarget.RoomDirectorySearch)
- }
-
override fun onLogoutForNativeSlidingSyncMigrationNeeded() {
backstack.push(NavTarget.LogoutForNativeSlidingSyncMigrationNeeded)
}
}
- roomListEntryPoint
+ homeEntryPoint
.nodeBuilder(this, buildContext)
.callback(callback)
.build()
@@ -326,7 +327,7 @@ class LoggedInFlowNode @AssistedInject constructor(
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
- coroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false) }
+ sessionCoroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false) }
}
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
@@ -392,6 +393,14 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onOpenRoomNotificationSettings(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.NotificationSettings))
}
+
+ override fun navigateTo(sessionId: SessionId, roomId: RoomId, eventId: EventId) {
+ // We do not check the sessionId, but it will have to be done at some point (multi account)
+ if (sessionId != matrixClient.sessionId) {
+ Timber.e("SessionId mismatch, expected ${matrixClient.sessionId} but got $sessionId")
+ }
+ backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Messages(eventId)))
+ }
}
val inputs = PreferencesEntryPoint.Params(navTarget.initialElement)
preferencesEntryPoint.nodeBuilder(this, buildContext)
@@ -401,8 +410,12 @@ class LoggedInFlowNode @AssistedInject constructor(
}
NavTarget.CreateRoom -> {
val callback = object : CreateRoomEntryPoint.Callback {
- override fun onSuccess(roomId: RoomId) {
- backstack.replace(NavTarget.Room(roomId.toRoomIdOrAlias()))
+ override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List) {
+ backstack.replace(NavTarget.Room(roomIdOrAlias = roomIdOrAlias, serverNames = serverNames))
+ }
+
+ override fun onOpenRoomDirectory() {
+ backstack.push(NavTarget.RoomDirectorySearch)
}
}
@@ -462,9 +475,6 @@ class LoggedInFlowNode @AssistedInject constructor(
}
logoutEntryPoint.nodeBuilder(this, buildContext)
- .onSuccessfulLogoutPendingAction {
- enableNativeSlidingSyncUseCase()
- }
.callback(callback)
.build()
}
@@ -489,7 +499,7 @@ class LoggedInFlowNode @AssistedInject constructor(
clearBackstack: Boolean,
) {
waitForNavTargetAttached { navTarget ->
- navTarget is NavTarget.RoomList
+ navTarget is NavTarget.Home
}
attachChild {
val roomNavTarget = NavTarget.Room(
@@ -506,7 +516,7 @@ class LoggedInFlowNode @AssistedInject constructor(
suspend fun attachUser(userId: UserId) {
waitForNavTargetAttached { navTarget ->
- navTarget is NavTarget.RoomList
+ navTarget is NavTarget.Home
}
attachChild {
backstack.push(
@@ -519,7 +529,7 @@ class LoggedInFlowNode @AssistedInject constructor(
internal suspend fun attachIncomingShare(intent: Intent) {
waitForNavTargetAttached { navTarget ->
- navTarget is NavTarget.RoomList
+ navTarget is NavTarget.Home
}
attachChild {
backstack.push(
@@ -557,7 +567,7 @@ private class AttachRoomOperation(
return if (clearBackstack) {
// Makes sure the room list target is alone in the backstack and stashed
elements.mapNotNull { element ->
- if (element.key.navTarget == LoggedInFlowNode.NavTarget.RoomList) {
+ if (element.key.navTarget == LoggedInFlowNode.NavTarget.Home) {
element.transitionTo(STASHED, this)
} else {
null
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt
index c8c88ad02df..bd4802b77ba 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt
@@ -5,27 +5,30 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(DelicateCoilApi::class)
+
package io.element.android.appnav
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import coil.Coil
+import coil3.SingletonImageLoader
+import coil3.annotation.DelicateCoilApi
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
-import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.api.LoginEntryPoint
-import io.element.android.features.login.api.LoginFlowType
-import io.element.android.features.onboarding.api.OnBoardingEntryPoint
+import io.element.android.features.login.api.LoginParams
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices
import io.element.android.libraries.designsystem.utils.ScreenOrientation
import io.element.android.libraries.di.AppScope
@@ -36,68 +39,59 @@ import kotlinx.parcelize.Parcelize
class NotLoggedInFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
- private val onBoardingEntryPoint: OnBoardingEntryPoint,
private val loginEntryPoint: LoginEntryPoint,
private val notLoggedInImageLoaderFactory: NotLoggedInImageLoaderFactory,
) : BaseFlowNode(
backstack = BackStack(
- initialElement = NavTarget.OnBoarding,
+ initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap
),
buildContext = buildContext,
plugins = plugins,
) {
+ data class Params(
+ val loginParams: LoginParams?,
+ ) : NodeInputs
+
interface Callback : Plugin {
fun onOpenBugReport()
}
+ private val inputs = inputs()
+
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onCreate = {
- Coil.setImageLoader(notLoggedInImageLoaderFactory)
+ SingletonImageLoader.setUnsafe(notLoggedInImageLoaderFactory.newImageLoader())
},
)
}
sealed interface NavTarget : Parcelable {
@Parcelize
- data object OnBoarding : NavTarget
-
- @Parcelize
- data class LoginFlow(val type: LoginFlowType) : NavTarget
+ data object Root : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
- NavTarget.OnBoarding -> {
- val callback = object : OnBoardingEntryPoint.Callback {
- override fun onSignUp() {
- backstack.push(NavTarget.LoginFlow(type = LoginFlowType.SIGN_UP))
- }
-
- override fun onSignIn() {
- backstack.push(NavTarget.LoginFlow(type = LoginFlowType.SIGN_IN_MANUAL))
- }
-
- override fun onSignInWithQrCode() {
- backstack.push(NavTarget.LoginFlow(type = LoginFlowType.SIGN_IN_QR_CODE))
- }
-
+ NavTarget.Root -> {
+ val callback = object : LoginEntryPoint.Callback {
override fun onReportProblem() {
plugins().forEach { it.onOpenBugReport() }
}
}
- onBoardingEntryPoint
+ loginEntryPoint
.nodeBuilder(this, buildContext)
+ .params(
+ LoginEntryPoint.Params(
+ accountProvider = inputs.loginParams?.accountProvider,
+ loginHint = inputs.loginParams?.loginHint,
+ )
+ )
.callback(callback)
.build()
}
- is NavTarget.LoginFlow -> {
- loginEntryPoint.nodeBuilder(this, buildContext)
- .params(LoginEntryPoint.Params(flowType = navTarget.type))
- .build()
- }
}
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
index 5f5eb7566ed..ae9f935061c 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
@@ -27,12 +27,14 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.anvilannotations.ContributesNode
-import io.element.android.appnav.di.MatrixClientsHolder
+import io.element.android.appnav.di.MatrixSessionCache
import io.element.android.appnav.intent.IntentResolver
import io.element.android.appnav.intent.ResolvedIntent
import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView
+import io.element.android.features.enterprise.api.EnterpriseService
+import io.element.android.features.login.api.LoginParams
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.features.signedout.api.SignedOutEntryPoint
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
@@ -40,6 +42,7 @@ import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached
+import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.di.AppScope
@@ -61,8 +64,9 @@ class RootFlowNode @AssistedInject constructor(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List,
private val authenticationService: MatrixAuthenticationService,
+ private val enterpriseService: EnterpriseService,
private val navStateFlowFactory: RootNavStateFlowFactory,
- private val matrixClientsHolder: MatrixClientsHolder,
+ private val matrixSessionCache: MatrixSessionCache,
private val presenter: RootPresenter,
private val bugReportEntryPoint: BugReportEntryPoint,
private val viewFolderEntryPoint: ViewFolderEntryPoint,
@@ -78,14 +82,14 @@ class RootFlowNode @AssistedInject constructor(
plugins = plugins
) {
override fun onBuilt() {
- matrixClientsHolder.restoreWithSavedState(buildContext.savedStateMap)
+ matrixSessionCache.restoreWithSavedState(buildContext.savedStateMap)
super.onBuilt()
observeNavState()
}
override fun onSaveInstanceState(state: MutableSavedStateMap) {
super.onSaveInstanceState(state)
- matrixClientsHolder.saveIntoSavedState(state)
+ matrixSessionCache.saveIntoSavedState(state)
navStateFlowFactory.saveIntoSavedState(state)
}
@@ -99,14 +103,14 @@ class RootFlowNode @AssistedInject constructor(
if (navState.loggedInState.isTokenValid) {
tryToRestoreLatestSession(
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
- onFailure = { switchToNotLoggedInFlow() }
+ onFailure = { switchToNotLoggedInFlow(null) }
)
} else {
switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId))
}
}
LoggedInState.NotLoggedIn -> {
- switchToNotLoggedInFlow()
+ switchToNotLoggedInFlow(null)
}
}
}
@@ -117,9 +121,9 @@ class RootFlowNode @AssistedInject constructor(
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
}
- private fun switchToNotLoggedInFlow() {
- matrixClientsHolder.removeAll()
- backstack.safeRoot(NavTarget.NotLoggedInFlow)
+ private fun switchToNotLoggedInFlow(params: LoginParams?) {
+ matrixSessionCache.removeAll()
+ backstack.safeRoot(NavTarget.NotLoggedInFlow(params))
}
private fun switchToSignedOutFlow(sessionId: SessionId) {
@@ -131,7 +135,7 @@ class RootFlowNode @AssistedInject constructor(
onFailure: () -> Unit,
onSuccess: (SessionId) -> Unit,
) {
- matrixClientsHolder.getOrRestore(sessionId)
+ matrixSessionCache.getOrRestore(sessionId)
.onSuccess {
Timber.v("Succeed to restore session $sessionId")
onSuccess(sessionId)
@@ -175,7 +179,9 @@ class RootFlowNode @AssistedInject constructor(
data object SplashScreen : NavTarget
@Parcelize
- data object NotLoggedInFlow : NavTarget
+ data class NotLoggedInFlow(
+ val params: LoginParams?
+ ) : NavTarget
@Parcelize
data class LoggedInFlow(
@@ -200,7 +206,7 @@ class RootFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.LoggedInFlow -> {
- val matrixClient = matrixClientsHolder.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also {
+ val matrixClient = matrixSessionCache.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also {
Timber.w("Couldn't find any session, go through SplashScreen")
}
val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient)
@@ -211,13 +217,16 @@ class RootFlowNode @AssistedInject constructor(
}
createNode(buildContext, plugins = listOf(inputs, callback))
}
- NavTarget.NotLoggedInFlow -> {
+ is NavTarget.NotLoggedInFlow -> {
val callback = object : NotLoggedInFlowNode.Callback {
override fun onOpenBugReport() {
backstack.push(NavTarget.BugReport)
}
}
- createNode(buildContext, plugins = listOf(callback))
+ val params = NotLoggedInFlowNode.Params(
+ loginParams = navTarget.params,
+ )
+ createNode(buildContext, plugins = listOf(params, callback))
}
is NavTarget.SignedOutFlow -> {
signedOutEntryPoint.nodeBuilder(this, buildContext)
@@ -272,18 +281,36 @@ class RootFlowNode @AssistedInject constructor(
val resolvedIntent = intentResolver.resolve(intent) ?: return
when (resolvedIntent) {
is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData)
+ is ResolvedIntent.Login -> onLoginLink(resolvedIntent.params)
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData)
is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.intent)
}
}
+ private suspend fun onLoginLink(params: LoginParams) {
+ // Is there a session already?
+ val latestSessionId = authenticationService.getLatestSessionId()
+ if (latestSessionId == null) {
+ // No session, open login
+ if (enterpriseService.isAllowedToConnectToHomeserver(params.accountProvider.ensureProtocol())) {
+ switchToNotLoggedInFlow(params)
+ } else {
+ Timber.w("Login link ignored, we are not allowed to connect to the homeserver")
+ switchToNotLoggedInFlow(null)
+ }
+ } else {
+ // Just ignore the login link if we already have a session
+ Timber.w("Login link ignored, we already have a session")
+ }
+ }
+
private suspend fun onIncomingShare(intent: Intent) {
// Is there a session already?
val latestSessionId = authenticationService.getLatestSessionId()
if (latestSessionId == null) {
// No session, open login
- switchToNotLoggedInFlow()
+ switchToNotLoggedInFlow(null)
} else {
attachSession(latestSessionId)
.attachIncomingShare(intent)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt
deleted file mode 100644
index 2871df28998..00000000000
--- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt
+++ /dev/null
@@ -1,97 +0,0 @@
-/*
- * Copyright 2023, 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.appnav.di
-
-import com.bumble.appyx.core.state.MutableSavedStateMap
-import com.bumble.appyx.core.state.SavedStateMap
-import com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.libraries.di.AppScope
-import io.element.android.libraries.di.SingleIn
-import io.element.android.libraries.matrix.api.MatrixClient
-import io.element.android.libraries.matrix.api.MatrixClientProvider
-import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
-import io.element.android.libraries.matrix.api.core.SessionId
-import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import timber.log.Timber
-import java.util.concurrent.ConcurrentHashMap
-import javax.inject.Inject
-
-private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey"
-
-@SingleIn(AppScope::class)
-@ContributesBinding(AppScope::class)
-class MatrixClientsHolder @Inject constructor(
- private val authenticationService: MatrixAuthenticationService,
-) : MatrixClientProvider {
- private val sessionIdsToMatrixClient = ConcurrentHashMap()
- private val restoreMutex = Mutex()
-
- init {
- authenticationService.listenToNewMatrixClients { matrixClient ->
- sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
- }
- }
-
- fun removeAll() {
- sessionIdsToMatrixClient.clear()
- }
-
- fun remove(sessionId: SessionId) {
- sessionIdsToMatrixClient.remove(sessionId)
- }
-
- override fun getOrNull(sessionId: SessionId): MatrixClient? {
- return sessionIdsToMatrixClient[sessionId]
- }
-
- override suspend fun getOrRestore(sessionId: SessionId): Result {
- return restoreMutex.withLock {
- when (val matrixClient = getOrNull(sessionId)) {
- null -> restore(sessionId)
- else -> Result.success(matrixClient)
- }
- }
- }
-
- @Suppress("UNCHECKED_CAST")
- fun restoreWithSavedState(state: SavedStateMap?) {
- Timber.d("Restore state")
- if (state == null || sessionIdsToMatrixClient.isNotEmpty()) {
- Timber.w("Restore with non-empty map")
- return
- }
- val sessionIds = state[SAVE_INSTANCE_KEY] as? Array
- Timber.d("Restore matrix session keys = ${sessionIds?.map { it.value }}")
- if (sessionIds.isNullOrEmpty()) return
- // Not ideal but should only happens in case of process recreation. This ensure we restore all the active sessions before restoring the node graphs.
- runBlocking {
- sessionIds.forEach { sessionId ->
- getOrRestore(sessionId)
- }
- }
- }
-
- fun saveIntoSavedState(state: MutableSavedStateMap) {
- val sessionKeys = sessionIdsToMatrixClient.keys.toTypedArray()
- Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}")
- state[SAVE_INSTANCE_KEY] = sessionKeys
- }
-
- private suspend fun restore(sessionId: SessionId): Result {
- Timber.d("Restore matrix session: $sessionId")
- return authenticationService.restoreSession(sessionId)
- .onSuccess { matrixClient ->
- sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
- }
- .onFailure {
- Timber.e(it, "Fail to restore session")
- }
- }
-}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt
new file mode 100644
index 00000000000..302a12d99b9
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt
@@ -0,0 +1,124 @@
+/*
+ * Copyright 2023, 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.appnav.di
+
+import androidx.annotation.VisibleForTesting
+import com.bumble.appyx.core.state.MutableSavedStateMap
+import com.bumble.appyx.core.state.SavedStateMap
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.MatrixClientProvider
+import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
+import io.element.android.libraries.matrix.api.core.SessionId
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import timber.log.Timber
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+
+private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey"
+
+/**
+ * In-memory cache for logged in Matrix sessions.
+ *
+ * This component contains both the [MatrixClient] and the [SyncOrchestrator] for each session.
+ */
+@SingleIn(AppScope::class)
+@ContributesBinding(AppScope::class)
+class MatrixSessionCache @Inject constructor(
+ private val authenticationService: MatrixAuthenticationService,
+ private val syncOrchestratorFactory: SyncOrchestrator.Factory,
+) : MatrixClientProvider {
+ private val sessionIdsToMatrixSession = ConcurrentHashMap()
+ private val restoreMutex = Mutex()
+
+ init {
+ authenticationService.listenToNewMatrixClients { matrixClient ->
+ val syncOrchestrator = syncOrchestratorFactory.create(matrixClient)
+ sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
+ matrixClient = matrixClient,
+ syncOrchestrator = syncOrchestrator,
+ )
+ syncOrchestrator.start()
+ }
+ }
+
+ fun removeAll() {
+ sessionIdsToMatrixSession.clear()
+ }
+
+ fun remove(sessionId: SessionId) {
+ sessionIdsToMatrixSession.remove(sessionId)
+ }
+
+ override fun getOrNull(sessionId: SessionId): MatrixClient? {
+ return sessionIdsToMatrixSession[sessionId]?.matrixClient
+ }
+
+ override suspend fun getOrRestore(sessionId: SessionId): Result {
+ return restoreMutex.withLock {
+ when (val cached = getOrNull(sessionId)) {
+ null -> restore(sessionId)
+ else -> Result.success(cached)
+ }
+ }
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal fun getSyncOrchestrator(sessionId: SessionId): SyncOrchestrator? {
+ return sessionIdsToMatrixSession[sessionId]?.syncOrchestrator
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ fun restoreWithSavedState(state: SavedStateMap?) {
+ Timber.d("Restore state")
+ if (state == null || sessionIdsToMatrixSession.isNotEmpty()) {
+ Timber.w("Restore with non-empty map")
+ return
+ }
+ val sessionIds = state[SAVE_INSTANCE_KEY] as? Array
+ Timber.d("Restore matrix session keys = ${sessionIds?.map { it.value }}")
+ if (sessionIds.isNullOrEmpty()) return
+ // Not ideal but should only happens in case of process recreation. This ensure we restore all the active sessions before restoring the node graphs.
+ runBlocking {
+ sessionIds.forEach { sessionId ->
+ getOrRestore(sessionId)
+ }
+ }
+ }
+
+ fun saveIntoSavedState(state: MutableSavedStateMap) {
+ val sessionKeys = sessionIdsToMatrixSession.keys.toTypedArray()
+ Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}")
+ state[SAVE_INSTANCE_KEY] = sessionKeys
+ }
+
+ private suspend fun restore(sessionId: SessionId): Result {
+ Timber.d("Restore matrix session: $sessionId")
+ return authenticationService.restoreSession(sessionId)
+ .onSuccess { matrixClient ->
+ val syncOrchestrator = syncOrchestratorFactory.create(matrixClient)
+ sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
+ matrixClient = matrixClient,
+ syncOrchestrator = syncOrchestrator,
+ )
+ syncOrchestrator.start()
+ }
+ .onFailure {
+ Timber.e(it, "Fail to restore session")
+ }
+ }
+}
+
+private data class InMemoryMatrixSession(
+ val matrixClient: MatrixClient,
+ val syncOrchestrator: SyncOrchestrator,
+)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/RoomComponentFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/RoomComponentFactory.kt
index 16575f1a91b..4911ab50b50 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/di/RoomComponentFactory.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/di/RoomComponentFactory.kt
@@ -7,8 +7,8 @@
package io.element.android.appnav.di
-import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.JoinedRoom
interface RoomComponentFactory {
- fun create(room: MatrixRoom): Any
+ fun create(room: JoinedRoom): Any
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt
new file mode 100644
index 00000000000..d3ab19466f6
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.appnav.di
+
+import androidx.annotation.VisibleForTesting
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import io.element.android.features.networkmonitor.api.NetworkMonitor
+import io.element.android.features.networkmonitor.api.NetworkStatus
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.core.coroutine.childScope
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.sync.SyncState
+import io.element.android.services.appnavstate.api.AppForegroundStateService
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+class SyncOrchestrator @AssistedInject constructor(
+ @Assisted matrixClient: MatrixClient,
+ private val appForegroundStateService: AppForegroundStateService,
+ private val networkMonitor: NetworkMonitor,
+ dispatchers: CoroutineDispatchers,
+) {
+ @AssistedFactory
+ interface Factory {
+ fun create(matrixClient: MatrixClient): SyncOrchestrator
+ }
+
+ private val syncService = matrixClient.syncService()
+
+ private val tag = "SyncOrchestrator"
+
+ private val coroutineScope = matrixClient.sessionCoroutineScope.childScope(dispatchers.io, tag)
+
+ private val started = AtomicBoolean(false)
+
+ /**
+ * Starting observing the app state and network state to start/stop the sync service.
+ *
+ * Before observing the state, a first attempt at starting the sync service will happen if it's not already running.
+ */
+ fun start() {
+ if (!started.compareAndSet(false, true)) {
+ Timber.tag(tag).d("already started, exiting early")
+ return
+ }
+
+ coroutineScope.launch {
+ // Perform an initial sync if the sync service is not running, to check whether the homeserver is accessible
+ // Otherwise, if the device is offline the sync service will never start and the SyncState will be Idle, not Offline
+ Timber.tag(tag).d("performing initial sync attempt")
+ syncService.startSync()
+
+ // Wait until the sync service is not idle, either it will be running or in error/offline state
+ syncService.syncState.first { it != SyncState.Idle }
+
+ observeStates()
+ }
+ }
+
+ @OptIn(FlowPreview::class)
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal fun observeStates() = coroutineScope.launch {
+ Timber.tag(tag).d("start observing the app and network state")
+
+ val isAppActiveFlow = combine(
+ appForegroundStateService.isInForeground,
+ appForegroundStateService.isInCall,
+ appForegroundStateService.isSyncingNotificationEvent,
+ appForegroundStateService.hasRingingCall,
+ ) { isInForeground, isInCall, isSyncingNotificationEvent, hasRingingCall ->
+ isInForeground || isInCall || isSyncingNotificationEvent || hasRingingCall
+ }
+
+ combine(
+ // small debounce to avoid spamming startSync when the state is changing quickly in case of error.
+ syncService.syncState.debounce(100.milliseconds),
+ networkMonitor.connectivity,
+ isAppActiveFlow,
+ ) { syncState, networkState, isAppActive ->
+ val isNetworkAvailable = networkState == NetworkStatus.Connected
+
+ Timber.tag(tag).d("isAppActive=$isAppActive, isNetworkAvailable=$isNetworkAvailable")
+ if (syncState == SyncState.Running && !isAppActive) {
+ SyncStateAction.StopSync
+ } else if (syncState == SyncState.Idle && isAppActive && isNetworkAvailable) {
+ SyncStateAction.StartSync
+ } else {
+ SyncStateAction.NoOp
+ }
+ }
+ .distinctUntilChanged()
+ .debounce { action ->
+ // Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often
+ if (action == SyncStateAction.StopSync) 3.seconds else 0.seconds
+ }
+ .onCompletion {
+ Timber.tag(tag).d("has been stopped")
+ }
+ .collect { action ->
+ when (action) {
+ SyncStateAction.StartSync -> {
+ syncService.startSync()
+ }
+ SyncStateAction.StopSync -> {
+ syncService.stopSync()
+ }
+ SyncStateAction.NoOp -> Unit
+ }
+ }
+ }
+}
+
+private enum class SyncStateAction {
+ StartSync,
+ StopSync,
+ NoOp,
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
index 8e802c323ee..786b694c3fd 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
@@ -8,6 +8,8 @@
package io.element.android.appnav.intent
import android.content.Intent
+import io.element.android.features.login.api.LoginIntentResolver
+import io.element.android.features.login.api.LoginParams
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser
import io.element.android.libraries.matrix.api.permalink.PermalinkData
@@ -21,11 +23,13 @@ sealed interface ResolvedIntent {
data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent
data class Oidc(val oidcAction: OidcAction) : ResolvedIntent
data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent
+ data class Login(val params: LoginParams) : ResolvedIntent
data class IncomingShare(val intent: Intent) : ResolvedIntent
}
class IntentResolver @Inject constructor(
private val deeplinkParser: DeeplinkParser,
+ private val loginIntentResolver: LoginIntentResolver,
private val oidcIntentResolver: OidcIntentResolver,
private val permalinkParser: PermalinkParser,
) {
@@ -40,10 +44,17 @@ class IntentResolver @Inject constructor(
val oidcAction = oidcIntentResolver.resolve(intent)
if (oidcAction != null) return ResolvedIntent.Oidc(oidcAction)
- // External link clicked? (matrix.to, element.io, etc.)
- val permalinkData = intent
+ val actionViewData = intent
.takeIf { it.action == Intent.ACTION_VIEW }
?.dataString
+
+ // Mobile configuration link clicked? (mobile.element.io)
+ val mobileLoginData = actionViewData
+ ?.let { loginIntentResolver.parse(it) }
+ if (mobileLoginData != null) return ResolvedIntent.Login(mobileLoginData)
+
+ // External link clicked? (matrix.to, element.io, etc.)
+ val permalinkData = actionViewData
?.let { permalinkParser.parse(it) }
?.takeIf { it !is PermalinkData.FallbackLink }
if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
index 9e673d59d3b..b825126ed7e 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
@@ -21,20 +21,22 @@ import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.core.meta.BuildMeta
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.RecoveryState
+import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService
-import io.element.android.libraries.matrix.api.sync.isOnline
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
-import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.pushproviders.api.RegistrationFailure
import io.element.android.services.analytics.api.AnalyticsService
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -51,7 +53,7 @@ class LoggedInPresenter @Inject constructor(
private val sessionVerificationService: SessionVerificationService,
private val analyticsService: AnalyticsService,
private val encryptionService: EncryptionService,
- private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase,
+ private val buildMeta: BuildMeta,
) : Presenter {
@Composable
override fun present(): LoggedInState {
@@ -60,6 +62,7 @@ class LoggedInPresenter @Inject constructor(
pushService.ignoreRegistrationError(matrixClient.sessionId)
}.collectAsState(initial = false)
val pusherRegistrationState = remember>> { mutableStateOf(AsyncData.Uninitialized) }
+ LaunchedEffect(Unit) { preloadAccountManagementUrl() }
LaunchedEffect(Unit) {
sessionVerificationService.sessionVerifiedStatus
.onEach { sessionVerifiedStatus ->
@@ -76,7 +79,7 @@ class LoggedInPresenter @Inject constructor(
.launchIn(this)
}
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
- val isOnline by syncService.isOnline().collectAsState()
+ val isOnline by syncService.isOnline.collectAsState()
val showSyncSpinner by remember {
derivedStateOf {
isOnline && syncIndicator == RoomListService.SyncIndicator.Show
@@ -103,12 +106,10 @@ class LoggedInPresenter @Inject constructor(
}
}
LoggedInEvents.CheckSlidingSyncProxyAvailability -> coroutineScope.launch {
- forceNativeSlidingSyncMigration = matrixClient.forceNativeSlidingSyncMigration().getOrDefault(false)
+ forceNativeSlidingSyncMigration = matrixClient.needsForcedNativeSlidingSyncMigration().getOrDefault(false)
}
LoggedInEvents.LogoutAndMigrateToNativeSlidingSync -> coroutineScope.launch {
- // Enable native sliding sync if it wasn't already the case
- enableNativeSlidingSyncUseCase()
- // Then force the logout
+ // Force the logout since Native Sliding Sync is already enforced by the SDK
matrixClient.logout(userInitiated = true, ignoreSdkError = true)
}
}
@@ -119,20 +120,15 @@ class LoggedInPresenter @Inject constructor(
pusherRegistrationState = pusherRegistrationState.value,
ignoreRegistrationError = ignoreRegistrationError,
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
+ appName = buildMeta.applicationName,
eventSink = ::handleEvent
)
}
- // Force the user to log out if they were using the proxy sliding sync and it's no longer available, but native sliding sync is.
- private suspend fun MatrixClient.forceNativeSlidingSyncMigration(): Result = runCatching {
+ // Force the user to log out if they were using the proxy sliding sync as it's no longer supported by the SDK
+ private suspend fun MatrixClient.needsForcedNativeSlidingSyncMigration(): Result = runCatchingExceptions {
val currentSlidingSyncVersion = currentSlidingSyncVersion().getOrThrow()
- if (currentSlidingSyncVersion == SlidingSyncVersion.Proxy) {
- val availableSlidingSyncVersions = availableSlidingSyncVersions().getOrThrow()
- availableSlidingSyncVersions.contains(SlidingSyncVersion.Native) &&
- !availableSlidingSyncVersions.contains(SlidingSyncVersion.Proxy)
- } else {
- false
- }
+ currentSlidingSyncVersion == SlidingSyncVersion.Proxy
}
private suspend fun ensurePusherIsRegistered(pusherRegistrationState: MutableState>) {
@@ -209,4 +205,9 @@ class LoggedInPresenter @Inject constructor(
analyticsService.capture(CryptoSessionStateChange(changeRecoveryState, changeVerificationState))
}
}
+
+ private fun CoroutineScope.preloadAccountManagementUrl() = launch {
+ matrixClient.getAccountManagementUrl(AccountManagementAction.Profile)
+ matrixClient.getAccountManagementUrl(AccountManagementAction.SessionsList)
+ }
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt
index 540d5d0d8f7..8f039ffa2be 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt
@@ -14,5 +14,6 @@ data class LoggedInState(
val pusherRegistrationState: AsyncData,
val ignoreRegistrationError: Boolean,
val forceNativeSlidingSyncMigration: Boolean,
+ val appName: String,
val eventSink: (LoggedInEvents) -> Unit,
)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt
index 43b7164f2a2..2ba0c8de81d 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt
@@ -24,10 +24,12 @@ fun aLoggedInState(
showSyncSpinner: Boolean = false,
pusherRegistrationState: AsyncData = AsyncData.Uninitialized,
forceNativeSlidingSyncMigration: Boolean = false,
+ appName: String = "Element X",
) = LoggedInState(
showSyncSpinner = showSyncSpinner,
pusherRegistrationState = pusherRegistrationState,
ignoreRegistrationError = false,
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
+ appName = appName,
eventSink = {},
)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt
index cd9ecf637a4..d6a12ea27bd 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt
@@ -73,9 +73,12 @@ fun LoggedInView(
// Set the force migration dialog here so it's always displayed over every screen
if (state.forceNativeSlidingSyncMigration) {
- ForceNativeSlidingSyncMigrationDialog(onSubmit = {
- state.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync)
- })
+ ForceNativeSlidingSyncMigrationDialog(
+ appName = state.appName,
+ onSubmit = {
+ state.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync)
+ }
+ )
}
}
@@ -98,11 +101,12 @@ private fun Throwable.getReason(): String? {
@Composable
private fun ForceNativeSlidingSyncMigrationDialog(
+ appName: String,
onSubmit: () -> Unit,
) {
ErrorDialog(
title = null,
- content = stringResource(R.string.banner_migrate_to_native_sliding_sync_force_logout_title),
+ content = stringResource(R.string.banner_migrate_to_native_sliding_sync_app_force_logout_title, appName),
submitText = stringResource(R.string.banner_migrate_to_native_sliding_sync_action),
onSubmit = onSubmit,
canDismiss = false,
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigration.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigration.kt
new file mode 100644
index 00000000000..d9ed15318a9
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigration.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.appnav.loggedin
+
+import io.element.android.libraries.di.annotations.SessionCoroutineScope
+import io.element.android.libraries.matrix.api.media.MediaPreviewService
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import javax.inject.Inject
+
+/**
+ * This migration is temporary, will be safe to remove after some time.
+ * The goal is to set the server config if it's not set, and remove the local data.
+ */
+class MediaPreviewConfigMigration @Inject constructor(
+ private val mediaPreviewService: MediaPreviewService,
+ private val appPreferencesStore: AppPreferencesStore,
+ @SessionCoroutineScope
+ private val sessionCoroutineScope: CoroutineScope,
+) {
+ @Suppress("DEPRECATION")
+ operator fun invoke() = sessionCoroutineScope.launch {
+ val hideInviteAvatars = appPreferencesStore.getHideInviteAvatarsFlow().first()
+ val mediaPreviewValue = appPreferencesStore.getTimelineMediaPreviewValueFlow().first()
+ if (hideInviteAvatars == null && mediaPreviewValue == null) {
+ // No local data, abort.
+ return@launch
+ }
+ mediaPreviewService
+ .fetchMediaPreviewConfig()
+ .onSuccess { config ->
+ if (config != null) {
+ appPreferencesStore.setHideInviteAvatars(null)
+ appPreferencesStore.setTimelineMediaPreviewValue(null)
+ } else {
+ if (hideInviteAvatars != null) {
+ mediaPreviewService.setHideInviteAvatars(hideInviteAvatars)
+ appPreferencesStore.setHideInviteAvatars(null)
+ }
+ if (mediaPreviewValue != null) {
+ mediaPreviewService.setMediaPreviewValue(mediaPreviewValue)
+ appPreferencesStore.setTimelineMediaPreviewValue(null)
+ }
+ }
+ }
+ .onFailure {
+ Timber.e(it, "Couldn't perform migration, failed to fetch media preview config.")
+ }
+ }
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt
index 458e6e5c495..cbb247569a5 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt
@@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import timber.log.Timber
import javax.inject.Inject
@VisibleForTesting
@@ -32,7 +33,7 @@ class SendQueues @Inject constructor(
) {
/**
* Launches the send queues retry mechanism in the given [coroutineScope].
- * Makes sure to re-enable all send queues when the network status is [NetworkStatus.Online].
+ * Makes sure to re-enable all send queues when the network status is [NetworkStatus.Connected].
*/
@OptIn(FlowPreview::class)
fun launchIn(coroutineScope: CoroutineScope) {
@@ -42,7 +43,9 @@ class SendQueues @Inject constructor(
) { syncState, _ -> syncState }
.debounce(SEND_QUEUES_RETRY_DELAY_MILLIS)
.onEach { syncState ->
+ Timber.tag("SendQueues").d("Sync state changed: $syncState")
if (syncState == SyncState.Running) {
+ Timber.tag("SendQueues").d("Enabling send queues again")
matrixClient.setAllSendQueuesEnabled(enabled = true)
}
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
index 56f3716ca1e..103e97b2583 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
@@ -28,7 +28,6 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.room.joined.JoinedRoomFlowNode
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.appnav.room.joined.LoadingRoomNodeView
-import io.element.android.appnav.room.joined.LoadingRoomState
import io.element.android.features.joinroom.api.JoinRoomEntryPoint
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint
import io.element.android.features.roomdirectory.api.RoomDescription
@@ -43,13 +42,11 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
-import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
-import io.element.android.libraries.matrix.api.getRoomInfoFlow
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.sync.SyncService
-import io.element.android.libraries.matrix.api.sync.isOnline
+import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
@@ -125,7 +122,7 @@ class RoomFlowNode @AssistedInject constructor(
}
private fun subscribeToRoomInfoFlow(roomId: RoomId, serverNames: List) {
- val roomInfoFlow = client.getRoomInfoFlow(roomIdOrAlias = roomId.toRoomIdOrAlias())
+ val roomInfoFlow = client.getRoomInfoFlow(roomId)
val isSpaceFlow = roomInfoFlow.map { it.getOrNull()?.isSpace.orFalse() }.distinctUntilChanged()
val currentMembershipFlow = roomInfoFlow.map { it.getOrNull()?.currentUserMembership }.distinctUntilChanged()
combine(currentMembershipFlow, isSpaceFlow) { membership, isSpace ->
@@ -211,7 +208,7 @@ class RoomFlowNode @AssistedInject constructor(
}
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
- val isOnline by syncService.isOnline().collectAsState()
+ val isOnline by syncService.isOnline.collectAsState()
LoadingRoomNodeView(
state = LoadingRoomState.Loading,
hasNetworkConnection = isOnline,
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt
index 49a05c9d365..07580060c73 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt
@@ -36,7 +36,8 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.sync.SyncService
-import io.element.android.libraries.matrix.api.sync.isOnline
+import io.element.android.libraries.matrix.ui.room.LoadingRoomState
+import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@@ -114,7 +115,7 @@ class JoinedRoomFlowNode @AssistedInject constructor(
private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier ->
val loadingRoomState by loadingRoomStateStateFlow.collectAsState()
- val isOnline by syncService.isOnline().collectAsState()
+ val isOnline by syncService.isOnline.collectAsState()
LoadingRoomNodeView(
state = loadingRoomState,
hasNetworkConnection = isOnline,
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt
index df2e25d4b38..3e5947aada9 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt
@@ -9,9 +9,7 @@ package io.element.android.appnav.room.joined
import android.os.Parcelable
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
-import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
@@ -32,12 +30,14 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
-import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.JoinedRoom
+import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -51,12 +51,14 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
private val messagesEntryPoint: MessagesEntryPoint,
private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
- private val appCoroutineScope: CoroutineScope,
+ @SessionCoroutineScope
+ private val sessionCoroutineScope: CoroutineScope,
private val matrixClient: MatrixClient,
+ private val activeRoomsHolder: ActiveRoomsHolder,
roomComponentFactory: RoomComponentFactory,
) : BaseFlowNode(
backstack = BackStack(
- initialElement = when (val input = plugins.filterIsInstance(Inputs::class.java).first().initialElement) {
+ initialElement = when (val input = plugins.filterIsInstance().first().initialElement) {
is RoomNavigationTarget.Messages -> NavTarget.Messages(input.focusedEventId)
RoomNavigationTarget.Details -> NavTarget.RoomDetails
RoomNavigationTarget.NotificationSettings -> NavTarget.RoomNotificationSettings
@@ -74,7 +76,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
}
data class Inputs(
- val room: MatrixRoom,
+ val room: JoinedRoom,
val initialElement: RoomNavigationTarget,
) : NodeInputs
@@ -87,16 +89,19 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
onCreate = {
Timber.v("OnCreate => ${inputs.room.roomId}")
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
+ activeRoomsHolder.addRoom(inputs.room)
fetchRoomMembers()
trackVisitedRoom()
},
onResume = {
- appCoroutineScope.launch {
+ sessionCoroutineScope.launch {
inputs.room.subscribeToSync()
}
},
onDestroy = {
Timber.v("OnDestroy")
+ activeRoomsHolder.removeRoom(inputs.room.sessionId, inputs.room.roomId)
+ inputs.room.destroy()
appNavigationStateService.onLeavingRoom(id)
}
)
@@ -197,16 +202,6 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
- // Rely on the View Lifecycle in addition to the Node Lifecycle,
- // because this node enters 'onDestroy' before his children, so it can leads to
- // using the room in a child node where it's already closed.
- DisposableEffect(Unit) {
- onDispose {
- if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
- inputs.room.destroy()
- }
- }
- }
BackstackView()
}
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt
index 83c4e0e6232..938e46f9156 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt
@@ -31,6 +31,8 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.matrix.ui.room.LoadingRoomState
+import io.element.android.libraries.matrix.ui.room.LoadingRoomStateProvider
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt
index 731072889dd..08e999245bf 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt
@@ -9,7 +9,7 @@ package io.element.android.appnav.root
import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.core.state.SavedStateMap
-import io.element.android.appnav.di.MatrixClientsHolder
+import io.element.android.appnav.di.MatrixSessionCache
import io.element.android.features.login.api.LoginUserStory
import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@@ -31,7 +31,7 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.RootNavStateFlowFact
class RootNavStateFlowFactory @Inject constructor(
private val authenticationService: MatrixAuthenticationService,
private val cacheService: CacheService,
- private val matrixClientsHolder: MatrixClientsHolder,
+ private val matrixSessionCache: MatrixSessionCache,
private val imageLoaderHolder: ImageLoaderHolder,
private val loginUserStory: LoginUserStory,
private val sessionPreferencesStoreFactory: SessionPreferencesStoreFactory,
@@ -63,7 +63,7 @@ class RootNavStateFlowFactory @Inject constructor(
val initialCacheIndex = savedStateMap.getCacheIndexOrDefault()
return cacheService.clearedCacheEventFlow
.onEach { sessionId ->
- matrixClientsHolder.remove(sessionId)
+ matrixSessionCache.remove(sessionId)
// Ensure image loader will be recreated with the new MatrixClient
imageLoaderHolder.remove(sessionId)
// Also remove cached value for SessionPreferencesStore
diff --git a/appnav/src/main/res/values-cs/translations.xml b/appnav/src/main/res/values-cs/translations.xml
index e749887bc4b..3652bda56f1 100644
--- a/appnav/src/main/res/values-cs/translations.xml
+++ b/appnav/src/main/res/values-cs/translations.xml
@@ -1,5 +1,6 @@
"Odhlásit se a upgradovat"
+ "%1$s již nepodporuje starý protokol. Odhlaste se a znovu přihlaste, abyste mohli pokračovat v používání aplikace."
"Váš domovský server již nepodporuje starý protokol. Chcete-li pokračovat v používání aplikace, odhlaste se a znovu se přihlaste."
diff --git a/appnav/src/main/res/values-cy/translations.xml b/appnav/src/main/res/values-cy/translations.xml
new file mode 100644
index 00000000000..06d53cd61bc
--- /dev/null
+++ b/appnav/src/main/res/values-cy/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Allgofnodi ac Uwchraddio"
+ "Nid yw %1$s bellach yn cefnogi\'r hen brotocol. Allgofnodwch a mewngofnodi\'n ôl i barhau i ddefnyddio\'r ap."
+ "Nid yw eich gweinydd cartref yn cefnogi\'r hen brotocol mwyach. Allgofnodwch a mewngofnodi yn ôl i barhau i ddefnyddio\'r ap."
+
diff --git a/appnav/src/main/res/values-da/translations.xml b/appnav/src/main/res/values-da/translations.xml
new file mode 100644
index 00000000000..ef5025ed614
--- /dev/null
+++ b/appnav/src/main/res/values-da/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Log ud og opgradér"
+ "%1$s understøtter ikke længere den gamle protokol. Log ud og log ind igen for at fortsætte med at bruge appen."
+ "Din hjemmeserver understøtter ikke længere den gamle protokol. Log ud og log ind igen for at fortsætte med at bruge appen."
+
diff --git a/appnav/src/main/res/values-de/translations.xml b/appnav/src/main/res/values-de/translations.xml
index 2b33dacaca0..339d346a6b7 100644
--- a/appnav/src/main/res/values-de/translations.xml
+++ b/appnav/src/main/res/values-de/translations.xml
@@ -1,5 +1,6 @@
"Abmelden und aktualisieren"
+ "%1$s unterstützt das alte Protokoll nicht mehr. Bitte melden Sie sich ab und wieder an, um die App weiter nutzen zu können."
"Dein Homeserver unterstützt das alte Protokoll nicht mehr. Bitte logge dich aus und melde dich wieder an, um die App weiter zu nutzen."
diff --git a/appnav/src/main/res/values-el/translations.xml b/appnav/src/main/res/values-el/translations.xml
index ef9f8d3c7dc..03848d79c07 100644
--- a/appnav/src/main/res/values-el/translations.xml
+++ b/appnav/src/main/res/values-el/translations.xml
@@ -1,5 +1,6 @@
"Αποσύνδεση & Αναβάθμιση"
+ "Το %1$s δεν υποστηρίζει πλέον το παλιό πρωτόκολλο. Αποσυνδεθείτε και συνδεθείτε ξανά για να συνεχίσετε να χρησιμοποιείτε την εφαρμογή."
"Ο οικιακός διακομιστής σου δεν υποστηρίζει πλέον το παλιό πρωτόκολλο. Αποσυνδέσου και συνδέσου ξανά για να συνεχίσεις να χρησιμοποιείς την εφαρμογή."
diff --git a/appnav/src/main/res/values-es/translations.xml b/appnav/src/main/res/values-es/translations.xml
new file mode 100644
index 00000000000..20f155513e2
--- /dev/null
+++ b/appnav/src/main/res/values-es/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Cerrar sesión y actualizar"
+ "%1$s ya no es compatible con el antiguo protocolo. Cierra sesión y vuelve a iniciarla para seguir usando la aplicación."
+ "Tu servidor base ya no es compatible con el protocolo anterior. Cierra sesión y vuelve a iniciarla para seguir usando la aplicación."
+
diff --git a/appnav/src/main/res/values-et/translations.xml b/appnav/src/main/res/values-et/translations.xml
index 1631134d607..b71640a4b4b 100644
--- a/appnav/src/main/res/values-et/translations.xml
+++ b/appnav/src/main/res/values-et/translations.xml
@@ -1,5 +1,6 @@
"Logi välja ja uuenda"
+ "%1$s enam ei toeta vana protokolli. Kui soovid rakendust edasi kasutada, siis logi korraks temast välja ning seejärel tagasi."
"Sinu koduserver enam ei toeta vana protokolli. Jätkamaks rakenduse kasutamist palun logi välja ning seejärel tagasi."
diff --git a/appnav/src/main/res/values-eu/translations.xml b/appnav/src/main/res/values-eu/translations.xml
new file mode 100644
index 00000000000..909ade151c6
--- /dev/null
+++ b/appnav/src/main/res/values-eu/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Amaitu saioa eta bertsio-berritu"
+ "%1$s(e)k ez da bateragarria lehengo protokoloarekin. Amaitu saioa eta hasi berriro aplikazioa erabiltzen jarraitzeko."
+ "Zure zerbitzaria ez da bateragarria protokolo zaharrarekin. Amaitu saioa eta hasi berriro aplikazioa erabiltzen jarraitzeko."
+
diff --git a/appnav/src/main/res/values-fi/translations.xml b/appnav/src/main/res/values-fi/translations.xml
index bdd10a92c44..14605ea6e1c 100644
--- a/appnav/src/main/res/values-fi/translations.xml
+++ b/appnav/src/main/res/values-fi/translations.xml
@@ -1,5 +1,6 @@
"Kirjaudu Ulos & Päivitä"
+ "%1$s ei enää tue vanhaa protokollaa. Kirjaudu ulos ja takaisin sisään jatkaaksesi sovelluksen käyttöä."
"Kotipalvelimesi ei enää tue vanhaa protokollaa. Kirjaudu ulos ja takaisin sisään jatkaaksesi sovelluksen käyttöä."
diff --git a/appnav/src/main/res/values-fr/translations.xml b/appnav/src/main/res/values-fr/translations.xml
index 6295bc88df3..e3885f84baf 100644
--- a/appnav/src/main/res/values-fr/translations.xml
+++ b/appnav/src/main/res/values-fr/translations.xml
@@ -1,5 +1,6 @@
"Déconnecter et mettre à niveau"
+ "%1$s ne prend plus en charge l’ancien protocole. Veuillez vous déconnecter puis vous reconnecter pour continuer à utiliser l’application."
"Votre serveur d’accueil ne prend plus en charge l’ancien protocole. Veuillez vous déconnecter puis vous reconnecter pour continuer à utiliser l’application."
diff --git a/appnav/src/main/res/values-hu/translations.xml b/appnav/src/main/res/values-hu/translations.xml
index e4b24f811b3..a4dca1c214a 100644
--- a/appnav/src/main/res/values-hu/translations.xml
+++ b/appnav/src/main/res/values-hu/translations.xml
@@ -1,5 +1,6 @@
"Kijelentkezés és frissítés"
+ "%1$s már nem támogatja a régi protokollt. Kérjük, jelentkezzen ki és jelentkezzen be újra az alkalmazás használatának folytatásához."
"A Matrix-kiszolgáló már nem támogatja a régi protokollt. Az alkalmazás további használatához jelentkezzen ki és be."
diff --git a/appnav/src/main/res/values-in/translations.xml b/appnav/src/main/res/values-in/translations.xml
new file mode 100644
index 00000000000..e4f445dbc7f
--- /dev/null
+++ b/appnav/src/main/res/values-in/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Keluar & Tingkatkan"
+ "%1$s tidak lagi mendukung protokol lama. Silakan keluar dan masuk kembali untuk terus menggunakan aplikasi."
+ "Homeserver Anda tidak lagi mendukung protokol lama. Silakan keluar dan masuk kembali untuk terus menggunakan aplikasi."
+
diff --git a/appnav/src/main/res/values-it/translations.xml b/appnav/src/main/res/values-it/translations.xml
index d93dc1e6883..725604ed3f7 100644
--- a/appnav/src/main/res/values-it/translations.xml
+++ b/appnav/src/main/res/values-it/translations.xml
@@ -1,5 +1,6 @@
"Esci e aggiorna"
+ "%1$s non supporta più il vecchio protocollo. Esci e accedi nuovamente per continuare a utilizzare l\'app."
"Il tuo homeserver non supporta più il vecchio protocollo. Esci e rientra per continuare a usare l\'app."
diff --git a/appnav/src/main/res/values-nb/translations.xml b/appnav/src/main/res/values-nb/translations.xml
new file mode 100644
index 00000000000..82704998535
--- /dev/null
+++ b/appnav/src/main/res/values-nb/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Logg ut og oppgrader"
+ "%1$s støtter ikke lenger den gamle protokollen. Logg ut og logg inn igjen for å fortsette å bruke appen."
+ "Hjemmeserveren din støtter ikke lenger den gamle protokollen. Vennligst logg ut og inn igjen for å fortsette å bruke appen."
+
diff --git a/appnav/src/main/res/values-pl/translations.xml b/appnav/src/main/res/values-pl/translations.xml
index 0df56520b1c..68de66a54eb 100644
--- a/appnav/src/main/res/values-pl/translations.xml
+++ b/appnav/src/main/res/values-pl/translations.xml
@@ -1,5 +1,6 @@
"Wyloguj się i zaktualizuj"
+ "%1$s już nie wspiera starego protokołu. Zaloguj się ponownie, aby dalej korzystać z aplikacji."
"Twój serwer domowy już nie wspiera starego protokołu. Zaloguj się ponownie, aby kontynuować korzystanie z aplikacji."
diff --git a/appnav/src/main/res/values-pt-rBR/translations.xml b/appnav/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 00000000000..385e07d7d19
--- /dev/null
+++ b/appnav/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Sair e atualizar"
+ "%1$s não suporta mais o protocolo antigo. Termine sessão e volte a iniciar sessão para continuar a utilizar a aplicação."
+ "Seu servidor doméstico não é mais compatível com o protocolo antigo. Faça logout e login novamente para continuar usando o aplicativo."
+
diff --git a/appnav/src/main/res/values-pt/translations.xml b/appnav/src/main/res/values-pt/translations.xml
index 8b29a17ecfe..d606a8d103a 100644
--- a/appnav/src/main/res/values-pt/translations.xml
+++ b/appnav/src/main/res/values-pt/translations.xml
@@ -1,5 +1,6 @@
"Sair & Atualizar"
+ "%1$s já não suporta o protocolo antigo. Termina a sessão e volta a iniciar sessão para continuares a utilizar a aplicação."
"Seu homeserver não suporta mais o protocolo antigo. Termine sessão e volte a iniciar sessão para continuar a utilizar a aplicação."
diff --git a/appnav/src/main/res/values-ru/translations.xml b/appnav/src/main/res/values-ru/translations.xml
index ac03f180984..45f00a7cb21 100644
--- a/appnav/src/main/res/values-ru/translations.xml
+++ b/appnav/src/main/res/values-ru/translations.xml
@@ -1,5 +1,6 @@
"Выйти и обновить"
+ "%1$s больше не поддерживает старый протокол. Пожалуйста, выйдите из системы и войдите снова, чтобы продолжить использование приложения."
"Ваш домашний сервер больше не поддерживает старый протокол. Пожалуйста, выйдите и войдите в свою учётную запись снова, чтобы продолжить использование приложения."
diff --git a/appnav/src/main/res/values-sk/translations.xml b/appnav/src/main/res/values-sk/translations.xml
index d1ac766f8cb..a75c2fd3d0f 100644
--- a/appnav/src/main/res/values-sk/translations.xml
+++ b/appnav/src/main/res/values-sk/translations.xml
@@ -1,5 +1,6 @@
"Odhlásiť sa a aktualizovať"
+ "%1$s už nepodporuje starý protokol. Odhláste sa a znova prihláste, aby ste mohli pokračovať v používaní aplikácie."
"Váš domovský server už nepodporuje starý protokol. Ak chcete pokračovať v používaní aplikácie, odhláste sa a znova sa prihláste."
diff --git a/appnav/src/main/res/values-sv/translations.xml b/appnav/src/main/res/values-sv/translations.xml
index 89726dd893f..408e02057ef 100644
--- a/appnav/src/main/res/values-sv/translations.xml
+++ b/appnav/src/main/res/values-sv/translations.xml
@@ -1,5 +1,6 @@
"Logga ut och uppgradera"
+ "%1$s stöder inte längre det gamla protokollet. Logga ut och logga in igen för att fortsätta använda appen."
"Din hemserver stöder inte längre det gamla protokollet. Logga ut och logga in igen för att fortsätta använda appen."
diff --git a/appnav/src/main/res/values-tr/translations.xml b/appnav/src/main/res/values-tr/translations.xml
new file mode 100644
index 00000000000..c0a8ccd1361
--- /dev/null
+++ b/appnav/src/main/res/values-tr/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Çıkış Yap ve Yükselt"
+ "Ana sunucunuz artık eski protokolü desteklemiyor. Lütfen oturumu kapatın ve uygulamayı kullanmaya devam etmek için tekrar oturum açın."
+
diff --git a/appnav/src/main/res/values-uk/translations.xml b/appnav/src/main/res/values-uk/translations.xml
index d2ee822d30c..b65ab1a1cb9 100644
--- a/appnav/src/main/res/values-uk/translations.xml
+++ b/appnav/src/main/res/values-uk/translations.xml
@@ -1,5 +1,6 @@
"Вийти та оновити"
+ "%1$s більше не підтримує старий протокол. Вийдіть і знов увійдіть, щоб продовжити користуватися застосунком."
"Ваш домашній сервер більше не підтримує старий протокол. Будь ласка, вийдіть і увійдіть знову, щоб продовжити використання програми."
diff --git a/appnav/src/main/res/values-zh-rTW/translations.xml b/appnav/src/main/res/values-zh-rTW/translations.xml
index 5ff8996b041..ea69a33df89 100644
--- a/appnav/src/main/res/values-zh-rTW/translations.xml
+++ b/appnav/src/main/res/values-zh-rTW/translations.xml
@@ -1,5 +1,6 @@
"登出並升級"
+ "%1$s 不再支援舊版通訊協定。請登出並重新登入以繼續使用應用程式。"
"您的家伺服器不再支援舊協定。請登出並重新登入以繼續使用應用程式。"
diff --git a/appnav/src/main/res/values-zh/translations.xml b/appnav/src/main/res/values-zh/translations.xml
index 134f3c07fe3..406471196e0 100644
--- a/appnav/src/main/res/values-zh/translations.xml
+++ b/appnav/src/main/res/values-zh/translations.xml
@@ -1,5 +1,6 @@
"登出并升级"
+ "%1$s 不再支持旧协议。请注销并重新登录以继续使用该应用程序。"
"您的服务器不再支持旧协议。请登出并重新登录以继续使用此应用。"
diff --git a/appnav/src/main/res/values/localazy.xml b/appnav/src/main/res/values/localazy.xml
index bb6df44051e..c018fbf7fd8 100644
--- a/appnav/src/main/res/values/localazy.xml
+++ b/appnav/src/main/res/values/localazy.xml
@@ -1,5 +1,6 @@
"Log Out & Upgrade"
+ "%1$s no longer supports the old protocol. Please log out and log back in to continue using the app."
"Your homeserver no longer supports the old protocol. Please log out and log back in to continue using the app."
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt
deleted file mode 100644
index be317701bbb..00000000000
--- a/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt
+++ /dev/null
@@ -1,160 +0,0 @@
-/*
- * Copyright 2023, 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.appnav
-
-import androidx.arch.core.executor.testing.InstantTaskExecutorRule
-import androidx.lifecycle.Lifecycle
-import com.bumble.appyx.core.modality.BuildContext
-import com.bumble.appyx.core.node.Node
-import com.bumble.appyx.core.node.node
-import com.bumble.appyx.core.plugin.Plugin
-import com.bumble.appyx.navmodel.backstack.activeElement
-import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
-import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
-import com.google.common.truth.Truth.assertThat
-import io.element.android.appnav.di.RoomComponentFactory
-import io.element.android.appnav.room.RoomNavigationTarget
-import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
-import io.element.android.features.messages.api.MessagesEntryPoint
-import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
-import io.element.android.libraries.architecture.childNode
-import io.element.android.libraries.matrix.api.room.MatrixRoom
-import io.element.android.libraries.matrix.test.FakeMatrixClient
-import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
-import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.test.runTest
-import org.junit.Rule
-import org.junit.Test
-
-class JoinRoomLoadedFlowNodeTest {
- @get:Rule
- val instantTaskExecutorRule = InstantTaskExecutorRule()
-
- @get:Rule
- val mainDispatcherRule = MainDispatcherRule()
-
- private class FakeMessagesEntryPoint : MessagesEntryPoint, MessagesEntryPoint.NodeBuilder {
- var buildContext: BuildContext? = null
- var nodeId: String? = null
- var parameters: MessagesEntryPoint.Params? = null
- var callback: MessagesEntryPoint.Callback? = null
-
- override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): MessagesEntryPoint.NodeBuilder {
- this.buildContext = buildContext
- return this
- }
-
- override fun params(params: MessagesEntryPoint.Params): MessagesEntryPoint.NodeBuilder {
- parameters = params
- return this
- }
-
- override fun callback(callback: MessagesEntryPoint.Callback): MessagesEntryPoint.NodeBuilder {
- this.callback = callback
- return this
- }
-
- override fun build(): Node {
- return node(buildContext!!) {}.also {
- nodeId = it.id
- }
- }
- }
-
- private class FakeRoomComponentFactory : RoomComponentFactory {
- override fun create(room: MatrixRoom): Any {
- return Unit
- }
- }
-
- private class FakeRoomDetailsEntryPoint : RoomDetailsEntryPoint {
- var nodeId: String? = null
-
- override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomDetailsEntryPoint.NodeBuilder {
- return object : RoomDetailsEntryPoint.NodeBuilder {
- override fun params(params: RoomDetailsEntryPoint.Params): RoomDetailsEntryPoint.NodeBuilder {
- return this
- }
-
- override fun callback(callback: RoomDetailsEntryPoint.Callback): RoomDetailsEntryPoint.NodeBuilder {
- return this
- }
-
- override fun build(): Node {
- return node(buildContext) {}.also {
- nodeId = it.id
- }
- }
- }
- }
- }
-
- private fun createJoinedRoomLoadedFlowNode(
- plugins: List,
- messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(),
- roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(),
- coroutineScope: CoroutineScope,
- ) = JoinedRoomLoadedFlowNode(
- buildContext = BuildContext.root(savedStateMap = null),
- plugins = plugins,
- messagesEntryPoint = messagesEntryPoint,
- roomDetailsEntryPoint = roomDetailsEntryPoint,
- appNavigationStateService = FakeAppNavigationStateService(),
- appCoroutineScope = coroutineScope,
- roomComponentFactory = FakeRoomComponentFactory(),
- matrixClient = FakeMatrixClient(),
- )
-
- @Test
- fun `given a room flow node when initialized then it loads messages entry point`() = runTest {
- // GIVEN
- val room = FakeMatrixRoom(
- updateMembersResult = { }
- )
- val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
- val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
- val roomFlowNode = createJoinedRoomLoadedFlowNode(
- plugins = listOf(inputs),
- messagesEntryPoint = fakeMessagesEntryPoint,
- coroutineScope = this
- )
- // WHEN
- val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
-
- // THEN
- assertThat(roomFlowNode.backstack.activeElement).isEqualTo(JoinedRoomLoadedFlowNode.NavTarget.Messages())
- roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(), Lifecycle.State.CREATED)
- val messagesNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.Messages())!!
- assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId)
- }
-
- @Test
- fun `given a room flow node when callback on room details is triggered then it loads room details entry point`() = runTest {
- // GIVEN
- val room = FakeMatrixRoom(
- updateMembersResult = { }
- )
- val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
- val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
- val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
- val roomFlowNode = createJoinedRoomLoadedFlowNode(
- plugins = listOf(inputs),
- messagesEntryPoint = fakeMessagesEntryPoint,
- roomDetailsEntryPoint = fakeRoomDetailsEntryPoint,
- coroutineScope = this
- )
- val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
- // WHEN
- fakeMessagesEntryPoint.callback?.onRoomDetailsClick()
- // THEN
- roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED)
- val roomDetailsNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails)!!
- assertThat(roomDetailsNode.id).isEqualTo(fakeRoomDetailsEntryPoint.nodeId)
- }
-}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt
new file mode 100644
index 00000000000..bbaa1965202
--- /dev/null
+++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt
@@ -0,0 +1,207 @@
+/*
+ * Copyright 2023, 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.appnav
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.lifecycle.Lifecycle
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.node.node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.navmodel.backstack.activeElement
+import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
+import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
+import com.google.common.truth.Truth.assertThat
+import io.element.android.appnav.di.RoomComponentFactory
+import io.element.android.appnav.room.RoomNavigationTarget
+import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
+import io.element.android.features.messages.api.MessagesEntryPoint
+import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
+import io.element.android.libraries.architecture.childNode
+import io.element.android.libraries.matrix.api.room.JoinedRoom
+import io.element.android.libraries.matrix.test.A_SESSION_ID
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.room.FakeBaseRoom
+import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
+import io.element.android.services.appnavstate.api.ActiveRoomsHolder
+import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class JoinedRoomLoadedFlowNodeTest {
+ @get:Rule
+ val instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ private class FakeMessagesEntryPoint : MessagesEntryPoint, MessagesEntryPoint.NodeBuilder {
+ var buildContext: BuildContext? = null
+ var nodeId: String? = null
+ var parameters: MessagesEntryPoint.Params? = null
+ var callback: MessagesEntryPoint.Callback? = null
+
+ override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): MessagesEntryPoint.NodeBuilder {
+ this.buildContext = buildContext
+ return this
+ }
+
+ override fun params(params: MessagesEntryPoint.Params): MessagesEntryPoint.NodeBuilder {
+ parameters = params
+ return this
+ }
+
+ override fun callback(callback: MessagesEntryPoint.Callback): MessagesEntryPoint.NodeBuilder {
+ this.callback = callback
+ return this
+ }
+
+ override fun build(): Node {
+ return node(buildContext!!) {}.also {
+ nodeId = it.id
+ }
+ }
+ }
+
+ private class FakeRoomComponentFactory : RoomComponentFactory {
+ override fun create(room: JoinedRoom): Any {
+ return Unit
+ }
+ }
+
+ private class FakeRoomDetailsEntryPoint : RoomDetailsEntryPoint {
+ var nodeId: String? = null
+
+ override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomDetailsEntryPoint.NodeBuilder {
+ return object : RoomDetailsEntryPoint.NodeBuilder {
+ override fun params(params: RoomDetailsEntryPoint.Params): RoomDetailsEntryPoint.NodeBuilder {
+ return this
+ }
+
+ override fun callback(callback: RoomDetailsEntryPoint.Callback): RoomDetailsEntryPoint.NodeBuilder {
+ return this
+ }
+
+ override fun build(): Node {
+ return node(buildContext) {}.also {
+ nodeId = it.id
+ }
+ }
+ }
+ }
+ }
+
+ private fun TestScope.createJoinedRoomLoadedFlowNode(
+ plugins: List,
+ messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(),
+ roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(),
+ activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
+ ) = JoinedRoomLoadedFlowNode(
+ buildContext = BuildContext.root(savedStateMap = null),
+ plugins = plugins,
+ messagesEntryPoint = messagesEntryPoint,
+ roomDetailsEntryPoint = roomDetailsEntryPoint,
+ appNavigationStateService = FakeAppNavigationStateService(),
+ sessionCoroutineScope = this,
+ roomComponentFactory = FakeRoomComponentFactory(),
+ matrixClient = FakeMatrixClient(),
+ activeRoomsHolder = activeRoomsHolder,
+ )
+
+ @Test
+ fun `given a room flow node when initialized then it loads messages entry point`() = runTest {
+ // GIVEN
+ val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}))
+ val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
+ val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
+ val roomFlowNode = createJoinedRoomLoadedFlowNode(
+ plugins = listOf(inputs),
+ messagesEntryPoint = fakeMessagesEntryPoint,
+ )
+ // WHEN
+ val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
+
+ // THEN
+ assertThat(roomFlowNode.backstack.activeElement).isEqualTo(JoinedRoomLoadedFlowNode.NavTarget.Messages())
+ roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(), Lifecycle.State.CREATED)
+ val messagesNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.Messages())!!
+ assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId)
+ }
+
+ @Test
+ fun `given a room flow node when callback on room details is triggered then it loads room details entry point`() = runTest {
+ // GIVEN
+ val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}))
+ val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
+ val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
+ val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
+ val roomFlowNode = createJoinedRoomLoadedFlowNode(
+ plugins = listOf(inputs),
+ messagesEntryPoint = fakeMessagesEntryPoint,
+ roomDetailsEntryPoint = fakeRoomDetailsEntryPoint,
+ )
+ val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
+ // WHEN
+ fakeMessagesEntryPoint.callback?.onRoomDetailsClick()
+ // THEN
+ roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED)
+ val roomDetailsNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails)!!
+ assertThat(roomDetailsNode.id).isEqualTo(fakeRoomDetailsEntryPoint.nodeId)
+ }
+
+ @Test
+ fun `the ActiveRoomsHolder will be updated with the loaded room on create`() = runTest {
+ // GIVEN
+ val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}))
+ val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
+ val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
+ val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
+ val activeRoomsHolder = ActiveRoomsHolder()
+ val roomFlowNode = createJoinedRoomLoadedFlowNode(
+ plugins = listOf(inputs),
+ messagesEntryPoint = fakeMessagesEntryPoint,
+ roomDetailsEntryPoint = fakeRoomDetailsEntryPoint,
+ activeRoomsHolder = activeRoomsHolder,
+ )
+
+ assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNull()
+ val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
+ // WHEN
+ roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(null), Lifecycle.State.CREATED)
+ // THEN
+ assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNotNull()
+ }
+
+ @Test
+ fun `the ActiveRoomsHolder will be removed on destroy`() = runTest {
+ // GIVEN
+ val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}))
+ val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
+ val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
+ val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
+ val activeRoomsHolder = ActiveRoomsHolder().apply {
+ addRoom(room)
+ }
+ val roomFlowNode = createJoinedRoomLoadedFlowNode(
+ plugins = listOf(inputs),
+ messagesEntryPoint = fakeMessagesEntryPoint,
+ roomDetailsEntryPoint = fakeRoomDetailsEntryPoint,
+ activeRoomsHolder = activeRoomsHolder,
+ )
+ val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
+ roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(null), Lifecycle.State.CREATED)
+ assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNotNull()
+ // WHEN
+ roomFlowNode.updateLifecycleState(Lifecycle.State.DESTROYED)
+ // THEN
+ roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(null), Lifecycle.State.DESTROYED)
+ assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNull()
+ }
+}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt
new file mode 100644
index 00000000000..2c143df095d
--- /dev/null
+++ b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt
@@ -0,0 +1,393 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.appnav
+
+import io.element.android.appnav.di.SyncOrchestrator
+import io.element.android.features.networkmonitor.api.NetworkStatus
+import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
+import io.element.android.libraries.matrix.api.sync.SyncState
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.sync.FakeSyncService
+import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class SyncOrchestratorTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `when the sync wasn't running before, an initial sync will take place, even with no network`() = runTest {
+ val startSyncRecorder = lambdaRecorder> { Result.success(Unit) }
+ val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
+ startSyncLambda = startSyncRecorder
+ }
+ val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
+ val syncOrchestrator = createSyncOrchestrator(
+ syncService = syncService,
+ networkMonitor = networkMonitor,
+ )
+
+ // We start observing with an initial sync
+ syncOrchestrator.start()
+
+ // Advance the time just enough to make sure the initial sync has run
+ advanceTimeBy(1.milliseconds)
+ startSyncRecorder.assertions().isCalledOnce()
+ }
+
+ @Test
+ fun `when the sync wasn't running before, an initial sync will take place`() = runTest {
+ val startSyncRecorder = lambdaRecorder> { Result.success(Unit) }
+ val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
+ startSyncLambda = startSyncRecorder
+ }
+ val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
+ val syncOrchestrator = createSyncOrchestrator(
+ syncService = syncService,
+ networkMonitor = networkMonitor,
+ )
+
+ // We start observing with an initial sync
+ syncOrchestrator.start()
+
+ // Advance the time just enough to make sure the initial sync has run
+ advanceTimeBy(1.milliseconds)
+ startSyncRecorder.assertions().isCalledOnce()
+
+ // If we wait for a while, the sync will not be started again by the observer since it's already running
+ advanceTimeBy(10.seconds)
+ startSyncRecorder.assertions().isCalledOnce()
+ }
+
+ @Test
+ fun `when the app goes to background and the sync was running, it will be stopped after a delay`() = runTest {
+ val stopSyncRecorder = lambdaRecorder> { Result.success(Unit) }
+ val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply {
+ stopSyncLambda = stopSyncRecorder
+ }
+ val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
+ val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
+ val syncOrchestrator = createSyncOrchestrator(
+ syncService = syncService,
+ networkMonitor = networkMonitor,
+ appForegroundStateService = appForegroundStateService,
+ )
+
+ // We start observing
+ syncOrchestrator.observeStates()
+
+ // Advance the time to make sure the orchestrator has had time to start processing the inputs
+ advanceTimeBy(100.milliseconds)
+
+ // Stop sync was never called
+ stopSyncRecorder.assertions().isNeverCalled()
+
+ // Now we send the app to background
+ appForegroundStateService.isInForeground.value = false
+
+ // Stop sync will be called after some delay
+ stopSyncRecorder.assertions().isNeverCalled()
+ advanceTimeBy(10.seconds)
+ stopSyncRecorder.assertions().isCalledOnce()
+ }
+
+ @Test
+ fun `when the app state changes several times in a short while, stop sync is only called once`() = runTest {
+ val stopSyncRecorder = lambdaRecorder> { Result.success(Unit) }
+ val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply {
+ stopSyncLambda = stopSyncRecorder
+ }
+ val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
+ val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
+ val syncOrchestrator = createSyncOrchestrator(
+ syncService = syncService,
+ networkMonitor = networkMonitor,
+ appForegroundStateService = appForegroundStateService,
+ )
+
+ // We start observing
+ syncOrchestrator.observeStates()
+
+ // Advance the time to make sure the orchestrator has had time to start processing the inputs
+ advanceTimeBy(100.milliseconds)
+
+ // Stop sync was never called
+ stopSyncRecorder.assertions().isNeverCalled()
+
+ // Now we send the app to background
+ appForegroundStateService.isInForeground.value = false
+
+ // Ensure the stop action wasn't called yet
+ stopSyncRecorder.assertions().isNeverCalled()
+ advanceTimeBy(1.seconds)
+ appForegroundStateService.isInForeground.value = true
+ advanceTimeBy(1.seconds)
+
+ // Ensure the stop action wasn't called yet either, since we didn't give it enough time to emit after the expected delay
+ stopSyncRecorder.assertions().isNeverCalled()
+
+ // Now change it again and wait for enough time
+ appForegroundStateService.isInForeground.value = false
+ advanceTimeBy(4.seconds)
+
+ // And confirm it's now called
+ stopSyncRecorder.assertions().isCalledOnce()
+ }
+
+ @Test
+ fun `when the app was in background and we receive a notification, a sync will be started then stopped`() = runTest {
+ val startSyncRecorder = lambdaRecorder> { Result.success(Unit) }
+ val stopSyncRecorder = lambdaRecorder> { Result.success(Unit) }
+ val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
+ startSyncLambda = startSyncRecorder
+ stopSyncLambda = stopSyncRecorder
+ }
+ val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
+ val appForegroundStateService = FakeAppForegroundStateService(
+ initialForegroundValue = false,
+ initialIsSyncingNotificationEventValue = false,
+ )
+ val syncOrchestrator = createSyncOrchestrator(
+ syncService = syncService,
+ networkMonitor = networkMonitor,
+ appForegroundStateService = appForegroundStateService,
+ )
+
+ // We start observing
+ syncOrchestrator.observeStates()
+
+ // Advance the time to make sure the orchestrator has had time to start processing the inputs
+ advanceTimeBy(100.milliseconds)
+
+ // Start sync was never called
+ startSyncRecorder.assertions().isNeverCalled()
+
+ // Now we receive a notification and need to sync
+ appForegroundStateService.updateIsSyncingNotificationEvent(true)
+
+ // Start sync will be called shortly after
+ advanceTimeBy(1.milliseconds)
+ startSyncRecorder.assertions().isCalledOnce()
+
+ // If the sync is running and we mark the notification sync as no longer necessary, the sync stops after a delay
+ syncService.emitSyncState(SyncState.Running)
+ appForegroundStateService.updateIsSyncingNotificationEvent(false)
+
+ advanceTimeBy(10.seconds)
+ stopSyncRecorder.assertions().isCalledOnce()
+ }
+
+ @Test
+ fun `when the app was in background and we join a call, a sync will be started`() = runTest {
+ val startSyncRecorder = lambdaRecorder> { Result.success(Unit) }
+ val stopSyncRecorder = lambdaRecorder> { Result.success(Unit) }
+ val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
+ startSyncLambda = startSyncRecorder
+ stopSyncLambda = stopSyncRecorder
+ }
+ val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
+ val appForegroundStateService = FakeAppForegroundStateService(
+ initialForegroundValue = false,
+ initialIsSyncingNotificationEventValue = false,
+ )
+ val syncOrchestrator = createSyncOrchestrator(
+ syncService = syncService,
+ networkMonitor = networkMonitor,
+ appForegroundStateService = appForegroundStateService,
+ )
+
+ // We start observing
+ syncOrchestrator.observeStates()
+
+ // Advance the time to make sure the orchestrator has had time to start processing the inputs
+ advanceTimeBy(100.milliseconds)
+
+ // Start sync was never called
+ startSyncRecorder.assertions().isNeverCalled()
+
+ // Now we join a call
+ appForegroundStateService.updateIsInCallState(true)
+
+ // Start sync will be called shortly after
+ advanceTimeBy(1.milliseconds)
+ startSyncRecorder.assertions().isCalledOnce()
+
+ // If the sync is running and we mark the in-call state as false, the sync stops after a delay
+ syncService.emitSyncState(SyncState.Running)
+ appForegroundStateService.updateIsInCallState(false)
+
+ advanceTimeBy(10.seconds)
+ stopSyncRecorder.assertions().isCalledOnce()
+ }
+
+ @Test
+ fun `when the app was in background and we have an incoming ringing call, a sync will be started`() = runTest {
+ val startSyncRecorder = lambdaRecorder> { Result.success(Unit) }
+ val stopSyncRecorder = lambdaRecorder> { Result.success(Unit) }
+ val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
+ startSyncLambda = startSyncRecorder
+ stopSyncLambda = stopSyncRecorder
+ }
+ val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
+ val appForegroundStateService = FakeAppForegroundStateService(
+ initialForegroundValue = false,
+ initialIsSyncingNotificationEventValue = false,
+ initialHasRingingCall = false,
+ )
+ val syncOrchestrator = createSyncOrchestrator(
+ syncService = syncService,
+ networkMonitor = networkMonitor,
+ appForegroundStateService = appForegroundStateService,
+ )
+
+ // We start observing
+ syncOrchestrator.observeStates()
+
+ // Advance the time to make sure the orchestrator has had time to start processing the inputs
+ advanceTimeBy(100.milliseconds)
+
+ // Start sync was never called
+ startSyncRecorder.assertions().isNeverCalled()
+
+ // Now we receive a ringing call
+ appForegroundStateService.updateHasRingingCall(true)
+
+ // Start sync will be called shortly after
+ advanceTimeBy(1.milliseconds)
+ startSyncRecorder.assertions().isCalledOnce()
+
+ // If the sync is running and the ringing call notification is now over, the sync stops after a delay
+ syncService.emitSyncState(SyncState.Running)
+ appForegroundStateService.updateHasRingingCall(false)
+
+ advanceTimeBy(10.seconds)
+ stopSyncRecorder.assertions().isCalledOnce()
+ }
+
+ @Test
+ fun `when the app is in foreground, we sync for a notification and a call is ongoing, the sync will only stop when all conditions are false`() = runTest {
+ val startSyncRecorder = lambdaRecorder> { Result.success(Unit) }
+ val stopSyncRecorder = lambdaRecorder> { Result.success(Unit) }
+ val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply {
+ startSyncLambda = startSyncRecorder
+ stopSyncLambda = stopSyncRecorder
+ }
+ val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
+ val appForegroundStateService = FakeAppForegroundStateService(
+ initialForegroundValue = true,
+ initialIsSyncingNotificationEventValue = true,
+ initialIsInCallValue = true,
+ )
+ val syncOrchestrator = createSyncOrchestrator(
+ syncService = syncService,
+ networkMonitor = networkMonitor,
+ appForegroundStateService = appForegroundStateService,
+ )
+
+ // We start observing
+ syncOrchestrator.observeStates()
+
+ // Advance the time to make sure the orchestrator has had time to start processing the inputs
+ advanceTimeBy(100.milliseconds)
+
+ // Start sync was never called
+ startSyncRecorder.assertions().isNeverCalled()
+
+ // We send the app to background, it's still syncing
+ appForegroundStateService.givenIsInForeground(false)
+ advanceTimeBy(10.seconds)
+ stopSyncRecorder.assertions().isNeverCalled()
+
+ // We stop the notification sync, it's still syncing
+ appForegroundStateService.updateIsSyncingNotificationEvent(false)
+ advanceTimeBy(10.seconds)
+ stopSyncRecorder.assertions().isNeverCalled()
+
+ // We set the in-call state to false, now it stops syncing after a delay
+ appForegroundStateService.updateIsInCallState(false)
+ advanceTimeBy(10.seconds)
+ stopSyncRecorder.assertions().isCalledOnce()
+ }
+
+ @Test
+ fun `if the sync was running, it's set to be stopped but something triggers a sync again, the sync is not stopped`() = runTest {
+ val stopSyncRecorder = lambdaRecorder> { Result.success(Unit) }
+ val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply {
+ stopSyncLambda = stopSyncRecorder
+ }
+ val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
+ val appForegroundStateService = FakeAppForegroundStateService(
+ initialForegroundValue = true,
+ initialIsSyncingNotificationEventValue = false,
+ initialIsInCallValue = false,
+ )
+ val syncOrchestrator = createSyncOrchestrator(
+ syncService = syncService,
+ networkMonitor = networkMonitor,
+ appForegroundStateService = appForegroundStateService,
+ )
+
+ // We start observing
+ syncOrchestrator.observeStates()
+
+ // Advance the time to make sure the orchestrator has had time to start processing the inputs
+ advanceTimeBy(100.milliseconds)
+
+ // This will set the sync to stop
+ appForegroundStateService.givenIsInForeground(false)
+
+ // But if we reset it quickly before the stop sync takes place, the sync is not stopped
+ advanceTimeBy(2.seconds)
+ appForegroundStateService.givenIsInForeground(true)
+
+ advanceTimeBy(10.seconds)
+ stopSyncRecorder.assertions().isNeverCalled()
+ }
+
+ @Test
+ fun `when network is offline, sync service should not start`() = runTest {
+ val startSyncRecorder = lambdaRecorder> { Result.success(Unit) }
+ val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
+ startSyncLambda = startSyncRecorder
+ }
+ val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
+ val syncOrchestrator = createSyncOrchestrator(
+ syncService = syncService,
+ networkMonitor = networkMonitor,
+ )
+
+ // We start observing
+ syncOrchestrator.observeStates()
+
+ // This should still not trigger a sync, since there is no network
+ advanceTimeBy(10.seconds)
+ startSyncRecorder.assertions().isNeverCalled()
+ }
+
+ private fun TestScope.createSyncOrchestrator(
+ syncService: FakeSyncService = FakeSyncService(),
+ networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
+ appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
+ ) = SyncOrchestrator(
+ matrixClient = FakeMatrixClient(syncService = syncService, sessionCoroutineScope = backgroundScope),
+ networkMonitor = networkMonitor,
+ appForegroundStateService = appForegroundStateService,
+ dispatchers = testCoroutineDispatchers(),
+ )
+}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt
deleted file mode 100644
index 23dfc9c5222..00000000000
--- a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt
+++ /dev/null
@@ -1,97 +0,0 @@
-/*
- * Copyright 2023, 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.appnav.di
-
-import com.bumble.appyx.core.state.MutableSavedStateMapImpl
-import com.google.common.truth.Truth.assertThat
-import io.element.android.libraries.matrix.test.A_SESSION_ID
-import io.element.android.libraries.matrix.test.FakeMatrixClient
-import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
-import kotlinx.coroutines.test.runTest
-import org.junit.Test
-
-class MatrixClientsHolderTest {
- @Test
- fun `test getOrNull`() {
- val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
- assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
- }
-
- @Test
- fun `test getOrRestore`() = runTest {
- val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
- val fakeMatrixClient = FakeMatrixClient()
- fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
- assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
- assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
- // Do it again to hit the cache
- assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
- assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
- }
-
- @Test
- fun `test remove`() = runTest {
- val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
- val fakeMatrixClient = FakeMatrixClient()
- fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
- assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
- assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
- // Remove
- matrixClientsHolder.remove(A_SESSION_ID)
- assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
- }
-
- @Test
- fun `test remove all`() = runTest {
- val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
- val fakeMatrixClient = FakeMatrixClient()
- fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
- assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
- assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
- // Remove all
- matrixClientsHolder.removeAll()
- assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
- }
-
- @Test
- fun `test save and restore`() = runTest {
- val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
- val fakeMatrixClient = FakeMatrixClient()
- fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
- matrixClientsHolder.getOrRestore(A_SESSION_ID)
- val savedStateMap = MutableSavedStateMapImpl { true }
- matrixClientsHolder.saveIntoSavedState(savedStateMap)
- assertThat(savedStateMap.size).isEqualTo(1)
- // Test Restore with non-empty map
- matrixClientsHolder.restoreWithSavedState(savedStateMap)
- // Empty the map
- matrixClientsHolder.removeAll()
- assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
- // Restore again
- matrixClientsHolder.restoreWithSavedState(savedStateMap)
- assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
- }
-
- @Test
- fun `test AuthenticationService listenToNewMatrixClients emits a Client value and we save it`() = runTest {
- val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
- assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
-
- fakeAuthenticationService.givenMatrixClient(FakeMatrixClient(sessionId = A_SESSION_ID))
- val loginSucceeded = fakeAuthenticationService.login("user", "pass")
-
- assertThat(loginSucceeded.isSuccess).isTrue()
- assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNotNull()
- }
-}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt
new file mode 100644
index 00000000000..47237d5f779
--- /dev/null
+++ b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2023, 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.appnav.di
+
+import com.bumble.appyx.core.state.MutableSavedStateMapImpl
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.test.A_SESSION_ID
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
+import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
+import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class MatrixSessionCacheTest {
+ @Test
+ fun `test getOrNull`() = runTest {
+ val fakeAuthenticationService = FakeMatrixAuthenticationService()
+ val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
+ assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
+ }
+
+ @Test
+ fun `test getSyncOrchestratorOrNull`() = runTest {
+ val fakeAuthenticationService = FakeMatrixAuthenticationService()
+ val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
+
+ // With no matrix client there is no sync orchestrator
+ assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
+ assertThat(matrixSessionCache.getSyncOrchestrator(A_SESSION_ID)).isNull()
+
+ // But as soon as we receive a client, we can get the sync orchestrator
+ val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
+ fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
+ assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
+ assertThat(matrixSessionCache.getSyncOrchestrator(A_SESSION_ID)).isNotNull()
+ }
+
+ @Test
+ fun `test getOrRestore`() = runTest {
+ val fakeAuthenticationService = FakeMatrixAuthenticationService()
+ val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
+ val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
+ fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
+ assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
+ assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
+ // Do it again to hit the cache
+ assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
+ assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
+ }
+
+ @Test
+ fun `test remove`() = runTest {
+ val fakeAuthenticationService = FakeMatrixAuthenticationService()
+ val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
+ val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
+ fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
+ assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
+ assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
+ // Remove
+ matrixSessionCache.remove(A_SESSION_ID)
+ assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
+ }
+
+ @Test
+ fun `test remove all`() = runTest {
+ val fakeAuthenticationService = FakeMatrixAuthenticationService()
+ val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
+ val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
+ fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
+ assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
+ assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
+ // Remove all
+ matrixSessionCache.removeAll()
+ assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
+ }
+
+ @Test
+ fun `test save and restore`() = runTest {
+ val fakeAuthenticationService = FakeMatrixAuthenticationService()
+ val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
+ val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
+ fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
+ matrixSessionCache.getOrRestore(A_SESSION_ID)
+ val savedStateMap = MutableSavedStateMapImpl { true }
+ matrixSessionCache.saveIntoSavedState(savedStateMap)
+ assertThat(savedStateMap.size).isEqualTo(1)
+ // Test Restore with non-empty map
+ matrixSessionCache.restoreWithSavedState(savedStateMap)
+ // Empty the map
+ matrixSessionCache.removeAll()
+ assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
+ // Restore again
+ matrixSessionCache.restoreWithSavedState(savedStateMap)
+ assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
+ }
+
+ @Test
+ fun `test AuthenticationService listenToNewMatrixClients emits a Client value and we save it`() = runTest {
+ val fakeAuthenticationService = FakeMatrixAuthenticationService()
+ val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
+ assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
+
+ fakeAuthenticationService.givenMatrixClient(FakeMatrixClient(sessionId = A_SESSION_ID, sessionCoroutineScope = backgroundScope))
+ val loginSucceeded = fakeAuthenticationService.login("user", "pass")
+
+ assertThat(loginSucceeded.isSuccess).isTrue()
+ assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNotNull()
+ }
+
+ private fun TestScope.createSyncOrchestratorFactory() = object : SyncOrchestrator.Factory {
+ override fun create(matrixClient: MatrixClient): SyncOrchestrator {
+ return SyncOrchestrator(
+ matrixClient,
+ appForegroundStateService = FakeAppForegroundStateService(),
+ networkMonitor = FakeNetworkMonitor(),
+ dispatchers = testCoroutineDispatchers(),
+ )
+ }
+ }
+}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
index 8640fb03c83..e95eb66cc38 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
@@ -12,6 +12,8 @@ import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import com.google.common.truth.Truth.assertThat
+import io.element.android.features.login.api.LoginParams
+import io.element.android.features.login.test.FakeLoginIntentResolver
import io.element.android.libraries.deeplink.DeepLinkCreator
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser
@@ -22,10 +24,8 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.oidc.api.OidcAction
-import io.element.android.libraries.oidc.impl.DefaultOidcIntentResolver
-import io.element.android.libraries.oidc.impl.OidcUrlParser
+import io.element.android.libraries.oidc.test.FakeOidcIntentResolver
import io.element.android.tests.testutils.lambda.lambdaError
-import org.junit.Assert.assertThrows
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@@ -115,56 +115,31 @@ class IntentResolverTest {
}
@Test
- fun `test resolve oidc go back`() {
- val sut = createIntentResolver()
- val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
- action = Intent.ACTION_VIEW
- data = "io.element:/callback?error=access_denied&state=IFF1UETGye2ZA8pO".toUri()
- }
- val result = sut.resolve(intent)
- assertThat(result).isEqualTo(
- ResolvedIntent.Oidc(
- oidcAction = OidcAction.GoBack
- )
+ fun `test resolve oidc`() {
+ val sut = createIntentResolver(
+ oidcIntentResolverResult = { OidcAction.GoBack },
)
- }
-
- @Test
- fun `test resolve oidc success`() {
- val sut = createIntentResolver()
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
- data = "io.element:/callback?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB".toUri()
+ data = "io.element.android:/?error=access_denied&state=IFF1UETGye2ZA8pO".toUri()
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
ResolvedIntent.Oidc(
- oidcAction = OidcAction.Success(
- url = "io.element:/callback?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
- )
+ oidcAction = OidcAction.GoBack
)
)
}
- @Test
- fun `test resolve oidc invalid`() {
- val sut = createIntentResolver()
- val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
- action = Intent.ACTION_VIEW
- data = "io.element:/callback/invalid".toUri()
- }
- assertThrows(IllegalStateException::class.java) {
- sut.resolve(intent)
- }
- }
-
@Test
fun `test resolve external permalink`() {
val permalinkData = PermalinkData.UserLink(
userId = UserId("@alice:matrix.org")
)
val sut = createIntentResolver(
- permalinkParserResult = { permalinkData }
+ loginIntentResolverResult = { null },
+ permalinkParserResult = { permalinkData },
+ oidcIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
@@ -181,7 +156,9 @@ class IntentResolverTest {
@Test
fun `test resolve external permalink, FallbackLink should be ignored`() {
val sut = createIntentResolver(
- permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) }
+ permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) },
+ loginIntentResolverResult = { null },
+ oidcIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
@@ -197,7 +174,8 @@ class IntentResolverTest {
userId = UserId("@alice:matrix.org")
)
val sut = createIntentResolver(
- permalinkParserResult = { permalinkData }
+ permalinkParserResult = { permalinkData },
+ oidcIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_BATTERY_LOW
@@ -209,7 +187,9 @@ class IntentResolverTest {
@Test
fun `test incoming share simple`() {
- val sut = createIntentResolver()
+ val sut = createIntentResolver(
+ oidcIntentResolverResult = { null },
+ )
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_SEND
}
@@ -219,7 +199,9 @@ class IntentResolverTest {
@Test
fun `test incoming share multiple`() {
- val sut = createIntentResolver()
+ val sut = createIntentResolver(
+ oidcIntentResolverResult = { null },
+ )
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_SEND_MULTIPLE
}
@@ -230,7 +212,9 @@ class IntentResolverTest {
@Test
fun `test resolve invalid`() {
val sut = createIntentResolver(
- permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) }
+ permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) },
+ loginIntentResolverResult = { null },
+ oidcIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
@@ -240,13 +224,33 @@ class IntentResolverTest {
assertThat(result).isNull()
}
+ @Test
+ fun `test resolve login param`() {
+ val aLoginParams = LoginParams("accountProvider", null)
+ val sut = createIntentResolver(
+ loginIntentResolverResult = { aLoginParams },
+ oidcIntentResolverResult = { null },
+ )
+ val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
+ action = Intent.ACTION_VIEW
+ data = "".toUri()
+ }
+ val result = sut.resolve(intent)
+ assertThat(result).isEqualTo(ResolvedIntent.Login(aLoginParams))
+ }
+
private fun createIntentResolver(
- permalinkParserResult: () -> PermalinkData = { lambdaError() }
+ permalinkParserResult: (String) -> PermalinkData = { lambdaError() },
+ loginIntentResolverResult: (String) -> LoginParams? = { lambdaError() },
+ oidcIntentResolverResult: (Intent) -> OidcAction? = { lambdaError() },
): IntentResolver {
return IntentResolver(
deeplinkParser = DeeplinkParser(),
- oidcIntentResolver = DefaultOidcIntentResolver(
- oidcUrlParser = OidcUrlParser()
+ loginIntentResolver = FakeLoginIntentResolver(
+ parseResult = loginIntentResolverResult,
+ ),
+ oidcIntentResolver = FakeOidcIntentResolver(
+ resolveResult = oidcIntentResolverResult,
),
permalinkParser = FakePermalinkParser(
result = permalinkParserResult
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
index 3f0713cd993..d86c46f1598 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
@@ -5,19 +5,20 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
package io.element.android.appnav.loggedin
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
-import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties
+import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient
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.RecoveryState
+import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncState
@@ -26,12 +27,11 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
-import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
-import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.test.FakePushService
import io.element.android.libraries.pushproviders.api.Distributor
@@ -45,9 +45,8 @@ import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -59,10 +58,7 @@ class LoggedInPresenterTest {
@Test
fun `present - initial state`() = runTest {
- val presenter = createLoggedInPresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ createLoggedInPresenter().test {
val initialState = awaitItem()
assertThat(initialState.showSyncSpinner).isFalse()
assertThat(initialState.pusherRegistrationState.isUninitialized()).isTrue()
@@ -70,13 +66,32 @@ class LoggedInPresenterTest {
}
}
+ @Test
+ fun `present - ensure that account urls are preloaded`() = runTest {
+ val accountManagementUrlResult = lambdaRecorder> { Result.success("aUrl") }
+ val matrixClient = FakeMatrixClient(
+ accountManagementUrlResult = accountManagementUrlResult,
+ )
+ createLoggedInPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ awaitItem()
+ advanceUntilIdle()
+ accountManagementUrlResult.assertions().isCalledExactly(2)
+ .withSequence(
+ listOf(value(AccountManagementAction.Profile)),
+ listOf(value(AccountManagementAction.SessionsList)),
+ )
+ }
+ }
+
@Test
fun `present - show sync spinner`() = runTest {
val roomListService = FakeRoomListService()
- val presenter = createLoggedInPresenter(roomListService, SyncState.Running)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ createLoggedInPresenter(
+ syncState = SyncState.Running,
+ matrixClient = FakeMatrixClient(roomListService = roomListService),
+ ).test {
val initialState = awaitItem()
assertThat(initialState.showSyncSpinner).isFalse()
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show)
@@ -92,18 +107,19 @@ class LoggedInPresenterTest {
val roomListService = FakeRoomListService()
val verificationService = FakeSessionVerificationService()
val encryptionService = FakeEncryptionService()
- val presenter = LoggedInPresenter(
- matrixClient = FakeMatrixClient(roomListService = roomListService, encryptionService = encryptionService),
+ val buildMeta = aBuildMeta()
+ LoggedInPresenter(
+ matrixClient = FakeMatrixClient(
+ roomListService = roomListService,
+ encryptionService = encryptionService,
+ ),
syncService = FakeSyncService(initialSyncState = SyncState.Running),
pushService = FakePushService(),
sessionVerificationService = verificationService,
analyticsService = analyticsService,
encryptionService = encryptionService,
- enableNativeSlidingSyncUseCase = EnableNativeSlidingSyncUseCase(InMemoryAppPreferencesStore(), this),
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ buildMeta = buildMeta,
+ ).test {
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
verificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
@@ -129,13 +145,10 @@ class LoggedInPresenterTest {
val verificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.NotVerified
)
- val presenter = createLoggedInPresenter(
+ createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = verificationService,
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ ).test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.errorOrNull())
.isInstanceOf(PusherRegistrationFailure.AccountNotVerified::class.java)
@@ -155,13 +168,13 @@ class LoggedInPresenterTest {
val pushService = createFakePushService(
registerWithLambda = lambda,
)
- val presenter = createLoggedInPresenter(
+ createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ matrixClient = FakeMatrixClient(
+ accountManagementUrlResult = { Result.success(null) },
+ ),
+ ).test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
lambda.assertions()
@@ -188,13 +201,13 @@ class LoggedInPresenterTest {
val pushService = createFakePushService(
registerWithLambda = lambda,
)
- val presenter = createLoggedInPresenter(
+ createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ matrixClient = FakeMatrixClient(
+ accountManagementUrlResult = { Result.success(null) },
+ ),
+ ).test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.isFailure()).isTrue()
lambda.assertions()
@@ -233,13 +246,13 @@ class LoggedInPresenterTest {
currentPushProvider = { pushProvider },
registerWithLambda = lambda,
)
- val presenter = createLoggedInPresenter(
+ createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ matrixClient = FakeMatrixClient(
+ accountManagementUrlResult = { Result.success(null) },
+ ),
+ ).test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
lambda.assertions()
@@ -277,13 +290,13 @@ class LoggedInPresenterTest {
currentPushProvider = { pushProvider },
registerWithLambda = lambda,
)
- val presenter = createLoggedInPresenter(
+ createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ matrixClient = FakeMatrixClient(
+ accountManagementUrlResult = { Result.success(null) },
+ ),
+ ).test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
lambda.assertions()
@@ -317,13 +330,10 @@ class LoggedInPresenterTest {
currentPushProvider = { pushProvider },
registerWithLambda = lambda,
)
- val presenter = createLoggedInPresenter(
+ createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ ).test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.errorOrNull())
.isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java)
@@ -345,13 +355,10 @@ class LoggedInPresenterTest {
registerWithLambda = lambda,
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda,
)
- val presenter = createLoggedInPresenter(
+ createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ ).test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.errorOrNull())
.isInstanceOf(PusherRegistrationFailure.NoProvidersAvailable::class.java)
@@ -394,13 +401,10 @@ class LoggedInPresenterTest {
registerWithLambda = lambda,
selectPushProviderLambda = selectPushProviderLambda,
)
- val presenter = createLoggedInPresenter(
+ createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ ).test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.errorOrNull())
.isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java)
@@ -445,13 +449,13 @@ class LoggedInPresenterTest {
pushProvider1 = pushProvider1,
registerWithLambda = lambda,
)
- val presenter = createLoggedInPresenter(
+ createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ matrixClient = FakeMatrixClient(
+ accountManagementUrlResult = { Result.success(null) },
+ ),
+ ).test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
lambda.assertions().isCalledOnce()
@@ -505,10 +509,9 @@ class LoggedInPresenterTest {
currentSlidingSyncVersionLambda = { Result.success(SlidingSyncVersion.Proxy) },
availableSlidingSyncVersionsLambda = { Result.success(listOf(SlidingSyncVersion.Native)) },
)
- val presenter = createLoggedInPresenter(matrixClient = matrixClient)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ createLoggedInPresenter(
+ matrixClient = matrixClient,
+ ).test {
val initialState = awaitItem()
assertThat(initialState.forceNativeSlidingSyncMigration).isFalse()
@@ -518,51 +521,27 @@ class LoggedInPresenterTest {
}
}
- @Test
- fun `present - CheckSlidingSyncProxyAvailability will not force the migration if native sliding sync is not supported too`() = runTest {
- val matrixClient = FakeMatrixClient(
- currentSlidingSyncVersionLambda = { Result.success(SlidingSyncVersion.Proxy) },
- availableSlidingSyncVersionsLambda = { Result.success(emptyList()) },
- )
- val presenter = createLoggedInPresenter(matrixClient = matrixClient)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- assertThat(initialState.forceNativeSlidingSyncMigration).isFalse()
-
- initialState.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability)
-
- expectNoEvents()
- }
- }
-
@OptIn(ExperimentalCoroutinesApi::class)
@Test
- fun `present - LogoutAndMigrateToNativeSlidingSync enables native sliding sync and logs out the user`() = runTest {
- val logoutLambda = lambdaRecorder { userInitiated, ignoreSdkError ->
+ fun `present - LogoutAndMigrateToNativeSlidingSync logs out the user`() = runTest {
+ val logoutLambda = lambdaRecorder { userInitiated, ignoreSdkError ->
assertThat(userInitiated).isTrue()
assertThat(ignoreSdkError).isTrue()
- null
}
- val matrixClient = FakeMatrixClient().apply {
+ val matrixClient = FakeMatrixClient(
+ accountManagementUrlResult = { Result.success(null) },
+ ).apply {
this.logoutLambda = logoutLambda
}
- val appPreferencesStore = InMemoryAppPreferencesStore()
- val enableNativeSlidingSyncUseCase = EnableNativeSlidingSyncUseCase(appPreferencesStore, this)
- val presenter = createLoggedInPresenter(matrixClient = matrixClient, enableNativeSlidingSyncUseCase = enableNativeSlidingSyncUseCase)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ createLoggedInPresenter(
+ matrixClient = matrixClient,
+ ).test {
val initialState = awaitItem()
- assertThat(appPreferencesStore.isSimplifiedSlidingSyncEnabledFlow().first()).isFalse()
-
initialState.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync)
advanceUntilIdle()
- assertThat(appPreferencesStore.isSimplifiedSlidingSyncEnabledFlow().first()).isTrue()
assertThat(logoutLambda.assertions().isCalledOnce())
}
}
@@ -572,15 +551,16 @@ class LoggedInPresenterTest {
return awaitItem()
}
- private fun TestScope.createLoggedInPresenter(
- roomListService: RoomListService = FakeRoomListService(),
+ private fun createLoggedInPresenter(
syncState: SyncState = SyncState.Running,
analyticsService: AnalyticsService = FakeAnalyticsService(),
sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(),
encryptionService: EncryptionService = FakeEncryptionService(),
pushService: PushService = FakePushService(),
- enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase = EnableNativeSlidingSyncUseCase(InMemoryAppPreferencesStore(), this),
- matrixClient: MatrixClient = FakeMatrixClient(roomListService = roomListService),
+ matrixClient: MatrixClient = FakeMatrixClient(
+ accountManagementUrlResult = { Result.success(null) },
+ ),
+ buildMeta: BuildMeta = aBuildMeta(),
): LoggedInPresenter {
return LoggedInPresenter(
matrixClient = matrixClient,
@@ -589,7 +569,7 @@ class LoggedInPresenterTest {
sessionVerificationService = sessionVerificationService,
analyticsService = analyticsService,
encryptionService = encryptionService,
- enableNativeSlidingSyncUseCase = enableNativeSlidingSyncUseCase,
+ buildMeta = buildMeta,
)
}
}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigrationTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigrationTest.kt
new file mode 100644
index 00000000000..459abc64d4d
--- /dev/null
+++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigrationTest.kt
@@ -0,0 +1,162 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:Suppress("DEPRECATION")
+
+package io.element.android.appnav.loggedin
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.media.MediaPreviewConfig
+import io.element.android.libraries.matrix.api.media.MediaPreviewValue
+import io.element.android.libraries.matrix.test.media.FakeMediaPreviewService
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class MediaPreviewConfigMigrationTest {
+ @Test
+ fun `when no local data exists, migration does nothing`() = runTest {
+ val appPreferencesStore = InMemoryAppPreferencesStore()
+ val mediaPreviewService = FakeMediaPreviewService(
+ fetchMediaPreviewConfigResult = { Result.success(null) }
+ )
+ val migration = createMigration(appPreferencesStore, mediaPreviewService)
+
+ migration().join()
+
+ // Verify no calls were made to set server config
+ // since there's nothing to migrate
+ }
+
+ @Test
+ fun `when local data exists and server has config, clears local data`() = runTest {
+ val appPreferencesStore = InMemoryAppPreferencesStore().apply {
+ setHideInviteAvatars(true)
+ setTimelineMediaPreviewValue(MediaPreviewValue.Private)
+ }
+ val serverConfig = MediaPreviewConfig(
+ hideInviteAvatar = false,
+ mediaPreviewValue = MediaPreviewValue.On
+ )
+ val mediaPreviewService = FakeMediaPreviewService(
+ fetchMediaPreviewConfigResult = { Result.success(serverConfig) }
+ )
+ val migration = createMigration(appPreferencesStore, mediaPreviewService)
+
+ migration().join()
+
+ // Verify local data was cleared
+ assertThat(appPreferencesStore.getHideInviteAvatarsFlow().first()).isNull()
+ assertThat(appPreferencesStore.getTimelineMediaPreviewValueFlow().first()).isNull()
+ }
+
+ @Test
+ fun `when local hideInviteAvatars exists and server has no config, migrates to server`() = runTest {
+ val appPreferencesStore = InMemoryAppPreferencesStore().apply {
+ setHideInviteAvatars(true)
+ }
+ var setHideInviteAvatarsValue: Boolean? = null
+ val mediaPreviewService = FakeMediaPreviewService(
+ fetchMediaPreviewConfigResult = { Result.success(null) },
+ setHideInviteAvatarsResult = { value ->
+ setHideInviteAvatarsValue = value
+ Result.success(Unit)
+ }
+ )
+ val migration = createMigration(appPreferencesStore, mediaPreviewService)
+
+ migration().join()
+
+ // Verify server was updated with local value
+ assertThat(setHideInviteAvatarsValue).isTrue()
+ // Verify local data was cleared
+ assertThat(appPreferencesStore.getHideInviteAvatarsFlow().first()).isNull()
+ }
+
+ @Test
+ fun `when local mediaPreviewValue exists and server has no config, migrates to server`() = runTest {
+ val appPreferencesStore = InMemoryAppPreferencesStore().apply {
+ setTimelineMediaPreviewValue(MediaPreviewValue.Private)
+ }
+ var setMediaPreviewValue: MediaPreviewValue? = null
+ val mediaPreviewService = FakeMediaPreviewService(
+ fetchMediaPreviewConfigResult = { Result.success(null) },
+ setMediaPreviewValueResult = { value ->
+ setMediaPreviewValue = value
+ Result.success(Unit)
+ }
+ )
+ val migration = createMigration(appPreferencesStore, mediaPreviewService)
+
+ migration().join()
+
+ // Verify server was updated with local value
+ assertThat(setMediaPreviewValue).isEqualTo(MediaPreviewValue.Private)
+ // Verify local data was cleared
+ assertThat(appPreferencesStore.getTimelineMediaPreviewValueFlow().first()).isNull()
+ }
+
+ @Test
+ fun `when both local values exist and server has no config, migrates both to server`() = runTest {
+ val appPreferencesStore = InMemoryAppPreferencesStore().apply {
+ setHideInviteAvatars(true)
+ setTimelineMediaPreviewValue(MediaPreviewValue.Off)
+ }
+ var setHideInviteAvatarsValue: Boolean? = null
+ var setMediaPreviewValue: MediaPreviewValue? = null
+ val mediaPreviewService = FakeMediaPreviewService(
+ fetchMediaPreviewConfigResult = { Result.success(null) },
+ setHideInviteAvatarsResult = { value ->
+ setHideInviteAvatarsValue = value
+ Result.success(Unit)
+ },
+ setMediaPreviewValueResult = { value ->
+ setMediaPreviewValue = value
+ Result.success(Unit)
+ }
+ )
+ val migration = createMigration(appPreferencesStore, mediaPreviewService)
+
+ migration().join()
+
+ // Verify server was updated with both local values
+ assertThat(setHideInviteAvatarsValue).isTrue()
+ assertThat(setMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
+ // Verify local data was cleared
+ assertThat(appPreferencesStore.getHideInviteAvatarsFlow().first()).isNull()
+ assertThat(appPreferencesStore.getTimelineMediaPreviewValueFlow().first()).isNull()
+ }
+
+ @Test
+ fun `when fetch config fails, migration does nothing`() = runTest {
+ val appPreferencesStore = InMemoryAppPreferencesStore().apply {
+ setHideInviteAvatars(true)
+ setTimelineMediaPreviewValue(MediaPreviewValue.Private)
+ }
+ val mediaPreviewService = FakeMediaPreviewService(
+ fetchMediaPreviewConfigResult = { Result.failure(Exception("Network error")) }
+ )
+ val migration = createMigration(appPreferencesStore, mediaPreviewService)
+
+ migration().join()
+
+ // Verify local data was not cleared since migration failed
+ assertThat(appPreferencesStore.getHideInviteAvatarsFlow().first()).isTrue()
+ assertThat(appPreferencesStore.getTimelineMediaPreviewValueFlow().first()).isEqualTo(MediaPreviewValue.Private)
+ }
+
+ private fun TestScope.createMigration(
+ appPreferencesStore: InMemoryAppPreferencesStore,
+ mediaPreviewService: FakeMediaPreviewService
+ ) = MediaPreviewConfigMigration(
+ mediaPreviewService = mediaPreviewService,
+ appPreferencesStore = appPreferencesStore,
+ sessionCoroutineScope = this
+ )
+}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/SendQueuesTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/SendQueuesTest.kt
index 597139c5e97..c117dfb8f76 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/SendQueuesTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/SendQueuesTest.kt
@@ -10,7 +10,7 @@ package io.element.android.appnav.loggedin
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.test.FakeMatrixClient
-import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -35,8 +35,8 @@ class SendQueuesTest {
matrixClient.sendQueueDisabledFlow = sendQueueDisabledFlow
matrixClient.setAllSendQueuesEnabledLambda = setAllSendQueuesEnabledLambda
val setRoomSendQueueEnabledLambda = lambdaRecorder { _: Boolean -> }
- val room = FakeMatrixRoom(
- setSendQueueEnabledLambda = setRoomSendQueueEnabledLambda
+ val room = FakeJoinedRoom(
+ setSendQueueEnabledResult = setRoomSendQueueEnabledLambda
)
matrixClient.givenGetRoomResult(room.roomId, room)
sut.launchIn(backgroundScope)
@@ -61,8 +61,8 @@ class SendQueuesTest {
matrixClient.setAllSendQueuesEnabledLambda = setAllSendQueuesEnabledLambda
syncService.emitSyncState(SyncState.Offline)
val setRoomSendQueueEnabledLambda = lambdaRecorder { _: Boolean -> }
- val room = FakeMatrixRoom(
- setSendQueueEnabledLambda = setRoomSendQueueEnabledLambda
+ val room = FakeJoinedRoom(
+ setSendQueueEnabledResult = setRoomSendQueueEnabledLambda
)
matrixClient.givenGetRoomResult(room.roomId, room)
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingBaseRoomStateFlowFactoryTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingBaseRoomStateFlowFactoryTest.kt
new file mode 100644
index 00000000000..91e0b9f9681
--- /dev/null
+++ b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingBaseRoomStateFlowFactoryTest.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2023, 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.appnav.room
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.roomlist.RoomList
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_SESSION_ID
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.room.FakeBaseRoom
+import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
+import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
+import io.element.android.libraries.matrix.ui.room.LoadingRoomState
+import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class LoadingBaseRoomStateFlowFactoryTest {
+ @Test
+ fun `flow should emit Loading and then Loaded when there is a room in cache`() = runTest {
+ val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID))
+ val matrixClient = FakeMatrixClient(A_SESSION_ID).apply {
+ givenGetRoomResult(A_ROOM_ID, room)
+ }
+ val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
+ flowFactory
+ .create(this, A_ROOM_ID)
+ .test {
+ assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
+ assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
+ }
+ }
+
+ @Test
+ fun `flow should emit Loading and then Loaded when there is a room in cache after SS is loaded`() = runTest {
+ val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID))
+ val roomListService = FakeRoomListService()
+ val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService)
+ val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
+ flowFactory
+ .create(this, A_ROOM_ID)
+ .test {
+ assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
+ matrixClient.givenGetRoomResult(A_ROOM_ID, room)
+ roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
+ assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
+ }
+ }
+
+ @Test
+ fun `flow should emit Loading and then Error when there is no room in cache after SS is loaded`() = runTest {
+ val roomListService = FakeRoomListService()
+ val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService)
+ val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
+ flowFactory
+ .create(this, A_ROOM_ID)
+ .test {
+ assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
+ roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
+ assertThat(awaitItem()).isEqualTo(LoadingRoomState.Error)
+ }
+ }
+}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt
deleted file mode 100644
index c94cbd229a1..00000000000
--- a/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * Copyright 2023, 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.appnav.room
-
-import app.cash.turbine.test
-import com.google.common.truth.Truth.assertThat
-import io.element.android.appnav.room.joined.LoadingRoomState
-import io.element.android.appnav.room.joined.LoadingRoomStateFlowFactory
-import io.element.android.libraries.matrix.api.roomlist.RoomList
-import io.element.android.libraries.matrix.test.A_ROOM_ID
-import io.element.android.libraries.matrix.test.A_SESSION_ID
-import io.element.android.libraries.matrix.test.FakeMatrixClient
-import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
-import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
-import kotlinx.coroutines.test.runTest
-import org.junit.Test
-
-class LoadingRoomStateFlowFactoryTest {
- @Test
- fun `flow should emit Loading and then Loaded when there is a room in cache`() = runTest {
- val room = FakeMatrixRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID)
- val matrixClient = FakeMatrixClient(A_SESSION_ID).apply {
- givenGetRoomResult(A_ROOM_ID, room)
- }
- val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
- flowFactory
- .create(this, A_ROOM_ID)
- .test {
- assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
- assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
- }
- }
-
- @Test
- fun `flow should emit Loading and then Loaded when there is a room in cache after SS is loaded`() = runTest {
- val room = FakeMatrixRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID)
- val roomListService = FakeRoomListService()
- val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService)
- val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
- flowFactory
- .create(this, A_ROOM_ID)
- .test {
- assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
- matrixClient.givenGetRoomResult(A_ROOM_ID, room)
- roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
- assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
- }
- }
-
- @Test
- fun `flow should emit Loading and then Error when there is no room in cache after SS is loaded`() = runTest {
- val roomListService = FakeRoomListService()
- val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService)
- val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
- flowFactory
- .create(this, A_ROOM_ID)
- .test {
- assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
- roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
- assertThat(awaitItem()).isEqualTo(LoadingRoomState.Error)
- }
- }
-}
diff --git a/build.gradle.kts b/build.gradle.kts
index afdbc03ac5d..0e585a3d6bc 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -50,6 +50,7 @@ allprojects {
}
dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.22")
+ detektPlugins(project(":tests:detekt-rules"))
}
tasks.withType().configureEach {
@@ -97,6 +98,10 @@ allprojects {
// Uncomment to suppress Compose Kotlin compiler compatibility warning
// freeCompilerArgs.addAll(listOf("-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true"))
+
+ // Fix compilation warning for annotations
+ // See https://youtrack.jetbrains.com/issue/KT-73255/Change-defaulting-rule-for-annotations for more details
+ freeCompilerArgs.add("-Xannotation-default-target=first-only")
}
}
}
@@ -166,14 +171,17 @@ allprojects {
// Register quality check tasks.
tasks.register("runQualityChecks") {
dependsOn(":tests:konsist:testDebugUnitTest")
+ dependsOn(":app:lintGplayDebug")
project.subprojects {
- // For some reason `findByName("lint")` doesn't work
- tasks.findByPath("$path:lint")?.let { dependsOn(it) }
+ tasks.findByPath("$path:lintDebug")?.let { dependsOn(it) }
tasks.findByName("detekt")?.let { dependsOn(it) }
tasks.findByName("ktlintCheck")?.let { dependsOn(it) }
// tasks.findByName("buildHealth")?.let { dependsOn(it) }
}
dependsOn(":app:knitCheck")
+
+ // Make sure all checks run even if some fail
+ gradle.startParameter.isContinueOnFailure = true
}
// Make sure to delete old screenshots before recording new ones
diff --git a/docs/_developer_onboarding.md b/docs/_developer_onboarding.md
index 738bd124303..b8141daef82 100644
--- a/docs/_developer_onboarding.md
+++ b/docs/_developer_onboarding.md
@@ -10,7 +10,7 @@
* [Sync](#sync)
* [Rust SDK](#rust-sdk)
* [Matrix Rust Component Kotlin](#matrix-rust-component-kotlin)
- * [Build the SDK locally](#build-the-sdk-locally)
+ * [Building the SDK locally](#building-the-sdk-locally)
* [The Android project](#the-android-project)
* [Application](#application)
* [Jetpack Compose](#jetpack-compose)
@@ -107,7 +107,7 @@ This is the goal of https://github.com/matrix-org/matrix-rust-components-kotlin.
This repository is used for distributing kotlin releases of the Matrix Rust SDK.
It'll provide the corresponding aar and also publish them on maven.
-Most of the time you want to use the releases made on maven with gradle:
+Most of the time **you want to use the releases made on maven with gradle**:
```groovy
implementation("org.matrix.rustcomponents:sdk-android:latest-version")
@@ -115,7 +115,9 @@ implementation("org.matrix.rustcomponents:sdk-android:latest-version")
You can also have access to the aars through the [release](https://github.com/matrix-org/matrix-rust-components-kotlin/releases) page.
-#### Build the SDK locally
+#### Building the SDK locally
+
+If you want to make changes to the SDK or test them before integrating it with your codebase, you can build the SDK locally too.
Prerequisites:
* Install the Android NDK (Native Development Kit). To do this from within
@@ -273,7 +275,7 @@ Follow these steps to install and configure the plugin and templates:
1. Install the AS plugin for generating modules :
[Generate Module from Template](https://plugins.jetbrains.com/plugin/13586-generate-module-from-template)
-2. Run the script `tools/templates/generate_templates.sh` to generate the template zip file
+2. From repository root, run `./tools/templates/generate_templates.sh` to generate the template zip file
3. Import file templates in AS :
- Navigate to File/Manage IDE Settings/Import Settings
- Pick the `tmp/file_templates.zip` files
diff --git a/docs/oidc.md b/docs/oidc.md
index 94b22660c01..23709b608c5 100644
--- a/docs/oidc.md
+++ b/docs/oidc.md
@@ -11,7 +11,7 @@ Server list: https://github.com/element-hq/oidc-playground
Metadata iOS: (from https://github.com/element-hq/element-x-ios/blob/5f9d07377cebc4f21d9668b1a25f6e3bb22f64a1/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift#L28)
clientName: InfoPlistReader.main.bundleDisplayName,
-redirectUri: "io.element:/callback",
+redirectUri: "io.element.android:/",
clientUri: "https://element.io",
tosUri: "https://element.io/user-terms-of-service",
policyUri: "https://element.io/privacy"
@@ -19,7 +19,7 @@ policyUri: "https://element.io/privacy"
Android:
clientName = "Element",
-redirectUri = "io.element:/callback",
+redirectUri = "io.element.android:/",
clientUri = "https://element.io",
tosUri = "https://element.io/user-terms-of-service",
policyUri = "https://element.io/privacy"
diff --git a/enterprise b/enterprise
index b4f0427e359..b7ababb9537 160000
--- a/enterprise
+++ b/enterprise
@@ -1 +1 @@
-Subproject commit b4f0427e3595049d39846aabcdc06e818f2e96ea
+Subproject commit b7ababb9537da8bec254b8ed00b5a4122e9f3e3b
diff --git a/fastlane/metadata/android/en-US/changelogs/202503000.txt b/fastlane/metadata/android/en-US/changelogs/202503000.txt
new file mode 100644
index 00000000000..9f4d0720523
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202503000.txt
@@ -0,0 +1,2 @@
+Main changes in this version: bug fixes.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/202503010.txt b/fastlane/metadata/android/en-US/changelogs/202503010.txt
new file mode 100644
index 00000000000..8e44d1e72fb
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202503010.txt
@@ -0,0 +1,2 @@
+Main changes in this version: Event cache / Join room by address.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/202503020.txt b/fastlane/metadata/android/en-US/changelogs/202503020.txt
new file mode 100644
index 00000000000..8955ade6804
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202503020.txt
@@ -0,0 +1,2 @@
+Main changes in this version: bug fixes and improvements.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/202503030.txt b/fastlane/metadata/android/en-US/changelogs/202503030.txt
new file mode 100644
index 00000000000..f7a79e068bf
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202503030.txt
@@ -0,0 +1,2 @@
+Main changes in this version: improvements to the event cache, Element Call now uses an embedded implementation, several bugfixes.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/202503040.txt b/fastlane/metadata/android/en-US/changelogs/202503040.txt
new file mode 100644
index 00000000000..21f4248fbc7
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202503040.txt
@@ -0,0 +1,2 @@
+Main changes in this version: added a fix for Element Call not being able to report issues.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/202504000.txt b/fastlane/metadata/android/en-US/changelogs/202504000.txt
new file mode 100644
index 00000000000..8955ade6804
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202504000.txt
@@ -0,0 +1,2 @@
+Main changes in this version: bug fixes and improvements.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/202504010.txt b/fastlane/metadata/android/en-US/changelogs/202504010.txt
new file mode 100644
index 00000000000..8955ade6804
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202504010.txt
@@ -0,0 +1,2 @@
+Main changes in this version: bug fixes and improvements.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/202504020.txt b/fastlane/metadata/android/en-US/changelogs/202504020.txt
new file mode 100644
index 00000000000..66578477e68
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202504020.txt
@@ -0,0 +1,2 @@
+Main changes in this version: security fix.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/202504030.txt b/fastlane/metadata/android/en-US/changelogs/202504030.txt
new file mode 100644
index 00000000000..a4b397f1bb8
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202504030.txt
@@ -0,0 +1,2 @@
+Main changes in this version: bug fixes and improvements.
+Full changelog: https://github.com/element-hq/element-x-android/releases
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/202505000.txt b/fastlane/metadata/android/en-US/changelogs/202505000.txt
new file mode 100644
index 00000000000..8955ade6804
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202505000.txt
@@ -0,0 +1,2 @@
+Main changes in this version: bug fixes and improvements.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/202505010.txt b/fastlane/metadata/android/en-US/changelogs/202505010.txt
new file mode 100644
index 00000000000..4712e9f21e1
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202505010.txt
@@ -0,0 +1,2 @@
+Main changes in this version: fix Element Call not working.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/202505030.txt b/fastlane/metadata/android/en-US/changelogs/202505030.txt
new file mode 100644
index 00000000000..f9194d12665
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202505030.txt
@@ -0,0 +1,2 @@
+Main changes in this version: fix issue with Element Call and to-device events.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/202505040.txt b/fastlane/metadata/android/en-US/changelogs/202505040.txt
new file mode 100644
index 00000000000..8955ade6804
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202505040.txt
@@ -0,0 +1,2 @@
+Main changes in this version: bug fixes and improvements.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/202506000.txt b/fastlane/metadata/android/en-US/changelogs/202506000.txt
new file mode 100644
index 00000000000..a4b397f1bb8
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202506000.txt
@@ -0,0 +1,2 @@
+Main changes in this version: bug fixes and improvements.
+Full changelog: https://github.com/element-hq/element-x-android/releases
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/202506010.txt b/fastlane/metadata/android/en-US/changelogs/202506010.txt
new file mode 100644
index 00000000000..a51bde477f4
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202506010.txt
@@ -0,0 +1,2 @@
+Main changes in this version: fix audio devices and volume selection in Element Call, improves moderation features.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/202506020.txt b/fastlane/metadata/android/en-US/changelogs/202506020.txt
new file mode 100644
index 00000000000..8955ade6804
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202506020.txt
@@ -0,0 +1,2 @@
+Main changes in this version: bug fixes and improvements.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/202506030.txt b/fastlane/metadata/android/en-US/changelogs/202506030.txt
new file mode 100644
index 00000000000..f52df1a3721
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202506030.txt
@@ -0,0 +1,2 @@
+Main changes in this version: add support for tombstoned rooms, improve notification reliability.
+Full changelog: https://github.com/element-hq/element-x-android/releases
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/202507000.txt b/fastlane/metadata/android/en-US/changelogs/202507000.txt
new file mode 100644
index 00000000000..3abd90d7c68
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202507000.txt
@@ -0,0 +1,2 @@
+Main changes in this version: improve accessibility.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index f3708552d3f..d0b94798861 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -1,38 +1,28 @@
-Element X brings you both sovereign & seamless collaboration built on Matrix.
+Freedom to communicate on your own terms
-The collaboration capabilities include chat & video calls with the modern set of features such as:
- • public & private channels
- • room moderation & access control
- • replies, reactions, polls, read receipts, pinned messages, etc.
- • simultaneous chat & calls (picture in picture)
- • decentralized & federated communication across organizations
+For individuals and communities - private communication between family, friends, hobby groups, clubs, etc.
-All this comes in a secure & sovereign fashion without compromising responsiveness or overall usability of the app:
- • enterprise-grade single sign-on
- • easy & secure login & device verification via QR-code
- • end to end encryption & zero trust
- • protection against MITM & other cyber attacks
+Element X gives you fast, secure and private instant messaging and video calls built on Matrix, the open standard for real-time communication. This is a free and open-source app maintained at https://github.com/element-hq/element-x-android.
-If you’re a new user, use the new Element X app from the start. Compared to the current Element app you will get:
- • greatly enhanced performance, sleek user interface and overall better user experience
- • enterprise-grade support for single sign-on (OIDC)
- • QR-code based login & device verification
- • natively integrated Element Call for video calls
- • continuous improvements, bug fixes and new features
+Stay in touch with friends, family and communities with:
+ • Real time messaging & video calls
+ • Public rooms for open group communication
+ • Private rooms for closed group communication
+ • Rich messaging features: emoji reactions, replies, polls, pinned messages and more.
+ • Video calling while browsing messages.
+ • Interoperability with other Matrix-based apps such as FluffyChat, Cinny and many more.
-If you’re an existing user, using the current Element app - check out the new Element X and start planning your transition. The current Element app will be phased out and will only get critical security updates.
+Privacy-first
+Unlike some other messengers from Big Tech companies, we don’t mine your data or monitor your communications.
-Own your data
-Matrix-based, Element X lets you self-host your data or choose from any free public server (the default is matrix.org, but there are plenty of others to choose from). However you host, you have ownership; it’s your data. You’re not the product. You’re in control.
+Own your conversations
+Choose where to host your data - from any public server (the largest free server is matrix.org, but there are plenty of others to choose from) to creating your own personal server and hosting it on your own domain. This ability to choose a server is a large part of what differentiates us from other real time communication apps. However you host, you have ownership; it’s your data. You’re not the product. You’re in control.
-Interoperate natively
-Enjoy the freedom of the Matrix open standard! You have native interoperability with any other Matrix-based app. So just like email, it doesn't matter if your friends, partners or customers are on a different Matrix-based app - you can still connect.
+Communicate in real time, all the time
+Use Element everywhere. Stay in touch wherever you are with fully synchronised message history across all your devices, including on the web at https://app.element.io
-Encrypt your data
-Enjoy your right to private conversations - free from data mining, ads and all the rest of it - and stay secure. Only the people in your conversation can read your messages.
-
-Chat across multiple devices
-Stay in touch wherever you are with fully synchronized message history across all your devices, even those running Element legacy app, and on the web at https://app.element.io
+Element X is our next-generation app
+If you’re using the original Element app, it’s time to try Element X! It’s faster, easier to use, and more powerful than the original app. It’s better in every way and we’re adding new features all the time.
The application requires the android.permission.REQUEST_INSTALL_PACKAGES permission to enable the installation of applications received as attachments, ensuring seamless and convenient access to new software within the app.
diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png
index a3107af4b1d..325bf570f53 100644
Binary files a/fastlane/metadata/android/en-US/images/icon.png and b/fastlane/metadata/android/en-US/images/icon.png differ
diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt
index 8e62bdd3054..647b2057227 100644
--- a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt
+++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt
@@ -13,12 +13,17 @@ open class AnalyticsPreferencesStateProvider : PreviewParameterProvider
get() = sequenceOf(
aAnalyticsPreferencesState().copy(isEnabled = true),
+ aAnalyticsPreferencesState().copy(isEnabled = true, policyUrl = ""),
)
}
-fun aAnalyticsPreferencesState() = AnalyticsPreferencesState(
- applicationName = "Element X",
- isEnabled = false,
- policyUrl = "https://element.io",
+fun aAnalyticsPreferencesState(
+ applicationName: String = "Element X",
+ isEnabled: Boolean = false,
+ policyUrl: String = "https://element.io",
+) = AnalyticsPreferencesState(
+ applicationName = applicationName,
+ isEnabled = isEnabled,
+ policyUrl = policyUrl,
eventSink = {}
)
diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt
index 2e7ee47736b..70461f22d31 100644
--- a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt
+++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt
@@ -36,11 +36,6 @@ fun AnalyticsPreferencesView(
id = R.string.screen_analytics_settings_help_us_improve,
state.applicationName
)
- val linkText = buildAnnotatedStringWithStyledPart(
- R.string.screen_analytics_settings_read_terms,
- R.string.screen_analytics_settings_read_terms_content_link,
- tagAndLink = LINK_TAG to state.policyUrl,
- )
Column(modifier) {
ListItem(
headlineContent = {
@@ -57,7 +52,14 @@ fun AnalyticsPreferencesView(
onEnabledChanged(!state.isEnabled)
}
)
- ListSupportingText(annotatedString = linkText)
+ if (state.policyUrl.isNotEmpty()) {
+ val linkText = buildAnnotatedStringWithStyledPart(
+ R.string.screen_analytics_settings_read_terms,
+ R.string.screen_analytics_settings_read_terms_content_link,
+ tagAndLink = LINK_TAG to state.policyUrl,
+ )
+ ListSupportingText(annotatedString = linkText)
+ }
}
}
diff --git a/features/analytics/api/src/main/res/values-cy/translations.xml b/features/analytics/api/src/main/res/values-cy/translations.xml
new file mode 100644
index 00000000000..308d8a07f10
--- /dev/null
+++ b/features/analytics/api/src/main/res/values-cy/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Rhannu data defnydd dienw i\'n helpu i nodi problemau."
+ "Gallwch ddarllen ein holl amodau %1$s."
+ "yma"
+ "Rhannu data dadansoddeg"
+
diff --git a/features/analytics/api/src/main/res/values-da/translations.xml b/features/analytics/api/src/main/res/values-da/translations.xml
new file mode 100644
index 00000000000..e302bae6267
--- /dev/null
+++ b/features/analytics/api/src/main/res/values-da/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Del anonyme brugsdata for at hjælpe os med at identificere problemer."
+ "Du kan læse alle vores vilkår %1$s."
+ "her"
+ "Del analysedata"
+
diff --git a/features/analytics/api/src/main/res/values-de/translations.xml b/features/analytics/api/src/main/res/values-de/translations.xml
index ec26ff08fce..30fd8cd7e5d 100644
--- a/features/analytics/api/src/main/res/values-de/translations.xml
+++ b/features/analytics/api/src/main/res/values-de/translations.xml
@@ -1,7 +1,7 @@
"Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."
- "Weitere Informationen findest du %1$s ."
+ "Sie können unsere Bedingungen %1$s lesen."
"hier"
"Analysedaten teilen"
diff --git a/features/analytics/api/src/main/res/values-eu/translations.xml b/features/analytics/api/src/main/res/values-eu/translations.xml
new file mode 100644
index 00000000000..52e18f32ef8
--- /dev/null
+++ b/features/analytics/api/src/main/res/values-eu/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Partekatu erabilerari buruzko datu anonimoak arazoak identifikatzen laguntzeko."
+ "Gure baldintza guztiak irakur ditzakezu %1$s."
+ "hemen"
+ "Partekatu analisi-datuak"
+
diff --git a/features/analytics/api/src/main/res/values-fa/translations.xml b/features/analytics/api/src/main/res/values-fa/translations.xml
index 8b672f0f00f..62a0e1cc902 100644
--- a/features/analytics/api/src/main/res/values-fa/translations.xml
+++ b/features/analytics/api/src/main/res/values-fa/translations.xml
@@ -1,5 +1,7 @@
+ "داده های استفاده ناشناس را به اشتراک بگذارید تا به ما در شناسایی مشکلات کمک کند."
+ "شما میتوانید تمام شرایط ما را بخوانید%1$s ."
"اینجا"
"هم رسانی دادههای تحلیلی"
diff --git a/features/analytics/api/src/main/res/values-lt/translations.xml b/features/analytics/api/src/main/res/values-lt/translations.xml
new file mode 100644
index 00000000000..08dd1553320
--- /dev/null
+++ b/features/analytics/api/src/main/res/values-lt/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Dalinkitės anoniminiais naudojimo duomenimis ir padėkite mums nustatyti problemas."
+ "Galite perskaityti visas mūsų sąlygas %1$s."
+ "čia"
+ "Dalytis analitiniais duomenimis"
+
diff --git a/features/analytics/api/src/main/res/values-pt-rBR/translations.xml b/features/analytics/api/src/main/res/values-pt-rBR/translations.xml
index 4e7dc9775f9..ab649cc9fff 100644
--- a/features/analytics/api/src/main/res/values-pt-rBR/translations.xml
+++ b/features/analytics/api/src/main/res/values-pt-rBR/translations.xml
@@ -1,7 +1,7 @@
"Compartilhe dados de uso anônimos para nos ajudar a identificar problemas."
- "Você pode ler todos os nossos termos %1$s ."
+ "Você pode ler todos os nossos termos %1$s."
"aqui"
"Compartilhar dados de utilização"
diff --git a/features/analytics/api/src/main/res/values-tr/translations.xml b/features/analytics/api/src/main/res/values-tr/translations.xml
new file mode 100644
index 00000000000..7229872f5ac
--- /dev/null
+++ b/features/analytics/api/src/main/res/values-tr/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Sorunları tanımlamamıza yardımcı olmak için anonim kullanım verilerini paylaşın."
+ "Tüm şartlarımızı okuyabilirsiniz %1$s."
+ "burada"
+ "Analitik verileri paylaşın"
+
diff --git a/features/analytics/api/src/main/res/values-ur/translations.xml b/features/analytics/api/src/main/res/values-ur/translations.xml
new file mode 100644
index 00000000000..237bb30d9e7
--- /dev/null
+++ b/features/analytics/api/src/main/res/values-ur/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "مسائل کی نشاندہی کرنے میں ہماری مدد کے لیے گمنام استعمال کے بیانات کا اشتراک کریں۔"
+ "آپ ہماری تمام شرائط پڑھ سکتے ہیں %1$s۔"
+ "یہاں"
+ "تجزیاتی بیانات کا اشتراک کریں"
+
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInNode.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInNode.kt
index 01ec23da385..ba1073d480c 100644
--- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInNode.kt
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInNode.kt
@@ -8,9 +8,9 @@
package io.element.android.features.analytics.impl
import android.app.Activity
+import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@@ -34,7 +34,7 @@ class AnalyticsOptInNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
- val activity = LocalContext.current as Activity
+ val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
val state = presenter.present()
AnalyticsOptInView(
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenter.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenter.kt
index d545d009518..39b99a9257c 100644
--- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenter.kt
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenter.kt
@@ -9,6 +9,7 @@ package io.element.android.features.analytics.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
+import io.element.android.appconfig.AnalyticsConfig
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@@ -36,6 +37,7 @@ class AnalyticsOptInPresenter @Inject constructor(
return AnalyticsOptInState(
applicationName = buildMeta.applicationName,
+ hasPolicyLink = AnalyticsConfig.POLICY_LINK.isNotEmpty(),
eventSink = ::handleEvents
)
}
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInState.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInState.kt
index d7e99e56c1c..a0913bdb4f5 100644
--- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInState.kt
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInState.kt
@@ -11,5 +11,6 @@ import io.element.android.features.analytics.api.AnalyticsOptInEvents
data class AnalyticsOptInState(
val applicationName: String,
+ val hasPolicyLink: Boolean,
val eventSink: (AnalyticsOptInEvents) -> Unit
)
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInStateProvider.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInStateProvider.kt
index c159b738a72..e6917c237ee 100644
--- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInStateProvider.kt
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInStateProvider.kt
@@ -14,10 +14,14 @@ open class AnalyticsOptInStateProvider @Inject constructor() : PreviewParameterP
override val values: Sequence
get() = sequenceOf(
aAnalyticsOptInState(),
+ aAnalyticsOptInState(hasPolicyLink = false),
)
}
-fun aAnalyticsOptInState() = AnalyticsOptInState(
+fun aAnalyticsOptInState(
+ hasPolicyLink: Boolean = true,
+) = AnalyticsOptInState(
applicationName = "Element X",
+ hasPolicyLink = hasPolicyLink,
eventSink = {}
)
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt
index f1781f1b08e..a6ae7549448 100644
--- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
-import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
@@ -96,25 +95,27 @@ private fun AnalyticsOptInHeader(
subtitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
iconStyle = BigIcon.Style.Default(CompoundIcons.Chart())
)
- val text = buildAnnotatedStringWithStyledPart(
- R.string.screen_analytics_prompt_read_terms,
- R.string.screen_analytics_prompt_read_terms_content_link,
- color = Color.Unspecified,
- underline = false,
- bold = true,
- tagAndLink = LINK_TAG to AnalyticsConfig.POLICY_LINK,
- )
- ClickableLinkText(
- annotatedString = text,
- onClick = { onClickTerms() },
- modifier = Modifier
- .padding(8.dp),
- style = ElementTheme.typography.fontBodyMdRegular
- .copy(
- color = MaterialTheme.colorScheme.secondary,
- textAlign = TextAlign.Center,
- )
- )
+ if (state.hasPolicyLink) {
+ val text = buildAnnotatedStringWithStyledPart(
+ R.string.screen_analytics_prompt_read_terms,
+ R.string.screen_analytics_prompt_read_terms_content_link,
+ color = Color.Unspecified,
+ underline = false,
+ bold = true,
+ tagAndLink = LINK_TAG to AnalyticsConfig.POLICY_LINK,
+ )
+ ClickableLinkText(
+ annotatedString = text,
+ onClick = { onClickTerms() },
+ modifier = Modifier
+ .padding(8.dp),
+ style = ElementTheme.typography.fontBodyMdRegular
+ .copy(
+ color = ElementTheme.colors.textSecondary,
+ textAlign = TextAlign.Center,
+ )
+ )
+ }
}
}
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenter.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenter.kt
index 8212cc6c88b..6904734f264 100644
--- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenter.kt
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenter.kt
@@ -27,8 +27,7 @@ class AnalyticsPreferencesPresenter @Inject constructor(
@Composable
override fun present(): AnalyticsPreferencesState {
val localCoroutineScope = rememberCoroutineScope()
- val isEnabled = analyticsService.getUserConsent()
- .collectAsState(initial = false)
+ val isEnabled = analyticsService.userConsentFlow.collectAsState(initial = false)
fun handleEvents(event: AnalyticsOptInEvents) {
when (event) {
diff --git a/features/analytics/impl/src/main/res/values-cy/translations.xml b/features/analytics/impl/src/main/res/values-cy/translations.xml
new file mode 100644
index 00000000000..1a53e3600ee
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-cy/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Fyddwn ni ddim yn cofnodi nac yn proffilio unrhyw ddata personol"
+ "Rhannu data defnydd dienw i\'n helpu i nodi problemau."
+ "Gallwch ddarllen ein holl amodau %1$s."
+ "yma"
+ "Gallwch ddiffodd hwn unrhyw bryd"
+ "Fyddwn ni ddim yn rhannu eich data gyda thrydydd parti"
+ "Helpwch i wella %1$s"
+
diff --git a/features/analytics/impl/src/main/res/values-da/translations.xml b/features/analytics/impl/src/main/res/values-da/translations.xml
new file mode 100644
index 00000000000..971a12ae37e
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-da/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Vi vil ikke registrere eller profilere nogen personlige data"
+ "Del anonyme brugsdata for at hjælpe os med at identificere problemer."
+ "Du kan læse alle vores vilkår %1$s."
+ "her"
+ "Du kan slå dette fra når som helst"
+ "Vi deler ikke dine data med tredjeparter"
+ "Hjælp med at forbedre %1$s"
+
diff --git a/features/analytics/impl/src/main/res/values-de/translations.xml b/features/analytics/impl/src/main/res/values-de/translations.xml
index e2180092d96..9f3bd7e6527 100644
--- a/features/analytics/impl/src/main/res/values-de/translations.xml
+++ b/features/analytics/impl/src/main/res/values-de/translations.xml
@@ -2,9 +2,9 @@
"Wir zeichnen keine persönlichen Daten auf und erstellen keine Profile."
"Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."
- "Weitere Informationen findest du %1$s ."
+ "Sie können unsere Bedingungen %1$s lesen."
"hier"
- "Du kannst diese Funktion jederzeit deaktivieren"
+ "Sie können dies jederzeit beenden"
"Wir geben deine Daten nicht an Dritte weiter"
"Hilf uns %1$s zu verbessern"
diff --git a/features/analytics/impl/src/main/res/values-eu/translations.xml b/features/analytics/impl/src/main/res/values-eu/translations.xml
new file mode 100644
index 00000000000..a0083af67bf
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-eu/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Partekatu erabilerari buruzko datu anonimoak arazoak identifikatzen laguntzeko."
+ "Gure baldintza guztiak irakur ditzakezu %1$s."
+ "hemen"
+ "Edozein unetan desaktibatu dezakezu"
+ "Ez ditugu zure datuak hirugarrenekin partekatuko"
+ "Lagundu %1$s hobetzen"
+
diff --git a/features/analytics/impl/src/main/res/values-fa/translations.xml b/features/analytics/impl/src/main/res/values-fa/translations.xml
index 713e2636d19..45ac8c1511a 100644
--- a/features/analytics/impl/src/main/res/values-fa/translations.xml
+++ b/features/analytics/impl/src/main/res/values-fa/translations.xml
@@ -1,5 +1,8 @@
+ "ما هیچ گونه اطلاعات شخصی را ضبط یا نمایهسازی نمیکنیم"
+ "داده های استفاده ناشناس را به اشتراک بگذارید تا به ما در شناسایی مشکلات کمک کند."
+ "شما میتوانید تمام شرایط ما را بخوانید%1$s ."
"اینجا"
"میتوانید در هر زمان خاموشش کنید"
"دادههایتان را با سومشخصها همنمیرسانیم"
diff --git a/features/analytics/impl/src/main/res/values-lt/translations.xml b/features/analytics/impl/src/main/res/values-lt/translations.xml
new file mode 100644
index 00000000000..11f2cbc74ef
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-lt/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Mes nekaupsime ir neprofiliuosime jokių asmens duomenų"
+ "Dalinkitės anoniminiais naudojimo duomenimis ir padėkite mums nustatyti problemas."
+ "Galite perskaityti visas mūsų sąlygas %1$s."
+ "čia"
+ "Tai galite bet kada išjungti"
+ "Mes nesidalinsime Jūsų duomenimis su trečiosiomis šalimis"
+ "Padėkite pagerinti %1$s"
+
diff --git a/features/analytics/impl/src/main/res/values-pt-rBR/translations.xml b/features/analytics/impl/src/main/res/values-pt-rBR/translations.xml
index b2fe6e00bcf..b91ab362565 100644
--- a/features/analytics/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/analytics/impl/src/main/res/values-pt-rBR/translations.xml
@@ -2,7 +2,7 @@
"Não registraremos nem criaremos perfil baseado em qualquer dado pessoal"
"Compartilhe dados de uso anônimos para nos ajudar a identificar problemas."
- "Você pode ler todos os nossos termos %1$s ."
+ "Você pode ler todos os nossos termos %1$s."
"aqui"
"Você pode desativar isso a qualquer momento"
"Não compartilharemos seus dados com terceiros"
diff --git a/features/analytics/impl/src/main/res/values-tr/translations.xml b/features/analytics/impl/src/main/res/values-tr/translations.xml
new file mode 100644
index 00000000000..238e53b8982
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-tr/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Hiçbir kişisel veriyi kaydetmeyeceğiz veya profillemeyeceğiz"
+ "Sorunları tanımlamamıza yardımcı olmak için anonim kullanım verilerini paylaşın."
+ "Tüm şartlarımızı okuyabilirsiniz %1$s."
+ "burada"
+ "Bu özelliği istediğiniz zaman kapatabilirsiniz"
+ "Verilerinizi üçüncü taraflarla paylaşmayacağız"
+ "%1$s geliştirilmesine yardımcı olun"
+
diff --git a/features/analytics/impl/src/main/res/values-ur/translations.xml b/features/analytics/impl/src/main/res/values-ur/translations.xml
new file mode 100644
index 00000000000..4b950335cbd
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-ur/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "ہم کسی بھی ذاتی ڈیٹا کو ثبت یا پروفائل نہیں کریں گے"
+ "مسائل کی نشاندہی کرنے میں ہماری مدد کے لیے گمنام استعمال کے بیانات کا اشتراک کریں۔"
+ "آپ ہماری تمام شرائط پڑھ سکتے ہیں %1$s۔"
+ "یہاں"
+ "آپ اسے کسی بھی وقت بند کر سکتے ہیں"
+ "ہم آپکے بیانات کا فریق ثالث کے ساتھ اشتراک نہیں کریں گے"
+ "%1$s کو بہتر بنانے میں مدد کریں"
+
diff --git a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt
index 1353d5ab5f7..e9319fd7bad 100644
--- a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt
+++ b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt
@@ -35,10 +35,10 @@ class AnalyticsOptInPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
- assertThat(analyticsService.didAskUserConsent().first()).isFalse()
+ assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true))
- assertThat(analyticsService.didAskUserConsent().first()).isTrue()
- assertThat(analyticsService.getUserConsent().first()).isTrue()
+ assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
+ assertThat(analyticsService.userConsentFlow.first()).isTrue()
}
}
@@ -53,10 +53,10 @@ class AnalyticsOptInPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
- assertThat(analyticsService.didAskUserConsent().first()).isFalse()
+ assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false))
- assertThat(analyticsService.didAskUserConsent().first()).isTrue()
- assertThat(analyticsService.getUserConsent().first()).isFalse()
+ assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
+ assertThat(analyticsService.userConsentFlow.first()).isFalse()
}
}
}
diff --git a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt
index 30b64eb8ab4..8ba915f03c8 100644
--- a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt
+++ b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt
@@ -11,6 +11,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
+import io.element.android.appconfig.AnalyticsConfig
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
@@ -35,7 +36,7 @@ class AnalyticsPreferencesPresenterTest {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isEnabled).isTrue()
- assertThat(initialState.policyUrl).isNotEmpty()
+ assertThat(initialState.policyUrl).isEqualTo(AnalyticsConfig.POLICY_LINK)
}
}
diff --git a/features/cachecleaner/impl/src/main/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleaner.kt b/features/cachecleaner/impl/src/main/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleaner.kt
index 16a8a151deb..86e6432cc5d 100644
--- a/features/cachecleaner/impl/src/main/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleaner.kt
+++ b/features/cachecleaner/impl/src/main/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleaner.kt
@@ -10,8 +10,10 @@ package io.element.android.features.cachecleaner.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.cachecleaner.api.CacheCleaner
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.CacheDirectory
+import io.element.android.libraries.di.annotations.AppCoroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -23,7 +25,8 @@ import javax.inject.Inject
*/
@ContributesBinding(AppScope::class)
class DefaultCacheCleaner @Inject constructor(
- private val scope: CoroutineScope,
+ @AppCoroutineScope
+ private val coroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
@CacheDirectory private val cacheDir: File,
) : CacheCleaner {
@@ -32,8 +35,8 @@ class DefaultCacheCleaner @Inject constructor(
}
override fun clearCache() {
- scope.launch(dispatchers.io) {
- runCatching {
+ coroutineScope.launch(dispatchers.io) {
+ runCatchingExceptions {
SUBDIRS_TO_CLEANUP.forEach {
File(cacheDir.path, it).apply {
if (exists()) {
diff --git a/features/cachecleaner/impl/src/test/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleanerTest.kt b/features/cachecleaner/impl/src/test/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleanerTest.kt
index 884d29ac88e..997c0fe7039 100644
--- a/features/cachecleaner/impl/src/test/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleanerTest.kt
+++ b/features/cachecleaner/impl/src/test/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleanerTest.kt
@@ -55,7 +55,7 @@ class DefaultCacheCleanerTest {
}
private fun TestScope.aCacheCleaner() = DefaultCacheCleaner(
- scope = this,
+ coroutineScope = this,
dispatchers = this.testCoroutineDispatchers(true),
cacheDir = temporaryFolder.root,
)
diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/CallType.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CallType.kt
index 0456751ae8d..2c279b77258 100644
--- a/features/call/api/src/main/kotlin/io/element/android/features/call/api/CallType.kt
+++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CallType.kt
@@ -15,11 +15,19 @@ import kotlinx.parcelize.Parcelize
sealed interface CallType : NodeInputs, Parcelable {
@Parcelize
- data class ExternalUrl(val url: String) : CallType
+ data class ExternalUrl(val url: String) : CallType {
+ override fun toString(): String {
+ return "ExternalUrl"
+ }
+ }
@Parcelize
data class RoomCall(
val sessionId: SessionId,
val roomId: RoomId,
- ) : CallType
+ ) : CallType {
+ override fun toString(): String {
+ return "RoomCall(sessionId=$sessionId, roomId=$roomId)"
+ }
+ }
}
diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt
index 239c960a39a..53e1b8c8461 100644
--- a/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt
+++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt
@@ -30,8 +30,9 @@ interface ElementCallEntryPoint {
* @param avatarUrl The avatar url of the room or DM.
* @param timestamp The timestamp of the event that started the call.
* @param notificationChannelId The id of the notification channel to use for the call notification.
+ * @param textContent The text content of the notification. If null the default content from the system will be used.
*/
- fun handleIncomingCall(
+ suspend fun handleIncomingCall(
callType: CallType.RoomCall,
eventId: EventId,
senderId: UserId,
@@ -40,5 +41,6 @@ interface ElementCallEntryPoint {
avatarUrl: String?,
timestamp: Long,
notificationChannelId: String,
+ textContent: String?,
)
}
diff --git a/features/call/impl/build.gradle.kts b/features/call/impl/build.gradle.kts
index 7852b5f53c0..13922366c2c 100644
--- a/features/call/impl/build.gradle.kts
+++ b/features/call/impl/build.gradle.kts
@@ -1,3 +1,5 @@
+import extension.buildConfigFieldStr
+import extension.readLocalProperty
import extension.setupAnvil
/*
@@ -23,13 +25,49 @@ android {
testOptions {
unitTests.isIncludeAndroidResources = true
}
+
+ defaultConfig {
+ buildConfigFieldStr(
+ name = "SENTRY_DSN",
+ value = System.getenv("ELEMENT_CALL_SENTRY_DSN")
+ ?: readLocalProperty("features.call.sentry.dsn")
+ ?: ""
+ )
+ buildConfigFieldStr(
+ name = "POSTHOG_USER_ID",
+ value = System.getenv("ELEMENT_CALL_POSTHOG_USER_ID")
+ ?: readLocalProperty("features.call.posthog.userid")
+ ?: ""
+ )
+ buildConfigFieldStr(
+ name = "POSTHOG_API_HOST",
+ value = System.getenv("ELEMENT_CALL_POSTHOG_API_HOST")
+ ?: readLocalProperty("features.call.posthog.api.host")
+ ?: ""
+ )
+ buildConfigFieldStr(
+ name = "POSTHOG_API_KEY",
+ value = System.getenv("ELEMENT_CALL_POSTHOG_API_KEY")
+ ?: readLocalProperty("features.call.posthog.api.key")
+ ?: ""
+ )
+ buildConfigFieldStr(
+ name = "RAGESHAKE_URL",
+ value = System.getenv("ELEMENT_CALL_RAGESHAKE_URL")
+ ?: readLocalProperty("features.call.regeshake.url")
+ ?: ""
+ )
+ }
}
setupAnvil()
dependencies {
implementation(projects.appconfig)
+ implementation(projects.features.enterprise.api)
implementation(projects.libraries.architecture)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.audio.api)
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.featureflag.api)
@@ -40,11 +78,13 @@ dependencies {
implementation(projects.libraries.push.api)
implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.api)
+ implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api)
implementation(libs.androidx.webkit)
implementation(libs.coil.compose)
implementation(libs.network.retrofit)
implementation(libs.serialization.json)
+ implementation(libs.element.call.embedded)
api(projects.features.call.api)
testImplementation(libs.coroutines.test)
@@ -59,6 +99,7 @@ dependencies {
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
+ testImplementation(projects.services.appnavstate.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt
index 1e42cb972dd..009840743c5 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt
@@ -34,7 +34,7 @@ class DefaultElementCallEntryPoint @Inject constructor(
context.startActivity(IntentProvider.createIntent(context, callType))
}
- override fun handleIncomingCall(
+ override suspend fun handleIncomingCall(
callType: CallType.RoomCall,
eventId: EventId,
senderId: UserId,
@@ -43,6 +43,7 @@ class DefaultElementCallEntryPoint @Inject constructor(
avatarUrl: String?,
timestamp: Long,
notificationChannelId: String,
+ textContent: String?,
) {
val incomingCallNotificationData = CallNotificationData(
sessionId = callType.sessionId,
@@ -54,6 +55,7 @@ class DefaultElementCallEntryPoint @Inject constructor(
avatarUrl = avatarUrl,
timestamp = timestamp,
notificationChannelId = notificationChannelId,
+ textContent = textContent,
)
activeCallManager.registerIncomingCall(notificationData = incomingCallNotificationData)
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/data/WidgetMessage.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/data/WidgetMessage.kt
index df518caeeeb..c18fef410ee 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/data/WidgetMessage.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/data/WidgetMessage.kt
@@ -30,9 +30,15 @@ data class WidgetMessage(
@Serializable
enum class Action {
+ @SerialName("io.element.join")
+ Join,
+
@SerialName("im.vector.hangup")
HangUp,
+ @SerialName("io.element.close")
+ Close,
+
@SerialName("send_event")
SendEvent,
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/CallNotificationData.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/CallNotificationData.kt
index cd9e4c2109b..7c97cc85295 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/CallNotificationData.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/CallNotificationData.kt
@@ -25,4 +25,5 @@ data class CallNotificationData(
val avatarUrl: String?,
val notificationChannelId: String,
val timestamp: Long,
+ val textContent: String?,
) : Parcelable
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt
index 5ccb4aa2278..de31b0b02bb 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt
@@ -11,7 +11,7 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.AudioManager
-import android.media.RingtoneManager
+import android.provider.Settings
import androidx.core.app.NotificationCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.app.Person
@@ -63,6 +63,7 @@ class RingingCallNotificationCreator @Inject constructor(
roomAvatarUrl: String?,
notificationChannelId: String,
timestamp: Long,
+ textContent: String?,
): Notification? {
val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null
val imageLoader = imageLoaderHolder.get(matrixClient)
@@ -84,7 +85,8 @@ class RingingCallNotificationCreator @Inject constructor(
senderName = senderDisplayName,
avatarUrl = roomAvatarUrl,
notificationChannelId = notificationChannelId,
- timestamp = timestamp
+ timestamp = timestamp,
+ textContent = textContent,
)
val declineIntent = PendingIntentCompat.getBroadcast(
@@ -107,10 +109,8 @@ class RingingCallNotificationCreator @Inject constructor(
false
)
- // TODO use a fallback ringtone if the default ringtone is not available
- val ringtoneUri = runCatching { RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE) }.getOrNull()
return NotificationCompat.Builder(context, notificationChannelId)
- .setSmallIcon(CommonDrawables.ic_notification_small)
+ .setSmallIcon(CommonDrawables.ic_notification)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, declineIntent, answerIntent).setIsVideo(true))
@@ -120,10 +120,12 @@ class RingingCallNotificationCreator @Inject constructor(
.setOngoing(true)
.setShowWhen(false)
.apply {
- if (ringtoneUri != null) {
- setSound(ringtoneUri, AudioManager.STREAM_RING)
+ if (textContent != null) {
+ setContentText(textContent)
+ // Else the content text is set by the style (will be "Incoming call")
}
}
+ .setSound(Settings.System.DEFAULT_RINGTONE_URI, AudioManager.STREAM_RING)
.setTimeoutAfter(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds.inWholeMilliseconds)
.setContentIntent(answerIntent)
.setDeleteIntent(declineIntent)
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureStateProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureStateProvider.kt
index cab5858e4db..f2afcd01962 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureStateProvider.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureStateProvider.kt
@@ -7,16 +7,6 @@
package io.element.android.features.call.impl.pip
-import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-
-open class PictureInPictureStateProvider : PreviewParameterProvider {
- override val values: Sequence
- get() = sequenceOf(
- aPictureInPictureState(supportPip = true),
- aPictureInPictureState(supportPip = true, isInPictureInPicture = true),
- )
-}
-
fun aPictureInPictureState(
supportPip: Boolean = false,
isInPictureInPicture: Boolean = false,
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt
index ab6f07ce494..c857c9e2c86 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt
@@ -16,6 +16,9 @@ import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.di.annotations.AppCoroutineScope
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@@ -27,10 +30,16 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() {
}
@Inject
lateinit var activeCallManager: ActiveCallManager
+
+ @AppCoroutineScope
+ @Inject lateinit var appCoroutineScope: CoroutineScope
+
override fun onReceive(context: Context, intent: Intent?) {
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
?: return
context.bindings().inject(this)
- activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
+ appCoroutineScope.launch {
+ activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
+ }
}
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/services/CallForegroundService.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/services/CallForegroundService.kt
index dc4541b2773..b779465a342 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/services/CallForegroundService.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/services/CallForegroundService.kt
@@ -24,6 +24,7 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import io.element.android.features.call.impl.R
import io.element.android.features.call.impl.ui.ElementCallActivity
+import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
@@ -67,7 +68,7 @@ class CallForegroundService : Service() {
val callActivityIntent = Intent(this, ElementCallActivity::class.java)
val pendingIntent = PendingIntentCompat.getActivity(this, 0, callActivityIntent, 0, false)
val notification = NotificationCompat.Builder(this, foregroundServiceChannel.id)
- .setSmallIcon(IconCompat.createWithResource(this, CommonDrawables.ic_notification_small))
+ .setSmallIcon(IconCompat.createWithResource(this, CommonDrawables.ic_notification))
.setContentTitle(getString(R.string.call_foreground_service_title_android))
.setContentText(getString(R.string.call_foreground_service_message_android))
.setContentIntent(pendingIntent)
@@ -78,7 +79,7 @@ class CallForegroundService : Service() {
} else {
0
}
- runCatching {
+ runCatchingExceptions {
ServiceCompat.startForeground(this, notificationId, notification, serviceType)
}.onFailure {
Timber.e(it, "Failed to start ongoing call foreground service")
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt
index 048e361ac10..04999c82ab6 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt
@@ -32,6 +32,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.RoomId
@@ -39,15 +40,17 @@ import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.services.analytics.api.ScreenTracker
+import io.element.android.services.appnavstate.api.ActiveRoomsHolder
+import io.element.android.services.appnavstate.api.AppForegroundStateService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
-import kotlinx.serialization.json.contentOrNull
-import kotlinx.serialization.json.jsonObject
-import kotlinx.serialization.json.jsonPrimitive
+import timber.log.Timber
import java.util.UUID
+import kotlin.time.Duration.Companion.seconds
class CallScreenPresenter @AssistedInject constructor(
@Assisted private val callType: CallType,
@@ -58,9 +61,12 @@ class CallScreenPresenter @AssistedInject constructor(
private val dispatchers: CoroutineDispatchers,
private val matrixClientsProvider: MatrixClientProvider,
private val screenTracker: ScreenTracker,
- private val appCoroutineScope: CoroutineScope,
private val activeCallManager: ActiveCallManager,
private val languageTagProvider: LanguageTagProvider,
+ private val appForegroundStateService: AppForegroundStateService,
+ private val activeRoomsHolder: ActiveRoomsHolder,
+ @AppCoroutineScope
+ private val appCoroutineScope: CoroutineScope,
) : Presenter {
@AssistedFactory
interface Factory {
@@ -82,11 +88,12 @@ class CallScreenPresenter @AssistedInject constructor(
var webViewError by remember { mutableStateOf(null) }
val languageTag = languageTagProvider.provideLanguageTag()
val theme = if (ElementTheme.isLightTheme) "light" else "dark"
+
DisposableEffect(Unit) {
coroutineScope.launch {
// Sets the call as joined
activeCallManager.joinedCall(callType)
- loadUrl(
+ fetchRoomCallUrl(
inputs = callType,
urlState = urlState,
callWidgetDriver = callWidgetDriver,
@@ -95,7 +102,7 @@ class CallScreenPresenter @AssistedInject constructor(
)
}
onDispose {
- activeCallManager.hungUpCall(callType)
+ appCoroutineScope.launch { activeCallManager.hungUpCall(callType) }
}
}
@@ -134,19 +141,27 @@ class CallScreenPresenter @AssistedInject constructor(
val parsedMessage = parseMessage(it)
if (parsedMessage?.direction == WidgetMessage.Direction.FromWidget) {
- if (parsedMessage.action == WidgetMessage.Action.HangUp) {
+ if (parsedMessage.action == WidgetMessage.Action.Close) {
close(callWidgetDriver.value, navigator)
- } else if (parsedMessage.action == WidgetMessage.Action.SendEvent) {
- // This event is received when a member joins the call, the first one will be the current one
- val type = parsedMessage.data?.jsonObject?.get("type")?.jsonPrimitive?.contentOrNull
- if (type == "org.matrix.msc3401.call.member") {
- isJoinedCall = true
- }
+ } else if (parsedMessage.action == WidgetMessage.Action.Join) {
+ isJoinedCall = true
}
}
}
.launchIn(this)
}
+
+ LaunchedEffect(Unit) {
+ // Wait for the call to be joined, if it takes too long, we display an error
+ delay(10.seconds)
+
+ if (!isJoinedCall) {
+ Timber.w("The call took too long to be joined. Displaying an error before exiting.")
+
+ // This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call
+ webViewError = ""
+ }
+ }
}
fun handleEvents(event: CallScreenEvents) {
@@ -158,6 +173,13 @@ class CallScreenPresenter @AssistedInject constructor(
// If the call was joined, we need to hang up first. Then the UI will be dismissed automatically.
sendHangupMessage(widgetId, interceptor)
isJoinedCall = false
+
+ coroutineScope.launch {
+ // Wait for a couple of seconds to receive the hangup message
+ // If we don't get it in time, we close the screen anyway
+ delay(2.seconds)
+ close(callWidgetDriver.value, navigator)
+ }
} else {
coroutineScope.launch {
close(callWidgetDriver.value, navigator)
@@ -186,7 +208,7 @@ class CallScreenPresenter @AssistedInject constructor(
)
}
- private suspend fun loadUrl(
+ private suspend fun fetchRoomCallUrl(
inputs: CallType,
urlState: MutableState>,
callWidgetDriver: MutableState,
@@ -207,6 +229,7 @@ class CallScreenPresenter @AssistedInject constructor(
theme = theme,
).getOrThrow()
callWidgetDriver.value = result.driver
+ Timber.d("Call widget driver initialized for sessionId: ${inputs.sessionId}, roomId: ${inputs.roomId}")
result.url
}
}
@@ -217,37 +240,53 @@ class CallScreenPresenter @AssistedInject constructor(
private fun HandleMatrixClientSyncState() {
val coroutineScope = rememberCoroutineScope()
DisposableEffect(Unit) {
- val client = (callType as? CallType.RoomCall)?.sessionId?.let {
- matrixClientsProvider.getOrNull(it)
- } ?: return@DisposableEffect onDispose { }
+ val roomCallType = callType as? CallType.RoomCall ?: return@DisposableEffect onDispose {}
+ val client = matrixClientsProvider.getOrNull(roomCallType.sessionId) ?: return@DisposableEffect onDispose {
+ Timber.w("No MatrixClient found for sessionId, can't send call notification: ${roomCallType.sessionId}")
+ }
coroutineScope.launch {
+ Timber.d("Observing sync state in-call for sessionId: ${roomCallType.sessionId}")
client.syncService().syncState
.collect { state ->
if (state == SyncState.Running) {
client.notifyCallStartIfNeeded(callType.roomId)
} else {
- client.syncService().startSync()
+ appForegroundStateService.updateIsInCallState(true)
}
}
}
onDispose {
- // We can't use the local coroutine scope here because it will be disposed before this effect
- appCoroutineScope.launch {
- client.syncService().run {
- if (syncState.value == SyncState.Running) {
- stopSync()
- }
- }
- }
+ Timber.d("Stopped observing sync state in-call for sessionId: ${roomCallType.sessionId}")
+ // Make sure we mark the call as ended in the app state
+ appForegroundStateService.updateIsInCallState(false)
}
}
}
private suspend fun MatrixClient.notifyCallStartIfNeeded(roomId: RoomId) {
- if (!notifiedCallStart) {
- getRoom(roomId)?.sendCallNotificationIfNeeded()
- ?.onSuccess { notifiedCallStart = true }
+ if (notifiedCallStart) return
+
+ val activeRoomForSession = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId)
+ val sendCallNotificationResult = if (activeRoomForSession != null) {
+ Timber.d("Notifying call start for room $roomId. Has room call: ${activeRoomForSession.info().hasRoomCall}")
+ activeRoomForSession.sendCallNotificationIfNeeded()
+ } else {
+ // Instantiate the room from the session and roomId and send the notification
+ getJoinedRoom(roomId)?.use { room ->
+ Timber.d("Notifying call start for room $roomId. Has room call: ${room.info().hasRoomCall}")
+ room.sendCallNotificationIfNeeded()
+ } ?: run {
+ Timber.w("No room found for session $sessionId and room $roomId, skipping call notification.")
+ return
+ }
}
+
+ sendCallNotificationResult.fold(
+ onSuccess = { notifiedCallStart = true },
+ onFailure = { error ->
+ Timber.e(error, "Failed to send call notification for room $roomId.")
+ }
+ )
}
private fun parseMessage(message: String): WidgetMessage? {
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
index 7ace349f6fa..88d2e81f8bb 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
@@ -11,6 +11,7 @@ import android.annotation.SuppressLint
import android.util.Log
import android.view.ViewGroup
import android.webkit.ConsoleMessage
+import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebView
@@ -19,31 +20,33 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.viewinterop.AndroidView
-import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.call.impl.R
import io.element.android.features.call.impl.pip.PictureInPictureEvents
import io.element.android.features.call.impl.pip.PictureInPictureState
-import io.element.android.features.call.impl.pip.PictureInPictureStateProvider
import io.element.android.features.call.impl.pip.aPictureInPictureState
+import io.element.android.features.call.impl.utils.InvalidAudioDeviceReason
+import io.element.android.features.call.impl.utils.WebViewAudioManager
import io.element.android.features.call.impl.utils.WebViewPipController
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.ProgressDialog
-import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
-import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
import timber.log.Timber
@@ -53,7 +56,6 @@ interface CallScreenNavigator {
fun close()
}
-@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun CallScreenView(
state: CallScreenState,
@@ -71,19 +73,6 @@ internal fun CallScreenView(
Scaffold(
modifier = modifier,
- topBar = {
- if (!pipState.isInPictureInPicture) {
- TopAppBar(
- title = { Text(stringResource(R.string.element_call)) },
- navigationIcon = {
- BackButton(
- imageVector = if (pipState.supportPip) CompoundIcons.ArrowLeft() else CompoundIcons.Close(),
- onClick = ::handleBack,
- )
- }
- )
- }
- }
) { padding ->
BackHandler {
handleBack()
@@ -97,6 +86,16 @@ internal fun CallScreenView(
onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
)
} else {
+ var webViewAudioManager by remember { mutableStateOf(null) }
+ val coroutineScope = rememberCoroutineScope()
+
+ var invalidAudioDeviceReason by remember { mutableStateOf(null) }
+ invalidAudioDeviceReason?.let {
+ InvalidAudioDeviceDialog(invalidAudioDeviceReason = it) {
+ invalidAudioDeviceReason = null
+ }
+ }
+
CallWebView(
modifier = Modifier
.padding(padding)
@@ -109,37 +108,74 @@ internal fun CallScreenView(
val callback: RequestPermissionCallback = { request.grant(it) }
requestPermissions(androidPermissions.toTypedArray(), callback)
},
- onWebViewCreate = { webView ->
+ onCreateWebView = { webView ->
+ webView.addBackHandler(onBackPressed = ::handleBack)
val interceptor = WebViewWidgetMessageInterceptor(
webView = webView,
+ onUrlLoaded = { url ->
+ webView.evaluateJavascript("controls.onBackButtonPressed = () => { backHandler.onBackPressed() }", null)
+ if (webViewAudioManager?.isInCallMode?.get() == false) {
+ Timber.d("URL $url is loaded, starting in-call audio mode")
+ webViewAudioManager?.onCallStarted()
+ } else {
+ Timber.d("Can't start in-call audio mode since the app is already in it.")
+ }
+ },
onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) },
)
+ webViewAudioManager = WebViewAudioManager(
+ webView = webView,
+ coroutineScope = coroutineScope,
+ onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
+ )
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
val pipController = WebViewPipController(webView)
pipState.eventSink(PictureInPictureEvents.SetPipController(pipController))
+ },
+ onDestroyWebView = {
+ // Reset audio mode
+ webViewAudioManager?.onCallStopped()
}
)
when (state.urlState) {
AsyncData.Uninitialized,
is AsyncData.Loading ->
ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait))
- is AsyncData.Failure ->
+ is AsyncData.Failure -> {
+ Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}")
ErrorDialog(
content = state.urlState.error.message.orEmpty(),
onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
)
+ }
is AsyncData.Success -> Unit
}
}
}
}
+@Composable
+private fun InvalidAudioDeviceDialog(
+ invalidAudioDeviceReason: InvalidAudioDeviceReason,
+ onDismiss: () -> Unit,
+) {
+ ErrorDialog(
+ content = when (invalidAudioDeviceReason) {
+ InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED -> {
+ stringResource(R.string.call_invalid_audio_device_bluetooth_devices_disabled)
+ }
+ },
+ onSubmit = onDismiss,
+ )
+}
+
@Composable
private fun CallWebView(
url: AsyncData,
userAgent: String,
onPermissionsRequest: (PermissionRequest) -> Unit,
- onWebViewCreate: (WebView) -> Unit,
+ onCreateWebView: (WebView) -> Unit,
+ onDestroyWebView: (WebView) -> Unit,
modifier: Modifier = Modifier,
) {
if (LocalInspectionMode.current) {
@@ -151,7 +187,7 @@ private fun CallWebView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
- onWebViewCreate(this)
+ onCreateWebView(this)
setup(userAgent, onPermissionsRequest)
}
},
@@ -161,6 +197,7 @@ private fun CallWebView(
}
},
onRelease = { webView ->
+ onDestroyWebView(webView)
webView.destroy()
}
)
@@ -200,6 +237,20 @@ private fun WebView.setup(
ConsoleMessage.MessageLevel.WARNING -> Log.WARN
else -> Log.DEBUG
}
+
+ val message = buildString {
+ append(consoleMessage.sourceId())
+ append(":")
+ append(consoleMessage.lineNumber())
+ append(" ")
+ append(consoleMessage.message())
+ }
+
+ if (message.contains("password=")) {
+ // Avoid logging any messages that contain "password" to prevent leaking sensitive information
+ return true
+ }
+
Timber.tag("WebView").log(
priority = priority,
message = buildString {
@@ -215,6 +266,17 @@ private fun WebView.setup(
}
}
+private fun WebView.addBackHandler(onBackPressed: () -> Unit) {
+ addJavascriptInterface(
+ object {
+ @Suppress("unused")
+ @JavascriptInterface
+ fun onBackPressed() = onBackPressed()
+ },
+ "backHandler"
+ )
+}
+
@PreviewsDayNight
@Composable
internal fun CallScreenViewPreview(
@@ -229,12 +291,6 @@ internal fun CallScreenViewPreview(
@PreviewsDayNight
@Composable
-internal fun CallScreenPipViewPreview(
- @PreviewParameter(PictureInPictureStateProvider::class) state: PictureInPictureState,
-) = ElementPreview {
- CallScreenView(
- state = aCallScreenState(),
- pipState = state,
- requestPermissions = { _, _ -> },
- )
+internal fun InvalidAudioDeviceDialogPreview() = ElementPreview {
+ InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {}
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
index 65859da60e1..870c2244648 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
@@ -10,10 +10,6 @@ package io.element.android.features.call.impl.ui
import android.Manifest
import android.app.PictureInPictureParams
import android.content.Intent
-import android.content.res.Configuration
-import android.media.AudioAttributes
-import android.media.AudioFocusRequest
-import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.util.Rational
@@ -44,9 +40,13 @@ import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.PipView
import io.element.android.features.call.impl.services.CallForegroundService
import io.element.android.features.call.impl.utils.CallIntentDataParser
+import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.audio.api.AudioFocus
+import io.element.android.libraries.audio.api.AudioFocusRequester
import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import timber.log.Timber
@@ -61,20 +61,17 @@ class ElementCallActivity :
@Inject lateinit var callIntentDataParser: CallIntentDataParser
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
@Inject lateinit var appPreferencesStore: AppPreferencesStore
+ @Inject lateinit var enterpriseService: EnterpriseService
@Inject lateinit var pictureInPicturePresenter: PictureInPicturePresenter
+ @Inject lateinit var buildMeta: BuildMeta
+ @Inject lateinit var audioFocus: AudioFocus
private lateinit var presenter: Presenter
- private lateinit var audioManager: AudioManager
-
private var requestPermissionCallback: RequestPermissionCallback? = null
- private var audiofocusRequest: AudioFocusRequest? = null
- private var audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null
-
private val requestPermissionsLauncher = registerPermissionResultLauncher()
- private var isDarkMode = false
private val webViewTarget = mutableStateOf(null)
private var eventSink: ((CallScreenEvents) -> Unit)? = null
@@ -84,13 +81,14 @@ class ElementCallActivity :
applicationContext.bindings().inject(this)
- @Suppress("DEPRECATION")
- window.addFlags(
- WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
- WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON or
- WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
- WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
- )
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ setShowWhenLocked(true)
+ } else {
+ @Suppress("DEPRECATION")
+ window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
+ }
setCallType(intent)
// If presenter is not created at this point, it means we have no call to display, the Activity is finishing, so return early
@@ -98,18 +96,18 @@ class ElementCallActivity :
return
}
- if (savedInstanceState == null) {
- updateUiMode(resources.configuration)
- }
-
pictureInPicturePresenter.setPipView(this)
- audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
+ Timber.d("Created ElementCallActivity with call type: ${webViewTarget.value}")
setContent {
val pipState = pictureInPicturePresenter.present()
ListenToAndroidEvents(pipState)
- ElementThemeApp(appPreferencesStore) {
+ ElementThemeApp(
+ appPreferencesStore = appPreferencesStore,
+ enterpriseService = enterpriseService,
+ buildMeta = buildMeta,
+ ) {
val state = presenter.present()
eventSink = state.eventSink
LaunchedEffect(state.isCallActive, state.isInWidgetMode) {
@@ -131,7 +129,13 @@ class ElementCallActivity :
}
private fun setCallIsActive() {
- requestAudioFocus()
+ audioFocus.requestAudioFocus(
+ requester = AudioFocusRequester.ElementCall,
+ onFocusLost = {
+ // If the audio focus is lost, we do not stop the call.
+ Timber.tag(loggerTag.value).w("Audio focus lost")
+ }
+ )
CallForegroundService.start(this)
}
@@ -166,11 +170,6 @@ class ElementCallActivity :
}
}
- override fun onConfigurationChanged(newConfig: Configuration) {
- super.onConfigurationChanged(newConfig)
- updateUiMode(newConfig)
- }
-
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setCallType(intent)
@@ -178,7 +177,7 @@ class ElementCallActivity :
override fun onDestroy() {
super.onDestroy()
- releaseAudioFocus()
+ audioFocus.releaseAudioFocus()
CallForegroundService.stop(this)
pictureInPicturePresenter.setPipView(null)
}
@@ -244,50 +243,6 @@ class ElementCallActivity :
}
}
- @Suppress("DEPRECATION")
- private fun requestAudioFocus() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- val audioAttributes = AudioAttributes.Builder()
- .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
- .build()
- val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
- .setAudioAttributes(audioAttributes)
- .build()
- audioManager.requestAudioFocus(request)
- audiofocusRequest = request
- } else {
- val listener = AudioManager.OnAudioFocusChangeListener { }
- audioManager.requestAudioFocus(
- listener,
- AudioManager.STREAM_VOICE_CALL,
- AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
- )
- audioFocusChangeListener = listener
- }
- }
-
- @Suppress("DEPRECATION")
- private fun releaseAudioFocus() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- audiofocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
- } else {
- audioFocusChangeListener?.let { audioManager.abandonAudioFocus(it) }
- }
- }
-
- private fun updateUiMode(configuration: Configuration) {
- val prevDarkMode = isDarkMode
- val currentNightMode = configuration.uiMode and Configuration.UI_MODE_NIGHT_YES
- isDarkMode = currentNightMode != 0
- if (prevDarkMode != isDarkMode) {
- if (isDarkMode) {
- window.setBackgroundDrawableResource(android.R.drawable.screen_background_dark)
- } else {
- window.setBackgroundDrawableResource(android.R.drawable.screen_background_light)
- }
- }
- }
-
@RequiresApi(Build.VERSION_CODES.O)
override fun setPipParams() {
setPictureInPictureParams(getPictureInPictureParams())
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt
index 7dd4b560577..dc77af9b774 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt
@@ -19,12 +19,17 @@ import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.CallState
+import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp
+import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@@ -47,6 +52,15 @@ class IncomingCallActivity : AppCompatActivity() {
@Inject
lateinit var appPreferencesStore: AppPreferencesStore
+ @Inject
+ lateinit var enterpriseService: EnterpriseService
+
+ @Inject
+ lateinit var buildMeta: BuildMeta
+
+ @AppCoroutineScope
+ @Inject lateinit var appCoroutineScope: CoroutineScope
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -64,7 +78,11 @@ class IncomingCallActivity : AppCompatActivity() {
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
if (notificationData != null) {
setContent {
- ElementThemeApp(appPreferencesStore) {
+ ElementThemeApp(
+ appPreferencesStore = appPreferencesStore,
+ enterpriseService = enterpriseService,
+ buildMeta = buildMeta,
+ ) {
IncomingCallScreen(
notificationData = notificationData,
onAnswer = ::onAnswer,
@@ -90,6 +108,8 @@ class IncomingCallActivity : AppCompatActivity() {
private fun onCancel() {
val activeCall = activeCallManager.activeCall.value ?: return
- activeCallManager.hungUpCall(callType = activeCall.callType)
+ appCoroutineScope.launch {
+ activeCallManager.hungUpCall(callType = activeCall.callType)
+ }
}
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt
index 5c9057a847b..954fa3568f3 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt
@@ -39,6 +39,7 @@ import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
@@ -74,7 +75,8 @@ internal fun IncomingCallScreen(
name = notificationData.senderName,
url = notificationData.avatarUrl,
size = AvatarSize.IncomingCall,
- )
+ ),
+ avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.height(24.dp))
Text(
@@ -100,7 +102,7 @@ internal fun IncomingCallScreen(
ActionButton(
size = 64.dp,
onClick = { onAnswer(notificationData) },
- icon = CompoundIcons.VoiceCall(),
+ icon = CompoundIcons.VoiceCallSolid(),
title = stringResource(CommonStrings.action_accept),
backgroundColor = ElementTheme.colors.iconSuccessPrimary,
borderColor = ElementTheme.colors.borderSuccessSubtle
@@ -173,6 +175,7 @@ internal fun IncomingCallScreenPreview() = ElementPreview {
avatarUrl = null,
notificationChannelId = "incoming_call",
timestamp = 0L,
+ textContent = null,
),
onAnswer = {},
onCancel = {},
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
index 421403d34f1..d4f5abb59db 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
@@ -8,20 +8,31 @@
package io.element.android.features.call.impl.utils
import android.annotation.SuppressLint
+import android.content.Context
+import android.os.PowerManager
import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.getSystemService
+import coil3.SingletonImageLoader
+import coil3.annotation.DelicateCoilApi
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
+import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClientProvider
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler
+import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
@@ -38,6 +49,8 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@@ -55,72 +68,108 @@ interface ActiveCallManager {
* Registers an incoming call if there isn't an existing active call and posts a [CallState.Ringing] notification.
* @param notificationData The data for the incoming call notification.
*/
- fun registerIncomingCall(notificationData: CallNotificationData)
+ suspend fun registerIncomingCall(notificationData: CallNotificationData)
/**
* Called when the active call has been hung up. It will remove any existing UI and the active call.
* @param callType The type of call that the user hung up, either an external url one or a room one.
*/
- fun hungUpCall(callType: CallType)
+ suspend fun hungUpCall(callType: CallType)
/**
* Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall].
*
* @param callType The type of call that the user joined, either an external url one or a room one.
*/
- fun joinedCall(callType: CallType)
+ suspend fun joinedCall(callType: CallType)
}
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultActiveCallManager @Inject constructor(
+ @ApplicationContext context: Context,
+ @AppCoroutineScope
private val coroutineScope: CoroutineScope,
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
private val ringingCallNotificationCreator: RingingCallNotificationCreator,
private val notificationManagerCompat: NotificationManagerCompat,
private val matrixClientProvider: MatrixClientProvider,
private val defaultCurrentCallService: DefaultCurrentCallService,
+ private val appForegroundStateService: AppForegroundStateService,
+ private val imageLoaderHolder: ImageLoaderHolder,
) : ActiveCallManager {
+ private val tag = "DefaultActiveCallManager"
private var timedOutCallJob: Job? = null
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal val activeWakeLock: PowerManager.WakeLock? = context.getSystemService()
+ ?.takeIf { it.isWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK) }
+ ?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "${context.packageName}:IncomingCallWakeLock")
+
override val activeCall = MutableStateFlow(null)
+ private val mutex = Mutex()
+
init {
observeRingingCall()
observeCurrentCall()
}
- override fun registerIncomingCall(notificationData: CallNotificationData) {
- if (activeCall.value != null) {
- displayMissedCallNotification(notificationData)
- Timber.w("Already have an active call, ignoring incoming call: $notificationData")
- return
- }
- activeCall.value = ActiveCall(
- callType = CallType.RoomCall(
- sessionId = notificationData.sessionId,
- roomId = notificationData.roomId,
- ),
- callState = CallState.Ringing(notificationData),
- )
+ override suspend fun registerIncomingCall(notificationData: CallNotificationData) {
+ mutex.withLock {
+ appForegroundStateService.updateHasRingingCall(true)
+ Timber.tag(tag).d("Received incoming call for room id: ${notificationData.roomId}")
+ if (activeCall.value != null) {
+ displayMissedCallNotification(notificationData)
+ Timber.tag(tag).w("Already have an active call, ignoring incoming call: $notificationData")
+ return
+ }
+ activeCall.value = ActiveCall(
+ callType = CallType.RoomCall(
+ sessionId = notificationData.sessionId,
+ roomId = notificationData.roomId,
+ ),
+ callState = CallState.Ringing(notificationData),
+ )
+
+ timedOutCallJob = coroutineScope.launch {
+ setUpCoil(notificationData.sessionId)
+ showIncomingCallNotification(notificationData)
- timedOutCallJob = coroutineScope.launch {
- showIncomingCallNotification(notificationData)
+ // Wait for the ringing call to time out
+ delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds)
+ incomingCallTimedOut(displayMissedCallNotification = true)
+ }
- // Wait for the ringing call to time out
- delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds)
- incomingCallTimedOut(displayMissedCallNotification = true)
+ // Acquire a wake lock to keep the device awake during the incoming call, so we can process the room info data
+ if (activeWakeLock?.isHeld == false) {
+ Timber.tag(tag).d("Acquiring partial wakelock")
+ activeWakeLock.acquire(ElementCallConfig.RINGING_CALL_DURATION_SECONDS * 1000L)
+ }
}
}
+ @OptIn(DelicateCoilApi::class)
+ private suspend fun setUpCoil(sessionId: SessionId) {
+ val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return
+ // Ensure that the image loader is set, else the IncomingCallActivity will not be able to render the caller avatar
+ SingletonImageLoader.setUnsafe(imageLoaderHolder.get(matrixClient))
+ }
+
/**
* Called when the incoming call timed out. It will remove the active call and remove any associated UI, adding a 'missed call' notification.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
- fun incomingCallTimedOut(displayMissedCallNotification: Boolean) {
+ suspend fun incomingCallTimedOut(displayMissedCallNotification: Boolean) = mutex.withLock {
+ Timber.tag(tag).d("Incoming call timed out")
+
val previousActiveCall = activeCall.value ?: return
val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return
activeCall.value = null
+ if (activeWakeLock?.isHeld == true) {
+ Timber.tag(tag).d("Releasing partial wakelock after timeout")
+ activeWakeLock.release()
+ }
cancelIncomingCallNotification()
@@ -129,18 +178,31 @@ class DefaultActiveCallManager @Inject constructor(
}
}
- override fun hungUpCall(callType: CallType) {
+ override suspend fun hungUpCall(callType: CallType) = mutex.withLock {
if (activeCall.value?.callType != callType) {
- Timber.w("Call type $callType does not match the active call type, ignoring")
+ Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
return
}
+
+ Timber.tag(tag).d("Hung up call: $callType")
+
cancelIncomingCallNotification()
+ if (activeWakeLock?.isHeld == true) {
+ Timber.tag(tag).d("Releasing partial wakelock after hang up")
+ activeWakeLock.release()
+ }
timedOutCallJob?.cancel()
activeCall.value = null
}
- override fun joinedCall(callType: CallType) {
+ override suspend fun joinedCall(callType: CallType) = mutex.withLock {
+ Timber.tag(tag).d("Joined call: $callType")
+
cancelIncomingCallNotification()
+ if (activeWakeLock?.isHeld == true) {
+ Timber.tag(tag).d("Releasing partial wakelock after joining call")
+ activeWakeLock.release()
+ }
timedOutCallJob?.cancel()
activeCall.value = ActiveCall(
@@ -151,6 +213,7 @@ class DefaultActiveCallManager @Inject constructor(
@SuppressLint("MissingPermission")
private suspend fun showIncomingCallNotification(notificationData: CallNotificationData) {
+ Timber.tag(tag).d("Displaying ringing call notification")
val notification = ringingCallNotificationCreator.createNotification(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
@@ -160,9 +223,10 @@ class DefaultActiveCallManager @Inject constructor(
senderDisplayName = notificationData.senderName ?: notificationData.senderId.value,
roomAvatarUrl = notificationData.avatarUrl,
notificationChannelId = notificationData.notificationChannelId,
- timestamp = notificationData.timestamp
+ timestamp = notificationData.timestamp,
+ textContent = notificationData.textContent,
) ?: return
- runCatching {
+ runCatchingExceptions {
notificationManagerCompat.notify(
NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL),
notification,
@@ -173,10 +237,13 @@ class DefaultActiveCallManager @Inject constructor(
}
private fun cancelIncomingCallNotification() {
+ appForegroundStateService.updateHasRingingCall(false)
+ Timber.tag(tag).d("Ringing call notification cancelled")
notificationManagerCompat.cancel(NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL))
}
private fun displayMissedCallNotification(notificationData: CallNotificationData) {
+ Timber.tag(tag).d("Displaying missed call notification")
coroutineScope.launch {
onMissedCallNotificationHandler.addMissedCallNotification(
sessionId = notificationData.sessionId,
@@ -196,13 +263,14 @@ class DefaultActiveCallManager @Inject constructor(
.flatMapLatest { activeCall ->
val callType = activeCall.callType as CallType.RoomCall
// Get a flow of updated `hasRoomCall` and `activeRoomCallParticipants` values for the room
- matrixClientProvider.getOrRestore(callType.sessionId).getOrNull()
- ?.getRoom(callType.roomId)
- ?.roomInfoFlow
- ?.map {
- it.hasRoomCall to (callType.sessionId in it.activeRoomCallParticipants)
- }
- ?: flowOf()
+ val room = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull()?.getRoom(callType.roomId) ?: run {
+ Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
+ return@flatMapLatest flowOf()
+ }
+ room.roomInfoFlow.map {
+ Timber.tag(tag).d("Has room call status changed for ringing call: ${it.hasRoomCall}")
+ it.hasRoomCall to (callType.sessionId in it.activeRoomCallParticipants)
+ }
}
// We only want to check if the room active call status changes
.distinctUntilChanged()
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallIntentDataParser.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallIntentDataParser.kt
index 22b9f6bef76..c81b87746e6 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallIntentDataParser.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallIntentDataParser.kt
@@ -8,35 +8,37 @@
package io.element.android.features.call.impl.utils
import android.net.Uri
+import androidx.core.net.toUri
import javax.inject.Inject
class CallIntentDataParser @Inject constructor() {
private val validHttpSchemes = sequenceOf("https")
+ private val knownHosts = sequenceOf(
+ "call.element.io",
+ )
fun parse(data: String?): String? {
- val parsedUrl = data?.let { Uri.parse(data) } ?: return null
+ val parsedUrl = data?.toUri() ?: return null
val scheme = parsedUrl.scheme
return when {
- scheme in validHttpSchemes && parsedUrl.host == "call.element.io" -> parsedUrl
+ scheme in validHttpSchemes -> parsedUrl
scheme == "element" && parsedUrl.host == "call" -> {
- // We use this custom scheme to load arbitrary URLs for other instances of Element Call,
- // so we can only verify it's an HTTP/HTTPs URL with a non-empty host
parsedUrl.getUrlParameter()
}
scheme == "io.element.call" && parsedUrl.host == null -> {
- // We use this custom scheme to load arbitrary URLs for other instances of Element Call,
- // so we can only verify it's an HTTP/HTTPs URL with a non-empty host
parsedUrl.getUrlParameter()
}
// This should never be possible, but we still need to take into account the possibility
else -> null
- }?.withCustomParameters()
+ }
+ ?.takeIf { it.host in knownHosts }
+ ?.withCustomParameters()
}
private fun Uri.getUrlParameter(): Uri? {
return getQueryParameter("url")
?.let { urlParameter ->
- Uri.parse(urlParameter).takeIf { uri ->
+ urlParameter.toUri().takeIf { uri ->
uri.scheme in validHttpSchemes && !uri.host.isNullOrBlank()
}
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallAnalyticCredentialsProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallAnalyticCredentialsProvider.kt
new file mode 100644
index 00000000000..c978aba7e8b
--- /dev/null
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallAnalyticCredentialsProvider.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.call.impl.utils
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.call.impl.BuildConfig
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.matrix.api.widget.CallAnalyticCredentialsProvider
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultCallAnalyticCredentialsProvider @Inject constructor() : CallAnalyticCredentialsProvider {
+ override val posthogUserId: String? = BuildConfig.POSTHOG_USER_ID.takeIf { it.isNotBlank() }
+ override val posthogApiHost: String? = BuildConfig.POSTHOG_API_HOST.takeIf { it.isNotBlank() }
+ override val posthogApiKey: String? = BuildConfig.POSTHOG_API_KEY.takeIf { it.isNotBlank() }
+ override val rageshakeSubmitUrl: String? = BuildConfig.RAGESHAKE_URL.takeIf { it.isNotBlank() }
+ override val sentryDsn: String? = BuildConfig.SENTRY_DSN.takeIf { it.isNotBlank() }
+}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt
index 097e8a65317..d87826fe460 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt
@@ -8,23 +8,25 @@
package io.element.android.features.call.impl.utils
import com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.appconfig.ElementCallConfig
+import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClientProvider
-import io.element.android.libraries.matrix.api.call.ElementCallBaseUrlProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import kotlinx.coroutines.flow.firstOrNull
import javax.inject.Inject
+private const val EMBEDDED_CALL_WIDGET_BASE_URL = "https://appassets.androidplatform.net/element-call/index.html"
+
@ContributesBinding(AppScope::class)
class DefaultCallWidgetProvider @Inject constructor(
private val matrixClientsProvider: MatrixClientProvider,
private val appPreferencesStore: AppPreferencesStore,
private val callWidgetSettingsProvider: CallWidgetSettingsProvider,
- private val elementCallBaseUrlProvider: ElementCallBaseUrlProvider,
+ private val activeRoomsHolder: ActiveRoomsHolder,
) : CallWidgetProvider {
override suspend fun getWidget(
sessionId: SessionId,
@@ -32,22 +34,29 @@ class DefaultCallWidgetProvider @Inject constructor(
clientId: String,
languageTag: String?,
theme: String?,
- ): Result = runCatching {
+ ): Result = runCatchingExceptions {
val matrixClient = matrixClientsProvider.getOrRestore(sessionId).getOrThrow()
- val room = matrixClient.getRoom(roomId) ?: error("Room not found")
- val baseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull()
- ?: elementCallBaseUrlProvider.provides(matrixClient)
- ?: ElementCallConfig.DEFAULT_BASE_URL
- val widgetSettings = callWidgetSettingsProvider.provide(baseUrl, encrypted = room.isEncrypted)
+ val room = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId)
+ ?: matrixClient.getJoinedRoom(roomId)
+ ?: error("Room not found")
+
+ val customBaseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull()
+ val baseUrl = customBaseUrl ?: EMBEDDED_CALL_WIDGET_BASE_URL
+
+ val isEncrypted = room.info().isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow()
+ val widgetSettings = callWidgetSettingsProvider.provide(baseUrl, encrypted = isEncrypted)
val callUrl = room.generateWidgetWebViewUrl(
widgetSettings = widgetSettings,
clientId = clientId,
languageTag = languageTag,
theme = theme,
).getOrThrow()
+
+ val driver = room.getWidgetDriver(widgetSettings).getOrThrow()
+
CallWidgetProvider.GetWidgetResult(
- driver = room.getWidgetDriver(widgetSettings).getOrThrow(),
- url = callUrl
+ driver = driver,
+ url = callUrl,
)
}
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt
new file mode 100644
index 00000000000..b7d1fdf466f
--- /dev/null
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt
@@ -0,0 +1,503 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.call.impl.utils
+
+import android.content.Context
+import android.media.AudioDeviceCallback
+import android.media.AudioDeviceInfo
+import android.media.AudioManager
+import android.os.Build
+import android.os.PowerManager
+import android.webkit.JavascriptInterface
+import android.webkit.WebView
+import androidx.annotation.RequiresApi
+import androidx.core.content.getSystemService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
+import kotlinx.serialization.json.Json
+import timber.log.Timber
+import java.util.concurrent.Executors
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * This class manages the audio devices for a WebView.
+ *
+ * It listens for audio device changes and updates the WebView with the available devices.
+ * It also handles the selection of the audio device by the user in the WebView and the default audio device based on the device type.
+ *
+ * See also: [Element Call controls docs.](https://github.com/element-hq/element-call/blob/livekit/docs/controls.md#audio-devices)
+ */
+class WebViewAudioManager(
+ private val webView: WebView,
+ private val coroutineScope: CoroutineScope,
+ private val onInvalidAudioDeviceAdded: (InvalidAudioDeviceReason) -> Unit,
+) {
+ /**
+ * Whether to disable bluetooth audio devices. This must be done on Android versions lower than Android 12,
+ * since the WebView approach breaks when using the legacy Bluetooth audio APIs.
+ */
+ private val disableBluetoothAudioDevices = Build.VERSION.SDK_INT < Build.VERSION_CODES.S
+
+ /**
+ * This flag indicates whether the WebView audio is enabled or not. By default, it is enabled.
+ */
+ private val isWebViewAudioEnabled = AtomicBoolean(true)
+
+ /**
+ * The list of device types that are considered as communication devices, sorted by likelihood of it being used for communication.
+ */
+ private val wantedDeviceTypes = listOf(
+ // Paired bluetooth device with microphone
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
+ // USB devices which can play or record audio
+ AudioDeviceInfo.TYPE_USB_HEADSET,
+ AudioDeviceInfo.TYPE_USB_DEVICE,
+ AudioDeviceInfo.TYPE_USB_ACCESSORY,
+ // Wired audio devices
+ AudioDeviceInfo.TYPE_WIRED_HEADSET,
+ AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
+ // The built-in speaker of the device
+ AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
+ // The built-in earpiece of the device
+ AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
+ )
+
+ private val audioManager = webView.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+
+ /**
+ * This wake lock is used to turn off the screen when the proximity sensor is triggered during a call,
+ * if the selected audio device is the built-in earpiece.
+ */
+ private val proximitySensorWakeLock by lazy {
+ webView.context.getSystemService()
+ ?.takeIf { it.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK) }
+ ?.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "${webView.context.packageName}:ProximitySensorCallWakeLock")
+ }
+
+ /**
+ * This listener tracks the current communication device and updates the WebView when it changes.
+ */
+ @get:RequiresApi(Build.VERSION_CODES.S)
+ private val commsDeviceChangedListener by lazy {
+ AudioManager.OnCommunicationDeviceChangedListener { device ->
+ if (device != null && device.id == expectedNewCommunicationDeviceId) {
+ expectedNewCommunicationDeviceId = null
+ Timber.d("Audio device changed, type: ${device.type}")
+ updateSelectedAudioDeviceInWebView(device.id.toString())
+ } else if (device != null && device.id != expectedNewCommunicationDeviceId) {
+ // We were expecting a device change but it didn't happen, so we should retry
+ val expectedDeviceId = expectedNewCommunicationDeviceId
+ if (expectedDeviceId != null) {
+ // Remove the expected id so we only retry once
+ expectedNewCommunicationDeviceId = null
+ audioManager.selectAudioDevice(expectedDeviceId.toString())
+ }
+ } else {
+ Timber.d("Audio device cleared")
+ expectedNewCommunicationDeviceId = null
+ audioManager.selectAudioDevice(null)
+ }
+ }
+ }
+
+ /**
+ * This callback is used to listen for audio device changes coming from the OS.
+ */
+ private val audioDeviceCallback = object : AudioDeviceCallback() {
+ override fun onAudioDevicesAdded(addedDevices: Array?) {
+ val validNewDevices = addedDevices.orEmpty().filter { it.type in wantedDeviceTypes && it.isSink }
+ if (validNewDevices.isEmpty()) return
+
+ // We need to calculate the available devices ourselves, since calling `listAudioDevices` will return an outdated list
+ val audioDevices = (listAudioDevices() + validNewDevices).distinctBy { it.id }
+ setAvailableAudioDevices(audioDevices.map(SerializableAudioDevice::fromAudioDeviceInfo))
+ // This should automatically switch to a new device if it has a higher priority than the current one
+ selectDefaultAudioDevice(audioDevices)
+ }
+
+ override fun onAudioDevicesRemoved(removedDevices: Array?) {
+ // Update the available devices
+ setAvailableAudioDevices()
+
+ // Unless the removed device is the current one, we don't need to do anything else
+ val removedCurrentDevice = removedDevices.orEmpty().any { it.id == currentDeviceId }
+ if (!removedCurrentDevice) return
+
+ val previousDevice = previousSelectedDevice
+ if (previousDevice != null) {
+ previousSelectedDevice = null
+ // If we have a previous device, we should select it again
+ audioManager.selectAudioDevice(previousDevice.id.toString())
+ } else {
+ // If we don't have a previous device, we should select the default one
+ selectDefaultAudioDevice()
+ }
+ }
+ }
+
+ /**
+ * The currently used audio device id.
+ */
+ private var currentDeviceId: Int? = null
+
+ /**
+ * When a new audio device is selected but not yet set as the communication device by the OS, this id is used to check if the device is the expected one.
+ */
+ private var expectedNewCommunicationDeviceId: Int? = null
+
+ /**
+ * Previously selected device, used to restore the selection when the selected device is removed.
+ */
+ private var previousSelectedDevice: AudioDeviceInfo? = null
+
+ private var hasRegisteredCallbacks = false
+
+ /**
+ * Marks if the WebView audio is in call mode or not.
+ */
+ val isInCallMode = AtomicBoolean(false)
+
+ init {
+ // Apparently, registering the javascript interface takes a while, so registering and immediately using it doesn't work
+ // We register it ahead of time to avoid this issue
+ registerWebViewDeviceSelectedCallback()
+ }
+
+ /**
+ * Call this method when the call starts to enable in-call audio mode.
+ *
+ * It'll set the audio mode to [AudioManager.MODE_IN_COMMUNICATION] if possible, register the audio device callback and set the available audio devices.
+ */
+ fun onCallStarted() {
+ if (!isInCallMode.compareAndSet(false, true)) {
+ Timber.w("Audio: tried to enable webview in-call audio mode while already in it")
+ return
+ }
+
+ Timber.d("Audio: enabling webview in-call audio mode")
+
+ audioManager.mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ // Set 'voice call' mode so volume keys actually control the call volume
+ AudioManager.MODE_IN_COMMUNICATION
+ } else {
+ // Workaround for Android 12 and lower, otherwise changing the audio device doesn't work
+ AudioManager.MODE_NORMAL
+ }
+
+ setWebViewAndroidNativeBridge()
+ }
+
+ /**
+ * Call this method when the call stops to disable in-call audio mode.
+ *
+ * It's the counterpart of [onCallStarted], and should be called as a pair with it once the call has ended.
+ */
+ fun onCallStopped() {
+ if (!isInCallMode.compareAndSet(true, false)) {
+ Timber.w("Audio: tried to disable webview in-call audio mode while already disabled")
+ return
+ }
+
+ if (proximitySensorWakeLock?.isHeld == true) {
+ proximitySensorWakeLock?.release()
+ }
+
+ audioManager.mode = AudioManager.MODE_NORMAL
+
+ if (!hasRegisteredCallbacks) {
+ Timber.w("Audio: tried to disable webview in-call audio mode without registering callbacks")
+ return
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ audioManager.clearCommunicationDevice()
+ audioManager.removeOnCommunicationDeviceChangedListener(commsDeviceChangedListener)
+ }
+
+ audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
+ }
+
+ /**
+ * Registers the WebView audio device selected callback.
+ *
+ * This should be called when the WebView is created to ensure that the callback is set before any audio device selection is made.
+ */
+ private fun registerWebViewDeviceSelectedCallback() {
+ val webViewAudioDeviceSelectedCallback = AndroidWebViewAudioBridge(
+ onAudioDeviceSelected = { selectedDeviceId ->
+ Timber.d("Audio device selected in webview, id: $selectedDeviceId")
+ previousSelectedDevice = listAudioDevices().find { it.id.toString() == selectedDeviceId }
+ audioManager.selectAudioDevice(selectedDeviceId)
+ },
+ onAudioPlaybackStarted = {
+ coroutineScope.launch(Dispatchers.Main) {
+ // Even with the callback, it seems like starting the audio takes a bit on the webview side,
+ // so we add an extra delay here to make sure it's ready
+ delay(500.milliseconds)
+
+ // Calling this ahead of time makes the default audio device to not use the right audio stream
+ setAvailableAudioDevices()
+
+ // Registering the audio devices changed callback will also set the default audio device
+ audioManager.registerAudioDeviceCallback(audioDeviceCallback, null)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ audioManager.addOnCommunicationDeviceChangedListener(Executors.newSingleThreadExecutor(), commsDeviceChangedListener)
+ }
+
+ hasRegisteredCallbacks = true
+ }
+ }
+ )
+ Timber.d("Setting androidNativeBridge javascript interface in webview")
+ webView.addJavascriptInterface(webViewAudioDeviceSelectedCallback, "androidNativeBridge")
+ }
+
+ /**
+ * Assigns the callback in the WebView to be called when the user selects an audio device.
+ *
+ * It should be called with some delay after [registerWebViewDeviceSelectedCallback].
+ */
+ private fun setWebViewAndroidNativeBridge() {
+ Timber.d("Adding callback in controls.onAudioPlaybackStarted")
+ webView.evaluateJavascript("controls.onAudioPlaybackStarted = () => { androidNativeBridge.onTrackReady(); };", null)
+ Timber.d("Adding callback in controls.onOutputDeviceSelect")
+ webView.evaluateJavascript("controls.onOutputDeviceSelect = (id) => { androidNativeBridge.setOutputDevice(id); };", null)
+ }
+
+ /**
+ * Returns the list of available audio devices.
+ *
+ * On Android 11 ([Build.VERSION_CODES.R]) and lower, it returns the list of output devices as a fallback.
+ */
+ private fun listAudioDevices(): List {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ audioManager.availableCommunicationDevices
+ } else {
+ val rawAudioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
+ rawAudioDevices.filter { it.type in wantedDeviceTypes && it.isSink }
+ }
+ }
+
+ /**
+ * Sets the available audio devices in the WebView.
+ *
+ * @param devices The list of audio devices to set. If not provided, it will use the current list of audio devices.
+ */
+ private fun setAvailableAudioDevices(
+ devices: List = listAudioDevices().map(SerializableAudioDevice::fromAudioDeviceInfo),
+ ) {
+ Timber.d("Updating available audio devices")
+ val jsonSerializer = Json {
+ encodeDefaults = true
+ explicitNulls = false
+ }
+ val deviceList = jsonSerializer.encodeToString(devices)
+ webView.evaluateJavascript("controls.setAvailableOutputDevices($deviceList);", {
+ Timber.d("Audio: setAvailableOutputDevices result: $it")
+ })
+ }
+
+ /**
+ * Selects the default audio device based on the available devices.
+ *
+ * @param availableDevices The list of available audio devices to select from. If not provided, it will use the current list of audio devices.
+ */
+ private fun selectDefaultAudioDevice(availableDevices: List = listAudioDevices()) {
+ val selectedDevice = availableDevices
+ .minByOrNull {
+ wantedDeviceTypes.indexOf(it.type).let { index ->
+ // If the device type is not in the wantedDeviceTypes list, we give it a low priority
+ if (index == -1) Int.MAX_VALUE else index
+ }
+ }
+
+ expectedNewCommunicationDeviceId = selectedDevice?.id
+ audioManager.selectAudioDevice(selectedDevice)
+
+ selectedDevice?.let {
+ updateSelectedAudioDeviceInWebView(it.id.toString())
+ } ?: run {
+ Timber.w("Audio: unable to select default audio device")
+ }
+ }
+
+ /**
+ * Updates the WebView's UI to reflect the selected audio device.
+ *
+ * @param deviceId The id of the selected audio device.
+ */
+ private fun updateSelectedAudioDeviceInWebView(deviceId: String) {
+ coroutineScope.launch(Dispatchers.Main) {
+ webView.evaluateJavascript("controls.setOutputDevice('$deviceId');", null)
+ }
+ }
+
+ /**
+ * Selects the audio device on the OS based on the provided device id.
+ *
+ * It will select the device only if it is available in the list of audio devices.
+ *
+ * @param device The id of the audio device to select.
+ */
+ private fun AudioManager.selectAudioDevice(device: String) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val audioDevice = availableCommunicationDevices.find { it.id.toString() == device }
+ selectAudioDevice(audioDevice)
+ } else {
+ val rawAudioDevices = getDevices(AudioManager.GET_DEVICES_OUTPUTS)
+ val audioDevice = rawAudioDevices.find { it.id.toString() == device }
+ selectAudioDevice(audioDevice)
+ }
+ }
+
+ /**
+ * Selects the audio device on the OS based on the provided device info.
+ *
+ * @param device The info of the audio device to select, or none to clear the selected device.
+ */
+ @Suppress("DEPRECATION")
+ private fun AudioManager.selectAudioDevice(device: AudioDeviceInfo?) {
+ currentDeviceId = device?.id
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ if (device != null) {
+ Timber.d("Setting communication device: ${device.id} - ${deviceName(device.type, device.productName.toString())}")
+ setCommunicationDevice(device)
+ } else {
+ audioManager.clearCommunicationDevice()
+ }
+ } else {
+ // On Android 11 and lower, we don't have the concept of communication devices
+ // We have to call the right methods based on the device type
+ if (device != null) {
+ if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO && disableBluetoothAudioDevices) {
+ Timber.w("Bluetooth audio devices are disabled on this Android version")
+ setAudioEnabled(false)
+ onInvalidAudioDeviceAdded(InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED)
+ return
+ }
+ setAudioEnabled(true)
+ isSpeakerphoneOn = device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
+ isBluetoothScoOn = device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
+ } else {
+ isSpeakerphoneOn = false
+ isBluetoothScoOn = false
+ }
+ }
+
+ expectedNewCommunicationDeviceId = null
+
+ @Suppress("WakeLock", "WakeLockTimeout")
+ if (device?.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE && proximitySensorWakeLock?.isHeld == false) {
+ // If the device is the built-in earpiece, we need to acquire the proximity sensor wake lock
+ proximitySensorWakeLock?.acquire()
+ } else if (proximitySensorWakeLock?.isHeld == true) {
+ // If the device is no longer the earpiece, we need to release the wake lock
+ proximitySensorWakeLock?.release()
+ }
+ }
+
+ /**
+ * Sets whether the audio is enabled for Element Call in the WebView.
+ * It will only perform the change if the audio state has changed.
+ */
+ private fun setAudioEnabled(enabled: Boolean) {
+ coroutineScope.launch(Dispatchers.Main) {
+ Timber.d("Setting audio enabled in Element Call: $enabled")
+ if (isWebViewAudioEnabled.getAndSet(enabled) != enabled) {
+ webView.evaluateJavascript("controls.setAudioEnabled($enabled);", null)
+ }
+ }
+ }
+}
+
+/**
+ * This class is used to handle the audio device selection in the WebView.
+ * It listens for the audio device selection event and calls the callback with the selected device ID.
+ */
+private class AndroidWebViewAudioBridge(
+ private val onAudioDeviceSelected: (String) -> Unit,
+ private val onAudioPlaybackStarted: () -> Unit,
+) {
+ @JavascriptInterface
+ fun setOutputDevice(id: String) {
+ Timber.d("Audio device selected in webview, id: $id")
+ onAudioDeviceSelected(id)
+ }
+
+ @JavascriptInterface
+ fun onTrackReady() {
+ // This method can be used to notify the WebView that the audio track is ready
+ // It can be used to start playing audio or to update the UI
+ Timber.d("Audio track is ready")
+
+ onAudioPlaybackStarted()
+ }
+}
+
+private fun deviceName(type: Int, name: String): String {
+ // TODO maybe translate these?
+ val typePart = when (type) {
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth"
+ AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB accessory"
+ AudioDeviceInfo.TYPE_USB_DEVICE -> "USB device"
+ AudioDeviceInfo.TYPE_USB_HEADSET -> "USB headset"
+ AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired headset"
+ AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "Wired headphones"
+ AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Built-in speaker"
+ AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "Built-in earpiece"
+ else -> "Unknown"
+ }
+ return if (isBuiltIn(type)) {
+ typePart
+ } else {
+ "$typePart - $name"
+ }
+}
+
+private fun isBuiltIn(type: Int): Boolean = when (type) {
+ AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
+ AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
+ AudioDeviceInfo.TYPE_BUILTIN_MIC,
+ AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE -> true
+ else -> false
+}
+
+enum class InvalidAudioDeviceReason {
+ BT_AUDIO_DEVICE_DISABLED,
+}
+
+/**
+ * This class is used to serialize the audio device information to JSON.
+ */
+@Suppress("unused")
+@Serializable
+internal data class SerializableAudioDevice(
+ val id: String,
+ val name: String,
+ @Transient val type: Int = 0,
+ // These have to be part of the constructor for the JSON serializer to pick them up
+ val isEarpiece: Boolean = type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
+ val isSpeaker: Boolean = type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
+ val isExternalHeadset: Boolean = type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
+) {
+ companion object {
+ fun fromAudioDeviceInfo(audioDeviceInfo: AudioDeviceInfo): SerializableAudioDevice {
+ return SerializableAudioDevice(
+ id = audioDeviceInfo.id.toString(),
+ name = deviceName(type = audioDeviceInfo.type, name = audioDeviceInfo.productName.toString()),
+ type = audioDeviceInfo.type,
+ )
+ }
+ }
+}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt
index 8fea797425a..f46db3ca2b6 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt
@@ -11,6 +11,10 @@ import android.webkit.WebView
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
+/**
+ * Documentation about the `controls` command can be found here:
+ * https://github.com/element-hq/element-call/blob/livekit/docs/controls.md#picture-in-picture
+ */
class WebViewPipController(
private val webView: WebView,
) : PipController {
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt
index c1013afa6e7..55adc246e9e 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt
@@ -16,6 +16,8 @@ import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
+import androidx.core.net.toUri
+import androidx.webkit.WebViewAssetLoader
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import io.element.android.features.call.impl.BuildConfig
@@ -24,6 +26,7 @@ import timber.log.Timber
class WebViewWidgetMessageInterceptor(
private val webView: WebView,
+ private val onUrlLoaded: (String) -> Unit,
private val onError: (String?) -> Unit,
) : WidgetMessageInterceptor {
companion object {
@@ -37,14 +40,18 @@ class WebViewWidgetMessageInterceptor(
override val interceptedMessages = MutableSharedFlow(extraBufferCapacity = 10)
init {
+ val assetLoader = WebViewAssetLoader.Builder()
+ .addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(webView.context))
+ .build()
+
webView.webViewClient = object : WebViewClient() {
- override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
+ override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
// Due to https://github.com/element-hq/element-x-android/issues/4097
// we need to supply a logging implementation that correctly includes
// objects in log lines.
- view?.evaluateJavascript(
+ view.evaluateJavascript(
"""
function logFn(consoleLogFn, ...args) {
consoleLogFn(
@@ -66,7 +73,7 @@ class WebViewWidgetMessageInterceptor(
// This listener will receive both messages:
// - EC widget API -> Element X (message.data.api == "fromWidget")
// - Element X -> EC widget API (message.data.api == "toWidget"), we should ignore these
- view?.evaluateJavascript(
+ view.evaluateJavascript(
"""
window.addEventListener('message', function(event) {
let message = {data: event.data, origin: event.origin}
@@ -84,6 +91,10 @@ class WebViewWidgetMessageInterceptor(
)
}
+ override fun onPageFinished(view: WebView, url: String) {
+ onUrlLoaded(url)
+ }
+
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
// No network for instance, transmit the error
Timber.e("onReceivedError error: ${error?.errorCode} ${error?.description}")
@@ -117,6 +128,14 @@ class WebViewWidgetMessageInterceptor(
super.onReceivedSslError(view, handler, error)
}
+
+ override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest): WebResourceResponse? {
+ return assetLoader.shouldInterceptRequest(request.url)
+ }
+
+ override fun shouldInterceptRequest(view: WebView?, url: String): WebResourceResponse? {
+ return assetLoader.shouldInterceptRequest(url.toUri())
+ }
}
// Create a WebMessageListener, which will receive messages from the WebView and reply to them
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WidgetMessageSerializer.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WidgetMessageSerializer.kt
index a78965a87f8..a21cadab7a8 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WidgetMessageSerializer.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WidgetMessageSerializer.kt
@@ -8,13 +8,14 @@
package io.element.android.features.call.impl.utils
import io.element.android.features.call.impl.data.WidgetMessage
+import io.element.android.libraries.core.extensions.runCatchingExceptions
import kotlinx.serialization.json.Json
object WidgetMessageSerializer {
private val coder = Json { ignoreUnknownKeys = true }
fun deserialize(message: String): Result {
- return runCatching { coder.decodeFromString(WidgetMessage.serializer(), message) }
+ return runCatchingExceptions { coder.decodeFromString(WidgetMessage.serializer(), message) }
}
fun serialize(message: WidgetMessage): String {
diff --git a/features/call/impl/src/main/res/values-cs/translations.xml b/features/call/impl/src/main/res/values-cs/translations.xml
index 6272a9e0ec9..5d458dcc1b2 100644
--- a/features/call/impl/src/main/res/values-cs/translations.xml
+++ b/features/call/impl/src/main/res/values-cs/translations.xml
@@ -3,5 +3,6 @@
"Probíhající hovor"
"Klepněte pro návrat k hovoru"
"☎️ Probíhá hovor"
+ "Element Call nepodporuje používání Bluetooth zvukových zařízení v této verzi systému Android. Vyberte jiné zvukové zařízení."
"Příchozí Element Call"
diff --git a/features/call/impl/src/main/res/values-cy/translations.xml b/features/call/impl/src/main/res/values-cy/translations.xml
new file mode 100644
index 00000000000..cd36fed48ff
--- /dev/null
+++ b/features/call/impl/src/main/res/values-cy/translations.xml
@@ -0,0 +1,8 @@
+
+
+ "Galwad cyfredol"
+ "Tapio i ddychwelyd i\'r alwad"
+ "☎️ Galwad ar y gweill"
+ "Nid yw Element Call yn cefnogi defnyddio dyfeisiau sain Bluetooth yn y fersiwn Android hon. Dewiswch ddyfais sain wahanol."
+ "Galwad Element"
+
diff --git a/features/call/impl/src/main/res/values-da/translations.xml b/features/call/impl/src/main/res/values-da/translations.xml
new file mode 100644
index 00000000000..9bb8c30af2f
--- /dev/null
+++ b/features/call/impl/src/main/res/values-da/translations.xml
@@ -0,0 +1,8 @@
+
+
+ "Igangværende opkald"
+ "Tryk for at vende tilbage til opkaldet"
+ "☎️ Opkald i gang"
+ "Element Call understøtter desværre ikke brug af Bluetooth-lydenheder i denne Android-version. Vælg venligst en anden lydenhed."
+ "Indgående Element opkald"
+
diff --git a/features/call/impl/src/main/res/values-de/translations.xml b/features/call/impl/src/main/res/values-de/translations.xml
index 6429dbe9560..9e2ef1cea44 100644
--- a/features/call/impl/src/main/res/values-de/translations.xml
+++ b/features/call/impl/src/main/res/values-de/translations.xml
@@ -3,5 +3,6 @@
"Laufender Anruf"
"Tippen, um zum Anruf zurückzukehren"
"☎️ Anruf läuft"
+ "In dieser Android-Version unterstützt Element Call derzeit keine Bluetooth-Audiogeräte. Bitte wählen Sie ein anderes Audiogerät aus."
"Eingehender Element Call"
diff --git a/features/call/impl/src/main/res/values-el/translations.xml b/features/call/impl/src/main/res/values-el/translations.xml
index 3dd54c238a8..fa80c689da5 100644
--- a/features/call/impl/src/main/res/values-el/translations.xml
+++ b/features/call/impl/src/main/res/values-el/translations.xml
@@ -3,5 +3,6 @@
"Συνεχής κλήση"
"Πάτα για να επιστρέψεις στην κλήση"
"☎️ Κλήση σε εξέλιξη"
+ "Το Element Call δεν υποστηρίζει τη χρήση συσκευών ήχου Bluetooth σε αυτήν την έκδοση Android. Επέλεξε μια διαφορετική συσκευή ήχου."
"Εισερχόμενη κλήση Element"
diff --git a/features/call/impl/src/main/res/values-es/translations.xml b/features/call/impl/src/main/res/values-es/translations.xml
index 2e931859265..b0c3092efaa 100644
--- a/features/call/impl/src/main/res/values-es/translations.xml
+++ b/features/call/impl/src/main/res/values-es/translations.xml
@@ -3,4 +3,5 @@
"Llamada en curso"
"Pulsa para regresar a la llamada"
"☎️ Llamada en curso"
+ "Llamada de Element Call entrante"
diff --git a/features/call/impl/src/main/res/values-et/translations.xml b/features/call/impl/src/main/res/values-et/translations.xml
index 05b36cedbc3..becc9e55657 100644
--- a/features/call/impl/src/main/res/values-et/translations.xml
+++ b/features/call/impl/src/main/res/values-et/translations.xml
@@ -3,5 +3,6 @@
"Käimasolev kõne"
"Kõne juurde naasmiseks klõpsa"
"☎️ Kõne on pooleli"
+ "Element Call ei võimalda selles Androidi versioonis Bluetoothi heliseadmete kasutamist. Palun vali mõni muu heliseade."
"Sissetulev Element Calli kõne"
diff --git a/features/call/impl/src/main/res/values-eu/translations.xml b/features/call/impl/src/main/res/values-eu/translations.xml
new file mode 100644
index 00000000000..9985c5633e5
--- /dev/null
+++ b/features/call/impl/src/main/res/values-eu/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Abian den deia"
+ "Sakatu deira itzultzeko."
+ "☎️ Deia abian"
+ "Element deia jasotzen"
+
diff --git a/features/call/impl/src/main/res/values-fr/translations.xml b/features/call/impl/src/main/res/values-fr/translations.xml
index bd04a5b3622..bab8f6376dd 100644
--- a/features/call/impl/src/main/res/values-fr/translations.xml
+++ b/features/call/impl/src/main/res/values-fr/translations.xml
@@ -3,5 +3,6 @@
"Appel en cours"
"Cliquez pour retourner à l’appel."
"☎️ Appel en cours"
+ "Element Call ne prend pas en charge l’utilisation d’accessoires Bluetooth dans cette version d’Android. Sélectionnez une autre sortie audio."
"Appel Element entrant"
diff --git a/features/call/impl/src/main/res/values-hu/translations.xml b/features/call/impl/src/main/res/values-hu/translations.xml
index 18f735cff7f..62d359c2bbd 100644
--- a/features/call/impl/src/main/res/values-hu/translations.xml
+++ b/features/call/impl/src/main/res/values-hu/translations.xml
@@ -3,5 +3,6 @@
"Folyamatban lévő hívás"
"Koppintson a híváshoz való visszatéréshez"
"☎️ Hívás folyamatban"
+ "Az Element Call nem támogatja a Bluetooth hangeszközök használatát ebben az Android-verzióban. Válasszon másik hangeszközt."
"Bejövő Element hívás"
diff --git a/features/call/impl/src/main/res/values-in/translations.xml b/features/call/impl/src/main/res/values-in/translations.xml
index e900f8fe9af..7da23d6fb7a 100644
--- a/features/call/impl/src/main/res/values-in/translations.xml
+++ b/features/call/impl/src/main/res/values-in/translations.xml
@@ -3,5 +3,6 @@
"Panggilan berlangsung"
"Ketuk untuk kembali ke panggilan"
"☎️ Panggilan sedang berlangsung"
+ "Element Call tidak mendukung penggunaan perangkat audio Bluetooth di versi Android ini. Silakan pilih perangkat audio yang berbeda."
"Element Call Masuk"
diff --git a/features/call/impl/src/main/res/values-it/translations.xml b/features/call/impl/src/main/res/values-it/translations.xml
index f88f6f6162c..086fc98f55c 100644
--- a/features/call/impl/src/main/res/values-it/translations.xml
+++ b/features/call/impl/src/main/res/values-it/translations.xml
@@ -3,5 +3,6 @@
"Chiamata in corso"
"Tocca per tornare alla chiamata"
"☎️ Chiamata in corso"
+ "Element Call non supporta l\'uso di dispositivi audio Bluetooth in questa versione di Android. Seleziona un dispositivo audio diverso."
"Chiamata Element Call in arrivo"
diff --git a/features/call/impl/src/main/res/values-nb/translations.xml b/features/call/impl/src/main/res/values-nb/translations.xml
index c27cfd0fa86..b93abc6d879 100644
--- a/features/call/impl/src/main/res/values-nb/translations.xml
+++ b/features/call/impl/src/main/res/values-nb/translations.xml
@@ -1,4 +1,8 @@
+ "Pågående samtale"
+ "Trykk for å gå tilbake til samtalen"
+ "☎️ Samtale pågår"
+ "Element Call støtter ikke bruk av Bluetooth-lydenheter i denne Android-versjonen. Velg en annen lydenhet."
"Innkommende Element-anrop"
diff --git a/features/call/impl/src/main/res/values-pt-rBR/translations.xml b/features/call/impl/src/main/res/values-pt-rBR/translations.xml
index c3d90e4bb03..b0895845ce4 100644
--- a/features/call/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/call/impl/src/main/res/values-pt-rBR/translations.xml
@@ -3,4 +3,6 @@
"Chamada em andamento"
"Toque para retornar à chamada"
"☎️ Chamada em andamento"
+ "O Element Call não tem suporte a dispositivos de áudio Bluetooth nesta versão do Android. Por favor, selecione um dispositivo de áudio diferente."
+ "Chamada do Element recebida"
diff --git a/features/call/impl/src/main/res/values-pt/translations.xml b/features/call/impl/src/main/res/values-pt/translations.xml
index 58ee9d523f3..639726bd07b 100644
--- a/features/call/impl/src/main/res/values-pt/translations.xml
+++ b/features/call/impl/src/main/res/values-pt/translations.xml
@@ -3,5 +3,6 @@
"Chamada em curso"
"Toca para voltar à chamada"
"☎️ Chamada em curso"
+ "As chamadas do Element não permitem o uso de dispositivos de áudio Bluetooth nesta versão do Android. Por favor, seleciona outro dispositivo."
"A receber chamada da Element"
diff --git a/features/call/impl/src/main/res/values-sk/translations.xml b/features/call/impl/src/main/res/values-sk/translations.xml
index 71188379c2a..4e5b7db9f71 100644
--- a/features/call/impl/src/main/res/values-sk/translations.xml
+++ b/features/call/impl/src/main/res/values-sk/translations.xml
@@ -3,5 +3,6 @@
"Prebiehajúci hovor"
"Ťuknutím sa vrátite k hovoru"
"☎️ Prebieha hovor"
+ "Element Call nepodporuje používanie zvukových zariadení Bluetooth v tejto verzii systému Android. Vyberte iné zvukové zariadenie."
"Prichádzajúci hovor Element Call"
diff --git a/features/call/impl/src/main/res/values-tr/translations.xml b/features/call/impl/src/main/res/values-tr/translations.xml
new file mode 100644
index 00000000000..37607db690e
--- /dev/null
+++ b/features/call/impl/src/main/res/values-tr/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Devam eden çağrı"
+ "Aramaya geri dönmek için dokunun"
+ "☎️ Çağrı devam ediyor"
+ "Gelen Element Call"
+
diff --git a/features/call/impl/src/main/res/values-uk/translations.xml b/features/call/impl/src/main/res/values-uk/translations.xml
index 9d63049ddca..f03df23bbed 100644
--- a/features/call/impl/src/main/res/values-uk/translations.xml
+++ b/features/call/impl/src/main/res/values-uk/translations.xml
@@ -3,5 +3,6 @@
"Поточний виклик"
"Торкніться, щоб повернутися до виклику"
"☎️ Триває виклик"
+ "Element Call не підтримує використання аудіопристроїв Bluetooth у цій версії Android. Виберіть інший аудіопристрій."
"Вхідний виклик Element"
diff --git a/features/call/impl/src/main/res/values-ur/translations.xml b/features/call/impl/src/main/res/values-ur/translations.xml
new file mode 100644
index 00000000000..d0cafe91c6d
--- /dev/null
+++ b/features/call/impl/src/main/res/values-ur/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "جاری مکالمہ"
+ "مکالمہ پر واپس جانے کے لیے تھپتھپائیں"
+ "☎️ مکالمہ جاری ہے"
+ "ورودی ایلیمنٹ کال"
+
diff --git a/features/call/impl/src/main/res/values/localazy.xml b/features/call/impl/src/main/res/values/localazy.xml
index 5a386b24164..5ab2caa4fda 100644
--- a/features/call/impl/src/main/res/values/localazy.xml
+++ b/features/call/impl/src/main/res/values/localazy.xml
@@ -3,5 +3,6 @@
"Ongoing call"
"Tap to return to the call"
"☎️ Call in progress"
+ "Element Call does not support using Bluetooth audio devices in this Android version. Please select a different audio device."
"Incoming Element Call"
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt
index 56fac5fc3ba..86d9b80d653 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt
@@ -20,16 +20,21 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.tests.testutils.lambda.lambdaRecorder
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
+import kotlin.time.Duration.Companion.seconds
@RunWith(RobolectricTestRunner::class)
class DefaultElementCallEntryPointTest {
@Test
- fun `startCall - starts ElementCallActivity setup with the needed extras`() {
+ fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest {
val entryPoint = createEntryPoint()
entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
@@ -39,8 +44,9 @@ class DefaultElementCallEntryPointTest {
assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue()
}
+ @OptIn(ExperimentalCoroutinesApi::class)
@Test
- fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() {
+ fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() = runTest {
val registerIncomingCallLambda = lambdaRecorder {}
val activeCallManager = FakeActiveCallManager(registerIncomingCallResult = registerIncomingCallLambda)
val entryPoint = createEntryPoint(activeCallManager = activeCallManager)
@@ -54,12 +60,15 @@ class DefaultElementCallEntryPointTest {
avatarUrl = "avatarUrl",
timestamp = 0,
notificationChannelId = "notificationChannelId",
+ textContent = "textContent",
)
+ advanceTimeBy(1.seconds)
+
registerIncomingCallLambda.assertions().isCalledOnce()
}
- private fun createEntryPoint(
+ private fun TestScope.createEntryPoint(
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
) = DefaultElementCallEntryPoint(
context = InstrumentationRegistry.getInstrumentation().targetContext,
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt
deleted file mode 100644
index 55ed644c47e..00000000000
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-/*
- * Copyright 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.call.impl.ui
-
-import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import io.element.android.features.call.impl.pip.PictureInPictureEvents
-import io.element.android.features.call.impl.pip.PictureInPictureState
-import io.element.android.features.call.impl.pip.aPictureInPictureState
-import io.element.android.tests.testutils.EventsRecorder
-import io.element.android.tests.testutils.pressBack
-import org.junit.Rule
-import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class CallScreenViewTest {
- @get:Rule val rule = createAndroidComposeRule()
-
- @Test
- fun `clicking on back when pip is not supported hangs up`() {
- val eventsRecorder = EventsRecorder()
- val pipEventsRecorder = EventsRecorder()
- rule.setCallScreenView(
- aCallScreenState(
- eventSink = eventsRecorder
- ),
- aPictureInPictureState(
- supportPip = false,
- eventSink = pipEventsRecorder,
- ),
- )
- rule.pressBack()
- eventsRecorder.assertSize(2)
- eventsRecorder.assertTrue(0) { it is CallScreenEvents.SetupMessageChannels }
- eventsRecorder.assertTrue(1) { it == CallScreenEvents.Hangup }
- pipEventsRecorder.assertSize(1)
- pipEventsRecorder.assertTrue(0) { it is PictureInPictureEvents.SetPipController }
- }
-
- @Test
- fun `clicking on back when pip is supported enables PiP`() {
- val eventsRecorder = EventsRecorder()
- val pipEventsRecorder = EventsRecorder()
- rule.setCallScreenView(
- aCallScreenState(
- eventSink = eventsRecorder
- ),
- aPictureInPictureState(
- supportPip = true,
- eventSink = pipEventsRecorder,
- ),
- )
- rule.pressBack()
- eventsRecorder.assertSize(1)
- eventsRecorder.assertTrue(0) { it is CallScreenEvents.SetupMessageChannels }
- pipEventsRecorder.assertSize(2)
- pipEventsRecorder.assertTrue(0) { it is PictureInPictureEvents.SetPipController }
- pipEventsRecorder.assertTrue(1) { it == PictureInPictureEvents.EnterPictureInPicture }
- }
-}
-
-private fun AndroidComposeTestRule.setCallScreenView(
- state: CallScreenState,
- pipState: PictureInPictureState,
- requestPermissions: (Array, RequestPermissionCallback) -> Unit = { _, _ -> },
-) {
- setContent {
- CallScreenView(
- state = state,
- pipState = pipState,
- requestPermissions = requestPermissions,
- )
- }
-}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt
index 9160ae92d8a..f36267a353e 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt
@@ -9,7 +9,7 @@ package io.element.android.features.call.notifications
import androidx.core.graphics.drawable.IconCompat
import androidx.test.platform.app.InstrumentationRegistry
-import coil.ImageLoader
+import coil3.ImageLoader
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -73,6 +73,7 @@ class RingingCallNotificationCreatorTest {
roomAvatarUrl = "https://example.com/avatar.jpg",
notificationChannelId = "channelId",
timestamp = 0L,
+ textContent = "textContent",
)
private fun createRingingCallNotificationCreator(
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt
index 2ab7156878b..7709066dd04 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt
@@ -26,16 +26,16 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
-import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.services.analytics.api.ScreenTracker
import io.element.android.services.analytics.test.FakeScreenTracker
+import io.element.android.services.appnavstate.api.ActiveRoomsHolder
+import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.tests.testutils.WarmUpRule
-import io.element.android.tests.testutils.consumeItemsUntilTimeout
-import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
@@ -45,12 +45,14 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
+import kotlin.time.Duration.Companion.seconds
-class CallScreenPresenterTest {
+@OptIn(ExperimentalCoroutinesApi::class) class CallScreenPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -67,7 +69,8 @@ class CallScreenPresenterTest {
presenter.present()
}.test {
// Wait until the URL is loaded
- skipItems(1)
+ advanceTimeBy(1.seconds)
+ skipItems(2)
val initialState = awaitItem()
assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
assertThat(initialState.webViewError).isNull()
@@ -80,9 +83,9 @@ class CallScreenPresenterTest {
@Test
fun `present - with CallType RoomCall sets call as active, loads URL, runs WidgetDriver and notifies the other clients a call started`() = runTest {
- val sendCallNotificationIfNeededLambda = lambdaRecorder> { Result.success(Unit) }
+ val sendCallNotificationIfNeededLambda = lambdaRecorder> { Result.success(true) }
val syncService = FakeSyncService(SyncState.Running)
- val fakeRoom = FakeMatrixRoom(sendCallNotificationIfNeededResult = sendCallNotificationIfNeededLambda)
+ val fakeRoom = FakeJoinedRoom(sendCallNotificationIfNeededResult = sendCallNotificationIfNeededLambda)
val client = FakeMatrixClient(syncService = syncService).apply {
givenGetRoomResult(A_ROOM_ID, fakeRoom)
}
@@ -102,16 +105,23 @@ class CallScreenPresenterTest {
presenter.present()
}.test {
// Wait until the URL is loaded
+ advanceTimeBy(1.seconds)
skipItems(1)
+
joinedCallLambda.assertions().isCalledOnce()
val initialState = awaitItem()
- assertThat(initialState.urlState).isInstanceOf(AsyncData.Success::class.java)
+ assertThat(initialState.urlState).isInstanceOf(AsyncData.Loading::class.java)
assertThat(initialState.isCallActive).isFalse()
assertThat(initialState.isInWidgetMode).isTrue()
assertThat(widgetProvider.getWidgetCalled).isTrue()
assertThat(widgetDriver.runCalledCount).isEqualTo(1)
analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall))
sendCallNotificationIfNeededLambda.assertions().isCalledOnce()
+
+ // Wait until the WidgetDriver is loaded
+ skipItems(1)
+
+ assertThat(awaitItem().urlState).isInstanceOf(AsyncData.Success::class.java)
}
}
@@ -127,6 +137,9 @@ class CallScreenPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
+ // Give it time to load the URL and WidgetDriver
+ advanceTimeBy(1.seconds)
+
val initialState = awaitItem()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
@@ -142,7 +155,6 @@ class CallScreenPresenterTest {
}
}
- @OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - hang up event closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
val navigator = FakeCallScreenNavigator()
@@ -159,11 +171,15 @@ class CallScreenPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
+
+ // Give it time to load the URL and WidgetDriver
+ advanceTimeBy(1.seconds)
+
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
initialState.eventSink(CallScreenEvents.Hangup)
- // Let background coroutines run
+ // Let background coroutines run and the widget drive be received
runCurrent()
assertThat(navigator.closeCalled).isTrue()
@@ -173,9 +189,8 @@ class CallScreenPresenterTest {
}
}
- @OptIn(ExperimentalCoroutinesApi::class)
@Test
- fun `present - a received hang up message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
+ fun `present - a received close message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
@@ -190,11 +205,16 @@ class CallScreenPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
+
+ // Give it time to load the URL and WidgetDriver
+ advanceTimeBy(1.seconds)
+
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
- messageInterceptor.givenInterceptedMessage("""{"action":"im.vector.hangup","api":"fromWidget","widgetId":"1","requestId":"1"}""")
+ messageInterceptor.givenInterceptedMessage("""{"action":"io.element.close","api":"fromWidget","widgetId":"1","requestId":"1"}""")
// Let background coroutines run
+ advanceTimeBy(1.seconds)
runCurrent()
assertThat(navigator.closeCalled).isTrue()
@@ -205,7 +225,7 @@ class CallScreenPresenterTest {
}
@Test
- fun `present - a received room member message makes the call to be active`() = runTest {
+ fun `present - a received 'joined' action makes the call to be active`() = runTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
@@ -219,66 +239,72 @@ class CallScreenPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- skipItems(1)
+ // Give it time to load the URL and WidgetDriver
+ advanceTimeBy(1.seconds)
+ skipItems(2)
val initialState = awaitItem()
assertThat(initialState.isCallActive).isFalse()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
messageInterceptor.givenInterceptedMessage(
"""
{
- "action":"send_event",
+ "action":"io.element.join",
"api":"fromWidget",
"widgetId":"1",
- "requestId":"1",
- "data":{
- "type":"org.matrix.msc3401.call.member"
- }
+ "requestId":"1"
}
""".trimIndent()
)
- skipItems(1)
+ skipItems(2)
val finalState = awaitItem()
assertThat(finalState.isCallActive).isTrue()
}
}
@Test
- fun `present - automatically starts the Matrix client sync when on RoomCall`() = runTest {
+ fun `present - if in room mode and no join action is received an error is displayed`() = runTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
- val startSyncLambda = lambdaRecorder> { Result.success(Unit) }
- val syncService = FakeSyncService(SyncState.Idle).apply {
- this.startSyncLambda = startSyncLambda
- }
- val matrixClient = FakeMatrixClient(syncService = syncService)
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
- matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }),
screenTracker = FakeScreenTracker {},
)
+ val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- consumeItemsUntilTimeout()
+ // Give it time to load the URL and WidgetDriver
+ advanceTimeBy(1.seconds)
+ skipItems(2)
+ val initialState = awaitItem()
+ assertThat(initialState.isCallActive).isFalse()
+ initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
+ skipItems(2)
- assert(startSyncLambda).isCalledOnce()
+ // Wait for the timeout to trigger
+ advanceTimeBy(10.seconds)
- cancelAndIgnoreRemainingEvents()
+ val finalState = awaitItem()
+ assertThat(finalState.isCallActive).isFalse()
+ // The error dialog that will force the user to leave the call is displayed
+ assertThat(finalState.webViewError).isNotNull()
+ assertThat(finalState.webViewError).isEmpty()
}
}
@Test
- fun `present - automatically stops the Matrix client sync on dispose`() = runTest {
+ fun `present - automatically sets the isInCall state when starting the call and disposing the screen`() = runTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
- val stopSyncLambda = lambdaRecorder> { Result.success(Unit) }
- val syncService = FakeSyncService(SyncState.Running).apply {
- this.stopSyncLambda = stopSyncLambda
+ val startSyncLambda = lambdaRecorder> { Result.success(Unit) }
+ val syncService = FakeSyncService(SyncState.Idle).apply {
+ this.startSyncLambda = startSyncLambda
}
val matrixClient = FakeMatrixClient(syncService = syncService)
+ val appForegroundStateService = FakeAppForegroundStateService()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
@@ -286,6 +312,7 @@ class CallScreenPresenterTest {
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }),
screenTracker = FakeScreenTracker {},
+ appForegroundStateService = appForegroundStateService,
)
val hasRun = Mutex(true)
val job = launch {
@@ -296,11 +323,25 @@ class CallScreenPresenterTest {
}
}
- hasRun.lock()
+ appForegroundStateService.isInCall.test {
+ // The initial isInCall state will always be false
+ assertThat(awaitItem()).isFalse()
+
+ // Wait until the call starts
+ hasRun.lock()
+
+ // Then it'll be true once the call is active
+ assertThat(awaitItem()).isTrue()
+
+ // If we dispose the screen
+ job.cancelAndJoin()
- job.cancelAndJoin()
+ // The isInCall state is now false
+ assertThat(awaitItem()).isFalse()
- assert(stopSyncLambda).isCalledOnce()
+ // And there are no more events
+ ensureAllEventsConsumed()
+ }
}
@Test
@@ -313,7 +354,8 @@ class CallScreenPresenterTest {
presenter.present()
}.test {
// Wait until the URL is loaded
- skipItems(1)
+ advanceTimeBy(1.seconds)
+ skipItems(2)
val initialState = awaitItem()
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
val finalState = awaitItem()
@@ -342,6 +384,8 @@ class CallScreenPresenterTest {
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
val finalState = awaitItem()
assertThat(finalState.webViewError).isNull()
+
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -354,6 +398,8 @@ class CallScreenPresenterTest {
matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
screenTracker: ScreenTracker = FakeScreenTracker(),
+ appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
+ activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
): CallScreenPresenter {
val userAgentProvider = object : UserAgentProvider {
override fun provide(): String {
@@ -369,10 +415,12 @@ class CallScreenPresenterTest {
clock = clock,
dispatchers = dispatchers,
matrixClientsProvider = matrixClientsProvider,
- appCoroutineScope = this,
activeCallManager = activeCallManager,
screenTracker = screenTracker,
languageTagProvider = FakeLanguageTagProvider("en-US"),
+ appForegroundStateService = appForegroundStateService,
+ appCoroutineScope = backgroundScope,
+ activeRoomsHolder = activeRoomsHolder,
)
}
}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt
index 098f396d222..bafbe3f5703 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt
@@ -45,6 +45,17 @@ class CallIntentDataParserTest {
doTest("http://call.element.io/some-actual-call?with=parameters", null)
}
+ @Test
+ fun `Element Call urls with unknown host returns null`() {
+ // Check valid host first, should not return null
+ doTest("https://call.element.io", "https://call.element.io#?appPrompt=false&confineToRoom=true")
+ // Unknown host should return null
+ doTest("https://unknown.io", null)
+ doTest("https://call.unknown.io", null)
+ doTest("https://call.element.com", null)
+ doTest("https://call.element.io.tld", null)
+ }
+
@Test
fun `Element Call urls will be returned as is`() {
doTest(
@@ -64,7 +75,7 @@ class CallIntentDataParserTest {
@Test
fun `HTTP and HTTPS urls that don't come from EC return null`() {
doTest("http://app.element.io", null)
- doTest("https://app.element.io", null, testEmbedded = false)
+ doTest("https://app.element.io", null)
doTest("http://", null)
doTest("https://", null)
}
@@ -193,20 +204,18 @@ class CallIntentDataParserTest {
)
}
- private fun doTest(url: String, expectedResult: String?, testEmbedded: Boolean = true) {
+ private fun doTest(url: String, expectedResult: String?) {
// Test direct parsing
assertThat(callIntentDataParser.parse(url)).isEqualTo(expectedResult)
- if (testEmbedded) {
- // Test embedded url, scheme 1
- val encodedUrl = URLEncoder.encode(url, "utf-8")
- val urlScheme1 = "element://call?url=$encodedUrl"
- assertThat(callIntentDataParser.parse(urlScheme1)).isEqualTo(expectedResult)
+ // Test embedded url, scheme 1
+ val encodedUrl = URLEncoder.encode(url, "utf-8")
+ val urlScheme1 = "element://call?url=$encodedUrl"
+ assertThat(callIntentDataParser.parse(urlScheme1)).isEqualTo(expectedResult)
- // Test embedded url, scheme 2
- val urlScheme2 = "io.element.call:/?url=$encodedUrl"
- assertThat(callIntentDataParser.parse(urlScheme2)).isEqualTo(expectedResult)
- }
+ // Test embedded url, scheme 2
+ val urlScheme2 = "io.element.call:/?url=$encodedUrl"
+ assertThat(callIntentDataParser.parse(urlScheme2)).isEqualTo(expectedResult)
}
companion object {
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
index ea1357c0e90..84084c38fe2 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
@@ -7,7 +7,9 @@
package io.element.android.features.call.utils
+import android.os.PowerManager
import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.getSystemService
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
@@ -26,22 +28,19 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
-import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
+import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.mockk.mockk
import io.mockk.verify
-import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
@@ -49,6 +48,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
@RunWith(RobolectricTestRunner::class)
class DefaultActiveCallManagerTest {
@@ -57,29 +57,30 @@ class DefaultActiveCallManagerTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `registerIncomingCall - sets the incoming call as active`() = runTest {
+ setupShadowPowerManager()
val notificationManagerCompat = mockk(relaxed = true)
- inCancellableScope {
- val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
-
- assertThat(manager.activeCall.value).isNull()
-
- val callNotificationData = aCallNotificationData()
- manager.registerIncomingCall(callNotificationData)
-
- assertThat(manager.activeCall.value).isEqualTo(
- ActiveCall(
- callType = CallType.RoomCall(
- sessionId = callNotificationData.sessionId,
- roomId = callNotificationData.roomId,
- ),
- callState = CallState.Ringing(callNotificationData)
- )
+ val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
+
+ assertThat(manager.activeWakeLock?.isHeld).isFalse()
+ assertThat(manager.activeCall.value).isNull()
+
+ val callNotificationData = aCallNotificationData()
+ manager.registerIncomingCall(callNotificationData)
+
+ assertThat(manager.activeCall.value).isEqualTo(
+ ActiveCall(
+ callType = CallType.RoomCall(
+ sessionId = callNotificationData.sessionId,
+ roomId = callNotificationData.roomId,
+ ),
+ callState = CallState.Ringing(callNotificationData)
)
+ )
- runCurrent()
+ runCurrent()
- verify { notificationManagerCompat.notify(notificationId, any()) }
- }
+ assertThat(manager.activeWakeLock?.isHeld).isTrue()
+ verify { notificationManagerCompat.notify(notificationId, any()) }
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -87,210 +88,198 @@ class DefaultActiveCallManagerTest {
fun `registerIncomingCall - when there is an already active call adds missed call notification`() = runTest {
val addMissedCallNotificationLambda = lambdaRecorder { _, _, _ -> }
val onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
- inCancellableScope {
- val manager = createActiveCallManager(
- onMissedCallNotificationHandler = onMissedCallNotificationHandler,
- )
+ val manager = createActiveCallManager(
+ onMissedCallNotificationHandler = onMissedCallNotificationHandler,
+ )
- // Register existing call
- val callNotificationData = aCallNotificationData()
- manager.registerIncomingCall(callNotificationData)
- val activeCall = manager.activeCall.value
+ // Register existing call
+ val callNotificationData = aCallNotificationData()
+ manager.registerIncomingCall(callNotificationData)
+ val activeCall = manager.activeCall.value
- // Now add a new call
- manager.registerIncomingCall(aCallNotificationData(roomId = A_ROOM_ID_2))
+ // Now add a new call
+ manager.registerIncomingCall(aCallNotificationData(roomId = A_ROOM_ID_2))
- assertThat(manager.activeCall.value).isEqualTo(activeCall)
- assertThat((manager.activeCall.value?.callType as? CallType.RoomCall)?.roomId).isNotEqualTo(A_ROOM_ID_2)
+ assertThat(manager.activeCall.value).isEqualTo(activeCall)
+ assertThat((manager.activeCall.value?.callType as? CallType.RoomCall)?.roomId).isNotEqualTo(A_ROOM_ID_2)
- advanceTimeBy(1)
+ advanceTimeBy(1)
- addMissedCallNotificationLambda.assertions()
- .isCalledOnce()
- .with(value(A_SESSION_ID), value(A_ROOM_ID_2), value(AN_EVENT_ID))
- }
+ addMissedCallNotificationLambda.assertions()
+ .isCalledOnce()
+ .with(value(A_SESSION_ID), value(A_ROOM_ID_2), value(AN_EVENT_ID))
}
@Test
fun `incomingCallTimedOut - when there isn't an active call does nothing`() = runTest {
val addMissedCallNotificationLambda = lambdaRecorder { _, _, _ -> }
- inCancellableScope {
- val manager = createActiveCallManager(
- onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
- )
+ val manager = createActiveCallManager(
+ onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
+ )
- manager.incomingCallTimedOut(displayMissedCallNotification = true)
+ manager.incomingCallTimedOut(displayMissedCallNotification = true)
- addMissedCallNotificationLambda.assertions().isNeverCalled()
- }
+ addMissedCallNotificationLambda.assertions().isNeverCalled()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `incomingCallTimedOut - when there is an active call removes it and adds a missed call notification`() = runTest {
+ setupShadowPowerManager()
val notificationManagerCompat = mockk(relaxed = true)
val addMissedCallNotificationLambda = lambdaRecorder { _, _, _ -> }
- inCancellableScope {
- val manager = createActiveCallManager(
- onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda),
- notificationManagerCompat = notificationManagerCompat,
- )
-
- manager.registerIncomingCall(aCallNotificationData())
- assertThat(manager.activeCall.value).isNotNull()
-
- manager.incomingCallTimedOut(displayMissedCallNotification = true)
- advanceTimeBy(1)
-
- assertThat(manager.activeCall.value).isNull()
- addMissedCallNotificationLambda.assertions().isCalledOnce()
- verify { notificationManagerCompat.cancel(notificationId) }
- }
+ val manager = createActiveCallManager(
+ onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda),
+ notificationManagerCompat = notificationManagerCompat,
+ )
+
+ manager.registerIncomingCall(aCallNotificationData())
+ assertThat(manager.activeCall.value).isNotNull()
+ assertThat(manager.activeWakeLock?.isHeld).isTrue()
+
+ manager.incomingCallTimedOut(displayMissedCallNotification = true)
+ advanceTimeBy(1)
+
+ assertThat(manager.activeCall.value).isNull()
+ assertThat(manager.activeWakeLock?.isHeld).isFalse()
+ addMissedCallNotificationLambda.assertions().isCalledOnce()
+ verify { notificationManagerCompat.cancel(notificationId) }
}
@Test
fun `hungUpCall - removes existing call if the CallType matches`() = runTest {
+ setupShadowPowerManager()
val notificationManagerCompat = mockk(relaxed = true)
- // Create a cancellable coroutine scope to cancel the test when needed
- inCancellableScope {
- val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
+ val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
- val notificationData = aCallNotificationData()
- manager.registerIncomingCall(notificationData)
- assertThat(manager.activeCall.value).isNotNull()
+ val notificationData = aCallNotificationData()
+ manager.registerIncomingCall(notificationData)
+ assertThat(manager.activeCall.value).isNotNull()
+ assertThat(manager.activeWakeLock?.isHeld).isTrue()
- manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
- assertThat(manager.activeCall.value).isNull()
+ manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
+ assertThat(manager.activeCall.value).isNull()
+ assertThat(manager.activeWakeLock?.isHeld).isFalse()
- verify { notificationManagerCompat.cancel(notificationId) }
- }
+ verify { notificationManagerCompat.cancel(notificationId) }
}
@Test
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
+ setupShadowPowerManager()
val notificationManagerCompat = mockk(relaxed = true)
- // Create a cancellable coroutine scope to cancel the test when needed
- inCancellableScope {
- val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
+ val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
- manager.registerIncomingCall(aCallNotificationData())
- assertThat(manager.activeCall.value).isNotNull()
+ manager.registerIncomingCall(aCallNotificationData())
+ assertThat(manager.activeCall.value).isNotNull()
+ assertThat(manager.activeWakeLock?.isHeld).isTrue()
- manager.hungUpCall(CallType.ExternalUrl("https://example.com"))
- assertThat(manager.activeCall.value).isNotNull()
+ manager.hungUpCall(CallType.ExternalUrl("https://example.com"))
+ assertThat(manager.activeCall.value).isNotNull()
+ assertThat(manager.activeWakeLock?.isHeld).isTrue()
- verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
- }
+ verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `joinedCall - register an ongoing call and tries sending the call notify event`() = runTest {
val notificationManagerCompat = mockk(relaxed = true)
- inCancellableScope {
- val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
- assertThat(manager.activeCall.value).isNull()
-
- manager.joinedCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
- assertThat(manager.activeCall.value).isEqualTo(
- ActiveCall(
- callType = CallType.RoomCall(
- sessionId = A_SESSION_ID,
- roomId = A_ROOM_ID,
- ),
- callState = CallState.InCall,
- )
+ val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
+ assertThat(manager.activeCall.value).isNull()
+
+ manager.joinedCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
+ assertThat(manager.activeCall.value).isEqualTo(
+ ActiveCall(
+ callType = CallType.RoomCall(
+ sessionId = A_SESSION_ID,
+ roomId = A_ROOM_ID,
+ ),
+ callState = CallState.InCall,
)
+ )
- runCurrent()
+ runCurrent()
- verify { notificationManagerCompat.cancel(notificationId) }
- }
+ verify { notificationManagerCompat.cancel(notificationId) }
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `observeRingingCalls - will cancel the active ringing call if the call is cancelled`() = runTest {
- val room = FakeMatrixRoom().apply {
+ val room = FakeBaseRoom().apply {
givenRoomInfo(aRoomInfo())
}
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
- // Create a cancellable coroutine scope to cancel the test when needed
- inCancellableScope {
- val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) })
- val manager = createActiveCallManager(matrixClientProvider = matrixClientProvider)
+ val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) })
+ val manager = createActiveCallManager(matrixClientProvider = matrixClientProvider)
- manager.registerIncomingCall(aCallNotificationData())
+ manager.registerIncomingCall(aCallNotificationData())
- // Call is active (the other user join the call)
- room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
- advanceTimeBy(1)
- // Call is cancelled (the other user left the call)
- room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
- advanceTimeBy(1)
+ // Call is active (the other user join the call)
+ room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
+ advanceTimeBy(1)
+ // Call is cancelled (the other user left the call)
+ room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
+ advanceTimeBy(1)
- assertThat(manager.activeCall.value).isNull()
- }
+ assertThat(manager.activeCall.value).isNull()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `observeRingingCalls - will do nothing if either the session or the room are not found`() = runTest {
- val room = FakeMatrixRoom().apply {
+ val room = FakeBaseRoom().apply {
givenRoomInfo(aRoomInfo())
}
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
- // Create a cancellable coroutine scope to cancel the test when needed
- inCancellableScope {
- val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(IllegalStateException("Matrix client not found")) })
- val manager = createActiveCallManager(matrixClientProvider = matrixClientProvider)
+ val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(IllegalStateException("Matrix client not found")) })
+ val manager = createActiveCallManager(matrixClientProvider = matrixClientProvider)
- // No matrix client
+ // No matrix client
- manager.registerIncomingCall(aCallNotificationData())
+ manager.registerIncomingCall(aCallNotificationData())
- room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
- advanceTimeBy(1)
- room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
- advanceTimeBy(1)
+ room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
+ advanceTimeBy(1)
+ room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
+ advanceTimeBy(1)
- // The call should still be active
- assertThat(manager.activeCall.value).isNotNull()
+ // The call should still be active
+ assertThat(manager.activeCall.value).isNotNull()
- // No room
- client.givenGetRoomResult(A_ROOM_ID, null)
- matrixClientProvider.getClient = { Result.success(client) }
+ // No room
+ client.givenGetRoomResult(A_ROOM_ID, null)
+ matrixClientProvider.getClient = { Result.success(client) }
- manager.registerIncomingCall(aCallNotificationData())
+ manager.registerIncomingCall(aCallNotificationData())
- room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
- advanceTimeBy(1)
- room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
- advanceTimeBy(1)
+ room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
+ advanceTimeBy(1)
+ room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
+ advanceTimeBy(1)
- // The call should still be active
- assertThat(manager.activeCall.value).isNotNull()
- }
+ // The call should still be active
+ assertThat(manager.activeCall.value).isNotNull()
}
- private fun TestScope.inCancellableScope(block: suspend CoroutineScope.() -> Unit) {
- launch(SupervisorJob()) {
- block()
- cancel()
+ private fun setupShadowPowerManager() {
+ shadowOf(InstrumentationRegistry.getInstrumentation().targetContext.getSystemService()).apply {
+ setIsWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK, true)
}
}
- private fun CoroutineScope.createActiveCallManager(
+ private fun TestScope.createActiveCallManager(
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
- coroutineScope: CoroutineScope = this,
) = DefaultActiveCallManager(
- coroutineScope = coroutineScope,
+ context = InstrumentationRegistry.getInstrumentation().targetContext,
+ coroutineScope = backgroundScope,
onMissedCallNotificationHandler = onMissedCallNotificationHandler,
ringingCallNotificationCreator = RingingCallNotificationCreator(
context = InstrumentationRegistry.getInstrumentation().targetContext,
@@ -301,5 +290,7 @@ class DefaultActiveCallManagerTest {
notificationManagerCompat = notificationManagerCompat,
matrixClientProvider = matrixClientProvider,
defaultCurrentCallService = DefaultCurrentCallService(),
+ appForegroundStateService = FakeAppForegroundStateService(),
+ imageLoaderHolder = FakeImageLoaderHolder(),
)
}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt
index 67f59713eea..a93dd5f91c9 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt
@@ -9,21 +9,19 @@ package io.element.android.features.call.utils
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.utils.DefaultCallWidgetProvider
-import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider
-import io.element.android.libraries.matrix.api.call.ElementCallBaseUrlProvider
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
-import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.room.FakeBaseRoom
+import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.widget.FakeCallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
-import io.element.android.tests.testutils.lambda.lambdaRecorder
-import io.element.android.tests.testutils.lambda.value
+import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -45,7 +43,7 @@ class DefaultCallWidgetProviderTest {
@Test
fun `getWidget - fails if it can't generate the URL for the widget`() = runTest {
- val room = FakeMatrixRoom(
+ val room = FakeJoinedRoom(
generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.failure(Exception("Can't generate URL for widget")) }
)
val client = FakeMatrixClient().apply {
@@ -57,7 +55,7 @@ class DefaultCallWidgetProviderTest {
@Test
fun `getWidget - fails if it can't get the widget driver`() = runTest {
- val room = FakeMatrixRoom(
+ val room = FakeJoinedRoom(
generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") },
getWidgetDriverResult = { Result.failure(Exception("Can't get a widget driver")) }
)
@@ -70,7 +68,7 @@ class DefaultCallWidgetProviderTest {
@Test
fun `getWidget - returns a widget driver when all steps are successful`() = runTest {
- val room = FakeMatrixRoom(
+ val room = FakeJoinedRoom(
generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") },
getWidgetDriverResult = { Result.success(FakeMatrixWidgetDriver()) },
)
@@ -82,64 +80,60 @@ class DefaultCallWidgetProviderTest {
}
@Test
- fun `getWidget - will use a custom base url if it exists`() = runTest {
- val room = FakeMatrixRoom(
- generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") },
- getWidgetDriverResult = { Result.success(FakeMatrixWidgetDriver()) },
- )
+ fun `getWidget - reuses the active room if possible`() = runTest {
val client = FakeMatrixClient().apply {
- givenGetRoomResult(A_ROOM_ID, room)
+ // No room from the client
+ givenGetRoomResult(A_ROOM_ID, null)
}
- val preferencesStore = InMemoryAppPreferencesStore().apply {
- setCustomElementCallBaseUrl("https://custom.element.io")
+ val activeRoomsHolder = ActiveRoomsHolder().apply {
+ // A current active room with the same room id
+ addRoom(
+ FakeJoinedRoom(
+ baseRoom = FakeBaseRoom(roomId = A_ROOM_ID),
+ generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") },
+ getWidgetDriverResult = { Result.success(FakeMatrixWidgetDriver()) },
+ )
+ )
}
- val settingsProvider = FakeCallWidgetSettingsProvider()
val provider = createProvider(
matrixClientProvider = FakeMatrixClientProvider { Result.success(client) },
- callWidgetSettingsProvider = settingsProvider,
- appPreferencesStore = preferencesStore,
+ activeRoomsHolder = activeRoomsHolder
)
- provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme")
-
- assertThat(settingsProvider.providedBaseUrls).containsExactly("https://custom.element.io")
+ assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isSuccess).isTrue()
}
@Test
- fun `getWidget - will use a wellknown base url if it exists`() = runTest {
- val aCustomUrl = "https://custom.element.io"
- val providesLambda = lambdaRecorder { _ -> aCustomUrl }
- val elementCallBaseUrlProvider = FakeElementCallBaseUrlProvider { matrixClient ->
- providesLambda(matrixClient)
- }
- val room = FakeMatrixRoom(
+ fun `getWidget - will use a custom base url if it exists`() = runTest {
+ val room = FakeJoinedRoom(
generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") },
getWidgetDriverResult = { Result.success(FakeMatrixWidgetDriver()) },
)
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
+ val preferencesStore = InMemoryAppPreferencesStore().apply {
+ setCustomElementCallBaseUrl("https://custom.element.io")
+ }
val settingsProvider = FakeCallWidgetSettingsProvider()
val provider = createProvider(
matrixClientProvider = FakeMatrixClientProvider { Result.success(client) },
callWidgetSettingsProvider = settingsProvider,
- elementCallBaseUrlProvider = elementCallBaseUrlProvider,
+ appPreferencesStore = preferencesStore,
)
provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme")
- assertThat(settingsProvider.providedBaseUrls).containsExactly(aCustomUrl)
- providesLambda.assertions()
- .isCalledOnce()
- .with(value(client))
+
+ assertThat(settingsProvider.providedBaseUrls).containsExactly("https://custom.element.io")
}
private fun createProvider(
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(),
- elementCallBaseUrlProvider: ElementCallBaseUrlProvider = FakeElementCallBaseUrlProvider { _ -> null },
+ activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
) = DefaultCallWidgetProvider(
matrixClientsProvider = matrixClientProvider,
appPreferencesStore = appPreferencesStore,
callWidgetSettingsProvider = callWidgetSettingsProvider,
- elementCallBaseUrlProvider = elementCallBaseUrlProvider,
+ activeRoomsHolder = activeRoomsHolder,
)
}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt
index 3024ce9f2f4..90527370e7b 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt
@@ -11,6 +11,7 @@ import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.ActiveCallManager
+import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.MutableStateFlow
class FakeActiveCallManager(
@@ -20,15 +21,15 @@ class FakeActiveCallManager(
) : ActiveCallManager {
override val activeCall = MutableStateFlow(null)
- override fun registerIncomingCall(notificationData: CallNotificationData) {
+ override suspend fun registerIncomingCall(notificationData: CallNotificationData) = simulateLongTask {
registerIncomingCallResult(notificationData)
}
- override fun hungUpCall(callType: CallType) {
+ override suspend fun hungUpCall(callType: CallType) = simulateLongTask {
hungUpCallResult(callType)
}
- override fun joinedCall(callType: CallType) {
+ override suspend fun joinedCall(callType: CallType) = simulateLongTask {
joinedCallResult(callType)
}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeElementCallBaseUrlProvider.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeElementCallBaseUrlProvider.kt
deleted file mode 100644
index 4ac716f22d0..00000000000
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeElementCallBaseUrlProvider.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Copyright 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.call.utils
-
-import io.element.android.libraries.matrix.api.MatrixClient
-import io.element.android.libraries.matrix.api.call.ElementCallBaseUrlProvider
-import io.element.android.tests.testutils.lambda.lambdaError
-
-class FakeElementCallBaseUrlProvider(
- private val providesLambda: (MatrixClient) -> String? = { lambdaError() }
-) : ElementCallBaseUrlProvider {
- override suspend fun provides(matrixClient: MatrixClient): String? {
- return providesLambda(matrixClient)
- }
-}
diff --git a/features/call/test/build.gradle.kts b/features/call/test/build.gradle.kts
index d40d5c8c8c0..4fe9b95384f 100644
--- a/features/call/test/build.gradle.kts
+++ b/features/call/test/build.gradle.kts
@@ -22,4 +22,5 @@ dependencies {
implementation(projects.features.call.impl)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.test)
+ implementation(projects.tests.testutils)
}
diff --git a/features/call/test/src/main/kotlin/io/element/android/features/call/test/CallNotificationData.kt b/features/call/test/src/main/kotlin/io/element/android/features/call/test/CallNotificationData.kt
index 3514cf425d1..8e6ee00a169 100644
--- a/features/call/test/src/main/kotlin/io/element/android/features/call/test/CallNotificationData.kt
+++ b/features/call/test/src/main/kotlin/io/element/android/features/call/test/CallNotificationData.kt
@@ -30,6 +30,7 @@ fun aCallNotificationData(
avatarUrl: String? = AN_AVATAR_URL,
notificationChannelId: String = "channel_id",
timestamp: Long = 0L,
+ textContent: String? = null,
): CallNotificationData = CallNotificationData(
sessionId = sessionId,
roomId = roomId,
@@ -40,4 +41,5 @@ fun aCallNotificationData(
avatarUrl = avatarUrl,
notificationChannelId = notificationChannelId,
timestamp = timestamp,
+ textContent = textContent,
)
diff --git a/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt
index 9a720bd2e75..09a12692597 100644
--- a/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt
+++ b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt
@@ -11,16 +11,26 @@ import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.tests.testutils.lambda.lambdaError
class FakeElementCallEntryPoint(
- var startCallResult: (CallType) -> Unit = {},
- var handleIncomingCallResult: (CallType.RoomCall, EventId, UserId, String?, String?, String?, String) -> Unit = { _, _, _, _, _, _, _ -> }
+ var startCallResult: (CallType) -> Unit = { lambdaError() },
+ var handleIncomingCallResult: (
+ CallType.RoomCall,
+ EventId,
+ UserId,
+ String?,
+ String?,
+ String?,
+ String,
+ String?,
+ ) -> Unit = { _, _, _, _, _, _, _, _ -> lambdaError() }
) : ElementCallEntryPoint {
override fun startCall(callType: CallType) {
startCallResult(callType)
}
- override fun handleIncomingCall(
+ override suspend fun handleIncomingCall(
callType: CallType.RoomCall,
eventId: EventId,
senderId: UserId,
@@ -28,8 +38,18 @@ class FakeElementCallEntryPoint(
senderName: String?,
avatarUrl: String?,
timestamp: Long,
- notificationChannelId: String
+ notificationChannelId: String,
+ textContent: String?,
) {
- handleIncomingCallResult(callType, eventId, senderId, roomName, senderName, avatarUrl, notificationChannelId)
+ handleIncomingCallResult(
+ callType,
+ eventId,
+ senderId,
+ roomName,
+ senderName,
+ avatarUrl,
+ notificationChannelId,
+ textContent,
+ )
}
}
diff --git a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/ConfirmingStartDmWithMatrixUser.kt b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/ConfirmingStartDmWithMatrixUser.kt
new file mode 100644
index 00000000000..af19408324d
--- /dev/null
+++ b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/ConfirmingStartDmWithMatrixUser.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright 2025 New Vector 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.createroom.api
+
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.user.MatrixUser
+
+data class ConfirmingStartDmWithMatrixUser(
+ val matrixUser: MatrixUser,
+) : AsyncAction.Confirming
diff --git a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt
index b37c8047015..3b35d0a3e1a 100644
--- a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt
+++ b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt
@@ -11,7 +11,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
-import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
interface CreateRoomEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
@@ -21,6 +21,7 @@ interface CreateRoomEntryPoint : FeatureEntryPoint {
}
interface Callback : Plugin {
- fun onSuccess(roomId: RoomId)
+ fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List)
+ fun onOpenRoomDirectory()
}
}
diff --git a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/StartDMAction.kt b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/StartDMAction.kt
index 50c67ba9563..e64be9f9230 100644
--- a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/StartDMAction.kt
+++ b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/StartDMAction.kt
@@ -10,13 +10,19 @@ package io.element.android.features.createroom.api
import androidx.compose.runtime.MutableState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.user.MatrixUser
interface StartDMAction {
/**
* Try to find an existing DM with the given user, or create one if none exists.
- * @param userId The user to start a DM with.
+ * @param matrixUser The user to start a DM with.
+ * @param createIfDmDoesNotExist If true, create a DM if one does not exist. If false and the DM
+ * does not exist, the action will fail with the value [ConfirmingStartDmWithMatrixUser].
* @param actionState The state to update with the result of the action.
*/
- suspend fun execute(userId: UserId, actionState: MutableState>)
+ suspend fun execute(
+ matrixUser: MatrixUser,
+ createIfDmDoesNotExist: Boolean,
+ actionState: MutableState>,
+ )
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/CreateRoomNavigator.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/CreateRoomNavigator.kt
new file mode 100644
index 00000000000..69eac7d3694
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/CreateRoomNavigator.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2025 New Vector 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.createroom
+
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.push
+import io.element.android.features.createroom.impl.CreateRoomFlowNode.NavTarget
+import io.element.android.libraries.architecture.overlay.Overlay
+import io.element.android.libraries.architecture.overlay.operation.hide
+import io.element.android.libraries.architecture.overlay.operation.show
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
+
+interface CreateRoomNavigator : Plugin {
+ fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List)
+ fun onCreateNewRoom()
+ fun onShowJoinRoomByAddress()
+ fun onDismissJoinRoomByAddress()
+ fun onOpenRoomDirectory()
+}
+
+class DefaultCreateRoomNavigator(
+ private val backstack: BackStack,
+ private val overlay: Overlay,
+ private val openRoom: (RoomIdOrAlias, List) -> Unit,
+ private val openRoomDirectory: () -> Unit,
+) : CreateRoomNavigator {
+ override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List) = openRoom(roomIdOrAlias, serverNames)
+
+ override fun onOpenRoomDirectory() = openRoomDirectory()
+
+ override fun onCreateNewRoom() {
+ backstack.push(NavTarget.NewRoom)
+ }
+
+ override fun onShowJoinRoomByAddress() {
+ overlay.show(NavTarget.JoinByAddress)
+ }
+
+ override fun onDismissJoinRoomByAddress() {
+ overlay.hide()
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/ConfigureRoomFlowNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/ConfigureRoomFlowNode.kt
index 1a2916b16a3..a7678f130f7 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/ConfigureRoomFlowNode.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/ConfigureRoomFlowNode.kt
@@ -19,6 +19,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.createroom.CreateRoomNavigator
import io.element.android.features.createroom.impl.addpeople.AddPeopleNode
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode
import io.element.android.features.createroom.impl.di.CreateRoomComponent
@@ -46,6 +47,7 @@ class ConfigureRoomFlowNode @AssistedInject constructor(
private val component by lazy {
parent!!.bindings().createRoomComponentBuilder().build()
}
+ private val navigator = plugins().first()
override val daggerComponent: Any
get() = component
@@ -69,8 +71,7 @@ class ConfigureRoomFlowNode @AssistedInject constructor(
createNode(buildContext = buildContext, plugins = listOf(callback))
}
NavTarget.ConfigureRoom -> {
- val callbacks = plugins()
- createNode(buildContext = buildContext, plugins = callbacks)
+ createNode(buildContext = buildContext, plugins = listOf(navigator))
}
}
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt
index 5f629d51eb0..29b1525f511 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt
@@ -39,7 +39,7 @@ class CreateRoomDataStore @Inject constructor(
}
val createRoomConfigWithInvites: Flow = combine(
- selectedUserListDataStore.selectedUsers(),
+ selectedUserListDataStore.selectedUsers,
createRoomConfigFlow,
) { selectedUsers, config ->
config.copy(invites = selectedUsers.toImmutableList())
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt
index 9e06bf60809..b8b755a0b33 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt
@@ -8,25 +8,28 @@
package io.element.android.features.createroom.impl
import android.os.Parcelable
+import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.navigation.transition.JumpToEndTransitionHandler
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
-import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.createroom.DefaultCreateRoomNavigator
import io.element.android.features.createroom.api.CreateRoomEntryPoint
-import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode
+import io.element.android.features.createroom.impl.joinbyaddress.JoinRoomByAddressNode
import io.element.android.features.createroom.impl.root.CreateRoomRootNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
+import io.element.android.libraries.architecture.OverlayView
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
-import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@@ -47,35 +50,41 @@ class CreateRoomFlowNode @AssistedInject constructor(
@Parcelize
data object NewRoom : NavTarget
+
+ @Parcelize
+ data object JoinByAddress : NavTarget
}
+ private val navigator = DefaultCreateRoomNavigator(
+ backstack = backstack,
+ overlay = overlay,
+ openRoom = { roomIdOrAlias, viaServers ->
+ plugins().forEach { it.onOpenRoom(roomIdOrAlias, viaServers) }
+ },
+ openRoomDirectory = {
+ plugins().forEach { it.onOpenRoomDirectory() }
+ }
+ )
+
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
- val callback = object : CreateRoomRootNode.Callback {
- override fun onCreateNewRoom() {
- backstack.push(NavTarget.NewRoom)
- }
-
- override fun onStartChatSuccess(roomId: RoomId) {
- plugins().forEach { it.onSuccess(roomId) }
- }
- }
- createNode(buildContext = buildContext, plugins = listOf(callback))
+ createNode(buildContext = buildContext, plugins = listOf(navigator))
}
NavTarget.NewRoom -> {
- val callback = object : ConfigureRoomNode.Callback {
- override fun onCreateRoomSuccess(roomId: RoomId) {
- plugins().forEach { it.onSuccess(roomId) }
- }
- }
- createNode(buildContext = buildContext, plugins = listOf(callback))
+ createNode(buildContext = buildContext, plugins = listOf(navigator))
+ }
+ NavTarget.JoinByAddress -> {
+ createNode(buildContext = buildContext, plugins = listOf(navigator))
}
}
}
@Composable
override fun View(modifier: Modifier) {
- BackstackView()
+ Box(modifier = modifier) {
+ BackstackView()
+ OverlayView(transitionHandler = remember { JumpToEndTransitionHandler() })
+ }
}
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultStartDMAction.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultStartDMAction.kt
index d48712c400c..c9b60786bde 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultStartDMAction.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultStartDMAction.kt
@@ -10,14 +10,15 @@ package io.element.android.features.createroom.impl
import androidx.compose.runtime.MutableState
import com.squareup.anvil.annotations.ContributesBinding
import im.vector.app.features.analytics.plan.CreatedRoom
+import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
import io.element.android.features.createroom.api.StartDMAction
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.StartDMResult
import io.element.android.libraries.matrix.api.room.startDM
+import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.services.analytics.api.AnalyticsService
import javax.inject.Inject
@@ -26,9 +27,13 @@ class DefaultStartDMAction @Inject constructor(
private val matrixClient: MatrixClient,
private val analyticsService: AnalyticsService,
) : StartDMAction {
- override suspend fun execute(userId: UserId, actionState: MutableState>) {
+ override suspend fun execute(
+ matrixUser: MatrixUser,
+ createIfDmDoesNotExist: Boolean,
+ actionState: MutableState>,
+ ) {
actionState.value = AsyncAction.Loading
- when (val result = matrixClient.startDM(userId)) {
+ when (val result = matrixClient.startDM(matrixUser.userId, createIfDmDoesNotExist)) {
is StartDMResult.Success -> {
if (result.isNew) {
analyticsService.capture(CreatedRoom(isDM = true))
@@ -38,6 +43,9 @@ class DefaultStartDMAction @Inject constructor(
is StartDMResult.Failure -> {
actionState.value = AsyncAction.Failure(result.throwable)
}
+ StartDMResult.DmDoesNotExist -> {
+ actionState.value = ConfirmingStartDmWithMatrixUser(matrixUser = matrixUser)
+ }
}
}
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt
index a26abd20fd4..d39ac4d2500 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt
@@ -15,7 +15,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
-import io.element.android.compound.theme.ElementTheme
import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.features.createroom.impl.userlist.UserListEvents
@@ -23,9 +22,7 @@ import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Scaffold
-import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
@@ -74,12 +71,7 @@ private fun AddPeopleViewTopBar(
onNextClick: () -> Unit,
) {
TopAppBar(
- title = {
- Text(
- text = stringResource(id = R.string.screen_create_room_add_people_title),
- style = ElementTheme.typography.aliasScreenTitle
- )
- },
+ titleStr = stringResource(id = R.string.screen_create_room_add_people_title),
navigationIcon = { BackButton(onClick = onBackClick) },
actions = {
val textActionResId = if (hasSelectedUsers) CommonStrings.action_next else CommonStrings.action_skip
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt
index 966369e6e3d..e7188251633 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt
@@ -18,8 +18,9 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.createroom.CreateRoomNavigator
import io.element.android.features.createroom.impl.di.CreateRoomScope
-import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(CreateRoomScope::class)
@@ -29,6 +30,8 @@ class ConfigureRoomNode @AssistedInject constructor(
private val presenter: ConfigureRoomPresenter,
private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
+ private val navigator = plugins().first()
+
init {
lifecycle.subscribe(
onResume = {
@@ -37,14 +40,6 @@ class ConfigureRoomNode @AssistedInject constructor(
)
}
- interface Callback : Plugin {
- fun onCreateRoomSuccess(roomId: RoomId)
- }
-
- private fun onCreateRoomSuccess(roomId: RoomId) {
- plugins().forEach { it.onCreateRoomSuccess(roomId) }
- }
-
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@@ -52,7 +47,9 @@ class ConfigureRoomNode @AssistedInject constructor(
state = state,
modifier = modifier,
onBackClick = this::navigateUp,
- onCreateRoomSuccess = this::onCreateRoomSuccess,
+ onCreateRoomSuccess = {
+ navigator.onOpenRoom(roomIdOrAlias = it.toRoomIdOrAlias(), serverNames = emptyList())
+ },
)
}
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
index f79bd1e25c2..3a7ec00e3ef 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
@@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
+import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
@@ -65,7 +66,9 @@ class ConfigureRoomPresenter @Inject constructor(
val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig by dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
val homeserverName = remember { matrixClient.userIdServerName() }
- val isKnockFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(initial = false)
+ val isKnockFeatureEnabled by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
+ }.collectAsState(initial = false)
val roomAddressValidity = remember {
mutableStateOf(RoomAddressValidity.Unknown)
}
@@ -175,6 +178,7 @@ class ConfigureRoomPresenter @Inject constructor(
isEncrypted = config.roomVisibility is RoomVisibilityState.Private,
isDirect = false,
visibility = RoomVisibility.Private,
+ historyVisibilityOverride = RoomHistoryVisibility.Invited,
preset = RoomPreset.PRIVATE_CHAT,
invite = config.invites.map { it.userId },
avatar = avatarUrl,
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt
index 2684c8334dd..9c468539ab8 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt
@@ -40,13 +40,14 @@ import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
-import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
@@ -187,12 +188,7 @@ private fun ConfigureRoomToolbar(
onNextClick: () -> Unit,
) {
TopAppBar(
- title = {
- Text(
- text = stringResource(R.string.screen_create_room_title),
- style = ElementTheme.typography.aliasScreenTitle,
- )
- },
+ titleStr = stringResource(R.string.screen_create_room_title),
navigationIcon = { BackButton(onClick = onBackClick) },
actions = {
TextButton(
@@ -219,6 +215,8 @@ private fun RoomNameWithAvatar(
) {
UnsavedAvatar(
avatarUri = avatarUri,
+ avatarSize = AvatarSize.EditRoomDetails,
+ avatarType = AvatarType.Room(),
modifier = Modifier.clickable(onClick = onAvatarClick),
)
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressEvents.kt
new file mode 100644
index 00000000000..dbbcef56fa7
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressEvents.kt
@@ -0,0 +1,14 @@
+/*
+ * Copyright 2025 New Vector 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.createroom.impl.joinbyaddress
+
+sealed interface JoinRoomByAddressEvents {
+ data object Dismiss : JoinRoomByAddressEvents
+ data object Continue : JoinRoomByAddressEvents
+ data class UpdateAddress(val address: String) : JoinRoomByAddressEvents
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressNode.kt
new file mode 100644
index 00000000000..d6338ab43c9
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressNode.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2025 New Vector 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.createroom.impl.joinbyaddress
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.createroom.CreateRoomNavigator
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+class JoinRoomByAddressNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ presenterFactory: JoinRoomByAddressPresenter.Factory,
+) : Node(buildContext, plugins = plugins) {
+ private val navigator = plugins().first()
+ private val presenter = presenterFactory.create(navigator)
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ JoinRoomByAddressView(
+ state = state,
+ modifier = modifier
+ )
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressPresenter.kt
new file mode 100644
index 00000000000..cf4788cbbfd
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressPresenter.kt
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2025 New Vector 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.createroom.impl.joinbyaddress
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import io.element.android.features.createroom.CreateRoomNavigator
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.data.tryOrNull
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomAlias
+import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
+import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlin.time.Duration.Companion.seconds
+
+private const val ADDRESS_RESOLVE_TIMEOUT_IN_SECONDS = 10
+
+class JoinRoomByAddressPresenter @AssistedInject constructor(
+ @Assisted private val navigator: CreateRoomNavigator,
+ private val client: MatrixClient,
+ private val roomAliasHelper: RoomAliasHelper,
+) : Presenter {
+ @AssistedFactory
+ interface Factory {
+ fun create(navigator: CreateRoomNavigator): JoinRoomByAddressPresenter
+ }
+
+ @Composable
+ override fun present(): JoinRoomByAddressState {
+ var address by remember { mutableStateOf("") }
+ var internalAddressState by remember { mutableStateOf(RoomAddressState.Unknown) }
+ var validateAddress: Boolean by remember { mutableStateOf(false) }
+
+ fun handleEvents(event: JoinRoomByAddressEvents) {
+ when (event) {
+ JoinRoomByAddressEvents.Continue -> {
+ when (val currentState = internalAddressState) {
+ is RoomAddressState.RoomFound -> onRoomFound(currentState)
+ else -> validateAddress = true
+ }
+ }
+ JoinRoomByAddressEvents.Dismiss -> navigator.onDismissJoinRoomByAddress()
+ is JoinRoomByAddressEvents.UpdateAddress -> {
+ validateAddress = false
+ address = event.address.trim()
+ }
+ }
+ }
+
+ RoomAddressStateEffect(
+ fullAddress = address,
+ onRoomAddressStateChange = { addressState ->
+ internalAddressState = addressState
+ if (addressState is RoomAddressState.RoomFound && validateAddress) {
+ onRoomFound(addressState)
+ }
+ }
+ )
+
+ val addressState by remember {
+ derivedStateOf {
+ // We only want to show the "RoomFound" state as long as the user didn't validate the address.
+ if (validateAddress || internalAddressState is RoomAddressState.RoomFound) {
+ internalAddressState
+ } else {
+ RoomAddressState.Unknown
+ }
+ }
+ }
+
+ return JoinRoomByAddressState(
+ address = address,
+ addressState = addressState,
+ eventSink = ::handleEvents
+ )
+ }
+
+ private fun onRoomFound(state: RoomAddressState.RoomFound) {
+ navigator.onDismissJoinRoomByAddress()
+ navigator.onOpenRoom(
+ roomIdOrAlias = state.resolved.roomId.toRoomIdOrAlias(),
+ serverNames = state.resolved.servers
+ )
+ }
+
+ @Composable
+ private fun RoomAddressStateEffect(
+ fullAddress: String,
+ onRoomAddressStateChange: (RoomAddressState) -> Unit,
+ ) {
+ val onChange by rememberUpdatedState(onRoomAddressStateChange)
+ LaunchedEffect(fullAddress) {
+ // Whenever the address changes, reset the state to unknown
+ onChange(RoomAddressState.Unknown)
+ // debounce the room address resolution
+ delay(300)
+ val roomAlias = tryOrNull { RoomAlias(fullAddress) }
+ if (roomAlias != null && roomAliasHelper.isRoomAliasValid(roomAlias)) {
+ onChange(RoomAddressState.Resolving)
+ onChange(client.resolveRoomAddress(roomAlias))
+ } else {
+ onChange(RoomAddressState.Invalid)
+ }
+ }
+ }
+
+ private suspend fun MatrixClient.resolveRoomAddress(roomAlias: RoomAlias): RoomAddressState {
+ return withTimeoutOrNull(ADDRESS_RESOLVE_TIMEOUT_IN_SECONDS.seconds) {
+ resolveRoomAlias(roomAlias)
+ .fold(
+ onSuccess = { resolved ->
+ if (resolved.isPresent) {
+ RoomAddressState.RoomFound(resolved.get())
+ } else {
+ RoomAddressState.RoomNotFound
+ }
+ },
+ onFailure = { _ -> RoomAddressState.RoomNotFound }
+ )
+ } ?: RoomAddressState.RoomNotFound
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressState.kt
new file mode 100644
index 00000000000..11791181e1b
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressState.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2025 New Vector 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.createroom.impl.joinbyaddress
+
+import androidx.compose.runtime.Immutable
+import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
+
+data class JoinRoomByAddressState(
+ val address: String,
+ val addressState: RoomAddressState,
+ val eventSink: (JoinRoomByAddressEvents) -> Unit
+)
+
+@Immutable
+sealed interface RoomAddressState {
+ data object Unknown : RoomAddressState
+ data object Invalid : RoomAddressState
+ data object Resolving : RoomAddressState
+ data object RoomNotFound : RoomAddressState
+ data class RoomFound(val resolved: ResolvedRoomAlias) : RoomAddressState
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressStateProvider.kt
new file mode 100644
index 00000000000..6281a8e8e38
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressStateProvider.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2025 New Vector 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.createroom.impl.joinbyaddress
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
+
+open class JoinRoomByAddressStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aJoinRoomByAddressState(),
+ aJoinRoomByAddressState(address = "#room-"),
+ aJoinRoomByAddressState(address = "#room-", addressState = RoomAddressState.Invalid),
+ aJoinRoomByAddressState(address = "#room-name:matrix.org", addressState = RoomAddressState.Resolving),
+ aJoinRoomByAddressState(address = "#room-name-none:matrix.org", addressState = RoomAddressState.RoomNotFound),
+ aJoinRoomByAddressState(
+ address = "#room-name:matrix.org",
+ addressState = RoomAddressState.RoomFound(ResolvedRoomAlias(RoomId("!aRoom:id"), emptyList())),
+ ),
+ )
+}
+
+fun aJoinRoomByAddressState(
+ address: String = "",
+ addressState: RoomAddressState = RoomAddressState.Unknown,
+ eventSink: (JoinRoomByAddressEvents) -> Unit = {},
+) = JoinRoomByAddressState(
+ address = address,
+ addressState = addressState,
+ eventSink = eventSink
+)
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressView.kt
new file mode 100644
index 00000000000..c256f5140c8
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressView.kt
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2025 New Vector 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.createroom.impl.joinbyaddress
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.createroom.impl.R
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
+import io.element.android.libraries.designsystem.theme.components.TextField
+import io.element.android.libraries.designsystem.theme.components.TextFieldValidity
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun JoinRoomByAddressView(
+ state: JoinRoomByAddressState,
+ modifier: Modifier = Modifier,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ ModalBottomSheet(
+ modifier = modifier,
+ sheetState = sheetState,
+ onDismissRequest = {
+ state.eventSink(JoinRoomByAddressEvents.Dismiss)
+ },
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(all = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ RoomAddressField(
+ address = state.address,
+ addressState = state.addressState,
+ requestFocus = sheetState.isVisible,
+ onAddressChange = {
+ state.eventSink(JoinRoomByAddressEvents.UpdateAddress(it))
+ },
+ onContinue = {
+ state.eventSink(JoinRoomByAddressEvents.Continue)
+ },
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Button(
+ text = stringResource(CommonStrings.action_continue),
+ modifier = Modifier.fillMaxWidth(),
+ showProgress = state.addressState is RoomAddressState.Resolving,
+ onClick = {
+ state.eventSink(JoinRoomByAddressEvents.Continue)
+ }
+ )
+ }
+ }
+}
+
+@Composable
+private fun RoomAddressField(
+ address: String,
+ addressState: RoomAddressState,
+ requestFocus: Boolean,
+ onAddressChange: (String) -> Unit,
+ onContinue: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val focusRequester = remember { FocusRequester() }
+ if (requestFocus) {
+ LaunchedEffect(Unit) { focusRequester.requestFocus() }
+ }
+ TextField(
+ modifier = modifier.focusRequester(focusRequester),
+ value = address,
+ label = stringResource(R.string.screen_start_chat_join_room_by_address_action),
+ placeholder = stringResource(R.string.screen_start_chat_join_room_by_address_placeholder),
+ supportingText = when (addressState) {
+ RoomAddressState.Invalid -> stringResource(R.string.screen_start_chat_join_room_by_address_invalid_address)
+ is RoomAddressState.RoomFound -> stringResource(R.string.screen_start_chat_join_room_by_address_room_found)
+ RoomAddressState.RoomNotFound -> stringResource(R.string.screen_start_chat_join_room_by_address_room_not_found)
+ RoomAddressState.Unknown, RoomAddressState.Resolving -> stringResource(R.string.screen_start_chat_join_room_by_address_supporting_text)
+ },
+ validity = when (addressState) {
+ RoomAddressState.Unknown, RoomAddressState.Resolving -> TextFieldValidity.None
+ RoomAddressState.Invalid, RoomAddressState.RoomNotFound -> TextFieldValidity.Invalid
+ is RoomAddressState.RoomFound -> TextFieldValidity.Valid
+ },
+ onValueChange = onAddressChange,
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.None,
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Go
+ ),
+ keyboardActions = KeyboardActions(
+ onGo = { onContinue() }
+ )
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun JoinRoomByAddressViewPreview(
+ @PreviewParameter(JoinRoomByAddressStateProvider::class) state: JoinRoomByAddressState
+) = ElementPreview {
+ JoinRoomByAddressView(state = state)
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt
index aef27b94dbf..f76a5d5c615 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt
@@ -8,9 +8,9 @@
package io.element.android.features.createroom.impl.root
import android.app.Activity
+import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@@ -20,9 +20,10 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.createroom.CreateRoomNavigator
import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
import io.element.android.libraries.di.SessionScope
-import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(SessionScope::class)
@@ -33,18 +34,7 @@ class CreateRoomRootNode @AssistedInject constructor(
private val analyticsService: AnalyticsService,
private val inviteFriendsUseCase: InviteFriendsUseCase,
) : Node(buildContext, plugins = plugins) {
- interface Callback : Plugin {
- fun onCreateNewRoom()
- fun onStartChatSuccess(roomId: RoomId)
- }
-
- private fun onCreateNewRoom() {
- plugins().forEach { it.onCreateNewRoom() }
- }
-
- private fun onStartChatSuccess(roomId: RoomId) {
- plugins().forEach { it.onStartChatSuccess(roomId) }
- }
+ private val navigator = plugins().first()
init {
lifecycle.subscribe(
@@ -55,14 +45,18 @@ class CreateRoomRootNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
- val activity = LocalContext.current as Activity
+ val activity = requireNotNull(LocalActivity.current)
CreateRoomRootView(
state = state,
modifier = modifier,
onCloseClick = this::navigateUp,
- onNewRoomClick = ::onCreateNewRoom,
- onOpenDM = ::onStartChatSuccess,
- onInviteFriendsClick = { invitePeople(activity) }
+ onNewRoomClick = navigator::onCreateNewRoom,
+ onOpenDM = {
+ navigator.onOpenRoom(roomIdOrAlias = it.toRoomIdOrAlias(), serverNames = emptyList())
+ },
+ onJoinByAddressClick = navigator::onShowJoinRoomByAddress,
+ onInviteFriendsClick = { invitePeople(activity) },
+ onRoomDirectorySearchClick = navigator::onOpenRoomDirectory
)
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt
index 2d5ebc87e18..ea8be45e6b7 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt
@@ -9,6 +9,8 @@ package io.element.android.features.createroom.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -20,6 +22,8 @@ import io.element.android.features.createroom.impl.userlist.UserListPresenterArg
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
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.core.RoomId
import io.element.android.libraries.usersearch.api.UserRepository
import kotlinx.coroutines.launch
@@ -31,6 +35,7 @@ class CreateRoomRootPresenter @Inject constructor(
userListDataStore: UserListDataStore,
private val startDMAction: StartDMAction,
private val buildMeta: BuildMeta,
+ private val featureFlagService: FeatureFlagService,
) : Presenter {
private val presenter = presenterFactory.create(
UserListPresenterArgs(
@@ -47,10 +52,18 @@ class CreateRoomRootPresenter @Inject constructor(
val localCoroutineScope = rememberCoroutineScope()
val startDmActionState: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
+ val isRoomDirectorySearchEnabled by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch)
+ }.collectAsState(initial = false)
+
fun handleEvents(event: CreateRoomRootEvents) {
when (event) {
is CreateRoomRootEvents.StartDM -> localCoroutineScope.launch {
- startDMAction.execute(event.matrixUser.userId, startDmActionState)
+ startDMAction.execute(
+ matrixUser = event.matrixUser,
+ createIfDmDoesNotExist = startDmActionState.value is AsyncAction.Confirming,
+ actionState = startDmActionState,
+ )
}
CreateRoomRootEvents.CancelStartDM -> startDmActionState.value = AsyncAction.Uninitialized
}
@@ -60,6 +73,7 @@ class CreateRoomRootPresenter @Inject constructor(
applicationName = buildMeta.applicationName,
userListState = userListState,
startDmAction = startDmActionState.value,
+ isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
eventSink = ::handleEvents,
)
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt
index 8e1c66b7df6..7a6d651db39 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt
@@ -15,5 +15,6 @@ data class CreateRoomRootState(
val applicationName: String,
val userListState: UserListState,
val startDmAction: AsyncAction,
+ val isRoomDirectorySearchEnabled: Boolean,
val eventSink: (CreateRoomRootEvents) -> Unit,
)
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt
index 6c453255762..9f2d59e3b6e 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt
@@ -8,6 +8,7 @@
package io.element.android.features.createroom.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
import io.element.android.features.createroom.impl.userlist.aUserListState
@@ -34,7 +35,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider = AsyncAction.Uninitialized,
+ isRoomDirectorySearchEnabled: Boolean = false,
eventSink: (CreateRoomRootEvents) -> Unit = {},
) = CreateRoomRootState(
applicationName = applicationName,
userListState = userListState,
startDmAction = startDmAction,
+ isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
eventSink = eventSink,
)
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt
index 0e33cf8b6e7..248d3319859 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt
@@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -28,6 +27,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.libraries.designsystem.components.async.AsyncActionView
@@ -36,13 +36,13 @@ import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@@ -54,6 +54,8 @@ fun CreateRoomRootView(
onNewRoomClick: () -> Unit,
onOpenDM: (RoomId) -> Unit,
onInviteFriendsClick: () -> Unit,
+ onJoinByAddressClick: () -> Unit,
+ onRoomDirectorySearchClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
@@ -88,6 +90,8 @@ fun CreateRoomRootView(
state = state,
onNewRoomClick = onNewRoomClick,
onInvitePeopleClick = onInviteFriendsClick,
+ onJoinByAddressClick = onJoinByAddressClick,
+ onRoomDirectorySearchClick = onRoomDirectorySearchClick,
onDmClick = onOpenDM,
)
}
@@ -110,6 +114,19 @@ fun CreateRoomRootView(
?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
},
onErrorDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) },
+ confirmationDialog = { data ->
+ if (data is ConfirmingStartDmWithMatrixUser) {
+ CreateDmConfirmationBottomSheet(
+ matrixUser = data.matrixUser,
+ onSendInvite = {
+ state.eventSink(CreateRoomRootEvents.StartDM(data.matrixUser))
+ },
+ onDismiss = {
+ state.eventSink(CreateRoomRootEvents.CancelStartDM)
+ },
+ )
+ }
+ },
)
}
@@ -119,12 +136,7 @@ private fun CreateRoomRootViewTopBar(
onCloseClick: () -> Unit,
) {
TopAppBar(
- title = {
- Text(
- text = stringResource(id = CommonStrings.action_start_chat),
- style = ElementTheme.typography.aliasScreenTitle,
- )
- },
+ titleStr = stringResource(id = CommonStrings.action_start_chat),
navigationIcon = {
BackButton(
imageVector = CompoundIcons.Close(),
@@ -139,6 +151,8 @@ private fun CreateRoomActionButtonsList(
state: CreateRoomRootState,
onNewRoomClick: () -> Unit,
onInvitePeopleClick: () -> Unit,
+ onJoinByAddressClick: () -> Unit,
+ onRoomDirectorySearchClick: () -> Unit,
onDmClick: (RoomId) -> Unit,
) {
LazyColumn {
@@ -149,6 +163,15 @@ private fun CreateRoomActionButtonsList(
onClick = onNewRoomClick,
)
}
+ if (state.isRoomDirectorySearchEnabled) {
+ item {
+ CreateRoomActionButton(
+ iconRes = CompoundDrawables.ic_compound_list_bulleted,
+ text = stringResource(id = R.string.screen_room_directory_search_title),
+ onClick = onRoomDirectorySearchClick,
+ )
+ }
+ }
item {
CreateRoomActionButton(
iconRes = CompoundDrawables.ic_compound_share_android,
@@ -156,6 +179,13 @@ private fun CreateRoomActionButtonsList(
onClick = onInvitePeopleClick,
)
}
+ item {
+ CreateRoomActionButton(
+ iconRes = CompoundDrawables.ic_compound_room,
+ text = stringResource(R.string.screen_start_chat_join_room_by_address_action),
+ onClick = onJoinByAddressClick,
+ )
+ }
if (state.userListState.recentDirectRooms.isNotEmpty()) {
item {
ListSectionHeader(
@@ -196,7 +226,7 @@ private fun CreateRoomActionButton(
) {
Icon(
modifier = Modifier.size(24.dp),
- tint = MaterialTheme.colorScheme.secondary,
+ tint = ElementTheme.colors.iconSecondary,
resourceId = iconRes,
contentDescription = null,
)
@@ -216,6 +246,8 @@ internal fun CreateRoomRootViewPreview(@PreviewParameter(CreateRoomRootStateProv
onCloseClick = {},
onNewRoomClick = {},
onOpenDM = {},
+ onJoinByAddressClick = {},
onInviteFriendsClick = {},
+ onRoomDirectorySearchClick = {},
)
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt
index ad9b18f6241..32d5767cc6a 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt
@@ -54,7 +54,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
recentDirectRooms = matrixClient.getRecentDirectRooms()
}
var isSearchActive by rememberSaveable { mutableStateOf(false) }
- val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
+ val selectedUsers by userListDataStore.selectedUsers.collectAsState(emptyList())
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults: SearchBarResultState> by remember {
mutableStateOf(SearchBarResultState.Initial())
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListDataStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListDataStore.kt
index 3be9868c980..a500e3a05fc 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListDataStore.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListDataStore.kt
@@ -8,22 +8,22 @@
package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.matrix.api.user.MatrixUser
-import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
class UserListDataStore @Inject constructor() {
- private val selectedUsers: MutableStateFlow> = MutableStateFlow(emptyList())
+ private val _selectedUsers: MutableStateFlow> = MutableStateFlow(emptyList())
fun selectUser(user: MatrixUser) {
- if (!selectedUsers.value.contains(user)) {
- selectedUsers.tryEmit(selectedUsers.value.plus(user))
+ if (!_selectedUsers.value.contains(user)) {
+ _selectedUsers.tryEmit(_selectedUsers.value.plus(user))
}
}
fun removeUserFromSelection(user: MatrixUser) {
- selectedUsers.tryEmit(selectedUsers.value.minus(user))
+ _selectedUsers.tryEmit(_selectedUsers.value.minus(user))
}
- fun selectedUsers(): Flow> = selectedUsers
+ val selectedUsers = _selectedUsers.asStateFlow()
}
diff --git a/features/createroom/impl/src/main/res/values-be/translations.xml b/features/createroom/impl/src/main/res/values-be/translations.xml
index 861fca4d057..8acb990b023 100644
--- a/features/createroom/impl/src/main/res/values-be/translations.xml
+++ b/features/createroom/impl/src/main/res/values-be/translations.xml
@@ -3,15 +3,17 @@
"Новы пакой"
"Запрасіць карыстальнікаў"
"Пры стварэнні пакоя адбылася памылка"
- "Паведамленні ў гэтым пакоі зашыфраваны. Гэта шыфраванне нельга адключыць."
- "Прыватны пакой (толькі па запрашэнні)"
- "Паведамленні не зашыфраваны, і кожны можа іх прачытаць. Вы можаце ўключыць шыфраванне пазней."
- "Публічны пакой (для ўсіх)"
+ "Толькі запрошаныя людзі могуць атрымаць доступ да гэтага пакоя. Усе паведамленні абаронены end-to-end шыфраваннем."
+ "Прыватны пакой"
+ "Любы можа знайсці гэты пакой.
+Вы можаце змяніць гэта ў любы час у наладах пакоя."
+ "Публічны пакой"
"Хто заўгодна"
"Доступ у пакой"
"Папрасіце далучыцца"
"Назва пакоя"
"Стварыце пакой"
"Тэма (неабавязкова)"
+ "Каталог пакояў"
"Пры спробе пачаць чат адбылася памылка"
diff --git a/features/createroom/impl/src/main/res/values-bg/translations.xml b/features/createroom/impl/src/main/res/values-bg/translations.xml
index 28f42b4156a..fa6dae371c5 100644
--- a/features/createroom/impl/src/main/res/values-bg/translations.xml
+++ b/features/createroom/impl/src/main/res/values-bg/translations.xml
@@ -3,11 +3,21 @@
"Нова стая"
"Поканване на хора"
"Възникна грешка при създаването на стаята"
- "Съобщенията в тази стая са шифровани. Шифроването не може да бъде изключено впоследствие."
- "Частна стая (само с покана)"
- "Съобщенията не са шифровани и всеки може да ги прочете. Можете да активирате шифроването на по-късна дата."
- "Публична стая (всеки)"
+ "Само поканени хора имат достъп до тази стая. Всички съобщения са шифровани от край до край."
+ "Частна стая"
+ "Всеки може да намери тази стая.
+Можете да промените това по всяко време в настройките на стаята."
+ "Общодостъпна стая"
+ "Всеки може да се присъедини към тази стая"
+ "Всеки"
+ "За да бъде тази стая видима в директорията на общодостъпните стаи, ще ви е необходим адрес на стаята."
"Име на стаята"
+ "Видимост на стаята"
"Създаване на стая"
"Тема за разговор (незадължително)"
+ "Присъединяване към стая по адрес"
+ "Не е валиден адрес"
+ "Въведете…"
+ "Стаята не е намерена"
+ "напр. #room-name:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-cs/translations.xml b/features/createroom/impl/src/main/res/values-cs/translations.xml
index 697120201d8..53592e0db0a 100644
--- a/features/createroom/impl/src/main/res/values-cs/translations.xml
+++ b/features/createroom/impl/src/main/res/values-cs/translations.xml
@@ -19,5 +19,12 @@ To můžete kdykoli změnit v nastavení místnosti."
"Viditelnost místnosti"
"Vytvořit místnost"
"Téma (nepovinné)"
+ "Adresář místností"
"Při pokusu o zahájení chatu došlo k chybě"
+ "Vstoupit do místnosti pomocí adresy"
+ "Neplatná adresa"
+ "Zadejte…"
+ "Odpovídající místnost nalezena"
+ "Místnost nebyla nalezena"
+ "např. #room-name:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-cy/translations.xml b/features/createroom/impl/src/main/res/values-cy/translations.xml
new file mode 100644
index 00000000000..a3a9e30e354
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-cy/translations.xml
@@ -0,0 +1,30 @@
+
+
+ "Ystafell newydd"
+ "Gwahodd pobl"
+ "Bu gwall wrth greu\'r ystafell"
+ "Dim ond pobl wahoddwyd all gael mynediad i\'r ystafell hon. Mae pob neges wedi\'i hamgryptio o\'r dechrau i\'r diwedd."
+ "Ystafell breifat"
+ "Gall unrhyw un ddod o hyd i\'r ystafell hon.
+Gallwch newid hyn unrhyw bryd yng ngosodiadau ystafell."
+ "Ystafell gyhoeddus"
+ "Gall unrhyw un ymuno â\'r ystafell hon"
+ "Unrhyw un"
+ "Mynediad i\'r Ystafell"
+ "Gall unrhyw un ofyn am gael ymuno â\'r ystafell ond bydd rhaid i weinyddwr neu gymedrolwr dderbyn y cais"
+ "Gofyn i gael ymuno"
+ "Er mwyn i\'r ystafell hon fod yn weladwy yn y cyfeiriadur ystafelloedd cyhoeddus, bydd angen cyfeiriad ystafell arnoch."
+ "Cyfeiriad yr ystafell"
+ "Enw\'r ystafell"
+ "Gwelededd yr ystafell"
+ "Creu ystafell"
+ "Pwnc (dewisol)"
+ "Cyfeiriadur ystafelloedd"
+ "Digwyddodd gwall wrth geisio cychwyn sgwrs"
+ "Ymuno â\'r ystafell yn ôl cyfeiriad"
+ "Ddim yn gyfeiriad dilys"
+ "Ewch i mewn…"
+ "Cafwyd hyd i ystafell gyfatebol"
+ "Heb ganfod yr ystafell"
+ "e.e. #enw-ystafell:matrix.org"
+
diff --git a/features/createroom/impl/src/main/res/values-da/translations.xml b/features/createroom/impl/src/main/res/values-da/translations.xml
new file mode 100644
index 00000000000..c9c182b474b
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-da/translations.xml
@@ -0,0 +1,30 @@
+
+
+ "Nyt rum"
+ "Invitér folk"
+ "Der opstod en fejl ved oprettelsen af rummet"
+ "Kun inviterede personer kan få adgang til dette rum. Alle meddelelser er ende-til-ende krypteret."
+ "Privat rum"
+ "Alle kan finde dette rum.
+Du kan ændre dette når som helst i rummets indstillinger."
+ "Offentligt rum"
+ "Alle kan deltage i dette rum"
+ "Enhver"
+ "Adgang til rummet"
+ "Alle kan bede om at deltage i rummet, men en administrator eller en moderator skal acceptere anmodningen"
+ "Spørg om at deltage"
+ "Hvis dette rum skal være synligt i det offentlige register, skal du bruge en rum-adresse."
+ "Rummets adresse"
+ "Navn på rum"
+ "Rummets synlighed"
+ "Opret et rum"
+ "Emne (valgfrit)"
+ "Register over rum"
+ "Der opstod en fejl under forsøget på at starte en samtale"
+ "Tilslut dig rummet med adressen"
+ "Ikke en gyldig adresse"
+ "Indtast…"
+ "Matchende rum fundet"
+ "Rum ikke fundet"
+ "f.eks. #rummets-navn:matrix.org"
+
diff --git a/features/createroom/impl/src/main/res/values-de/translations.xml b/features/createroom/impl/src/main/res/values-de/translations.xml
index 10cda04d9ed..90438aa38c1 100644
--- a/features/createroom/impl/src/main/res/values-de/translations.xml
+++ b/features/createroom/impl/src/main/res/values-de/translations.xml
@@ -1,23 +1,30 @@
"Neuer Raum"
- "Personen einladen"
+ "Nutzer einladen"
"Beim Erstellen des Chats ist ein Fehler aufgetreten"
- "Nur eingeladene Personen haben Zutritt zu diesem Chatroom. Alle Nachrichten sind durchgehend verschlüsselt."
+ "Nur eingeladene Personen haben Zutritt zu diesem Chatroom. Alle Nachrichten sind Ende-zu-Ende verschlüsselt."
"Privater Chatroom"
- "Jeder kann diesen Chatroom finden.
+ "Alle können diesen Chatroom finden.
Sie können dies aber jederzeit in den Chatroomeinstellungen ändern."
- "Öffentlicher Chatroom"
- "Jeder kann diesem Chatroom beitreten"
- "Jemand"
- "Chatroom Zugang"
- "Jeder kann darum bitten, dem Chatroom beizutreten, aber ein Administrator oder ein Moderator muss die Anfrage akzeptieren."
+ "Öffentlicher Raum"
+ "Jeder darf diesen Raum betreten"
+ "Jeder"
+ "Chatroomzugang"
+ "Jeder kann den Zutritt zum Raum beantragen, aber ein Moderator muss die Anfrage akzeptieren."
"Beitritt beantragen"
"Damit dieser Chatroom im öffentlichen Chatroomverzeichnis sichtbar ist, benötigen Sie eine Chatroomadresse."
- "Chatroom Adresse"
+ "Chatroomadresse"
"Raumname"
" Sichtbarkeit des Chatrooms"
"Raum erstellen"
"Thema (optional)"
+ "Raum-Verzeichnis"
"Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten"
+ "Raum per Adresse betreten"
+ "Keine gültige Adresse"
+ "Eintreten…"
+ "Passender Raum gefunden"
+ "Raum nicht gefunden"
+ "z. B. #room -name:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-el/translations.xml b/features/createroom/impl/src/main/res/values-el/translations.xml
index 4dc3acaaa32..c036b43a05e 100644
--- a/features/createroom/impl/src/main/res/values-el/translations.xml
+++ b/features/createroom/impl/src/main/res/values-el/translations.xml
@@ -1,23 +1,30 @@
- "Νέο δωμάτιο"
+ "Νέα αίθουσα"
"Πρόσκληση ατόμων"
- "Παρουσιάστηκε σφάλμα κατά τη δημιουργία του δωματίου"
- "Μόνο άτομα που έχουν προσκληθεί μπορούν να έχουν πρόσβαση σε αυτό το δωμάτιο. Όλα τα μηνύματα είναι κρυπτογραφημένα από άκρο σε άκρο."
- "Ιδιωτικό δωμάτιο"
- "Ο καθένας μπορεί να βρει αυτό το δωμάτιο.
-Μπορείς να το αλλάξεις ανά πάσα στιγμή στις ρυθμίσεις δωματίου."
- "Δημόσιο δωμάτιο"
- "Οποιοσδήποτε μπορεί να συμμετάσχει σε αυτό το δωμάτιο"
+ "Προέκυψε σφάλμα κατά τη δημιουργία της αίθουσας"
+ "Μόνο τα άτομα που έχουν προσκληθεί μπορούν να έχουν πρόσβαση σε αυτή την αίθουσα. Όλα τα μηνύματα είναι κρυπτογραφημένα από άκρο σε άκρο."
+ "Ιδιωτική αίθουσα"
+ "Ο καθένας μπορεί να βρει αυτή την αίθουσα.
+Αυτό μπορείτε να το αλλάξετε ανά πάσα στιγμή στις ρυθμίσεις της αίθουσας."
+ "Δημόσια αίθουσα"
+ "Οποιοσδήποτε μπορεί να συμμετάσχει σε αυτή την αίθουσα"
"Οποιοσδήποτε"
- "Πρόσβαση Δωματίου"
- "Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στο δωμάτιο, αλλά ένας διαχειριστής ή συντονιστής θα πρέπει να αποδεχθεί το αίτημα"
+ "Πρόσβαση στην Αίθουσα"
+ "Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στην αίθουσα, αλλά ένας διαχειριστής ή ένας συντονιστής θα πρέπει να αποδεχτεί το αίτημα"
"Αίτημα συμμετοχής"
- "Για να είναι ορατό αυτό το δωμάτιο στον κατάλογο των δημόσιων δωματίων, θα χρειαστείς μια διεύθυνση δωματίου."
- "Διεύθυνση δωματίου"
- "Όνομα δωματίου"
- "Ορατότητα δωματίου"
- "Δημιούργησε ένα δωμάτιο"
+ "Για να είναι ορατή αυτή η αίθουσα στον δημόσιο κατάλογο αιθουσών, θα χρειαστείτε μια διεύθυνση αίθουσας."
+ "Διεύθυνση αίθουσας"
+ "Όνομα αίθουσας"
+ "Ορατότητα αίθουσας"
+ "Δημιουργία αίθουσας"
"Θέμα (προαιρετικό)"
+ "Κατάλογος αιθουσών"
"Παρουσιάστηκε σφάλμα κατά την προσπάθεια έναρξης μιας συνομιλίας"
+ "Συμμετοχή σε αίθουσα μέσω διεύθυνσης"
+ "Μη έγκυρη διεύθυνση"
+ "Εισάγετε…"
+ "Βρέθηκε η αντίστοιχη αίθουσα"
+ "Η αίθουσα δεν βρέθηκε"
+ "π.χ. #όνομα-αίθουσας:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-es/translations.xml b/features/createroom/impl/src/main/res/values-es/translations.xml
index 5946dab1024..c11f08fc141 100644
--- a/features/createroom/impl/src/main/res/values-es/translations.xml
+++ b/features/createroom/impl/src/main/res/values-es/translations.xml
@@ -3,12 +3,28 @@
"Nueva sala"
"Invitar personas"
"Se ha producido un error al crear la sala"
- "Los mensajes de esta sala están cifrados. La encriptación no se puede desactivar después."
- "Sala privada (sólo con invitación)"
- "Los mensajes no están cifrados y cualquiera puede leerlos. Puedes activar la encriptación más adelante."
- "Sala pública (cualquiera)"
+ "Solo las personas invitadas pueden acceder a esta sala. Todos los mensajes están cifrados de extremo a extremo."
+ "Sala privada"
+ "Cualquiera puede encontrar esta sala.
+Puedes cambiar esto en cualquier momento en los ajustes de la sala."
+ "Sala pública"
+ "Cualquiera puede unirse a esta sala"
+ "Cualquiera"
+ "Acceso a la sala"
+ "Cualquiera puede solicitar unirse a la sala, pero un administrador o un moderador tendrá que aceptar la solicitud"
+ "Solicitud para unirse"
+ "Para que esta sala sea visible en el directorio de salas públicas, necesitarás una dirección de sala."
+ "Dirección de la sala"
"Nombre de la sala"
+ "Visibilidad de la sala"
"Crear una sala"
"Tema (opcional)"
+ "Directorio de salas"
"Se ha producido un error al intentar iniciar un chat"
+ "Unirse a una sala por su dirección"
+ "Dirección no válida"
+ "Introducir…"
+ "Sala encontrada"
+ "No se encontró la sala"
+ "p. ej., #nombre-de-la-sala:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-et/translations.xml b/features/createroom/impl/src/main/res/values-et/translations.xml
index 67db887f2c6..57a26106b3f 100644
--- a/features/createroom/impl/src/main/res/values-et/translations.xml
+++ b/features/createroom/impl/src/main/res/values-et/translations.xml
@@ -19,5 +19,12 @@ Sa võid seda jututoa seadistustest alati muuta."
"Jututoa nähtavus"
"Loo jututuba"
"Teema (kui soovid lisada)"
+ "Jututubade kataloog"
"Vestluse alustamisel tekkis viga"
+ "Liitu jututoaga aadressi alusel"
+ "See pole kehtiv aadress"
+ "Sisene…"
+ "Leidsime vastava jututoa"
+ "Jututuba ei leidu"
+ "nt. #jututoa-nimi:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-eu/translations.xml b/features/createroom/impl/src/main/res/values-eu/translations.xml
new file mode 100644
index 00000000000..43f67e429c1
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-eu/translations.xml
@@ -0,0 +1,23 @@
+
+
+ "Gela berria"
+ "Gonbidatu jendea"
+ "Errorea gertatu da gela sortzean"
+ "Gonbidatutako jendea soilik sar daiteke gelara. Mezu guztiak daude ertzetik ertzera zifratuta."
+ "Gela pribatua"
+ "Edonork aurki dezake gela hau.
+Gelaren ezarpenetan aldatu dezakezu hobespena."
+ "Gela publikoa"
+ "Edonor sar daiteke gela honetara"
+ "Edonork"
+ "Gelarako sarbidea"
+ "Gelaren helbidea"
+ "Gelaren izena"
+ "Gelaren ikusgarritasuna"
+ "Sortu gela"
+ "Mintzagaia (aukerakoa)"
+ "Gelen direktorioa"
+ "Errorea gertatu da txata hasten saiatzean"
+ "Sartu…"
+ "Ez da gela aurkitu"
+
diff --git a/features/createroom/impl/src/main/res/values-fa/translations.xml b/features/createroom/impl/src/main/res/values-fa/translations.xml
index 467fae53271..03bc5c3f58e 100644
--- a/features/createroom/impl/src/main/res/values-fa/translations.xml
+++ b/features/createroom/impl/src/main/res/values-fa/translations.xml
@@ -3,11 +3,26 @@
"اتاق جدید"
"دعوت افراد"
"هنگام ایجاد اتاق خطایی رخ داد"
- "پیامهای این اتاق رمز شدهاند. رمزنگاری نمیتواند از این پس تغییر کند."
- "اتاق خصوصی (فقط دعوت)"
- "پیامها رمزنگاری نشده و هرکسی میتواند بخواندشان. میتوانید بعداً رمزنگاری را به کار بیندازید."
- "اتاق عمومی (هرکسی)"
+ "تنها افراد دعوت شده میتوانند به این اتاق دسترسی داشته باشند. همهٔ پیامها رمزنگاری سرتاسری شدهاند."
+ "اتاق خصوصی"
+ "هرکسی میتواند اتاق را بیابد.
+میتوانید بعداً در تظیمات اتاق عوضش کنید."
+ "اتاق عمومی"
+ "هرکسی میتواند به این اتاق بپیوندد"
+ "هرکسی"
+ "دسترسی اتاق"
+ "درخواست دعوت"
+ "نشانی اتاق"
"نام اتاق"
+ "نمایانی اتاق"
"ایجاد اتاق"
"موضوع (اختیاری)"
+ "فهرست اتاقها"
+ "هنگام تلاش برای شروع چت خطایی روی داد"
+ "پیوستن به اتاق با نشانی"
+ "نشانی معتبری نیست"
+ "ورود…"
+ "اتاق مطابق پیدا شد"
+ "اتاق پیدا نشد"
+ "نمونه: #room-name:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-fi/translations.xml b/features/createroom/impl/src/main/res/values-fi/translations.xml
index 39b0d54646d..cdb6d04d598 100644
--- a/features/createroom/impl/src/main/res/values-fi/translations.xml
+++ b/features/createroom/impl/src/main/res/values-fi/translations.xml
@@ -19,5 +19,12 @@ Voit muuttaa tämän milloin tahansa huoneen asetuksista."
"Huoneen näkyvyys"
"Luo huone"
"Aihe (valinnainen)"
+ "Huoneluettelo"
"Keskustelun aloituksessa tapahtui virhe"
+ "Liity huoneeseen osoitteella"
+ "Osoite ei ole kelvollinen"
+ "Syötä…"
+ "Täsmäävä huone löytyi"
+ "Huonetta ei löytynyt"
+ "esim. #huoneen-nimi:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-fr/translations.xml b/features/createroom/impl/src/main/res/values-fr/translations.xml
index af40d1f9fef..3f9e01dc85a 100644
--- a/features/createroom/impl/src/main/res/values-fr/translations.xml
+++ b/features/createroom/impl/src/main/res/values-fr/translations.xml
@@ -19,5 +19,12 @@ Vous pouvez modifier cela à tout moment dans les paramètres du salon.""Visibilité du salon"
"Créer un salon"
"Sujet (facultatif)"
+ "Annuaire des salons"
"Une erreur s’est produite lors de la tentative de création de la discussion"
+ "Saisir une adresse de salon"
+ "Ce n’est pas une adresse valide"
+ "Saisir…"
+ "Ce salon existe"
+ "Salon non trouvé"
+ "ex: #nom-du-salon:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-hu/translations.xml b/features/createroom/impl/src/main/res/values-hu/translations.xml
index ee935c8c60e..1e4a7212793 100644
--- a/features/createroom/impl/src/main/res/values-hu/translations.xml
+++ b/features/createroom/impl/src/main/res/values-hu/translations.xml
@@ -14,10 +14,17 @@ Ezt bármikor módosíthatja a szobabeállításokban."
"Bárki kérheti, hogy csatlakozzon a szobához, de egy adminisztrátornak vagy moderátornak el kell fogadnia a kérést"
"Csatlakozás kérése"
"Ahhoz, hogy ez a szoba látható legyen a nyilvános szobák címtárában, meg kell adnia a szoba címét."
- "Szoba címe"
+ "A szoba címe"
"Szoba neve"
"Szoba láthatósága"
"Szoba létrehozása"
"Téma (nem kötelező)"
+ "Szobakatalógus"
"Hiba történt a csevegés indításakor"
+ "Csatlakozás a szobához cím szerint"
+ "Nem érvényes cím"
+ "Írja be…"
+ "Megfelelő szoba található"
+ "Szoba nem található"
+ "pl. #szoba-neve:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-in/translations.xml b/features/createroom/impl/src/main/res/values-in/translations.xml
index b1c7aeef1cf..65f135e617e 100644
--- a/features/createroom/impl/src/main/res/values-in/translations.xml
+++ b/features/createroom/impl/src/main/res/values-in/translations.xml
@@ -19,5 +19,12 @@ Anda dapat mengubah ini kapan pun dalam pengaturan ruangan."
"Keterlihatan ruangan"
"Buat ruangan"
"Topik (opsional)"
+ "Direktori ruangan"
"Terjadi kesalahan saat mencoba memulai obrolan"
+ "Bergabung dalam ruangan berdasarkan alamat"
+ "Bukan alamat yang valid"
+ "Masuk…"
+ "Ruangan yang cocok ditemukan"
+ "Ruangan tidak ditemukan"
+ "mis. #nama-ruangan:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-it/translations.xml b/features/createroom/impl/src/main/res/values-it/translations.xml
index 9cc30fc0e65..701ebc967f0 100644
--- a/features/createroom/impl/src/main/res/values-it/translations.xml
+++ b/features/createroom/impl/src/main/res/values-it/translations.xml
@@ -19,5 +19,12 @@ Puoi modificarlo in qualsiasi momento nelle impostazioni della stanza."
"Visibilità della stanza"
"Crea una stanza"
"Argomento (facoltativo)"
+ "Elenco delle stanze"
"Si è verificato un errore durante il tentativo di avviare una chat"
+ "Accedi alla stanza tramite indirizzo"
+ "Indirizzo non valido"
+ "Inserisci…"
+ "Stanza trovata"
+ "Stanza non trovata"
+ "ad esempio #room -name:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-ka/translations.xml b/features/createroom/impl/src/main/res/values-ka/translations.xml
index 45e87809b25..bb4a4303209 100644
--- a/features/createroom/impl/src/main/res/values-ka/translations.xml
+++ b/features/createroom/impl/src/main/res/values-ka/translations.xml
@@ -7,9 +7,9 @@
"კერძო ოთახი"
"ყველას ამ ოთახის მოძებნა შეუძლია.
თქვენ ნებისმიერ დროს შეგიძლიათ ამის შეცვლა ოთახის პარამეტრებში."
- "საჯარო ოთახი"
"ოთახის სახელი"
"ოთახის შექმნა"
"თემა (სურვილისამებრ)"
+ "ოთახის კატალოგი"
"ჩატის დაწყების მცდელობისას შეცდომა მოხდა"
diff --git a/features/createroom/impl/src/main/res/values-lt/translations.xml b/features/createroom/impl/src/main/res/values-lt/translations.xml
new file mode 100644
index 00000000000..2f7a4438ea7
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-lt/translations.xml
@@ -0,0 +1,14 @@
+
+
+ "Naujas kambarys"
+ "Pakviesti žmonių"
+ "Kuriant kambarį įvyko klaida"
+ "Į šį kambarį gali patekti tik pakviesti žmonės. Visi pranešimai yra užšifruoti nuo pradžios iki galo."
+ "Privatus kambarys"
+ "Bet kas gali rasti šį kambarį.
+Tai galite bet kada pakeisti kambario nustatymuose."
+ "Kambario pavadinimas"
+ "Kurti kambarį"
+ "Tema (nebūtina)"
+ "Bandant pradėti pokalbį įvyko klaida"
+
diff --git a/features/createroom/impl/src/main/res/values-nb/translations.xml b/features/createroom/impl/src/main/res/values-nb/translations.xml
index 4692daad04b..9266993fd4e 100644
--- a/features/createroom/impl/src/main/res/values-nb/translations.xml
+++ b/features/createroom/impl/src/main/res/values-nb/translations.xml
@@ -8,8 +8,23 @@
"Alle kan finne dette rommet.
Du kan endre dette når som helst i rominnstillingene."
"Offentlig rom"
+ "Alle kan bli med i dette rommet"
+ "Alle"
+ "Tilgang til rom"
+ "Alle kan be om å få bli med i rommet, men en administrator eller moderator må godta forespørselen"
+ "Be om å bli med"
+ "For at dette rommet skal være synlig i den offentlige romkatalogen, trenger du en romadresse."
+ "Romadresse"
"Romnavn"
+ "Romsynlighet"
"Opprett et rom"
"Emne (valgfritt)"
+ "Romkatalog"
"Det oppstod en feil når du prøvde å starte en chat"
+ "Bli med i rommet med adresse"
+ "Ikke en gyldig adresse"
+ "Gå inn…"
+ "Matchende rom funnet"
+ "Rom ikke funnet"
+ "f.eks. #rom-navn:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-nl/translations.xml b/features/createroom/impl/src/main/res/values-nl/translations.xml
index c1f0fd92f5a..29d070a2638 100644
--- a/features/createroom/impl/src/main/res/values-nl/translations.xml
+++ b/features/createroom/impl/src/main/res/values-nl/translations.xml
@@ -3,7 +3,7 @@
"Nieuwe kamer"
"Mensen uitnodigen"
"Er is een fout opgetreden bij het aanmaken van de kamer"
- "Berichten in deze kamer zijn versleuteld. Versleuteling kan achteraf niet worden uitgeschakeld."
+ "Alleen uitgenodigde personen hebben toegang tot deze kamer. Alle berichten zijn end-to-end versleuteld."
"Privé kamer"
"Iedereen kan deze kamer vinden.
Je kunt dit op elk gewenst moment wijzigen in de kamerinstellingen."
@@ -16,5 +16,6 @@ Je kunt dit op elk gewenst moment wijzigen in de kamerinstellingen."
"Naam van de kamer"
"Creëer een kamer"
"Onderwerp (optioneel)"
+ "Kamergids"
"Er is een fout opgetreden bij het starten van een chat"
diff --git a/features/createroom/impl/src/main/res/values-pl/translations.xml b/features/createroom/impl/src/main/res/values-pl/translations.xml
index 7902fb4c3c8..3ed917b7bb9 100644
--- a/features/createroom/impl/src/main/res/values-pl/translations.xml
+++ b/features/createroom/impl/src/main/res/values-pl/translations.xml
@@ -19,5 +19,12 @@ Możesz to zmienić w ustawieniach pokoju."
"Widoczność pomieszczenia"
"Utwórz pokój"
"Temat (opcjonalnie)"
+ "Katalog pokoi"
"Wystąpił błąd podczas próby rozpoczęcia czatu"
+ "Dołącz do pokoju za pomocą adresu"
+ "Nieprawidłowy adres"
+ "Wprowadź…"
+ "Znaleziono pasujący pokój"
+ "Nie znaleziono pokoju"
+ "np. #room-name:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml b/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml
index b10b86f5a07..52c9f569af5 100644
--- a/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml
@@ -4,12 +4,27 @@
"Convidar pessoas"
"Ocorreu um erro ao criar a sala"
"Apenas as pessoas convidadas podem aceder a esta sala. Todas as mensagens são criptografadas de ponta a ponta."
- "Sala privativa (somente por convite)"
+ "Sala privada"
"Qualquer um pode encontrar esta sala.
Você pode mudar isso a qualquer momento nas configurações da sala."
- "Sala pública (qualquer pessoa)"
+ "Sala pública"
+ "Qualquer pessoa pode entrar nesta sala"
+ "Qualquer pessoa"
+ "Acesso à sala"
+ "Qualquer pessoa pode pedir para entrar na sala, mas um administrador ou moderador terá de aceitar a solicitação"
+ "Pedir para entrar"
+ "Para que esta sala fique visível no diretório público de salas, você precisará de um endereço de sala."
+ "Endereço da sala"
"Nome da sala"
+ "Visibilidade da sala"
"Criar uma sala"
"Tópico (opcional)"
+ "Diretório de salas"
"Ocorreu um erro ao tentar iniciar um chat"
+ "Entrar na sala pelo endereço"
+ "Não é um endereço válido"
+ "Entrar…"
+ "Foi encontrada uma sala correspondente"
+ "Sala não encontrada"
+ "Por exemplo, #nome-da-sala:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-pt/translations.xml b/features/createroom/impl/src/main/res/values-pt/translations.xml
index 60ee753b133..5e5f5fc9c3f 100644
--- a/features/createroom/impl/src/main/res/values-pt/translations.xml
+++ b/features/createroom/impl/src/main/res/values-pt/translations.xml
@@ -3,7 +3,7 @@
"Nova sala"
"Convidar pessoas"
"Ocorreu um erro ao criar a sala"
- "Apenas as pessoas convidadas podem aceder a esta sala. Todas as mensagens são encriptadas ponta a ponta."
+ "Apenas as pessoas convidadas podem aceder a esta sala. Todas as mensagens são cifradas ponta-a-ponta."
"Sala privada"
"Qualquer um pode encontrar esta sala.
Pode alterar esta opção nas definições da sala."
@@ -19,5 +19,12 @@ Pode alterar esta opção nas definições da sala."
"Visibilidade da sala"
"Criar uma sala"
"Descrição (opcional)"
+ "Diretório de salas"
"Ocorreu um erro ao tentar iniciar uma conversa"
+ "Entrar na sala pelo endereço"
+ "Não é um endereço válido"
+ "Entrar…"
+ "Sala correspondente encontrado"
+ "Sala não encontrada"
+ "por exemplo, #sala:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-ro/translations.xml b/features/createroom/impl/src/main/res/values-ro/translations.xml
index 329ca6de1c9..8d9e74b5307 100644
--- a/features/createroom/impl/src/main/res/values-ro/translations.xml
+++ b/features/createroom/impl/src/main/res/values-ro/translations.xml
@@ -14,9 +14,9 @@ Puteți modifica acest lucru oricând în setări."
"Oricine poate cere să se alăture camerei, dar un administrator sau un moderator va trebui să accepte solicitarea"
"Cereți să vă alăturați"
"Pentru ca această cameră să fie vizibilă în directorul de camere publice, veți avea nevoie de o adresă de cameră."
- "Adresa camerei"
"Numele camerei"
"Creați o cameră"
"Subiect (opțional)"
+ "Director de camere"
"A apărut o eroare la încercarea începerii conversației"
diff --git a/features/createroom/impl/src/main/res/values-ru/translations.xml b/features/createroom/impl/src/main/res/values-ru/translations.xml
index b21bcb68acd..6344491248c 100644
--- a/features/createroom/impl/src/main/res/values-ru/translations.xml
+++ b/features/createroom/impl/src/main/res/values-ru/translations.xml
@@ -19,5 +19,12 @@
"Видимость комнаты"
"Создать комнату"
"Тема (необязательно)"
+ "Каталог комнат"
"Произошла ошибка при запуске чата"
+ "Присоединиться к комнате по адресу"
+ "Недействительный адрес"
+ "Ввести…"
+ "Соответствующая комната найдена"
+ "Комната не найдена"
+ "прим. #room-name:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-sk/translations.xml b/features/createroom/impl/src/main/res/values-sk/translations.xml
index d2d742239f6..eaf6c2f19c3 100644
--- a/features/createroom/impl/src/main/res/values-sk/translations.xml
+++ b/features/createroom/impl/src/main/res/values-sk/translations.xml
@@ -19,5 +19,12 @@ Môžete to kedykoľvek zmeniť v nastaveniach miestnosti."
"Viditeľnosť miestnosti"
"Vytvoriť miestnosť"
"Téma (voliteľné)"
+ "Adresár miestností"
"Pri pokuse o spustenie konverzácie sa vyskytla chyba"
+ "Pripojte sa do miestnosti podľa adresy"
+ "Neplatná adresa"
+ "Zadajte…"
+ "Nájdená zodpovedajúca miestnosť"
+ "Miestnosť sa nenašla"
+ "napr. #nazov-miestnosti:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-sv/translations.xml b/features/createroom/impl/src/main/res/values-sv/translations.xml
index 980d6ae6435..316c9cc32e4 100644
--- a/features/createroom/impl/src/main/res/values-sv/translations.xml
+++ b/features/createroom/impl/src/main/res/values-sv/translations.xml
@@ -13,8 +13,18 @@ Du kan ändra detta när som helst i rumsinställningarna."
"Rumsåtkomst"
"Vem som helst kan be om att gå med i rummet men en administratör eller en moderator måste acceptera begäran"
"Be om att gå med"
+ "För att detta rum ska vara synligt i den allmänna rumskatalogen behöver du en rumsadress."
+ "Rumsadress"
"Rumsnamn"
+ "Rumssynlighet"
"Skapa ett rum"
"Ämne (valfritt)"
+ "Rumskatalog"
"Ett fel uppstod när du försökte starta en chatt"
+ "Gå med i rum med adress"
+ "Inte en giltig adress"
+ "Ange …"
+ "Matchande rum hittades"
+ "Rummet hittades inte"
+ "t.ex. #rumsnamn:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-tr/translations.xml b/features/createroom/impl/src/main/res/values-tr/translations.xml
new file mode 100644
index 00000000000..c8e31365171
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-tr/translations.xml
@@ -0,0 +1,24 @@
+
+
+ "Yeni oda"
+ "İnsanları davet et"
+ "Oda oluşturulurken bir hata oluştu"
+ "Bu odaya yalnızca davet edilen kişiler erişebilir. Tüm mesajlar uçtan uca şifrelenir."
+ "Özel oda"
+ "Bu odayı herkes bulabilir.
+Bunu istediğiniz zaman oda ayarlarından değiştirebilirsiniz."
+ "Herkese açık oda"
+ "Bu odaya herkes katılabilir"
+ "Herkes"
+ "Oda Erişimi"
+ "Herkes odaya katılmayı isteyebilir ancak bir yönetici veya moderatörün isteği kabul etmesi gerekecektir"
+ "Katılmak için sor"
+ "Bu odanın genel oda dizininde görünür olması için bir oda adresine ihtiyacınız olacaktır."
+ "Oda adresi"
+ "Oda adı"
+ "Oda görünürlüğü"
+ "Bir oda oluştur"
+ "Konu (isteğe bağlı)"
+ "Oda dizini"
+ "Sohbet başlatmaya çalışırken bir hata oluştu"
+
diff --git a/features/createroom/impl/src/main/res/values-uk/translations.xml b/features/createroom/impl/src/main/res/values-uk/translations.xml
index a6ad600672f..a43172fb8b5 100644
--- a/features/createroom/impl/src/main/res/values-uk/translations.xml
+++ b/features/createroom/impl/src/main/res/values-uk/translations.xml
@@ -7,7 +7,7 @@
"Приватна кімната (тільки за запрошенням)"
"Будь-хто може знайти цю кімнату.
Ви можете змінити це в будь-який час у налаштуваннях кімнати."
- "Публічна кімната"
+ "Загальнодоступна кімната"
"Будь-хто може приєднатися до цієї кімнати"
"Кожний"
"Доступ до кімнати"
@@ -19,5 +19,12 @@
"Видимість кімнати"
"Створити кімнату"
"Тема (необов\'язково)"
+ "Каталог кімнат"
"Під час спроби почати бесіду сталася помилка"
+ "Приєднатися до кімнати за адресою"
+ "Недійсна адреса"
+ "Введіть…"
+ "Знайдено відповідну кімнату"
+ "Кімната не знайдена"
+ "наприклад, #room-name:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-ur/translations.xml b/features/createroom/impl/src/main/res/values-ur/translations.xml
new file mode 100644
index 00000000000..6fe476462ed
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-ur/translations.xml
@@ -0,0 +1,16 @@
+
+
+ "نیا کمرہ"
+ "لوگوں کو مدعو کریں"
+ "کمرہ تخلیق کرتے ہوئے ایک نقص واقع ہوا"
+ "صرف مدعو لوگ ہی اس کمرے تک رسائی حاصل کر سکتے ہیں۔ تمام پیغامات آخر تا آخر مرموز کردہ ہیں۔"
+ "نجی کمرہ"
+ "کوئی بھی یہ کمرہ ڈھونڈ سکتا ہے۔
+آپ اسے کمرے کی ترتیبات میں کسی بھی وقت تبدیل کرسکتے ہیں۔"
+ "عوامی کمرہ"
+ "کمرے کا نام"
+ "ایک کمرہ بنائیں"
+ "موضوع (اختیاری)"
+ "کمرے کا راہنامچہ"
+ "گفتگو شروع کرنے کی کوشش کرتے وقت ایک خرابی واقع ہوگئی"
+
diff --git a/features/createroom/impl/src/main/res/values-uz/translations.xml b/features/createroom/impl/src/main/res/values-uz/translations.xml
index 649bdf86132..e3bc9de5288 100644
--- a/features/createroom/impl/src/main/res/values-uz/translations.xml
+++ b/features/createroom/impl/src/main/res/values-uz/translations.xml
@@ -6,7 +6,6 @@
"Bu xonadagi xabarlar shifrlangan. Keyinchalik shifrlashni o‘chirib bo‘lmaydi."
"Shaxsiy xona (faqat taklif)"
"Xabarlar shifrlanmagan va har kim ularni o\'qiy oladi. Keyinchalik shifrlashni yoqishingiz mumkin."
- "Jamoat xonasi (har kim)"
"Xona nomi"
"Xonani yaratish"
"Mavzu (ixtiyoriy)"
diff --git a/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml b/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml
index c94c35ad5b3..0d67c883c7b 100644
--- a/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml
@@ -7,7 +7,7 @@
"私密聊天室"
"任何人都可以找到此聊天室。
您隨時都可以在聊天室設定中變更此設定。"
- "公開聊天室"
+ "公開的聊天室"
"任何人都可以加入此聊天室"
"任何人"
"聊天室存取權"
@@ -19,5 +19,12 @@
"聊天室能見度"
"建立聊天室"
"主題(非必填)"
+ "聊天室目錄"
"嘗試開始聊天時發生錯誤"
+ "按地址加入聊天室"
+ "不是有效的位址"
+ "輸入……"
+ "找到相符的聊天室"
+ "找不到聊天室"
+ "例如 #room-name:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-zh/translations.xml b/features/createroom/impl/src/main/res/values-zh/translations.xml
index 0049da0bbbf..24d7df7f353 100644
--- a/features/createroom/impl/src/main/res/values-zh/translations.xml
+++ b/features/createroom/impl/src/main/res/values-zh/translations.xml
@@ -7,7 +7,7 @@
"私有聊天室"
"任何人都能找到此聊天室。
你可以随时在聊天室设置中更改。"
- "公开聊天室"
+ "公共聊天室"
"任何人都可以加入此房间"
"任何人"
"房间访问权限"
@@ -19,5 +19,6 @@
"房间可见性"
"创建聊天室"
"主题(可选)"
+ "聊天室目录"
"在开始聊天时发生了错误"
diff --git a/features/createroom/impl/src/main/res/values/localazy.xml b/features/createroom/impl/src/main/res/values/localazy.xml
index 6ed5510ce0d..d011a1d87b0 100644
--- a/features/createroom/impl/src/main/res/values/localazy.xml
+++ b/features/createroom/impl/src/main/res/values/localazy.xml
@@ -19,5 +19,12 @@ You can change this anytime in room settings."
"Room visibility"
"Create a room"
"Topic (optional)"
+ "Room directory"
"An error occurred when trying to start a chat"
+ "Join room by address"
+ "Not a valid address"
+ "Enter…"
+ "Matching room found"
+ "Room not found"
+ "e.g. #room-name:matrix.org"
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultStartDMActionTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultStartDMActionTest.kt
index dabdc288db8..ef6b3b95170 100644
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultStartDMActionTest.kt
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultStartDMActionTest.kt
@@ -10,13 +10,14 @@ package io.element.android.features.createroom.impl
import androidx.compose.runtime.mutableStateOf
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom
+import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
-import io.element.android.libraries.matrix.test.A_THROWABLE
-import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.test.runTest
@@ -26,38 +27,70 @@ class DefaultStartDMActionTest {
@Test
fun `when dm is found, assert state is updated with given room id`() = runTest {
val matrixClient = FakeMatrixClient().apply {
- givenFindDmResult(A_ROOM_ID)
+ givenFindDmResult(Result.success(A_ROOM_ID))
}
- val action = createStartDMAction(matrixClient)
+ val analyticsService = FakeAnalyticsService()
+ val action = createStartDMAction(matrixClient, analyticsService)
val state = mutableStateOf>(AsyncAction.Uninitialized)
- action.execute(A_USER_ID, state)
+ action.execute(aMatrixUser(), true, state)
assertThat(state.value).isEqualTo(AsyncAction.Success(A_ROOM_ID))
+ assertThat(analyticsService.capturedEvents).isEmpty()
+ }
+
+ @Test
+ fun `when finding the dm fails, assert state is updated with given error`() = runTest {
+ val matrixClient = FakeMatrixClient().apply {
+ givenFindDmResult(Result.failure(AN_EXCEPTION))
+ }
+ val analyticsService = FakeAnalyticsService()
+ val action = createStartDMAction(matrixClient, analyticsService)
+ val state = mutableStateOf>(AsyncAction.Uninitialized)
+ action.execute(aMatrixUser(), true, state)
+ assertThat(state.value).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
+ assertThat(analyticsService.capturedEvents).isEmpty()
}
@Test
fun `when dm is not found, assert dm is created, state is updated with given room id and analytics get called`() = runTest {
val matrixClient = FakeMatrixClient().apply {
- givenFindDmResult(null)
+ givenFindDmResult(Result.success(null))
givenCreateDmResult(Result.success(A_ROOM_ID))
}
val analyticsService = FakeAnalyticsService()
val action = createStartDMAction(matrixClient, analyticsService)
val state = mutableStateOf>(AsyncAction.Uninitialized)
- action.execute(A_USER_ID, state)
+ action.execute(aMatrixUser(), true, state)
assertThat(state.value).isEqualTo(AsyncAction.Success(A_ROOM_ID))
assertThat(analyticsService.capturedEvents).containsExactly(CreatedRoom(isDM = true))
}
+ @Test
+ fun `when dm is not found, and createIfDmDoesNotExist is false, assert dm is not created and state is updated to confirmation state`() = runTest {
+ val matrixClient = FakeMatrixClient().apply {
+ givenFindDmResult(Result.success(null))
+ givenCreateDmResult(Result.success(A_ROOM_ID))
+ }
+ val analyticsService = FakeAnalyticsService()
+ val action = createStartDMAction(matrixClient, analyticsService)
+ val state = mutableStateOf>(AsyncAction.Uninitialized)
+ val matrixUser = aMatrixUser()
+ action.execute(matrixUser, false, state)
+ assertThat(state.value).isEqualTo(ConfirmingStartDmWithMatrixUser(matrixUser))
+ assertThat(analyticsService.capturedEvents).isEmpty()
+ }
+
@Test
fun `when dm creation fails, assert state is updated with given error`() = runTest {
val matrixClient = FakeMatrixClient().apply {
- givenFindDmResult(null)
- givenCreateDmResult(Result.failure(A_THROWABLE))
+ givenFindDmResult(Result.success(null))
+ givenCreateDmResult(Result.failure(AN_EXCEPTION))
}
- val action = createStartDMAction(matrixClient)
+ val analyticsService = FakeAnalyticsService()
+ val action = createStartDMAction(matrixClient, analyticsService)
val state = mutableStateOf>(AsyncAction.Uninitialized)
- action.execute(A_USER_ID, state)
- assertThat(state.value).isEqualTo(AsyncAction.Failure(A_THROWABLE))
+ action.execute(aMatrixUser(), true, state)
+ assertThat(state.value).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
+ assertThat(analyticsService.capturedEvents).isEmpty()
}
private fun createStartDMAction(
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/FakeCreateRoomNavigator.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/FakeCreateRoomNavigator.kt
new file mode 100644
index 00000000000..28dcdd409d6
--- /dev/null
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/FakeCreateRoomNavigator.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2025 New Vector 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.createroom.impl
+
+import io.element.android.features.createroom.CreateRoomNavigator
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
+
+class FakeCreateRoomNavigator(
+ private val openRoomLambda: (roomIdOrAlias: RoomIdOrAlias, serverNames: List) -> Unit = { _, _ -> },
+ private val createNewRoomLambda: () -> Unit = {},
+ private val showJoinRoomByAddressLambda: () -> Unit = {},
+ private val dismissJoinRoomByAddressLambda: () -> Unit = {},
+ private val openRoomDirectoryLambda: () -> Unit = {},
+) : CreateRoomNavigator {
+ override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List) {
+ openRoomLambda(roomIdOrAlias, serverNames)
+ }
+
+ override fun onCreateNewRoom() {
+ createNewRoomLambda()
+ }
+
+ override fun onShowJoinRoomByAddress() {
+ showJoinRoomByAddressLambda()
+ }
+
+ override fun onDismissJoinRoomByAddress() {
+ dismissJoinRoomByAddressLambda()
+ }
+
+ override fun onOpenRoomDirectory() {
+ openRoomDirectoryLambda()
+ }
+}
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureBaseRoomPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureBaseRoomPresenterTest.kt
new file mode 100644
index 00000000000..a0a4783093b
--- /dev/null
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureBaseRoomPresenterTest.kt
@@ -0,0 +1,426 @@
+/*
+ * Copyright 2023, 2024 New Vector 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.createroom.impl.configureroom
+
+import android.net.Uri
+import app.cash.turbine.TurbineTestContext
+import com.google.common.truth.Truth.assertThat
+import im.vector.app.features.analytics.plan.CreatedRoom
+import io.element.android.features.createroom.impl.CreateRoomConfig
+import io.element.android.features.createroom.impl.CreateRoomDataStore
+import io.element.android.features.createroom.impl.userlist.UserListDataStore
+import io.element.android.libraries.architecture.AsyncAction
+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.core.RoomId
+import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
+import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
+import io.element.android.libraries.matrix.test.AN_AVATAR_URL
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.libraries.matrix.test.A_MESSAGE
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_ROOM_NAME
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
+import io.element.android.libraries.matrix.ui.components.aMatrixUser
+import io.element.android.libraries.matrix.ui.media.AvatarAction
+import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
+import io.element.android.libraries.mediapickers.api.PickerProvider
+import io.element.android.libraries.mediapickers.test.FakePickerProvider
+import io.element.android.libraries.mediaupload.api.MediaPreProcessor
+import io.element.android.libraries.mediaupload.api.MediaUploadInfo
+import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
+import io.element.android.libraries.permissions.api.PermissionsPresenter
+import io.element.android.libraries.permissions.test.FakePermissionsPresenter
+import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
+import io.element.android.services.analytics.api.AnalyticsService
+import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.test
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import java.io.File
+import java.util.Optional
+
+private const val AN_URI_FROM_CAMERA = "content://uri_from_camera"
+private const val AN_URI_FROM_CAMERA_2 = "content://uri_from_camera_2"
+private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery"
+
+@RunWith(RobolectricTestRunner::class)
+class ConfigureBaseRoomPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Before
+ fun setup() {
+ mockkStatic(File::readBytes)
+ every { any().readBytes() } returns byteArrayOf()
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = createConfigureRoomPresenter()
+ presenter.test {
+ val initialState = initialState()
+ assertThat(initialState.config).isEqualTo(CreateRoomConfig())
+ assertThat(initialState.config.roomName).isNull()
+ assertThat(initialState.config.topic).isNull()
+ assertThat(initialState.config.invites).isEmpty()
+ assertThat(initialState.config.avatarUri).isNull()
+ assertThat(initialState.config.roomVisibility).isEqualTo(RoomVisibilityState.Private)
+ assertThat(initialState.createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ assertThat(initialState.homeserverName).isEqualTo("matrix.org")
+ }
+ }
+
+ @Test
+ fun `present - create room button is enabled only if the required fields are completed`() = runTest {
+ val presenter = createConfigureRoomPresenter()
+ presenter.test {
+ val initialState = initialState()
+ var config = initialState.config
+ assertThat(initialState.isValid).isFalse()
+
+ // Room name not empty
+ initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME))
+ var newState: ConfigureRoomState = awaitItem()
+ config = config.copy(roomName = A_ROOM_NAME)
+ assertThat(newState.config).isEqualTo(config)
+ assertThat(newState.isValid).isTrue()
+
+ // Clear room name
+ newState.eventSink(ConfigureRoomEvents.RoomNameChanged(""))
+ newState = awaitItem()
+ config = config.copy(roomName = null)
+ assertThat(newState.config).isEqualTo(config)
+ assertThat(newState.isValid).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - state is updated when fields are changed`() = runTest {
+ val userListDataStore = UserListDataStore()
+ val pickerProvider = FakePickerProvider()
+ val permissionsPresenter = FakePermissionsPresenter()
+ val roomAliasHelper = FakeRoomAliasHelper()
+ val presenter = createConfigureRoomPresenter(
+ createRoomDataStore = CreateRoomDataStore(userListDataStore, roomAliasHelper),
+ pickerProvider = pickerProvider,
+ permissionsPresenter = permissionsPresenter,
+ )
+ presenter.test {
+ val initialState = initialState()
+ var expectedConfig = CreateRoomConfig()
+ assertThat(initialState.config).isEqualTo(expectedConfig)
+
+ // Select User
+ val selectedUser1 = aMatrixUser()
+ val selectedUser2 = aMatrixUser("@id_of_bob:server.org", "Bob")
+ userListDataStore.selectUser(selectedUser1)
+ skipItems(1)
+ userListDataStore.selectUser(selectedUser2)
+ var newState = awaitItem()
+ expectedConfig = expectedConfig.copy(invites = persistentListOf(selectedUser1, selectedUser2))
+ assertThat(newState.config).isEqualTo(expectedConfig)
+
+ // Room name
+ initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME))
+ newState = awaitItem()
+ expectedConfig = expectedConfig.copy(roomName = A_ROOM_NAME)
+ assertThat(newState.config).isEqualTo(expectedConfig)
+
+ // Room topic
+ newState.eventSink(ConfigureRoomEvents.TopicChanged(A_MESSAGE))
+ newState = awaitItem()
+ expectedConfig = expectedConfig.copy(topic = A_MESSAGE)
+ assertThat(newState.config).isEqualTo(expectedConfig)
+
+ // Room avatar
+ // Pick avatar
+ pickerProvider.givenResult(null)
+ // From gallery
+ val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY)
+ pickerProvider.givenResult(uriFromGallery)
+ newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ newState = awaitItem()
+ expectedConfig = expectedConfig.copy(avatarUri = uriFromGallery)
+ assertThat(newState.config).isEqualTo(expectedConfig)
+ // From camera
+ val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA)
+ pickerProvider.givenResult(uriFromCamera)
+ assertThat(newState.cameraPermissionState.permissionGranted).isFalse()
+ newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
+ newState = awaitItem()
+ assertThat(newState.cameraPermissionState.showDialog).isTrue()
+ permissionsPresenter.setPermissionGranted()
+ newState = awaitItem()
+ assertThat(newState.cameraPermissionState.permissionGranted).isTrue()
+ newState = awaitItem()
+ expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera)
+ assertThat(newState.config).isEqualTo(expectedConfig)
+ // Do it again, no permission is requested
+ val uriFromCamera2 = Uri.parse(AN_URI_FROM_CAMERA_2)
+ pickerProvider.givenResult(uriFromCamera2)
+ newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
+ newState = awaitItem()
+ expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera2)
+ assertThat(newState.config).isEqualTo(expectedConfig)
+ // Remove
+ newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.Remove))
+ newState = awaitItem()
+ expectedConfig = expectedConfig.copy(avatarUri = null)
+ assertThat(newState.config).isEqualTo(expectedConfig)
+
+ // Room privacy
+ newState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
+ newState = awaitItem()
+ expectedConfig = expectedConfig.copy(
+ roomVisibility = RoomVisibilityState.Public(
+ roomAddress = RoomAddress.AutoFilled(roomAliasHelper.roomAliasNameFromRoomDisplayName(expectedConfig.roomName ?: "")),
+ roomAccess = RoomAccess.Anyone,
+ )
+ )
+ assertThat(newState.config).isEqualTo(expectedConfig)
+
+ // Remove user
+ newState.eventSink(ConfigureRoomEvents.RemoveUserFromSelection(selectedUser1))
+ newState = awaitItem()
+ expectedConfig = expectedConfig.copy(invites = expectedConfig.invites.minus(selectedUser1).toImmutableList())
+ assertThat(newState.config).isEqualTo(expectedConfig)
+ }
+ }
+
+ @Test
+ fun `present - trigger create room action`() = runTest {
+ val matrixClient = createMatrixClient()
+ val presenter = createConfigureRoomPresenter(
+ matrixClient = matrixClient
+ )
+ presenter.test {
+ val initialState = initialState()
+ val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
+
+ matrixClient.givenCreateRoomResult(createRoomResult)
+
+ initialState.eventSink(ConfigureRoomEvents.CreateRoom)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
+ val stateAfterCreateRoom = awaitItem()
+ assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
+ assertThat(stateAfterCreateRoom.createRoomAction.dataOrNull()).isEqualTo(createRoomResult.getOrNull())
+ }
+ }
+
+ @Test
+ fun `present - record analytics when creating room`() = runTest {
+ val matrixClient = createMatrixClient()
+ val analyticsService = FakeAnalyticsService()
+ val presenter = createConfigureRoomPresenter(
+ matrixClient = matrixClient,
+ analyticsService = analyticsService
+ )
+ presenter.test {
+ val initialState = initialState()
+ val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
+
+ matrixClient.givenCreateRoomResult(createRoomResult)
+
+ initialState.eventSink(ConfigureRoomEvents.CreateRoom)
+ skipItems(2)
+
+ val analyticsEvent = analyticsService.capturedEvents.filterIsInstance().firstOrNull()
+ assertThat(analyticsEvent).isNotNull()
+ assertThat(analyticsEvent?.isDM).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - trigger create room with upload error and retry`() = runTest {
+ val matrixClient = createMatrixClient()
+ val analyticsService = FakeAnalyticsService()
+ val mediaPreProcessor = FakeMediaPreProcessor()
+ val createRoomDataStore = CreateRoomDataStore(UserListDataStore(), FakeRoomAliasHelper())
+ val presenter = createConfigureRoomPresenter(
+ createRoomDataStore = createRoomDataStore,
+ mediaPreProcessor = mediaPreProcessor,
+ matrixClient = matrixClient,
+ analyticsService = analyticsService
+ )
+ presenter.test {
+ val initialState = initialState()
+ createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY))
+ skipItems(1)
+ mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk())))
+ matrixClient.givenUploadMediaResult(Result.failure(AN_EXCEPTION))
+
+ initialState.eventSink(ConfigureRoomEvents.CreateRoom)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
+ val stateAfterCreateRoom = awaitItem()
+ assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
+ assertThat(analyticsService.capturedEvents.filterIsInstance()).isEmpty()
+
+ matrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
+ stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
+ }
+ }
+
+ @Test
+ fun `present - trigger retry and cancel actions`() = runTest {
+ val fakeMatrixClient = createMatrixClient()
+ val presenter = createConfigureRoomPresenter(
+ matrixClient = fakeMatrixClient
+ )
+ presenter.test {
+ val initialState = initialState()
+ val createRoomResult = Result.failure(AN_EXCEPTION)
+
+ fakeMatrixClient.givenCreateRoomResult(createRoomResult)
+
+ // Create
+ initialState.eventSink(ConfigureRoomEvents.CreateRoom)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
+ val stateAfterCreateRoom = awaitItem()
+ assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
+ assertThat((stateAfterCreateRoom.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull())
+
+ // Retry
+ stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
+ val stateAfterRetry = awaitItem()
+ assertThat(stateAfterRetry.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
+ assertThat((stateAfterRetry.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull())
+
+ // Cancel
+ stateAfterRetry.eventSink(ConfigureRoomEvents.CancelCreateRoom)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `present - address is invalid when format is invalid`() = runTest {
+ val aliasHelper = FakeRoomAliasHelper(
+ isRoomAliasValidLambda = { false }
+ )
+ val presenter = createConfigureRoomPresenter(
+ roomAliasHelper = aliasHelper
+ )
+ presenter.test {
+ val initialState = initialState()
+ initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
+ skipItems(1)
+ initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("invalid address"))
+ skipItems(1)
+ advanceUntilIdle()
+ awaitItem().also { state ->
+ assertThat(state.roomAddressValidity).isEqualTo(RoomAddressValidity.InvalidSymbols)
+ }
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `present - address is not available when alias is not available`() = runTest {
+ val fakeMatrixClient = createMatrixClient(isAliasAvailable = false)
+ val presenter = createConfigureRoomPresenter(
+ matrixClient = fakeMatrixClient,
+ )
+ presenter.test {
+ val initialState = initialState()
+ initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
+ skipItems(1)
+ initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address"))
+ skipItems(1)
+ advanceUntilIdle()
+ awaitItem().also { state ->
+ assertThat(state.roomAddressValidity).isEqualTo(RoomAddressValidity.NotAvailable)
+ }
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `present - address is valid when alias is available and format is valid`() = runTest {
+ val fakeMatrixClient = createMatrixClient(isAliasAvailable = true)
+ val presenter = createConfigureRoomPresenter(
+ matrixClient = fakeMatrixClient,
+ )
+ presenter.test {
+ val initialState = initialState()
+ initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
+ skipItems(1)
+ initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address"))
+ skipItems(1)
+ advanceUntilIdle()
+ awaitItem().also { state ->
+ assertThat(state.roomAddressValidity).isEqualTo(RoomAddressValidity.Valid)
+ }
+ }
+ }
+
+ private suspend fun TurbineTestContext.initialState(): ConfigureRoomState {
+ skipItems(1)
+ return awaitItem()
+ }
+
+ private fun createMatrixClient(isAliasAvailable: Boolean = true) = FakeMatrixClient(
+ userIdServerNameLambda = { "matrix.org" },
+ resolveRoomAliasResult = {
+ val resolvedRoomAlias = if (isAliasAvailable) {
+ Optional.empty()
+ } else {
+ Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList()))
+ }
+ Result.success(resolvedRoomAlias)
+ }
+ )
+
+ private fun createConfigureRoomPresenter(
+ roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(),
+ createRoomDataStore: CreateRoomDataStore = CreateRoomDataStore(UserListDataStore(), roomAliasHelper),
+ matrixClient: MatrixClient = createMatrixClient(),
+ pickerProvider: PickerProvider = FakePickerProvider(),
+ mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
+ analyticsService: AnalyticsService = FakeAnalyticsService(),
+ permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
+ isKnockFeatureEnabled: Boolean = true,
+ ) = ConfigureRoomPresenter(
+ dataStore = createRoomDataStore,
+ matrixClient = matrixClient,
+ mediaPickerProvider = pickerProvider,
+ mediaPreProcessor = mediaPreProcessor,
+ analyticsService = analyticsService,
+ permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
+ roomAliasHelper = roomAliasHelper,
+ featureFlagService = FakeFeatureFlagService(
+ mapOf(FeatureFlags.Knock.key to isKnockFeatureEnabled)
+ )
+ )
+}
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTest.kt
deleted file mode 100644
index 60698b8e981..00000000000
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTest.kt
+++ /dev/null
@@ -1,426 +0,0 @@
-/*
- * Copyright 2023, 2024 New Vector 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.createroom.impl.configureroom
-
-import android.net.Uri
-import app.cash.turbine.TurbineTestContext
-import com.google.common.truth.Truth.assertThat
-import im.vector.app.features.analytics.plan.CreatedRoom
-import io.element.android.features.createroom.impl.CreateRoomConfig
-import io.element.android.features.createroom.impl.CreateRoomDataStore
-import io.element.android.features.createroom.impl.userlist.UserListDataStore
-import io.element.android.libraries.architecture.AsyncAction
-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.core.RoomId
-import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
-import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
-import io.element.android.libraries.matrix.test.AN_AVATAR_URL
-import io.element.android.libraries.matrix.test.A_MESSAGE
-import io.element.android.libraries.matrix.test.A_ROOM_ID
-import io.element.android.libraries.matrix.test.A_ROOM_NAME
-import io.element.android.libraries.matrix.test.A_THROWABLE
-import io.element.android.libraries.matrix.test.FakeMatrixClient
-import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
-import io.element.android.libraries.matrix.ui.components.aMatrixUser
-import io.element.android.libraries.matrix.ui.media.AvatarAction
-import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
-import io.element.android.libraries.mediapickers.api.PickerProvider
-import io.element.android.libraries.mediapickers.test.FakePickerProvider
-import io.element.android.libraries.mediaupload.api.MediaPreProcessor
-import io.element.android.libraries.mediaupload.api.MediaUploadInfo
-import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
-import io.element.android.libraries.permissions.api.PermissionsPresenter
-import io.element.android.libraries.permissions.test.FakePermissionsPresenter
-import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
-import io.element.android.services.analytics.api.AnalyticsService
-import io.element.android.services.analytics.test.FakeAnalyticsService
-import io.element.android.tests.testutils.WarmUpRule
-import io.element.android.tests.testutils.test
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.mockkStatic
-import io.mockk.unmockkAll
-import kotlinx.collections.immutable.persistentListOf
-import kotlinx.collections.immutable.toImmutableList
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.advanceUntilIdle
-import kotlinx.coroutines.test.runTest
-import org.junit.After
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import java.io.File
-import java.util.Optional
-
-private const val AN_URI_FROM_CAMERA = "content://uri_from_camera"
-private const val AN_URI_FROM_CAMERA_2 = "content://uri_from_camera_2"
-private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery"
-
-@RunWith(RobolectricTestRunner::class)
-class ConfigureRoomPresenterTest {
- @get:Rule
- val warmUpRule = WarmUpRule()
-
- @Before
- fun setup() {
- mockkStatic(File::readBytes)
- every { any().readBytes() } returns byteArrayOf()
- }
-
- @After
- fun tearDown() {
- unmockkAll()
- }
-
- @Test
- fun `present - initial state`() = runTest {
- val presenter = createConfigureRoomPresenter()
- presenter.test {
- val initialState = initialState()
- assertThat(initialState.config).isEqualTo(CreateRoomConfig())
- assertThat(initialState.config.roomName).isNull()
- assertThat(initialState.config.topic).isNull()
- assertThat(initialState.config.invites).isEmpty()
- assertThat(initialState.config.avatarUri).isNull()
- assertThat(initialState.config.roomVisibility).isEqualTo(RoomVisibilityState.Private)
- assertThat(initialState.createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
- assertThat(initialState.homeserverName).isEqualTo("matrix.org")
- }
- }
-
- @Test
- fun `present - create room button is enabled only if the required fields are completed`() = runTest {
- val presenter = createConfigureRoomPresenter()
- presenter.test {
- val initialState = initialState()
- var config = initialState.config
- assertThat(initialState.isValid).isFalse()
-
- // Room name not empty
- initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME))
- var newState: ConfigureRoomState = awaitItem()
- config = config.copy(roomName = A_ROOM_NAME)
- assertThat(newState.config).isEqualTo(config)
- assertThat(newState.isValid).isTrue()
-
- // Clear room name
- newState.eventSink(ConfigureRoomEvents.RoomNameChanged(""))
- newState = awaitItem()
- config = config.copy(roomName = null)
- assertThat(newState.config).isEqualTo(config)
- assertThat(newState.isValid).isFalse()
- }
- }
-
- @Test
- fun `present - state is updated when fields are changed`() = runTest {
- val userListDataStore = UserListDataStore()
- val pickerProvider = FakePickerProvider()
- val permissionsPresenter = FakePermissionsPresenter()
- val roomAliasHelper = FakeRoomAliasHelper()
- val presenter = createConfigureRoomPresenter(
- createRoomDataStore = CreateRoomDataStore(userListDataStore, roomAliasHelper),
- pickerProvider = pickerProvider,
- permissionsPresenter = permissionsPresenter,
- )
- presenter.test {
- val initialState = initialState()
- var expectedConfig = CreateRoomConfig()
- assertThat(initialState.config).isEqualTo(expectedConfig)
-
- // Select User
- val selectedUser1 = aMatrixUser()
- val selectedUser2 = aMatrixUser("@id_of_bob:server.org", "Bob")
- userListDataStore.selectUser(selectedUser1)
- skipItems(1)
- userListDataStore.selectUser(selectedUser2)
- var newState = awaitItem()
- expectedConfig = expectedConfig.copy(invites = persistentListOf(selectedUser1, selectedUser2))
- assertThat(newState.config).isEqualTo(expectedConfig)
-
- // Room name
- initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME))
- newState = awaitItem()
- expectedConfig = expectedConfig.copy(roomName = A_ROOM_NAME)
- assertThat(newState.config).isEqualTo(expectedConfig)
-
- // Room topic
- newState.eventSink(ConfigureRoomEvents.TopicChanged(A_MESSAGE))
- newState = awaitItem()
- expectedConfig = expectedConfig.copy(topic = A_MESSAGE)
- assertThat(newState.config).isEqualTo(expectedConfig)
-
- // Room avatar
- // Pick avatar
- pickerProvider.givenResult(null)
- // From gallery
- val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY)
- pickerProvider.givenResult(uriFromGallery)
- newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
- newState = awaitItem()
- expectedConfig = expectedConfig.copy(avatarUri = uriFromGallery)
- assertThat(newState.config).isEqualTo(expectedConfig)
- // From camera
- val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA)
- pickerProvider.givenResult(uriFromCamera)
- assertThat(newState.cameraPermissionState.permissionGranted).isFalse()
- newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
- newState = awaitItem()
- assertThat(newState.cameraPermissionState.showDialog).isTrue()
- permissionsPresenter.setPermissionGranted()
- newState = awaitItem()
- assertThat(newState.cameraPermissionState.permissionGranted).isTrue()
- newState = awaitItem()
- expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera)
- assertThat(newState.config).isEqualTo(expectedConfig)
- // Do it again, no permission is requested
- val uriFromCamera2 = Uri.parse(AN_URI_FROM_CAMERA_2)
- pickerProvider.givenResult(uriFromCamera2)
- newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
- newState = awaitItem()
- expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera2)
- assertThat(newState.config).isEqualTo(expectedConfig)
- // Remove
- newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.Remove))
- newState = awaitItem()
- expectedConfig = expectedConfig.copy(avatarUri = null)
- assertThat(newState.config).isEqualTo(expectedConfig)
-
- // Room privacy
- newState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
- newState = awaitItem()
- expectedConfig = expectedConfig.copy(
- roomVisibility = RoomVisibilityState.Public(
- roomAddress = RoomAddress.AutoFilled(roomAliasHelper.roomAliasNameFromRoomDisplayName(expectedConfig.roomName ?: "")),
- roomAccess = RoomAccess.Anyone,
- )
- )
- assertThat(newState.config).isEqualTo(expectedConfig)
-
- // Remove user
- newState.eventSink(ConfigureRoomEvents.RemoveUserFromSelection(selectedUser1))
- newState = awaitItem()
- expectedConfig = expectedConfig.copy(invites = expectedConfig.invites.minus(selectedUser1).toImmutableList())
- assertThat(newState.config).isEqualTo(expectedConfig)
- }
- }
-
- @Test
- fun `present - trigger create room action`() = runTest {
- val matrixClient = createMatrixClient()
- val presenter = createConfigureRoomPresenter(
- matrixClient = matrixClient
- )
- presenter.test {
- val initialState = initialState()
- val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
-
- matrixClient.givenCreateRoomResult(createRoomResult)
-
- initialState.eventSink(ConfigureRoomEvents.CreateRoom)
- assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
- val stateAfterCreateRoom = awaitItem()
- assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
- assertThat(stateAfterCreateRoom.createRoomAction.dataOrNull()).isEqualTo(createRoomResult.getOrNull())
- }
- }
-
- @Test
- fun `present - record analytics when creating room`() = runTest {
- val matrixClient = createMatrixClient()
- val analyticsService = FakeAnalyticsService()
- val presenter = createConfigureRoomPresenter(
- matrixClient = matrixClient,
- analyticsService = analyticsService
- )
- presenter.test {
- val initialState = initialState()
- val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
-
- matrixClient.givenCreateRoomResult(createRoomResult)
-
- initialState.eventSink(ConfigureRoomEvents.CreateRoom)
- skipItems(2)
-
- val analyticsEvent = analyticsService.capturedEvents.filterIsInstance().firstOrNull()
- assertThat(analyticsEvent).isNotNull()
- assertThat(analyticsEvent?.isDM).isFalse()
- }
- }
-
- @Test
- fun `present - trigger create room with upload error and retry`() = runTest {
- val matrixClient = createMatrixClient()
- val analyticsService = FakeAnalyticsService()
- val mediaPreProcessor = FakeMediaPreProcessor()
- val createRoomDataStore = CreateRoomDataStore(UserListDataStore(), FakeRoomAliasHelper())
- val presenter = createConfigureRoomPresenter(
- createRoomDataStore = createRoomDataStore,
- mediaPreProcessor = mediaPreProcessor,
- matrixClient = matrixClient,
- analyticsService = analyticsService
- )
- presenter.test {
- val initialState = initialState()
- createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY))
- skipItems(1)
- mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk())))
- matrixClient.givenUploadMediaResult(Result.failure(A_THROWABLE))
-
- initialState.eventSink(ConfigureRoomEvents.CreateRoom)
- assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
- val stateAfterCreateRoom = awaitItem()
- assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
- assertThat(analyticsService.capturedEvents.filterIsInstance()).isEmpty()
-
- matrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
- stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
- assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
- assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
- assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
- }
- }
-
- @Test
- fun `present - trigger retry and cancel actions`() = runTest {
- val fakeMatrixClient = createMatrixClient()
- val presenter = createConfigureRoomPresenter(
- matrixClient = fakeMatrixClient
- )
- presenter.test {
- val initialState = initialState()
- val createRoomResult = Result.failure(A_THROWABLE)
-
- fakeMatrixClient.givenCreateRoomResult(createRoomResult)
-
- // Create
- initialState.eventSink(ConfigureRoomEvents.CreateRoom)
- assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
- val stateAfterCreateRoom = awaitItem()
- assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
- assertThat((stateAfterCreateRoom.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull())
-
- // Retry
- stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
- assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
- assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
- val stateAfterRetry = awaitItem()
- assertThat(stateAfterRetry.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
- assertThat((stateAfterRetry.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull())
-
- // Cancel
- stateAfterRetry.eventSink(ConfigureRoomEvents.CancelCreateRoom)
- assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
- }
- }
-
- @OptIn(ExperimentalCoroutinesApi::class)
- @Test
- fun `present - address is invalid when format is invalid`() = runTest {
- val aliasHelper = FakeRoomAliasHelper(
- isRoomAliasValidLambda = { false }
- )
- val presenter = createConfigureRoomPresenter(
- roomAliasHelper = aliasHelper
- )
- presenter.test {
- val initialState = initialState()
- initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
- skipItems(1)
- initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("invalid address"))
- skipItems(1)
- advanceUntilIdle()
- awaitItem().also { state ->
- assertThat(state.roomAddressValidity).isEqualTo(RoomAddressValidity.InvalidSymbols)
- }
- }
- }
-
- @OptIn(ExperimentalCoroutinesApi::class)
- @Test
- fun `present - address is not available when alias is not available`() = runTest {
- val fakeMatrixClient = createMatrixClient(isAliasAvailable = false)
- val presenter = createConfigureRoomPresenter(
- matrixClient = fakeMatrixClient,
- )
- presenter.test {
- val initialState = initialState()
- initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
- skipItems(1)
- initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address"))
- skipItems(1)
- advanceUntilIdle()
- awaitItem().also { state ->
- assertThat(state.roomAddressValidity).isEqualTo(RoomAddressValidity.NotAvailable)
- }
- }
- }
-
- @OptIn(ExperimentalCoroutinesApi::class)
- @Test
- fun `present - address is valid when alias is available and format is valid`() = runTest {
- val fakeMatrixClient = createMatrixClient(isAliasAvailable = true)
- val presenter = createConfigureRoomPresenter(
- matrixClient = fakeMatrixClient,
- )
- presenter.test {
- val initialState = initialState()
- initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
- skipItems(1)
- initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address"))
- skipItems(1)
- advanceUntilIdle()
- awaitItem().also { state ->
- assertThat(state.roomAddressValidity).isEqualTo(RoomAddressValidity.Valid)
- }
- }
- }
-
- private suspend fun TurbineTestContext.initialState(): ConfigureRoomState {
- skipItems(1)
- return awaitItem()
- }
-
- private fun createMatrixClient(isAliasAvailable: Boolean = true) = FakeMatrixClient(
- userIdServerNameLambda = { "matrix.org" },
- resolveRoomAliasResult = {
- val resolvedRoomAlias = if (isAliasAvailable) {
- Optional.empty()
- } else {
- Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList()))
- }
- Result.success(resolvedRoomAlias)
- }
- )
-
- private fun createConfigureRoomPresenter(
- roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(),
- createRoomDataStore: CreateRoomDataStore = CreateRoomDataStore(UserListDataStore(), roomAliasHelper),
- matrixClient: MatrixClient = createMatrixClient(),
- pickerProvider: PickerProvider = FakePickerProvider(),
- mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
- analyticsService: AnalyticsService = FakeAnalyticsService(),
- permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
- isKnockFeatureEnabled: Boolean = true,
- ) = ConfigureRoomPresenter(
- dataStore = createRoomDataStore,
- matrixClient = matrixClient,
- mediaPickerProvider = pickerProvider,
- mediaPreProcessor = mediaPreProcessor,
- analyticsService = analyticsService,
- permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
- roomAliasHelper = roomAliasHelper,
- featureFlagService = FakeFeatureFlagService(
- mapOf(FeatureFlags.Knock.key to isKnockFeatureEnabled)
- )
- )
-}
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinBaseRoomByAddressPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinBaseRoomByAddressPresenterTest.kt
new file mode 100644
index 00000000000..10cca20fa35
--- /dev/null
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinBaseRoomByAddressPresenterTest.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2025 New Vector 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.createroom.impl.joinbyaddress
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.createroom.CreateRoomNavigator
+import io.element.android.features.createroom.impl.FakeCreateRoomNavigator
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
+import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
+import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class JoinBaseRoomByAddressPresenterTest {
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = createJoinRoomByAddressPresenter()
+ presenter.test {
+ with(awaitItem()) {
+ assertThat(address).isEmpty()
+ assertThat(addressState).isEqualTo(RoomAddressState.Unknown)
+ }
+ }
+ }
+
+ @Test
+ fun `present - invalid address`() = runTest {
+ val presenter = createJoinRoomByAddressPresenter(
+ roomAliasHelper = FakeRoomAliasHelper(
+ isRoomAliasValidLambda = { false }
+ )
+ )
+ presenter.test {
+ with(awaitItem()) {
+ eventSink(JoinRoomByAddressEvents.UpdateAddress("invalid_address"))
+ }
+ with(awaitItem()) {
+ assertThat(address).isEqualTo("invalid_address")
+ assertThat(addressState).isEqualTo(RoomAddressState.Unknown)
+ eventSink(JoinRoomByAddressEvents.Continue)
+ }
+ // The address should be marked as invalid only after the user tries to continue
+ with(awaitItem()) {
+ assertThat(address).isEqualTo("invalid_address")
+ assertThat(addressState).isEqualTo(RoomAddressState.Invalid)
+ }
+ }
+ }
+
+ @Test
+ fun `present - room found`() = runTest {
+ val openRoomLambda = lambdaRecorder, Unit> { _, _ -> }
+ val dismissJoinRoomByAddressLambda = lambdaRecorder { }
+ val navigator = FakeCreateRoomNavigator(
+ openRoomLambda = openRoomLambda,
+ dismissJoinRoomByAddressLambda = dismissJoinRoomByAddressLambda
+ )
+ val presenter = createJoinRoomByAddressPresenter(navigator = navigator)
+ presenter.test {
+ with(awaitItem()) {
+ eventSink(JoinRoomByAddressEvents.UpdateAddress("#room_found:matrix.org"))
+ }
+ with(awaitItem()) {
+ assertThat(address).isEqualTo("#room_found:matrix.org")
+ assertThat(addressState).isEqualTo(RoomAddressState.Unknown)
+ }
+ with(awaitItem()) {
+ assertThat(address).isEqualTo("#room_found:matrix.org")
+ assertThat(addressState).isInstanceOf(RoomAddressState.RoomFound::class.java)
+ eventSink(JoinRoomByAddressEvents.Continue)
+ }
+ assert(openRoomLambda).isCalledOnce()
+ assert(dismissJoinRoomByAddressLambda).isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `present - room not found`() = runTest {
+ val presenter = createJoinRoomByAddressPresenter(
+ matrixClient = FakeMatrixClient(
+ resolveRoomAliasResult = { Result.failure(RuntimeException()) }
+ )
+ )
+ presenter.test {
+ with(awaitItem()) {
+ eventSink(JoinRoomByAddressEvents.UpdateAddress("#room_not_found:matrix.org"))
+ }
+ with(awaitItem()) {
+ assertThat(address).isEqualTo("#room_not_found:matrix.org")
+ assertThat(addressState).isEqualTo(RoomAddressState.Unknown)
+ eventSink(JoinRoomByAddressEvents.Continue)
+ }
+ with(awaitItem()) {
+ assertThat(address).isEqualTo("#room_not_found:matrix.org")
+ assertThat(addressState).isEqualTo(RoomAddressState.Resolving)
+ }
+ with(awaitItem()) {
+ assertThat(address).isEqualTo("#room_not_found:matrix.org")
+ assertThat(addressState).isEqualTo(RoomAddressState.RoomNotFound)
+ }
+ }
+ }
+
+ @Test
+ fun `present - dismiss`() = runTest {
+ val dismissJoinRoomByAddressLambda = lambdaRecorder { }
+ val navigator = FakeCreateRoomNavigator(
+ dismissJoinRoomByAddressLambda = dismissJoinRoomByAddressLambda
+ )
+ val presenter = createJoinRoomByAddressPresenter(navigator = navigator)
+ presenter.test {
+ with(awaitItem()) {
+ eventSink(JoinRoomByAddressEvents.Dismiss)
+ }
+ assert(dismissJoinRoomByAddressLambda).isCalledOnce()
+ }
+ }
+
+ private fun createJoinRoomByAddressPresenter(
+ navigator: CreateRoomNavigator = FakeCreateRoomNavigator(),
+ matrixClient: MatrixClient = FakeMatrixClient(),
+ roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(),
+ ): JoinRoomByAddressPresenter {
+ return JoinRoomByAddressPresenter(
+ navigator = navigator,
+ client = matrixClient,
+ roomAliasHelper = roomAliasHelper,
+ )
+ }
+}
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinBaseRoomByAddressViewTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinBaseRoomByAddressViewTest.kt
new file mode 100644
index 00000000000..b2e75c9f5a2
--- /dev/null
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinBaseRoomByAddressViewTest.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2025 New Vector 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.createroom.impl.joinbyaddress
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performTextInput
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.createroom.impl.R
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.setSafeContent
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class JoinBaseRoomByAddressViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `entering text emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setJoinRoomByAddressView(
+ aJoinRoomByAddressState(
+ eventSink = eventsRecorder,
+ )
+ )
+ val text = rule.activity.getString(R.string.screen_start_chat_join_room_by_address_action)
+ rule.onNodeWithText(text).performTextInput("#address:matrix.org")
+ eventsRecorder.assertSingle(JoinRoomByAddressEvents.UpdateAddress("#address:matrix.org"))
+ }
+
+ @Test
+ fun `clicking on continue emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setJoinRoomByAddressView(
+ aJoinRoomByAddressState(
+ eventSink = eventsRecorder,
+ )
+ )
+ rule.clickOn(CommonStrings.action_continue)
+ eventsRecorder.assertSingle(JoinRoomByAddressEvents.Continue)
+ }
+}
+
+private fun AndroidComposeTestRule.setJoinRoomByAddressView(
+ state: JoinRoomByAddressState,
+) {
+ setSafeContent {
+ JoinRoomByAddressView(state = state)
+ }
+}
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateBaseRoomRootPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateBaseRoomRootPresenterTest.kt
new file mode 100644
index 00000000000..d2d9959e2a6
--- /dev/null
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateBaseRoomRootPresenterTest.kt
@@ -0,0 +1,199 @@
+/*
+ * Copyright 2023, 2024 New Vector 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.createroom.impl.root
+
+import androidx.compose.runtime.MutableState
+import app.cash.molecule.RecompositionMode
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
+import io.element.android.features.createroom.api.StartDMAction
+import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
+import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
+import io.element.android.features.createroom.impl.userlist.UserListDataStore
+import io.element.android.features.createroom.test.FakeStartDMAction
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.core.aBuildMeta
+import io.element.android.libraries.usersearch.test.FakeUserRepository
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.any
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class CreateBaseRoomRootPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - start DM action failure scenario`() = runTest {
+ val startDMFailureResult = AsyncAction.Failure(AN_EXCEPTION)
+ val executeResult = lambdaRecorder>, Unit> { _, _, actionState ->
+ actionState.value = startDMFailureResult
+ }
+ val startDMAction = FakeStartDMAction(executeResult = executeResult)
+ val presenter = createCreateRoomRootPresenter(startDMAction)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
+ assertThat(initialState.userListState.selectedUsers).isEmpty()
+ assertThat(initialState.userListState.isSearchActive).isFalse()
+ assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
+ val matrixUser = MatrixUser(UserId("@name:domain"))
+ initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
+ awaitItem().also { state ->
+ assertThat(state.startDmAction).isEqualTo(startDMFailureResult)
+ executeResult.assertions().isCalledOnce().with(
+ value(matrixUser),
+ value(false),
+ any(),
+ )
+ state.eventSink(CreateRoomRootEvents.CancelStartDM)
+ }
+ awaitItem().also { state ->
+ assertThat(state.startDmAction.isUninitialized()).isTrue()
+ }
+ }
+ }
+
+ @Test
+ fun `present - start DM action success scenario`() = runTest {
+ val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID)
+ val executeResult = lambdaRecorder>, Unit> { _, _, actionState ->
+ actionState.value = startDMSuccessResult
+ }
+ val startDMAction = FakeStartDMAction(executeResult = executeResult)
+ val presenter = createCreateRoomRootPresenter(startDMAction)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
+ assertThat(initialState.userListState.selectedUsers).isEmpty()
+ assertThat(initialState.userListState.isSearchActive).isFalse()
+ assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
+ val matrixUser = MatrixUser(UserId("@name:domain"))
+ initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
+ awaitItem().also { state ->
+ assertThat(state.startDmAction).isEqualTo(startDMSuccessResult)
+ executeResult.assertions().isCalledOnce().with(
+ value(matrixUser),
+ value(false),
+ any(),
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `present - start DM action confirmation scenario - cancel`() = runTest {
+ val matrixUser = MatrixUser(UserId("@name:domain"))
+ val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser)
+ val executeResult = lambdaRecorder>, Unit> { _, _, actionState ->
+ actionState.value = startDMConfirmationResult
+ }
+ val startDMAction = FakeStartDMAction(executeResult = executeResult)
+ val presenter = createCreateRoomRootPresenter(startDMAction)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
+ val confirmingState = awaitItem()
+ assertThat(confirmingState.startDmAction).isEqualTo(startDMConfirmationResult)
+ executeResult.assertions().isCalledOnce().with(
+ value(matrixUser),
+ value(false),
+ any(),
+ )
+ // Cancelling should not create the DM
+ confirmingState.eventSink(CreateRoomRootEvents.CancelStartDM)
+ val finalState = awaitItem()
+ assertThat(finalState.startDmAction.isUninitialized()).isTrue()
+ executeResult.assertions().isCalledExactly(1)
+ }
+ }
+
+ @Test
+ fun `present - start DM action confirmation scenario - confirm`() = runTest {
+ val matrixUser = MatrixUser(UserId("@name:domain"))
+ val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser)
+ val executeResult = lambdaRecorder>, Unit> { _, _, actionState ->
+ actionState.value = startDMConfirmationResult
+ }
+ val startDMAction = FakeStartDMAction(executeResult = executeResult)
+ val presenter = createCreateRoomRootPresenter(startDMAction)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
+ val confirmingState = awaitItem()
+ assertThat(confirmingState.startDmAction).isEqualTo(startDMConfirmationResult)
+ executeResult.assertions().isCalledOnce().with(
+ value(matrixUser),
+ value(false),
+ any(),
+ )
+ // Start DM again should invoke the action with createIfDmDoesNotExist = true
+ confirmingState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
+ executeResult.assertions().isCalledExactly(2).withSequence(
+ listOf(value(matrixUser), value(false), any()),
+ listOf(value(matrixUser), value(true), any()),
+ )
+ }
+ }
+
+ @Test
+ fun `present - room directory search`() = runTest {
+ val presenter = createCreateRoomRootPresenter(isRoomDirectorySearchEnabled = true)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ awaitItem().let { state ->
+ assertThat(state.isRoomDirectorySearchEnabled).isTrue()
+ }
+ }
+ }
+
+ private fun createCreateRoomRootPresenter(
+ startDMAction: StartDMAction = FakeStartDMAction(),
+ isRoomDirectorySearchEnabled: Boolean = false,
+ ): CreateRoomRootPresenter {
+ val featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(
+ FeatureFlags.RoomDirectorySearch.key to isRoomDirectorySearchEnabled,
+ ),
+ )
+ return CreateRoomRootPresenter(
+ presenterFactory = FakeUserListPresenterFactory(FakeUserListPresenter()),
+ userRepository = FakeUserRepository(),
+ userListDataStore = UserListDataStore(),
+ startDMAction = startDMAction,
+ featureFlagService = featureFlagService,
+ buildMeta = aBuildMeta(),
+ )
+ }
+}
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateBaseRoomRootViewTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateBaseRoomRootViewTest.kt
new file mode 100644
index 00000000000..9104b2dfb2e
--- /dev/null
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateBaseRoomRootViewTest.kt
@@ -0,0 +1,156 @@
+/*
+ * Copyright 2024 New Vector 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.createroom.impl.root
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.createroom.impl.R
+import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
+import io.element.android.features.createroom.impl.userlist.aUserListState
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.ui.model.getBestName
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EnsureNeverCalledWithParam
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.ensureCalledOnceWithParam
+import io.element.android.tests.testutils.pressBack
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+
+@RunWith(AndroidJUnit4::class)
+class CreateBaseRoomRootViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `clicking on back invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce {
+ rule.setCreateRoomRootView(
+ aCreateRoomRootState(
+ eventSink = eventsRecorder,
+ ),
+ onCloseClick = it
+ )
+ rule.pressBack()
+ }
+ }
+
+ @Test
+ fun `clicking on New room invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce {
+ rule.setCreateRoomRootView(
+ aCreateRoomRootState(
+ eventSink = eventsRecorder,
+ ),
+ onNewRoomClick = it
+ )
+ rule.clickOn(R.string.screen_create_room_action_create_room)
+ }
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on Invite people invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce {
+ rule.setCreateRoomRootView(
+ aCreateRoomRootState(
+ applicationName = "test",
+ eventSink = eventsRecorder,
+ ),
+ onInviteFriendsClick = it
+ )
+ val text = rule.activity.getString(CommonStrings.action_invite_friends_to_app, "test")
+ rule.onNodeWithText(text).performClick()
+ }
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on a user suggestion invokes the expected callback`() {
+ val recentDirectRoomList = aRecentDirectRoomList()
+ val firstRoom = recentDirectRoomList[0]
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnceWithParam(firstRoom.roomId) {
+ rule.setCreateRoomRootView(
+ aCreateRoomRootState(
+ userListState = aUserListState(
+ recentDirectRooms = recentDirectRoomList
+ ),
+ eventSink = eventsRecorder,
+ ),
+ onOpenDM = it
+ )
+ rule.onNodeWithText(firstRoom.matrixUser.getBestName()).performClick()
+ }
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on Join room by address invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce {
+ rule.setCreateRoomRootView(
+ aCreateRoomRootState(
+ eventSink = eventsRecorder,
+ ),
+ onJoinRoomByAddressClick = it
+ )
+ rule.clickOn(R.string.screen_start_chat_join_room_by_address_action)
+ }
+ }
+
+ @Test
+ fun `clicking on room directory invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce {
+ rule.setCreateRoomRootView(
+ aCreateRoomRootState(
+ eventSink = eventsRecorder,
+ isRoomDirectorySearchEnabled = true
+ ),
+ onRoomDirectorySearchClick = it
+ )
+ rule.clickOn(R.string.screen_room_directory_search_title)
+ }
+ }
+}
+
+private fun AndroidComposeTestRule.setCreateRoomRootView(
+ state: CreateRoomRootState,
+ onCloseClick: () -> Unit = EnsureNeverCalled(),
+ onNewRoomClick: () -> Unit = EnsureNeverCalled(),
+ onOpenDM: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
+ onInviteFriendsClick: () -> Unit = EnsureNeverCalled(),
+ onJoinRoomByAddressClick: () -> Unit = EnsureNeverCalled(),
+ onRoomDirectorySearchClick: () -> Unit = EnsureNeverCalled(),
+) {
+ setContent {
+ CreateRoomRootView(
+ state = state,
+ onCloseClick = onCloseClick,
+ onNewRoomClick = onNewRoomClick,
+ onOpenDM = onOpenDM,
+ onInviteFriendsClick = onInviteFriendsClick,
+ onJoinByAddressClick = onJoinRoomByAddressClick,
+ onRoomDirectorySearchClick = onRoomDirectorySearchClick,
+ )
+ }
+}
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTest.kt
deleted file mode 100644
index 139fae5982a..00000000000
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTest.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- * Copyright 2023, 2024 New Vector 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.createroom.impl.root
-
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
-import app.cash.turbine.test
-import com.google.common.truth.Truth.assertThat
-import io.element.android.features.createroom.api.StartDMAction
-import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
-import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
-import io.element.android.features.createroom.impl.userlist.UserListDataStore
-import io.element.android.features.createroom.test.FakeStartDMAction
-import io.element.android.libraries.architecture.AsyncAction
-import io.element.android.libraries.matrix.api.core.UserId
-import io.element.android.libraries.matrix.api.user.MatrixUser
-import io.element.android.libraries.matrix.test.A_ROOM_ID
-import io.element.android.libraries.matrix.test.A_THROWABLE
-import io.element.android.libraries.matrix.test.core.aBuildMeta
-import io.element.android.libraries.usersearch.test.FakeUserRepository
-import io.element.android.tests.testutils.WarmUpRule
-import kotlinx.coroutines.test.runTest
-import org.junit.Rule
-import org.junit.Test
-
-class CreateRoomRootPresenterTest {
- @get:Rule
- val warmUpRule = WarmUpRule()
-
- @Test
- fun `present - start DM action complete scenario`() = runTest {
- val startDMAction = FakeStartDMAction()
- val presenter = createCreateRoomRootPresenter(startDMAction)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
-
- assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
- assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
- assertThat(initialState.userListState.selectedUsers).isEmpty()
- assertThat(initialState.userListState.isSearchActive).isFalse()
- assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
-
- val matrixUser = MatrixUser(UserId("@name:domain"))
- val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID)
- val startDMFailureResult = AsyncAction.Failure(A_THROWABLE)
-
- // Failure
- startDMAction.givenExecuteResult(startDMFailureResult)
- initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
- assertThat(awaitItem().startDmAction).isInstanceOf(AsyncAction.Loading::class.java)
- awaitItem().also { state ->
- assertThat(state.startDmAction).isEqualTo(startDMFailureResult)
- state.eventSink(CreateRoomRootEvents.CancelStartDM)
- }
-
- // Success
- startDMAction.givenExecuteResult(startDMSuccessResult)
- awaitItem().also { state ->
- assertThat(state.startDmAction).isEqualTo(AsyncAction.Uninitialized)
- state.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
- }
- assertThat(awaitItem().startDmAction).isInstanceOf(AsyncAction.Loading::class.java)
- awaitItem().also { state ->
- assertThat(state.startDmAction).isEqualTo(startDMSuccessResult)
- }
- }
- }
-
- private fun createCreateRoomRootPresenter(
- startDMAction: StartDMAction = FakeStartDMAction(),
- ): CreateRoomRootPresenter {
- return CreateRoomRootPresenter(
- presenterFactory = FakeUserListPresenterFactory(FakeUserListPresenter()),
- userRepository = FakeUserRepository(),
- userListDataStore = UserListDataStore(),
- startDMAction = startDMAction,
- buildMeta = aBuildMeta(),
- )
- }
-}
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootViewTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootViewTest.kt
deleted file mode 100644
index 88390f3b8c4..00000000000
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootViewTest.kt
+++ /dev/null
@@ -1,122 +0,0 @@
-/*
- * Copyright 2024 New Vector 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.createroom.impl.root
-
-import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
-import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.performClick
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import io.element.android.features.createroom.impl.R
-import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
-import io.element.android.features.createroom.impl.userlist.aUserListState
-import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.matrix.ui.model.getBestName
-import io.element.android.libraries.ui.strings.CommonStrings
-import io.element.android.tests.testutils.EnsureNeverCalled
-import io.element.android.tests.testutils.EnsureNeverCalledWithParam
-import io.element.android.tests.testutils.EventsRecorder
-import io.element.android.tests.testutils.clickOn
-import io.element.android.tests.testutils.ensureCalledOnce
-import io.element.android.tests.testutils.ensureCalledOnceWithParam
-import io.element.android.tests.testutils.pressBack
-import org.junit.Rule
-import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-import org.robolectric.annotation.Config
-
-@RunWith(AndroidJUnit4::class)
-class CreateRoomRootViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
-
- @Test
- fun `clicking on back invokes the expected callback`() {
- val eventsRecorder = EventsRecorder(expectEvents = false)
- ensureCalledOnce {
- rule.setCreateRoomRootView(
- aCreateRoomRootState(
- eventSink = eventsRecorder,
- ),
- onCloseClick = it
- )
- rule.pressBack()
- }
- }
-
- @Test
- fun `clicking on New room invokes the expected callback`() {
- val eventsRecorder = EventsRecorder(expectEvents = false)
- ensureCalledOnce {
- rule.setCreateRoomRootView(
- aCreateRoomRootState(
- eventSink = eventsRecorder,
- ),
- onNewRoomClick = it
- )
- rule.clickOn(R.string.screen_create_room_action_create_room)
- }
- }
-
- @Config(qualifiers = "h1024dp")
- @Test
- fun `clicking on Invite people invokes the expected callback`() {
- val eventsRecorder = EventsRecorder(expectEvents = false)
- ensureCalledOnce {
- rule.setCreateRoomRootView(
- aCreateRoomRootState(
- applicationName = "test",
- eventSink = eventsRecorder,
- ),
- onInviteFriendsClick = it
- )
- val text = rule.activity.getString(CommonStrings.action_invite_friends_to_app, "test")
- rule.onNodeWithText(text).performClick()
- }
- }
-
- @Config(qualifiers = "h1024dp")
- @Test
- fun `clicking on a user suggestion invokes the expected callback`() {
- val recentDirectRoomList = aRecentDirectRoomList()
- val firstRoom = recentDirectRoomList[0]
- val eventsRecorder = EventsRecorder(expectEvents = false)
- ensureCalledOnceWithParam(firstRoom.roomId) {
- rule.setCreateRoomRootView(
- aCreateRoomRootState(
- userListState = aUserListState(
- recentDirectRooms = recentDirectRoomList
- ),
- eventSink = eventsRecorder,
- ),
- onOpenDM = it
- )
- rule.onNodeWithText(firstRoom.matrixUser.getBestName()).performClick()
- }
- }
-}
-
-private fun AndroidComposeTestRule.setCreateRoomRootView(
- state: CreateRoomRootState,
- onCloseClick: () -> Unit = EnsureNeverCalled(),
- onNewRoomClick: () -> Unit = EnsureNeverCalled(),
- onOpenDM: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
- onInviteFriendsClick: () -> Unit = EnsureNeverCalled(),
-) {
- setContent {
- CreateRoomRootView(
- state = state,
- onCloseClick = onCloseClick,
- onNewRoomClick = onNewRoomClick,
- onOpenDM = onOpenDM,
- onInviteFriendsClick = onInviteFriendsClick,
- )
- }
-}
diff --git a/features/createroom/test/build.gradle.kts b/features/createroom/test/build.gradle.kts
index f2f2b8280a8..b7df0caab66 100644
--- a/features/createroom/test/build.gradle.kts
+++ b/features/createroom/test/build.gradle.kts
@@ -18,5 +18,6 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.test)
implementation(projects.libraries.architecture)
+ implementation(projects.tests.testutils)
api(projects.features.createroom.api)
}
diff --git a/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/test/FakeStartDMAction.kt b/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/test/FakeStartDMAction.kt
index 27ad5f7d62a..90e2ecf1c1a 100644
--- a/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/test/FakeStartDMAction.kt
+++ b/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/test/FakeStartDMAction.kt
@@ -11,20 +11,19 @@ import androidx.compose.runtime.MutableState
import io.element.android.features.createroom.api.StartDMAction
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.matrix.api.core.UserId
-import io.element.android.libraries.matrix.test.A_ROOM_ID
-import kotlinx.coroutines.delay
+import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.tests.testutils.lambda.lambdaError
-class FakeStartDMAction : StartDMAction {
- private var executeResult: AsyncAction = AsyncAction.Success(A_ROOM_ID)
-
- fun givenExecuteResult(result: AsyncAction) {
- executeResult = result
+class FakeStartDMAction(
+ private val executeResult: (MatrixUser, Boolean, MutableState>) -> Unit = { _, _, _ ->
+ lambdaError()
}
-
- override suspend fun execute(userId: UserId, actionState: MutableState>) {
- actionState.value = AsyncAction.Loading
- delay(1)
- actionState.value = executeResult
+) : StartDMAction {
+ override suspend fun execute(
+ matrixUser: MatrixUser,
+ createIfDmDoesNotExist: Boolean,
+ actionState: MutableState>,
+ ) {
+ executeResult(matrixUser, createIfDmDoesNotExist, actionState)
}
}
diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt
index b7d9ab4ba1a..af1bcdad8df 100644
--- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt
+++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt
@@ -5,8 +5,6 @@
* Please see LICENSE files in the repository root for full details.
*/
-@file:OptIn(ExperimentalComposeUiApi::class)
-
package io.element.android.features.logout.impl
import androidx.compose.foundation.clickable
@@ -32,12 +30,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
-import androidx.compose.ui.autofill.AutofillType
+import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentType
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -54,12 +53,10 @@ import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrgani
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.components.list.SwitchListItem
-import io.element.android.libraries.designsystem.modifiers.autofill
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
-import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
@@ -87,12 +84,7 @@ fun AccountDeactivationView(
navigationIcon = {
BackButton(onClick = onBackClick)
},
- title = {
- Text(
- text = stringResource(R.string.screen_deactivate_account_title),
- style = ElementTheme.typography.aliasScreenTitle,
- )
- },
+ titleStr = stringResource(R.string.screen_deactivate_account_title),
)
},
) { padding ->
@@ -276,14 +268,9 @@ private fun Content(
.fillMaxWidth()
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginPassword)
- .autofill(
- autofillTypes = listOf(AutofillType.Password),
- onFill = {
- val sanitized = it.sanitize()
- passwordFieldState = sanitized
- eventSink(AccountDeactivationEvents.SetPassword(sanitized))
- }
- ),
+ .semantics {
+ contentType = ContentType.Password
+ },
onValueChange = {
val sanitized = it.sanitize()
passwordFieldState = sanitized
diff --git a/features/deactivation/impl/src/main/res/values-bg/translations.xml b/features/deactivation/impl/src/main/res/values-bg/translations.xml
new file mode 100644
index 00000000000..34ad5b47722
--- /dev/null
+++ b/features/deactivation/impl/src/main/res/values-bg/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Моля, потвърдете, че искате да деактивирате акаунта си. Това действие не може да бъде отменено."
+ "Деактивиране на акаунта"
+
diff --git a/features/deactivation/impl/src/main/res/values-cy/translations.xml b/features/deactivation/impl/src/main/res/values-cy/translations.xml
new file mode 100644
index 00000000000..e49073c36ed
--- /dev/null
+++ b/features/deactivation/impl/src/main/res/values-cy/translations.xml
@@ -0,0 +1,14 @@
+
+
+ "Cadarnhewch eich bod am gau\'r cyfrif. Does dom modd dadwneud y weithred hon."
+ "Dileu fy holl negeseuon"
+ "Rhybudd: Mae\'n bosibl y bydd defnyddwyr y dyfodol yn gweld sgyrsiau anghyflawn."
+ "Bydd cau eich cyfrif yn %1$s yn:"
+ "dim modd ei adfer"
+ "%1$s eich cyfrif (does dim modd i chi fewngofnodi eto, ac nid oes modd ailddefnyddio\'ch dull adnabod)."
+ "Analluogi\'n barhaol"
+ "Eich tynnu o bob ystafell sgwrsio."
+ "Dileu manylion eich cyfrif o\'n gweinydd hunaniaeth."
+ "Bydd eich negeseuon yn dal i fod yn weladwy i ddefnyddwyr cofrestredig ond fyddan nhw ddim ar gael i ddefnyddwyr newydd neu anghofrestredig os byddwch yn dewis eu dileu."
+ "Cau cyfrif"
+
diff --git a/features/deactivation/impl/src/main/res/values-da/translations.xml b/features/deactivation/impl/src/main/res/values-da/translations.xml
new file mode 100644
index 00000000000..e9d5d9fddae
--- /dev/null
+++ b/features/deactivation/impl/src/main/res/values-da/translations.xml
@@ -0,0 +1,14 @@
+
+
+ "Bekræft venligst, at du vil deaktivere din konto. Denne handling kan ikke fortrydes."
+ "Slet alle mine beskeder"
+ "Advarsel: Fremtidige brugere kan muligvis se ufuldstændige samtaler."
+ "Deaktivering af din konto er %1$s, det vil:"
+ "irreversibel"
+ "%1$s din konto (du kan ikke logge ind igen, og dit ID kan ikke genbruges)."
+ "Deaktiver permanent"
+ "Fjern dig fra alle samtalerum"
+ "Slette dine kontooplysninger fra vores identitetsserver."
+ "Dine beskeder vil stadig være synlige for registrerede brugere, men vil ikke være tilgængelige for nye eller uregistrerede brugere, hvis du vælger at slette dem."
+ "Deaktiver konto"
+
diff --git a/features/deactivation/impl/src/main/res/values-de/translations.xml b/features/deactivation/impl/src/main/res/values-de/translations.xml
index 0830d6ba3f9..cd61a6a9d00 100644
--- a/features/deactivation/impl/src/main/res/values-de/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-de/translations.xml
@@ -10,5 +10,5 @@
"Sie werden aus allen Chatrooms entfernt."
"Löschen Sie Ihre Kontoinformationen von unserem Identitätsserver."
"Gelöschte Nachrichten werden für bereits registrierte Benutzer weiterhin sichtbar sein, wenn sie auch neuen oder nicht registrierten Benutzern nicht mehr zur Verfügung stehen."
- "Benutzerkonto deaktivieren"
+ "Nutzerkonto deaktivieren"
diff --git a/features/deactivation/impl/src/main/res/values-el/translations.xml b/features/deactivation/impl/src/main/res/values-el/translations.xml
index 53bd1bbbea4..ac645f30634 100644
--- a/features/deactivation/impl/src/main/res/values-el/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-el/translations.xml
@@ -7,7 +7,7 @@
"μη αναστρέψιμο"
"%1$s τον λογαριασμό σου (δεν μπορείς να συνδεθείς ξανά και το αναγνωριστικό σου δεν μπορεί να επαναχρησιμοποιηθεί)."
"Μόνιμη απενεργοποίηση"
- "Σε αφαιρέσει από όλα τα δωμάτια συνομιλίας."
+ "Αποχώρησή σας από όλες τις αίθουσες συνομιλίας."
"Διαγράψει τα στοιχεία του λογαριασμού σου από τον διακομιστή ταυτότητάς μας."
"Τα μηνύματά σου θα εξακολουθούν να είναι ορατά στους εγγεγραμμένους χρήστες, αλλά δεν θα είναι διαθέσιμα σε νέους ή μη εγγεγραμμένους χρήστες εάν επιλέξεις να τα διαγράψεις."
"Απενεργοποίηση λογαριασμού"
diff --git a/features/deactivation/impl/src/main/res/values-es/translations.xml b/features/deactivation/impl/src/main/res/values-es/translations.xml
new file mode 100644
index 00000000000..cd0757ba3ed
--- /dev/null
+++ b/features/deactivation/impl/src/main/res/values-es/translations.xml
@@ -0,0 +1,14 @@
+
+
+ "Confirma que quieres desactivar tu cuenta. Esta acción no se puede deshacer."
+ "Borrar todos mis mensajes"
+ "Advertencia: Futuros usuarios pueden ver conversaciones incompletas."
+ "Desactivar tu cuenta es %1$s:"
+ "irreversible"
+ "%1$s tu cuenta (no podrás volver a iniciar sesión y tu ID no se podrá volver a utilizar)."
+ "Inhabilitará permanentemente"
+ "Te eliminará de todas las salas de chat."
+ "Eliminará la información de tu cuenta de nuestro servidor de identidad."
+ "Tus mensajes seguirán siendo visibles para los usuarios registrados, pero no estarán disponibles para los usuarios nuevos o no registrados si decides eliminarlos."
+ "Desactivar cuenta"
+
diff --git a/features/deactivation/impl/src/main/res/values-eu/translations.xml b/features/deactivation/impl/src/main/res/values-eu/translations.xml
new file mode 100644
index 00000000000..d55c272e656
--- /dev/null
+++ b/features/deactivation/impl/src/main/res/values-eu/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Ezabatu nire mezu guztiak"
+ "Kontuaren desaktibazioa %1$s, honakoa eragingo du:"
+ "ezin da desegin"
+ "Ezgaitu betiko"
+ "Kendu zure burua txat gela guztietatik."
+ "Desaktibatu kontua"
+
diff --git a/features/deactivation/impl/src/main/res/values-fr/translations.xml b/features/deactivation/impl/src/main/res/values-fr/translations.xml
index 875142bc019..675ac1e1e0b 100644
--- a/features/deactivation/impl/src/main/res/values-fr/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-fr/translations.xml
@@ -10,5 +10,5 @@
"Vous retirer de tous les salons et toutes les discussions."
"Supprimer les informations de votre compte du serveur d’identité."
"Rendre vos messages invisibles aux futurs membres des salons si vous choisissez de les supprimer. Vos messages seront toujours visibles pour les utilisateurs qui les ont déjà récupérés."
- "Désactiver votre compte"
+ "Désactiver le compte"
diff --git a/features/deactivation/impl/src/main/res/values-in/translations.xml b/features/deactivation/impl/src/main/res/values-in/translations.xml
new file mode 100644
index 00000000000..e255ad34e99
--- /dev/null
+++ b/features/deactivation/impl/src/main/res/values-in/translations.xml
@@ -0,0 +1,14 @@
+
+
+ "Harap konfirmasi bahwa Anda ingin menonaktifkan akun Anda. Tindakan ini tidak dapat diurungkan."
+ "Hapus semua pesan saya"
+ "Peringatan: Pengguna masa depan mungkin melihat percakapan yang tidak lengkap."
+ "Penonaktifan akun Anda %1$s, ini akan:"
+ "tidak dapat diurungkan"
+ "%1$s akun Anda (Anda tidak dapat masuk kembali, dan ID Anda tidak dapat digunakan kembali)."
+ "Nonaktifkan secara permanen"
+ "Mengeluarkan Anda dari semua ruangan obrolan."
+ "Hapus informasi akun Anda dari server identitas kami."
+ "Pesan Anda akan tetap terlihat oleh pengguna terdaftar tetapi tidak akan tersedia bagi pengguna baru atau tidak terdaftar jika Anda memilih untuk menghapusnya."
+ "Nonaktifkan akun"
+
diff --git a/features/deactivation/impl/src/main/res/values-it/translations.xml b/features/deactivation/impl/src/main/res/values-it/translations.xml
index 7cd484d0e4e..3fbc9d536b7 100644
--- a/features/deactivation/impl/src/main/res/values-it/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-it/translations.xml
@@ -10,5 +10,5 @@
"Ti rimuove da tutte le stanze di chat."
"Elimina le informazioni del tuo account dal nostro server di identità."
"I tuoi messaggi saranno ancora visibili agli utenti registrati, ma non saranno disponibili per gli utenti nuovi o non registrati se decidi di eliminarli."
- "Disattivazione dell\'account"
+ "Disattiva account"
diff --git a/features/deactivation/impl/src/main/res/values-nb/translations.xml b/features/deactivation/impl/src/main/res/values-nb/translations.xml
index 082160d636c..cdff7f886df 100644
--- a/features/deactivation/impl/src/main/res/values-nb/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-nb/translations.xml
@@ -5,8 +5,10 @@
"Advarsel: Fremtidige brukere vil kunne se ufullstendige samtaler."
"Deaktivering av kontoen din er %1$s , det vil:"
"irreversibel"
+ "%1$s kontoen din (du kan ikke logge på igjen, og ID-en din kan ikke brukes på nytt)."
"Deaktiver permanent"
"Fjern deg fra alle chatterom."
"Slett kontoinformasjonen din fra vår identitetsserver."
+ "Meldingene dine vil fortsatt være synlige for registrerte brukere, men vil ikke være tilgjengelige for nye eller uregistrerte brukere hvis du velger å slette dem."
"Deaktiver kontoen"
diff --git a/features/deactivation/impl/src/main/res/values-nl/translations.xml b/features/deactivation/impl/src/main/res/values-nl/translations.xml
index 5f7cf788477..e03ffa50467 100644
--- a/features/deactivation/impl/src/main/res/values-nl/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-nl/translations.xml
@@ -7,7 +7,7 @@
"onomkeerbaar"
"Je account %1$s (je kunt niet opnieuw inloggen en je ID kan niet opnieuw worden gebruikt)"
"permanent uitschakelen"
- "Je verwijderen uit alle chatrooms."
+ "Je verwijderen uit alle chatkamers."
"Je accountgegevens verwijderen van onze identiteitsserver."
"Je berichten zijn nog steeds zichtbaar voor geregistreerde gebruikers, maar niet beschikbaar voor nieuwe of niet-geregistreerde gebruikers als je ervoor kiest ze te verwijderen."
"Account sluiten"
diff --git a/features/deactivation/impl/src/main/res/values-pt-rBR/translations.xml b/features/deactivation/impl/src/main/res/values-pt-rBR/translations.xml
index 4125db4664f..51a412a7a3a 100644
--- a/features/deactivation/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-pt-rBR/translations.xml
@@ -1,5 +1,14 @@
+ "Confirme que você deseja desativar sua conta. Essa ação não pode ser desfeita."
+ "Excluir todas as minhas mensagens"
+ "Aviso: Os futuros usuários poderão ver conversas incompletas."
"Desativar sua conta é %1$s, isso irá:"
+ "irreversível"
+ "%1$s sua conta (você não poderá fazer login novamente, e seu ID não poderá ser reutilizado)."
+ "Desativar permanentemente"
"Te remover de todas as salas de conversa."
+ "Exclua as informações da sua conta do nosso servidor de identidade."
+ "Suas mensagens ainda estarão visíveis para os usuários registrados, mas não estarão disponíveis para usuários novos ou não registrados se você optar por excluí-las."
+ "Desativar conta"
diff --git a/features/deactivation/impl/src/main/res/values-tr/translations.xml b/features/deactivation/impl/src/main/res/values-tr/translations.xml
new file mode 100644
index 00000000000..cc61b80c540
--- /dev/null
+++ b/features/deactivation/impl/src/main/res/values-tr/translations.xml
@@ -0,0 +1,14 @@
+
+
+ "Lütfen hesabınızı devre dışı bırakmak istediğinizi onaylayın. Bu işlem geri alınamaz."
+ "Tüm mesajlarımı sil"
+ "Uyarı: Gelecekteki kullanıcılar eksik konuşmalar görebilir."
+ "Hesabınızı devre dışı bırakmak %1$s, şunları yapacaktır:"
+ "geri alınamaz"
+ "%1$s (tekrar giriş yapamazsınız ve kimliğiniz yeniden kullanılamaz)."
+ "Kalıcı olarak devre dışı bırak"
+ "Sizi tüm sohbet odalarından çıkarmak."
+ "Hesap bilgileriniz kimlik sunucumuzdan silinecek."
+ "Mesajlarınız kayıtlı kullanıcılar tarafından görülmeye devam eder, ancak silmeyi seçerseniz yeni veya kayıtlı olmayan kullanıcılar tarafından görüntülenemeyecek."
+ "Hesabı devre dışı bırak"
+
diff --git a/features/deactivation/impl/src/main/res/values-uk/translations.xml b/features/deactivation/impl/src/main/res/values-uk/translations.xml
index 62cf66cec1d..04b32df8b2c 100644
--- a/features/deactivation/impl/src/main/res/values-uk/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-uk/translations.xml
@@ -10,5 +10,5 @@
"Видалити вас з усіх чатів."
"Видаліть інформацію свого облікового запису з нашого сервера ідентифікації."
"Ваші повідомлення залишатимуться видимими для зареєстрованих користувачів, але недоступними для нових або незареєстрованих користувачів, якщо ви вирішите їх видалити."
- "Відключити обліковий запис"
+ "Деактивувати обліковий запис"
diff --git a/features/enterprise/api/build.gradle.kts b/features/enterprise/api/build.gradle.kts
index 91c54bc42c5..9f63ab2cf1f 100644
--- a/features/enterprise/api/build.gradle.kts
+++ b/features/enterprise/api/build.gradle.kts
@@ -13,6 +13,7 @@ android {
}
dependencies {
+ implementation(libs.compound)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
}
diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt
index 2ac334653ed..4e2a3cc1f2f 100644
--- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt
+++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt
@@ -7,9 +7,28 @@
package io.element.android.features.enterprise.api
+import io.element.android.compound.tokens.generated.SemanticColors
import io.element.android.libraries.matrix.api.core.SessionId
interface EnterpriseService {
val isEnterpriseBuild: Boolean
suspend fun isEnterpriseUser(sessionId: SessionId): Boolean
+ fun defaultHomeserverList(): List
+ suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean
+
+ fun semanticColorsLight(): SemanticColors
+ fun semanticColorsDark(): SemanticColors
+
+ fun firebasePushGateway(): String?
+ fun unifiedPushDefaultPushGateway(): String?
+
+ companion object {
+ const val ANY_ACCOUNT_PROVIDER = "*"
+ }
+}
+
+fun EnterpriseService.canConnectToAnyHomeserver(): Boolean {
+ return defaultHomeserverList().let {
+ it.isEmpty() || it.contains(EnterpriseService.ANY_ACCOUNT_PROVIDER)
+ }
}
diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt
new file mode 100644
index 00000000000..c67b94476be
--- /dev/null
+++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt
@@ -0,0 +1,12 @@
+/*
+ * Copyright 2025 New Vector 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
+
+interface SessionEnterpriseService {
+ suspend fun isElementCallAvailable(): Boolean
+}
diff --git a/features/enterprise/impl/build.gradle.kts b/features/enterprise/impl/build.gradle.kts
index 6beea5b83a6..642b2fef40f 100644
--- a/features/enterprise/impl/build.gradle.kts
+++ b/features/enterprise/impl/build.gradle.kts
@@ -17,6 +17,7 @@ android {
setupAnvil()
dependencies {
+ implementation(libs.compound)
implementation(projects.anvilannotations)
api(projects.features.enterprise.api)
implementation(projects.libraries.architecture)
diff --git a/features/enterprise/impl/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt b/features/enterprise/impl/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt
index c77c7377d88..7f4f4b387b3 100644
--- a/features/enterprise/impl/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt
+++ b/features/enterprise/impl/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt
@@ -8,6 +8,9 @@
package io.element.android.features.enterprise.impl
import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.compound.tokens.generated.SemanticColors
+import io.element.android.compound.tokens.generated.compoundColorsDark
+import io.element.android.compound.tokens.generated.compoundColorsLight
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.SessionId
@@ -18,4 +21,14 @@ class DefaultEnterpriseService @Inject constructor() : EnterpriseService {
override val isEnterpriseBuild = false
override suspend fun isEnterpriseUser(sessionId: SessionId) = false
+
+ override fun defaultHomeserverList(): List = emptyList()
+ override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
+
+ override fun semanticColorsLight(): SemanticColors = compoundColorsLight
+
+ override fun semanticColorsDark(): SemanticColors = compoundColorsDark
+
+ override fun firebasePushGateway(): String? = null
+ override fun unifiedPushDefaultPushGateway(): String? = null
}
diff --git a/features/enterprise/impl/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt b/features/enterprise/impl/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt
new file mode 100644
index 00000000000..2728bdad6ae
--- /dev/null
+++ b/features/enterprise/impl/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2025 New Vector 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
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.enterprise.api.SessionEnterpriseService
+import io.element.android.libraries.di.SessionScope
+import javax.inject.Inject
+
+@ContributesBinding(SessionScope::class)
+class DefaultSessionEnterpriseService @Inject constructor() : SessionEnterpriseService {
+ override suspend fun isElementCallAvailable(): Boolean = true
+}
diff --git a/features/enterprise/impl/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt b/features/enterprise/impl/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt
index e2a6a11a9ae..9f3ebbb8454 100644
--- a/features/enterprise/impl/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt
+++ b/features/enterprise/impl/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt
@@ -8,6 +8,7 @@
package io.element.android.features.enterprise.impl
import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_SESSION_ID
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -19,6 +20,18 @@ class DefaultEnterpriseServiceTest {
assertThat(defaultEnterpriseService.isEnterpriseBuild).isFalse()
}
+ @Test
+ fun `defaultHomeserverList should return empty list`() {
+ val defaultEnterpriseService = DefaultEnterpriseService()
+ assertThat(defaultEnterpriseService.defaultHomeserverList()).isEmpty()
+ }
+
+ @Test
+ fun `isAllowedToConnectToHomeserver is true for all homeserver urls`() = runTest {
+ val defaultEnterpriseService = DefaultEnterpriseService()
+ assertThat(defaultEnterpriseService.isAllowedToConnectToHomeserver(A_HOMESERVER_URL)).isTrue()
+ }
+
@Test
fun `isEnterpriseUser always return false`() = runTest {
val defaultEnterpriseService = DefaultEnterpriseService()
diff --git a/features/enterprise/impl/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseServiceTest.kt b/features/enterprise/impl/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseServiceTest.kt
new file mode 100644
index 00000000000..b1e70ef045c
--- /dev/null
+++ b/features/enterprise/impl/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseServiceTest.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2025 New Vector 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
+
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class DefaultSessionEnterpriseServiceTest {
+ @Test
+ fun `isElementCallAvailable is always true`() = runTest {
+ val service = DefaultSessionEnterpriseService()
+ assertThat(service.isElementCallAvailable()).isTrue()
+ }
+}
diff --git a/features/enterprise/test/build.gradle.kts b/features/enterprise/test/build.gradle.kts
new file mode 100644
index 00000000000..91b76f4fa78
--- /dev/null
+++ b/features/enterprise/test/build.gradle.kts
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.enterprise.test"
+}
+
+dependencies {
+ api(projects.features.enterprise.api)
+ implementation(libs.compound)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.tests.testutils)
+}
diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt
new file mode 100644
index 00000000000..14195e12432
--- /dev/null
+++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2025 New Vector 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
+
+import io.element.android.compound.tokens.generated.SemanticColors
+import io.element.android.features.enterprise.api.EnterpriseService
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.tests.testutils.lambda.lambdaError
+import io.element.android.tests.testutils.simulateLongTask
+
+class FakeEnterpriseService(
+ override val isEnterpriseBuild: Boolean = false,
+ private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() },
+ private val defaultHomeserverListResult: () -> List = { emptyList() },
+ private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() },
+ private val semanticColorsLightResult: () -> SemanticColors = { lambdaError() },
+ private val semanticColorsDarkResult: () -> SemanticColors = { lambdaError() },
+ private val firebasePushGatewayResult: () -> String? = { lambdaError() },
+ private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
+) : EnterpriseService {
+ override suspend fun isEnterpriseUser(sessionId: SessionId): Boolean = simulateLongTask {
+ isEnterpriseUserResult(sessionId)
+ }
+
+ override fun defaultHomeserverList(): List {
+ return defaultHomeserverListResult()
+ }
+
+ override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean = simulateLongTask {
+ isAllowedToConnectToHomeserverResult(homeserverUrl)
+ }
+
+ override fun semanticColorsLight(): SemanticColors {
+ return semanticColorsLightResult()
+ }
+
+ override fun semanticColorsDark(): SemanticColors {
+ return semanticColorsDarkResult()
+ }
+
+ override fun firebasePushGateway(): String? {
+ return firebasePushGatewayResult()
+ }
+
+ override fun unifiedPushDefaultPushGateway(): String? {
+ return unifiedPushDefaultPushGatewayResult()
+ }
+
+ companion object {
+ const val A_FAKE_HOMESERVER = "a_fake_homeserver"
+ }
+}
diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt
new file mode 100644
index 00000000000..a1e811ed107
--- /dev/null
+++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2025 New Vector 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
+
+import io.element.android.features.enterprise.api.SessionEnterpriseService
+import io.element.android.tests.testutils.lambda.lambdaError
+import io.element.android.tests.testutils.simulateLongTask
+
+class FakeSessionEnterpriseService(
+ private val isElementCallAvailableResult: () -> Boolean = { lambdaError() },
+) : SessionEnterpriseService {
+ override suspend fun isElementCallAvailable(): Boolean = simulateLongTask {
+ isElementCallAvailableResult()
+ }
+}
diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts
index d9cd9930437..e95a4697ae9 100644
--- a/features/ftue/impl/build.gradle.kts
+++ b/features/ftue/impl/build.gradle.kts
@@ -14,6 +14,12 @@ plugins {
android {
namespace = "io.element.android.features.ftue.impl"
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
}
setupAnvil()
@@ -30,6 +36,7 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api)
+ implementation(projects.features.logout.api)
implementation(projects.features.securebackup.api)
implementation(projects.features.verifysession.api)
implementation(projects.services.analytics.api)
@@ -37,16 +44,19 @@ dependencies {
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.permissions.noop)
implementation(projects.services.toolbox.api)
+ implementation(projects.appconfig)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
+ testImplementation(libs.test.robolectric)
+ testImplementation(libs.androidx.compose.ui.test.junit)
+ testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.analytics.noop)
- testImplementation(projects.libraries.permissions.impl)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.features.lockscreen.test)
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
index c5987508fe9..71a27dcaae1 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
@@ -16,7 +16,6 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
-import com.bumble.appyx.core.navigation.backpresshandlerstrategies.BaseBackPressHandlerStrategy
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
@@ -38,8 +37,6 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope
import io.element.android.services.analytics.api.AnalyticsService
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
@@ -59,7 +56,6 @@ class FtueFlowNode @AssistedInject constructor(
backstack = BackStack(
initialElement = NavTarget.Placeholder,
savedStateMap = buildContext.savedStateMap,
- backPressHandler = NoOpBackstackHandlerStrategy(),
),
buildContext = buildContext,
plugins = plugins,
@@ -88,7 +84,7 @@ class FtueFlowNode @AssistedInject constructor(
moveToNextStepIfNeeded()
})
- analyticsService.didAskUserConsent()
+ analyticsService.didAskUserConsentFlow
.distinctUntilChanged()
.onEach { moveToNextStepIfNeeded() }
.launchIn(lifecycleScope)
@@ -104,7 +100,7 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.Placeholder -> {
createNode(buildContext)
}
- NavTarget.SessionVerification -> {
+ is NavTarget.SessionVerification -> {
val callback = object : FtueSessionVerificationFlowNode.Callback {
override fun onDone() {
moveToNextStepIfNeeded()
@@ -175,11 +171,3 @@ class FtueFlowNode @AssistedInject constructor(
}
}
}
-
-private class NoOpBackstackHandlerStrategy : BaseBackPressHandlerStrategy() {
- override val canHandleBackPressFlow: StateFlow = MutableStateFlow(true)
-
- override fun onBackPressed() {
- // No-op
- }
-}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/di/FtueModule.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/di/FtueModule.kt
new file mode 100644
index 00000000000..4387b3dc00f
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/di/FtueModule.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2025 New Vector 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.ftue.impl.di
+
+import com.squareup.anvil.annotations.ContributesTo
+import dagger.Binds
+import dagger.Module
+import io.element.android.features.ftue.impl.sessionverification.choosemode.ChooseSelfVerificationModePresenter
+import io.element.android.features.ftue.impl.sessionverification.choosemode.ChooseSelfVerificationModeState
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.di.SessionScope
+
+@ContributesTo(SessionScope::class)
+@Module
+interface FtueModule {
+ @Binds
+ fun bindChooseSelfVerificationMethodPresenter(presenter: ChooseSelfVerificationModePresenter): Presenter
+}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt
index 05bd14efcd4..5db0955c5ac 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt
@@ -16,6 +16,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
@@ -27,6 +28,7 @@ import kotlinx.coroutines.launch
class NotificationsOptInPresenter @AssistedInject constructor(
permissionsPresenterFactory: PermissionsPresenter.Factory,
@Assisted private val callback: NotificationsOptInNode.Callback,
+ @AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
private val permissionStateProvider: PermissionStateProvider,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt
index 69bca0250bd..444c311a637 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt
@@ -38,6 +38,7 @@ import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
@@ -149,6 +150,7 @@ private fun NotificationRow(
) {
Avatar(
avatarData = AvatarData(id = avatarColorsId, name = avatarLetter, size = AvatarSize.NotificationsOptIn),
+ avatarType = AvatarType.User,
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Box(
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/FtueSessionVerificationFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/FtueSessionVerificationFlowNode.kt
index 4ff7e393b4a..a37fdd21922 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/FtueSessionVerificationFlowNode.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/FtueSessionVerificationFlowNode.kt
@@ -9,6 +9,7 @@ package io.element.android.features.ftue.impl.sessionverification
import android.os.Parcelable
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext
@@ -17,15 +18,21 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
+import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
+import io.element.android.appconfig.LearnMoreConfig
+import io.element.android.features.ftue.impl.sessionverification.choosemode.ChooseSelfVerificationModeNode
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
-import io.element.android.features.verifysession.api.VerifySessionEntryPoint
+import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.designsystem.utils.OpenUrlInTabView
import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.verification.VerificationRequest
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@@ -33,11 +40,11 @@ import kotlinx.parcelize.Parcelize
class FtueSessionVerificationFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
- private val verifySessionEntryPoint: VerifySessionEntryPoint,
+ private val outgoingVerificationEntryPoint: OutgoingVerificationEntryPoint,
private val secureBackupEntryPoint: SecureBackupEntryPoint,
) : BaseFlowNode(
backstack = BackStack(
- initialElement = NavTarget.Root(showDeviceVerifiedScreen = false),
+ initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
@@ -45,7 +52,10 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
) {
sealed interface NavTarget : Parcelable {
@Parcelize
- data class Root(val showDeviceVerifiedScreen: Boolean) : NavTarget
+ data object Root : NavTarget
+
+ @Parcelize
+ data object UseAnotherDevice : NavTarget
@Parcelize
data object EnterRecoveryKey : NavTarget
@@ -62,7 +72,7 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
override fun onDone() {
lifecycleScope.launch {
// Move to the completed state view in the verification flow
- backstack.newRoot(NavTarget.Root(showDeviceVerifiedScreen = true))
+ backstack.newRoot(NavTarget.UseAnotherDevice)
}
}
}
@@ -70,19 +80,44 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.Root -> {
- verifySessionEntryPoint.nodeBuilder(this, buildContext)
- .params(VerifySessionEntryPoint.Params(navTarget.showDeviceVerifiedScreen))
- .callback(object : VerifySessionEntryPoint.Callback {
- override fun onEnterRecoveryKey() {
- backstack.push(NavTarget.EnterRecoveryKey)
- }
+ val callback = object : ChooseSelfVerificationModeNode.Callback {
+ override fun onUseAnotherDevice() {
+ backstack.push(NavTarget.UseAnotherDevice)
+ }
+
+ override fun onUseRecoveryKey() {
+ backstack.push(NavTarget.EnterRecoveryKey)
+ }
+
+ override fun onResetKey() {
+ backstack.push(NavTarget.ResetIdentity)
+ }
+
+ override fun onLearnMoreAboutEncryption() {
+ learnMoreUrl.value = LearnMoreConfig.DEVICE_VERIFICATION_URL
+ }
+ }
+ createNode(buildContext, plugins = listOf(callback))
+ }
+ is NavTarget.UseAnotherDevice -> {
+ outgoingVerificationEntryPoint.nodeBuilder(this, buildContext)
+ .params(OutgoingVerificationEntryPoint.Params(
+ showDeviceVerifiedScreen = true,
+ verificationRequest = VerificationRequest.Outgoing.CurrentSession,
+ ))
+ .callback(object : OutgoingVerificationEntryPoint.Callback {
override fun onDone() {
plugins().forEach { it.onDone() }
}
- override fun onResetKey() {
- backstack.push(NavTarget.ResetIdentity)
+ override fun onBack() {
+ backstack.pop()
+ }
+
+ override fun onLearnMoreAboutEncryption() {
+ // Note that this callback is never called. The "Learn more" link is not displayed
+ // for the self session interactive verification.
}
})
.build()
@@ -106,8 +141,12 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
}
}
+ private val learnMoreUrl = mutableStateOf(null)
+
@Composable
override fun View(modifier: Modifier) {
BackstackView()
+
+ OpenUrlInTabView(learnMoreUrl)
}
}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeEvent.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeEvent.kt
new file mode 100644
index 00000000000..99e59506937
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeEvent.kt
@@ -0,0 +1,12 @@
+/*
+ * Copyright 2025 New Vector 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.ftue.impl.sessionverification.choosemode
+
+sealed interface ChooseSelfVerificationModeEvent {
+ data object SignOut : ChooseSelfVerificationModeEvent
+}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeNode.kt
new file mode 100644
index 00000000000..687dc4aefeb
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeNode.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2025 New Vector 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.ftue.impl.sessionverification.choosemode
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.logout.api.direct.DirectLogoutView
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+class ChooseSelfVerificationModeNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: Presenter,
+ private val directLogoutView: DirectLogoutView,
+) : Node(buildContext, plugins = plugins) {
+ interface Callback : Plugin {
+ fun onUseAnotherDevice()
+ fun onUseRecoveryKey()
+ fun onResetKey()
+ fun onLearnMoreAboutEncryption()
+ }
+
+ private val callback = plugins().first()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+
+ ChooseSelfVerificationModeView(
+ state = state,
+ onUseAnotherDevice = callback::onUseAnotherDevice,
+ onUseRecoveryKey = callback::onUseRecoveryKey,
+ onResetKey = callback::onResetKey,
+ onLearnMore = callback::onLearnMoreAboutEncryption,
+ modifier = modifier,
+ )
+
+ directLogoutView.Render(state = state.directLogoutState)
+ }
+}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt
new file mode 100644
index 00000000000..0aec3b4e1d1
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2025 New Vector 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.ftue.impl.sessionverification.choosemode
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import io.element.android.features.logout.api.direct.DirectLogoutEvents
+import io.element.android.features.logout.api.direct.DirectLogoutState
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.matrix.api.encryption.EncryptionService
+import io.element.android.libraries.matrix.api.encryption.RecoveryState
+import javax.inject.Inject
+
+class ChooseSelfVerificationModePresenter @Inject constructor(
+ private val encryptionService: EncryptionService,
+ private val directLogoutPresenter: Presenter,
+) : Presenter {
+ @Composable
+ override fun present(): ChooseSelfVerificationModeState {
+ val isLastDevice by encryptionService.isLastDevice.collectAsState()
+ val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
+ val canEnterRecoveryKey by remember { derivedStateOf { recoveryState == RecoveryState.INCOMPLETE } }
+
+ val directLogoutState = directLogoutPresenter.present()
+
+ fun eventHandler(event: ChooseSelfVerificationModeEvent) {
+ when (event) {
+ ChooseSelfVerificationModeEvent.SignOut -> directLogoutState.eventSink(DirectLogoutEvents.Logout(ignoreSdkError = false))
+ }
+ }
+
+ return ChooseSelfVerificationModeState(
+ isLastDevice = isLastDevice,
+ canEnterRecoveryKey = canEnterRecoveryKey,
+ directLogoutState = directLogoutState,
+ eventSink = ::eventHandler,
+ )
+ }
+}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt
new file mode 100644
index 00000000000..21c37a4ae29
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2025 New Vector 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.ftue.impl.sessionverification.choosemode
+
+import io.element.android.features.logout.api.direct.DirectLogoutState
+
+data class ChooseSelfVerificationModeState(
+ val isLastDevice: Boolean,
+ val canEnterRecoveryKey: Boolean,
+ val directLogoutState: DirectLogoutState,
+ val eventSink: (ChooseSelfVerificationModeEvent) -> Unit,
+)
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStateProvider.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStateProvider.kt
new file mode 100644
index 00000000000..574aa367c61
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStateProvider.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2025 New Vector 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.ftue.impl.sessionverification.choosemode
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.logout.api.direct.aDirectLogoutState
+
+class ChooseSelfVerificationModeStateProvider :
+ PreviewParameterProvider {
+ override val values = sequenceOf(
+ aChooseSelfVerificationModeState(isLastDevice = true, canEnterRecoveryKey = true),
+ aChooseSelfVerificationModeState(isLastDevice = true, canEnterRecoveryKey = false),
+ aChooseSelfVerificationModeState(isLastDevice = false, canEnterRecoveryKey = true),
+ aChooseSelfVerificationModeState(isLastDevice = false, canEnterRecoveryKey = false),
+ )
+}
+
+fun aChooseSelfVerificationModeState(
+ isLastDevice: Boolean = false,
+ canEnterRecoveryKey: Boolean = true,
+) = ChooseSelfVerificationModeState(
+ isLastDevice = isLastDevice,
+ canEnterRecoveryKey = canEnterRecoveryKey,
+ directLogoutState = aDirectLogoutState(),
+ eventSink = {},
+)
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt
new file mode 100644
index 00000000000..9839f3bf7c7
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2025 New Vector 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.ftue.impl.sessionverification.choosemode
+
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.LocalActivity
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.ftue.impl.R
+import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
+import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.components.PageTitle
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.OutlinedButton
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TextButton
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ChooseSelfVerificationModeView(
+ state: ChooseSelfVerificationModeState,
+ onUseAnotherDevice: () -> Unit,
+ onUseRecoveryKey: () -> Unit,
+ onResetKey: () -> Unit,
+ onLearnMore: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val activity = LocalActivity.current
+ BackHandler {
+ activity?.finish()
+ }
+
+ HeaderFooterPage(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {},
+ actions = {
+ TextButton(
+ text = stringResource(CommonStrings.action_signout),
+ onClick = { state.eventSink(ChooseSelfVerificationModeEvent.SignOut) }
+ )
+ }
+ )
+ },
+ header = {
+ PageTitle(
+ iconStyle = BigIcon.Style.Default(CompoundIcons.LockSolid()),
+ title = stringResource(id = R.string.screen_identity_confirmation_title),
+ subtitle = stringResource(id = R.string.screen_identity_confirmation_subtitle)
+ )
+ },
+ footer = {
+ ButtonColumnMolecule(
+ modifier = Modifier.padding(bottom = 16.dp)
+ ) {
+ if (state.isLastDevice.not()) {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.screen_identity_use_another_device),
+ onClick = onUseAnotherDevice,
+ )
+ }
+ if (state.canEnterRecoveryKey) {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.screen_session_verification_enter_recovery_key),
+ onClick = onUseRecoveryKey,
+ )
+ }
+ OutlinedButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.screen_identity_confirmation_cannot_confirm),
+ onClick = onResetKey,
+ )
+ }
+ }
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ modifier = Modifier
+ .clickable(onClick = onLearnMore)
+ .padding(vertical = 4.dp, horizontal = 16.dp),
+ text = stringResource(CommonStrings.action_learn_more),
+ style = ElementTheme.typography.fontBodyLgMedium
+ )
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun ChooseSelfVerificationModeViewPreview(
+ @PreviewParameter(ChooseSelfVerificationModeStateProvider::class) state: ChooseSelfVerificationModeState
+) = ElementPreview {
+ ChooseSelfVerificationModeView(
+ state = state,
+ onUseAnotherDevice = {},
+ onUseRecoveryKey = {},
+ onResetKey = {},
+ onLearnMore = {},
+ )
+}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
index 0409d73538e..744053b976a 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
@@ -66,7 +66,7 @@ class DefaultFtueService @Inject constructor(
.onEach { updateState() }
.launchIn(sessionCoroutineScope)
- analyticsService.didAskUserConsent()
+ analyticsService.didAskUserConsentFlow
.distinctUntilChanged()
.onEach { updateState() }
.launchIn(sessionCoroutineScope)
@@ -118,7 +118,7 @@ class DefaultFtueService @Inject constructor(
}
private suspend fun needsAnalyticsOptIn(): Boolean {
- return analyticsService.didAskUserConsent().first().not()
+ return analyticsService.didAskUserConsentFlow.first().not()
}
private suspend fun shouldAskNotificationPermissions(): Boolean {
diff --git a/features/ftue/impl/src/main/res/values-be/translations.xml b/features/ftue/impl/src/main/res/values-be/translations.xml
index 413bad91322..6c10902ca61 100644
--- a/features/ftue/impl/src/main/res/values-be/translations.xml
+++ b/features/ftue/impl/src/main/res/values-be/translations.xml
@@ -1,7 +1,18 @@
+ "Не можаце пацвердзіць?"
+ "Стварыць новы ключ аднаўлення"
+ "Пацвердзіце гэтую прыладу, каб наладзіць бяспечны абмен паведамленнямі."
+ "Пацвердзіце, што гэта вы"
+ "Выкарыстоўвайце іншую прыладу"
+ "Выкарыстоўваць ключ аднаўлення"
+ "Цяпер вы можаце бяспечна чытаць і адпраўляць паведамленні, і ўсе, з кім вы маеце зносіны ў чаце, таксама могуць давяраць гэтай прыладзе."
+ "Прылада праверана"
+ "Выкарыстоўвайце іншую прыладу"
+ "Чаканне на іншай прыладзе…"
"Вы можаце змяніць налады пазней."
"Дазвольце апавяшчэнні і ніколі не прапускайце іх"
+ "Увядзіце ключ аднаўлення"
"Званкі, апытанні, пошук і многае іншае будзе дададзена пазней у гэтым годзе."
"Гісторыя паведамленняў для зашыфраваных пакояў пакуль недаступна."
"Мы будзем рады пачуць вашае меркаванне, паведаміце нам аб гэтым праз старонку налад."
diff --git a/features/ftue/impl/src/main/res/values-bg/translations.xml b/features/ftue/impl/src/main/res/values-bg/translations.xml
index 22370ab1c5f..7918ac7a0c5 100644
--- a/features/ftue/impl/src/main/res/values-bg/translations.xml
+++ b/features/ftue/impl/src/main/res/values-bg/translations.xml
@@ -1,7 +1,15 @@
+ "Не можете да потвърдите?"
+ "Потвърдете това устройство, за да настроите защитени съобщения."
+ "Потвърдете самоличността си"
+ "Използване на друго устройство"
+ "Използване на ключ за възстановяване"
+ "Устройството е потвърдено"
+ "Използване на друго устройство"
"Можете да промените настройките си по-късно."
"Разрешете известията и никога не пропускайте съобщение"
+ "Въвеждане на ключ за възстановяване"
"Хронологията на съобщенията за шифровани стаи все още не е налична."
"Добре дошли в %1$s!"
diff --git a/features/ftue/impl/src/main/res/values-cs/translations.xml b/features/ftue/impl/src/main/res/values-cs/translations.xml
index bc262cf2b46..9d852feed61 100644
--- a/features/ftue/impl/src/main/res/values-cs/translations.xml
+++ b/features/ftue/impl/src/main/res/values-cs/translations.xml
@@ -1,7 +1,18 @@
+ "Nemůžete potvrdit?"
+ "Vytvoření nového klíče pro obnovení"
+ "Ověřte toto zařízení a nastavte zabezpečené zasílání zpráv."
+ "Potvrďte, že jste to vy"
+ "Použít jiné zařízení"
+ "Použít klíč pro obnovení"
+ "Nyní můžete bezpečně číst nebo odesílat zprávy, a kdokoli, s kým chatujete, může tomuto zařízení důvěřovat."
+ "Zařízení ověřeno"
+ "Použít jiné zařízení"
+ "Čekání na jiném zařízení…"
"Nastavení můžete později změnit."
"Povolte oznámení a nezmeškejte žádnou zprávu"
+ "Zadejte klíč pro obnovení"
"Hovory, hlasování, vyhledávání a další budou přidány koncem tohoto roku."
"Historie zpráv šifrovaných místností nebude v této aktualizaci k dispozici."
"Rádi bychom se od vás dozvěděli, co si o tom myslíte, dejte nám vědět prostřednictvím stránky s nastavením."
diff --git a/features/ftue/impl/src/main/res/values-cy/translations.xml b/features/ftue/impl/src/main/res/values-cy/translations.xml
new file mode 100644
index 00000000000..bf87c2ca70c
--- /dev/null
+++ b/features/ftue/impl/src/main/res/values-cy/translations.xml
@@ -0,0 +1,22 @@
+
+
+ "Methu cadarnhau?"
+ "Crëwch allwedd adfer newydd"
+ "Dilyswch y ddyfais hon er mwyn gosod negeseuon diogel."
+ "Cadarnhewch eich hunaniaeth"
+ "Defnyddiwch ddyfais arall"
+ "Defnyddiwch allwedd adfer"
+ "Nawr gallwch chi ddarllen neu anfon negeseuon yn ddiogel, a gall unrhyw un rydych chi\'n sgwrsio â nhw ymddiried yn y ddyfais hon hefyd."
+ "Dyfais wedi\'i dilysu"
+ "Defnyddiwch ddyfais arall"
+ "Yn aros ar ddyfais arall…"
+ "Gallwch newid eich gosodiadau yn nes ymlaen."
+ "Caniatáu hysbysiadau a pheidio byth â cholli neges"
+ "Rhowch eich allwedd adfer"
+ "Bydd galwadau, pleidleisiau, chwilio a mwy yn cael eu hychwanegu yn ddiweddarach eleni."
+ "Nid yw hanes negeseuon ar gyfer ystafelloedd sydd wedi\'u hamgryptio ar gael eto."
+ "Byddem wrth ein bodd yn clywed gennych, gadewch i ni wybod beth yw eich barn drwy\'r dudalen gosodiadau."
+ "Ymlaen!"
+ "Dyma beth sydd angen i chi ei wybod:"
+ "Croeso i %1$s!"
+
diff --git a/features/ftue/impl/src/main/res/values-da/translations.xml b/features/ftue/impl/src/main/res/values-da/translations.xml
new file mode 100644
index 00000000000..b1ae89a3b43
--- /dev/null
+++ b/features/ftue/impl/src/main/res/values-da/translations.xml
@@ -0,0 +1,22 @@
+
+
+ "Kan ikke bekræfte?"
+ "Opret en ny gendannelsesnøgle"
+ "Verificér denne enhed for at konfigurere sikre meddelelser."
+ "Bekræft din identitet"
+ "Brug en anden enhed"
+ "Brug gendannelsesnøgle"
+ "Nu kan du læse eller sende beskeder sikkert, og enhver du samtaler med kan også stole på denne enhed."
+ "Enhed verificeret"
+ "Brug en anden enhed"
+ "Venter på en anden enhed…"
+ "Du kan ændre dine indstillinger senere."
+ "Tillad notifikationer, og gå aldrig glip af en besked"
+ "Indtast gendannelsesnøgle"
+ "Opkald, afstemninger, søgninger og mere vil blive tilføjet senere på året."
+ "Beskedhistorik for krypterede rum er ikke tilgængelig endnu."
+ "Vi vil meget gerne høre fra dig. Fortæl os din mening via indstillingssiden."
+ "Lad os komme i gang!"
+ "Her er, hvad du har brug for at vide:"
+ "Velkommen til %1$s!"
+
diff --git a/features/ftue/impl/src/main/res/values-de/translations.xml b/features/ftue/impl/src/main/res/values-de/translations.xml
index 151500addad..10473d297e3 100644
--- a/features/ftue/impl/src/main/res/values-de/translations.xml
+++ b/features/ftue/impl/src/main/res/values-de/translations.xml
@@ -1,11 +1,22 @@
- "Du kannst deine Einstellungen später ändern."
+ "Bestätigung unmöglich?"
+ "Erstelle einen neuen Wiederherstellungsschlüssel"
+ "Verifiziere dieses Gerät, um sicheres Messaging einzurichten."
+ "Bestätigen Sie Ihre Identität"
+ "Ein anderes Gerät verwenden"
+ "Wiederherstellungsschlüssel verwenden"
+ "Sie können jetzt verschlüsselte Nachrichten lesen und versenden. Ihre Chatpartner vertrauen nun diesem Gerät auch."
+ "Gerät verifiziert"
+ "Ein anderes Gerät verwenden"
+ "Bitte warten bis das andere Gerät bereit ist."
+ "Sie können Ihre Einstellungen später ändern."
"Erlaube Benachrichtigungen und verpasse keine Nachricht"
+ "Wiederherstellungsschlüssel eingeben"
"Anrufe, Umfragen, Suchfunktionen und mehr werden im Laufe des Jahres hinzugefügt."
"Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein."
"Wir würden uns freuen, von dir zu hören. Teile uns deine Meinung über die Einstellungsseite mit."
"Los geht\'s!"
- "Folgendes musst du wissen:"
+ "Folgendes sollten Sie wissen:"
"Willkommen bei %1$s!"
diff --git a/features/ftue/impl/src/main/res/values-el/translations.xml b/features/ftue/impl/src/main/res/values-el/translations.xml
index 8c384bb8f49..585f5f0952d 100644
--- a/features/ftue/impl/src/main/res/values-el/translations.xml
+++ b/features/ftue/impl/src/main/res/values-el/translations.xml
@@ -1,9 +1,20 @@
+ "Δεν μπορείς να επιβεβαιώσεις;"
+ "Δημιουργία νέου κλειδιού ανάκτησης"
+ "Επαλήθευσε αυτήν τη συσκευή για να ρυθμίσεις την ασφαλή επικοινωνία."
+ "Επιβεβαίωσε ότι είσαι εσύ"
+ "Χρήση άλλης συσκευής"
+ "Χρήση κλειδιού ανάκτησης"
+ "Τώρα μπορείς να διαβάζεις ή να στέλνεις μηνύματα με ασφάλεια και επίσης μπορεί να εμπιστευτεί αυτήν τη συσκευή οποιοσδήποτε με τον οποίο συνομιλείς."
+ "Επαληθευμένη συσκευή"
+ "Χρήση άλλης συσκευής"
+ "Αναμονή σε άλλη συσκευή…"
"Μπορείς να αλλάξεις τις ρυθμίσεις σου αργότερα."
"Επέτρεψε τις ειδοποιήσεις και μην χάσεις ούτε ένα μήνυμα"
+ "Εισαγωγή κλειδιού ανάκτησης"
"Κλήσεις, δημοσκοπήσεις, αναζήτηση και άλλα, θα προστεθούν αργότερα φέτος."
- "Το ιστορικό μηνυμάτων για κρυπτογραφημένα δωμάτια δεν είναι ακόμα διαθέσιμο."
+ "Το ιστορικό μηνυμάτων για κρυπτογραφημένες αίθουσες δεν είναι ακόμη διαθέσιμο."
"Θα θέλαμε να ακούσουμε τη γνώμη σου, πες μας τη γνώμη σου μέσω της σελίδας ρυθμίσεων."
"Πάμε!"
"Να τί πρέπει να ξέρεις:"
diff --git a/features/ftue/impl/src/main/res/values-es/translations.xml b/features/ftue/impl/src/main/res/values-es/translations.xml
index 6a3224c5313..36015aa6dd9 100644
--- a/features/ftue/impl/src/main/res/values-es/translations.xml
+++ b/features/ftue/impl/src/main/res/values-es/translations.xml
@@ -1,7 +1,18 @@
+ "¿No puedes confirmar?"
+ "Crear una nueva clave de recuperación"
+ "Verifica este dispositivo para configurar la mensajería segura."
+ "Confirma que eres tú"
+ "Usar otro dispositivo"
+ "Usar clave de recuperación"
+ "Ahora puedes leer o enviar mensajes de forma segura y cualquier persona con la que chatees también puede confiar en este dispositivo."
+ "Dispositivo verificado"
+ "Usar otro dispositivo"
+ "Esperando en otro dispositivo…"
"Puedes cambiar la configuración más tarde."
"Activa las notificaciones y nunca te pierdas un mensaje"
+ "Introduce la clave de recuperación"
"Las llamadas, las encuestas, la búsqueda y más se agregarán más adelante este año."
"El historial de mensajes de las salas cifradas aún no está disponible."
"Nos encantaría saber de ti, haznos saber lo que piensas a través de la página de configuración."
diff --git a/features/ftue/impl/src/main/res/values-et/translations.xml b/features/ftue/impl/src/main/res/values-et/translations.xml
index 83f6cb93fac..09762a4f631 100644
--- a/features/ftue/impl/src/main/res/values-et/translations.xml
+++ b/features/ftue/impl/src/main/res/values-et/translations.xml
@@ -1,7 +1,18 @@
+ "Kas kinnitamine pole võimalik?"
+ "Loo uus taastevõti"
+ "Krüptitud sõnumivahetuse tagamiseks verifitseeri see seade."
+ "Kinnita, et see oled sina"
+ "Kasuta teist seadet"
+ "Kasuta taastevõtit"
+ "Nüüd saad saata või lugeda sõnumeid turvaliselt ning kõik sinu vestluspartnerid võivad usaldada seda seadet."
+ "Seade on verifitseeritud"
+ "Kasuta teist seadet"
+ "Ootame teise seadme järgi…"
"Sa võid seadistusi hiljem alati muuta."
"Luba teavitused ja kunagi ei jää sul sõnumid märkamata"
+ "Sisesta taastevõti"
"Kõned, küsitlused, otsing ja palju muud lisanduvad hiljem selle aasta jooksul."
"Krüptitud jututubade sõnumite ajalugu pole veel saadaval."
"Me soovime teada mida sa arvad. Seadistuste lehel olevast valikust võid saata meile oma kommentaare."
diff --git a/features/ftue/impl/src/main/res/values-eu/translations.xml b/features/ftue/impl/src/main/res/values-eu/translations.xml
new file mode 100644
index 00000000000..2dcdff7330d
--- /dev/null
+++ b/features/ftue/impl/src/main/res/values-eu/translations.xml
@@ -0,0 +1,21 @@
+
+
+ "Ezin duzu baieztatu?"
+ "Sortu berreskuratze-gako berria"
+ "Egiaztatu gailua mezularitza segurua konfiguratzeko."
+ "Berretsi zure identitatea"
+ "Erabili beste gailu bat"
+ "Erabili berreskuratze-gakoa"
+ "Orain mezuak modu seguruan irakurri edo bidal ditzakezu, eta txateatzen duzun edonor ere fida daiteke gailu honetaz."
+ "Gailua egiaztatu da"
+ "Erabili beste gailu bat"
+ "Beste gailuaren zain…"
+ "Geroago alda ditzakezu ezarpenak."
+ "Baimendu jakinarazpenak eta ez galdu inoiz mezurik"
+ "Sartu berreskuratze-gakoa"
+ "Deiak, inkestak, bilaketa eta gehiago gehituko dira aurten."
+ "Enkriptatutako geletarako mezuen historia ez dago oraindik erabilgarri."
+ "Goazen!"
+ "Hona hemen jakin beharrekoa:"
+ "Ongi etorri %1$s(e)ra!"
+
diff --git a/features/ftue/impl/src/main/res/values-fa/translations.xml b/features/ftue/impl/src/main/res/values-fa/translations.xml
index 43d968b7c57..72151a11760 100644
--- a/features/ftue/impl/src/main/res/values-fa/translations.xml
+++ b/features/ftue/impl/src/main/res/values-fa/translations.xml
@@ -1,7 +1,21 @@
+ "نمیتوانید تأیید کنید؟"
+ "ایجاد کلید بازیابی جدید"
+ "تأیید این افزاره برای برپایی پیامرسانی امن."
+ "تأیید هویتتان"
+ "استفاده از افزارهای دیگر"
+ "استفاده از کلید بازیابی"
+ "اکنون میتوانید پیامها را به صورت امن فرستاده و بگیرید و هرکسی که با او گپ میزنید نیز میتواند به این افزاره اعتماد کند."
+ "افزاره تأیید شده"
+ "استفاده از افزارهای دیگر"
+ "منتظر افزارهٔ دیگر…"
"میتوانید بعداً تنظیماتتان را تغییر دهید."
"اجازه به آگاهیها و از دست ندادن پیامها"
+ "ورود کلید بازیابی"
+ "تماس ها، نظرسنجی، جستجو و موارد دیگر در اواخر امسال اضافه خواهند شد."
+ "سابقه پیام برای اتاق های رمزگذاری شده هنوز دردسترس نیست."
+ "ما دوست داریم از شما بشنویم، نظر خود را از طریق صفحه تنظیمات با ما در میان بگذارید."
"بزن بریم!"
"چیزهایی که باید بدانید:"
"به %1$s خوش آمدید!"
diff --git a/features/ftue/impl/src/main/res/values-fi/translations.xml b/features/ftue/impl/src/main/res/values-fi/translations.xml
index f04854ffefe..3f3110bcdf9 100644
--- a/features/ftue/impl/src/main/res/values-fi/translations.xml
+++ b/features/ftue/impl/src/main/res/values-fi/translations.xml
@@ -1,7 +1,18 @@
+ "Etkö voi vahvistaa?"
+ "Luo uusi palautusavain"
+ "Vahvista tämä laite suojattua viestintää varten."
+ "Vahvista identiteettisi"
+ "Käytä toista laitetta"
+ "Käytä palautusavainta"
+ "Nyt voit lukea ja lähettää viestejä turvallisesti, ja kaikki, joiden kanssa keskustelet, voivat myös luottaa tähän laitteeseen."
+ "Laite vahvistettu"
+ "Käytä toista laitetta"
+ "Odotetaan toista laitetta…"
"Voit muuttaa asetuksia myöhemmin."
"Salli ilmoitukset ja älä koskaan missaa viestejä"
+ "Syötä palautusavain"
"Puhelut, kyselyt, haku ja paljon muuta lisätään myöhemmin tänä vuonna."
"Salattujen huoneiden viestihistoria ei ole vielä käytettävissä."
"Haluaisimme kuulla mielipiteesi, kerro mitä mieltä olet asetuksien kautta."
diff --git a/features/ftue/impl/src/main/res/values-fr/translations.xml b/features/ftue/impl/src/main/res/values-fr/translations.xml
index 9fc11413cbe..04396014474 100644
--- a/features/ftue/impl/src/main/res/values-fr/translations.xml
+++ b/features/ftue/impl/src/main/res/values-fr/translations.xml
@@ -1,7 +1,18 @@
+ "Confirmation impossible ?"
+ "Créer une nouvelle clé de récupération"
+ "Vérifier cette session pour configurer votre messagerie sécurisée."
+ "Confirmez votre identité"
+ "Utiliser une autre session"
+ "Utiliser la clé de récupération"
+ "Vous pouvez désormais lire ou envoyer des messages en toute sécurité, et toute personne avec qui vous discutez peut également faire confiance à cette session."
+ "Session vérifiée"
+ "Utiliser une autre session"
+ "En attente d’une autre session…"
"Vous pourrez modifier vos paramètres ultérieurement."
"Autorisez les notifications et ne manquez aucun message"
+ "Utiliser la clé de récupération"
"Les appels, les sondages, les recherches et plus encore seront ajoutés plus tard cette année."
"L’historique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour."
"N’hésitez pas à nous faire part de vos commentaires via l’écran des paramètres."
diff --git a/features/ftue/impl/src/main/res/values-hu/translations.xml b/features/ftue/impl/src/main/res/values-hu/translations.xml
index 6a7acab0a4a..5dfdedd4b0e 100644
--- a/features/ftue/impl/src/main/res/values-hu/translations.xml
+++ b/features/ftue/impl/src/main/res/values-hu/translations.xml
@@ -1,7 +1,18 @@
+ "Nem tudja megerősíteni?"
+ "Új helyreállítási kulcs létrehozása"
+ "A biztonságos üzenetkezelés beállításához ellenőrizze ezt az eszközt."
+ "Erősítse meg, hogy Ön az"
+ "Másik eszköz használata"
+ "Helyreállítási kulcs használata"
+ "Mostantól biztonságosan olvashat vagy küldhet üzeneteket, és bármelyik csevegőpartnere megbízhat ebben az eszközben."
+ "Eszköz ellenőrizve"
+ "Másik eszköz használata"
+ "Várakozás a másik eszközre…"
"A beállításokat később is módosíthatja."
"Értesítések engedélyezése, hogy soha ne maradjon le egyetlen üzenetről sem"
+ "Adja meg a helyreállítási kulcsot"
"A hívások, szavazások, keresések és egyebek az év további részében kerülnek hozzáadásra."
"A titkosított szobák üzenetelőzményei nem lesznek elérhetők ebben a frissítésben."
"Szeretnénk hallani a véleményét, ossza meg velünk a beállítások oldalon."
diff --git a/features/ftue/impl/src/main/res/values-in/translations.xml b/features/ftue/impl/src/main/res/values-in/translations.xml
index 1004f8a6431..5325b7f58d8 100644
--- a/features/ftue/impl/src/main/res/values-in/translations.xml
+++ b/features/ftue/impl/src/main/res/values-in/translations.xml
@@ -1,7 +1,18 @@
+ "Tidak dapat mengonfirmasi?"
+ "Buat kunci pemulihan baru"
+ "Verifikasi perangkat ini untuk menyiapkan perpesanan aman."
+ "Konfirmasi bahwa ini Anda"
+ "Gunakan perangkat lain"
+ "Gunakan kunci pemulihan"
+ "Sekarang Anda dapat membaca atau mengirim pesan dengan aman, dan siapa pun yang mengobrol dengan Anda juga dapat mempercayai perangkat ini."
+ "Perangkat terverifikasi"
+ "Gunakan perangkat lain"
+ "Menunggu di perangkat lain…"
"Anda dapat mengubah pengaturan Anda nanti."
"Izinkan pemberitahuan dan jangan pernah melewatkan pesan"
+ "Masukkan kunci pemulihan"
"Panggilan, pemungutan suara, pencarian, dan lainnya akan ditambahkan di tahun ini."
"Riwayat pesan untuk ruangan terenkripsi tidak akan tersedia dalam pembaruan ini."
"Kami ingin mendengar dari Anda, beri tahu kami pendapat Anda melalui halaman pengaturan."
diff --git a/features/ftue/impl/src/main/res/values-it/translations.xml b/features/ftue/impl/src/main/res/values-it/translations.xml
index eaff5216dce..3992acd63eb 100644
--- a/features/ftue/impl/src/main/res/values-it/translations.xml
+++ b/features/ftue/impl/src/main/res/values-it/translations.xml
@@ -1,7 +1,18 @@
+ "Non puoi confermare?"
+ "Crea una nuova chiave di recupero"
+ "Verifica questo dispositivo per segnare i tuoi messaggi come sicuri."
+ "Conferma la tua identità"
+ "Usa un altro dispositivo"
+ "Usa la chiave di recupero"
+ "Ora puoi leggere o inviare messaggi in tutta sicurezza e anche chi chatta con te può fidarsi di questo dispositivo."
+ "Dispositivo verificato"
+ "Usa un altro dispositivo"
+ "In attesa sull\'altro dispositivo…"
"Potrai modificare le tue impostazioni in seguito."
"Consenti le notifiche e non perdere mai un messaggio"
+ "Inserisci la chiave di recupero"
"Chiamate, sondaggi, ricerche e altro ancora saranno aggiunti nel corso dell\'anno."
"La cronologia dei messaggi per le stanze crittografate non è ancora disponibile."
"Ci piacerebbe sentire il tuo parere, facci sapere cosa ne pensi tramite la pagina delle impostazioni."
diff --git a/features/ftue/impl/src/main/res/values-ka/translations.xml b/features/ftue/impl/src/main/res/values-ka/translations.xml
index 04aacccf836..f8ae896aba7 100644
--- a/features/ftue/impl/src/main/res/values-ka/translations.xml
+++ b/features/ftue/impl/src/main/res/values-ka/translations.xml
@@ -1,7 +1,14 @@
+ "ახალი აღდგენის გასაღების შექმნა"
+ "დაადასტურეთ ეს მოწყობილობა უსაფრთხო მიმოწერისათვის."
+ "დაამტკიცეთ თქვენი პიროვნება"
+ "ახლა თქვენ შეძლებთ შეტყობინებების წაკითხვას ან გაგზავნას უსაფრთხოდ, სხვა მომხმარებლებსაც შეუძლიათ ამ მოწყობილობას ენდონ."
+ "მოწყობილობა დადასტურებულია"
+ "ველოდებით სხვა მოწყობილობას…"
"თქვენ შეგიძლიათ შეცვალოთ თქვენი პარამეტრები მოგვიანებით."
"ყველა შეტყობინებაზე შეტყობინებების მიღება"
+ "შეიყვანეთ აღდგენის გასაღები"
"ზარები, გამოკითხვები, ძიება და სხვა დაემატება ამ წლის ბოლოს."
"დაშიფრული ოთახებისთვის შეტყობინებების ისტორია ჯერ არ არის ხელმისაწვდომი."
"ჩვენ სიამოვნებით მოვისმინოთ თქვენგან, შეგვატყობინეთ რას ფიქრობთ პარამეტრების გვერდზე."
diff --git a/features/ftue/impl/src/main/res/values-nb/translations.xml b/features/ftue/impl/src/main/res/values-nb/translations.xml
new file mode 100644
index 00000000000..61810952ba0
--- /dev/null
+++ b/features/ftue/impl/src/main/res/values-nb/translations.xml
@@ -0,0 +1,22 @@
+
+
+ "Kan du ikke bekrefte?"
+ "Opprett en ny gjenopprettingsnøkkel"
+ "Verifiser denne enheten for å sette opp sikker meldingsutveksling."
+ "Bekreft identiteten din"
+ "Bruk en annen enhet"
+ "Bruk gjenopprettingsnøkkel"
+ "Nå kan du lese eller sende meldinger på en sikker måte, og alle du chatter med kan også stole på denne enheten."
+ "Enhet verifisert"
+ "Bruk en annen enhet"
+ "Venter på en annen enhet…"
+ "Du kan endre innstillingene dine senere."
+ "Tillat varslinger og gå aldri glipp av en melding"
+ "Skriv inn gjenopprettingsnøkkel"
+ "Samtaler, avstemninger, søk og mer vil bli lagt til senere i år."
+ "Meldingshistorikk for krypterte rom er ikke tilgjengelig ennå."
+ "Vi vil gjerne høre fra deg, så la oss få vite hva du synes via innstillingssiden."
+ "Kom igjen!"
+ "Her er hva du trenger å vite:"
+ "Velkommen til %1$s!"
+
diff --git a/features/ftue/impl/src/main/res/values-nl/translations.xml b/features/ftue/impl/src/main/res/values-nl/translations.xml
index 1975f26467f..459b003e53c 100644
--- a/features/ftue/impl/src/main/res/values-nl/translations.xml
+++ b/features/ftue/impl/src/main/res/values-nl/translations.xml
@@ -1,7 +1,18 @@
+ "Kan ik dit niet bevestigen?"
+ "Maak een nieuwe herstelsleutel"
+ "Verifieer dit apparaat om beveiligde berichten in te stellen."
+ "Bevestig dat jij het bent"
+ "Gebruik een ander apparaat"
+ "Gebruik de herstelsleutel"
+ "Nu kun je veilig berichten lezen of verzenden, en iedereen met wie je chat kan dit apparaat ook vertrouwen."
+ "Apparaat geverifieerd"
+ "Gebruik een ander apparaat"
+ "Wachten op ander apparaat…"
"Je kunt je instellingen later wijzigen."
"Sta meldingen toe en mis nooit meer een bericht"
+ "Voer herstelsleutel in"
"Oproepen, peilingen, zoekopdrachten en meer zullen later dit jaar worden toegevoegd."
"Berichtgeschiedenis voor versleutelde kamers is nog niet beschikbaar."
"We horen graag van je, laat ons weten wat je ervan vindt via de instellingenpagina."
diff --git a/features/ftue/impl/src/main/res/values-pl/translations.xml b/features/ftue/impl/src/main/res/values-pl/translations.xml
index b1c1909ac28..682bae9cf91 100644
--- a/features/ftue/impl/src/main/res/values-pl/translations.xml
+++ b/features/ftue/impl/src/main/res/values-pl/translations.xml
@@ -1,7 +1,18 @@
+ "Nie możesz potwierdzić?"
+ "Utwórz nowy klucz przywracania"
+ "Zweryfikuj to urządzenie, aby skonfigurować bezpieczne przesyłanie wiadomości."
+ "Potwierdź, że to Ty"
+ "Użyj innego urządzenia"
+ "Użyj klucza przywracania"
+ "Teraz możesz bezpiecznie czytać i wysyłać wiadomości, każdy z kim czatujesz również może ufać temu urządzeniu."
+ "Urządzenie zweryfikowane"
+ "Użyj innego urządzenia"
+ "Oczekiwanie na inne urządzenie…"
"Możesz zmienić ustawienia później."
"Zezwól na powiadomienia i nie przegap żadnej wiadomości"
+ "Wprowadź klucz przywracania"
"Połączenia, ankiety, wyszukiwanie i inne zostaną dodane później w tym roku."
"Historia wiadomości dla pokoi szyfrowanych nie jest jeszcze dostępna."
"Chętnie poznamy Twoją opinię. Daj nam znać, co myślisz na stronie ustawień."
diff --git a/features/ftue/impl/src/main/res/values-pt-rBR/translations.xml b/features/ftue/impl/src/main/res/values-pt-rBR/translations.xml
index 1528027bd49..223ceedd31c 100644
--- a/features/ftue/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/ftue/impl/src/main/res/values-pt-rBR/translations.xml
@@ -1,7 +1,18 @@
+ "Não consegue confirmar?"
+ "Criar uma nova chave de recuperação"
+ "Verifique este dispositivo para configurar mensagens seguras."
+ "Confirme sua identidade"
+ "Usar outro dispositivo"
+ "Use a chave de recuperação"
+ "Agora você pode ler ou enviar mensagens com segurança, e qualquer pessoa com quem você conversa também pode confiar neste dispositivo."
+ "Dispositivo verificado"
+ "Usar outro dispositivo"
+ "Aguardando outro dispositivo…"
"Você pode alterar suas configurações mais tarde."
"Permita notificações e nunca perca uma mensagem"
+ "Insira a chave de recuperação"
"Chamadas, enquetes, pesquisa e muito mais serão adicionadas ainda este ano."
"O histórico de mensagens para salas criptografadas ainda não está disponível."
"Adoraríamos ouvir sua opinião. Deixe-nos saber o que você pensa através da página de configurações."
diff --git a/features/ftue/impl/src/main/res/values-pt/translations.xml b/features/ftue/impl/src/main/res/values-pt/translations.xml
index 8ff79c3b64e..d96caa5065c 100644
--- a/features/ftue/impl/src/main/res/values-pt/translations.xml
+++ b/features/ftue/impl/src/main/res/values-pt/translations.xml
@@ -1,7 +1,18 @@
+ "Não é possível confirmar?"
+ "Criar uma nova chave de recuperação"
+ "Verifica este dispositivo para configurar o envio seguro de mensagens."
+ "Confirma que és tu"
+ "Utilizar outro dispositivo"
+ "Utilizar chave de recuperação"
+ "Agora podes ler ou enviar mensagens de forma segura, e qualquer pessoa com quem converses também pode confiar neste dispositivo."
+ "Dispositivo verificado"
+ "Utilizar outro dispositivo"
+ "A aguardar por outros dispositivos…"
"Podes alterar as tuas definições mais tarde."
"Permite as notificações e nunca percas uma mensagem"
+ "Insere a chave de recuperação"
"Chamadas, sondagens, pesquisa e mais funcionalidades vão ser adicionadas ao longo do ano."
"O histórico de mensagens em salas cifradas ainda não está disponível."
"Gostaríamos de ouvir a tua opinião, diz-nos o que pensas através da página de configurações."
diff --git a/features/ftue/impl/src/main/res/values-ro/translations.xml b/features/ftue/impl/src/main/res/values-ro/translations.xml
index b89537903d9..4d2c9febaeb 100644
--- a/features/ftue/impl/src/main/res/values-ro/translations.xml
+++ b/features/ftue/impl/src/main/res/values-ro/translations.xml
@@ -1,7 +1,18 @@
+ "Nu puteți confirma?"
+ "Creați o nouă cheie de recuperare"
+ "Verificați acest dispozitiv pentru a configura mesagerie securizată."
+ "Confirmați că sunteți dumneavoastră"
+ "Utilizați un alt dispozitiv"
+ "Utilizați cheia de recuperare"
+ "Acum puteți citi sau trimite mesaje în siguranță, iar oricine cu care conversați poate avea încredere în acest dispozitiv."
+ "Dispozitiv verificat"
+ "Utilizați un alt dispozitiv"
+ "Se așteaptă celălalt dispozitiv…"
"Puteți modifica setările mai târziu."
"Permiteți notificările și nu pierdeți niciodată un mesaj"
+ "Introduceți cheia de recuperare"
"Apelurile, sondajele, căutare și multe altele vor fi adăugate în cursul acestui an."
"Istoricul mesajelor pentru camerele criptate nu va fi disponibil în această actualizare."
"Ne-ar plăcea să auzim de la dumneavoastră, spuneți-ne ce părere aveți prin intermediul paginii de setări."
diff --git a/features/ftue/impl/src/main/res/values-ru/translations.xml b/features/ftue/impl/src/main/res/values-ru/translations.xml
index 7a5ed89a217..d287e656407 100644
--- a/features/ftue/impl/src/main/res/values-ru/translations.xml
+++ b/features/ftue/impl/src/main/res/values-ru/translations.xml
@@ -1,7 +1,18 @@
+ "Не можете подтвердить?"
+ "Создайте новый ключ восстановления"
+ "Подтвердите это устройство, чтобы настроить безопасный обмен сообщениями."
+ "Подтвердите, что это вы"
+ "Использовать другое устройство"
+ "Используйте recovery key"
+ "Теперь вы можете безопасно читать и отправлять сообщения, и все, с кем вы общаетесь в чате, также могут доверять этому устройству."
+ "Устройство проверено"
+ "Использовать другое устройство"
+ "Ожидание на другом устройстве…"
"Вы можете изменить настройки позже."
"Разрешите отправку уведомлений и ни одно сообщение не будет пропущено"
+ "Введите ключ восстановления"
"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."
"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."
"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."
diff --git a/features/ftue/impl/src/main/res/values-sk/translations.xml b/features/ftue/impl/src/main/res/values-sk/translations.xml
index d963dbd7343..9f39ca6065e 100644
--- a/features/ftue/impl/src/main/res/values-sk/translations.xml
+++ b/features/ftue/impl/src/main/res/values-sk/translations.xml
@@ -1,7 +1,18 @@
+ "Nemôžete potvrdiť?"
+ "Vytvoriť nový kľúč na obnovenie"
+ "Ak chcete nastaviť zabezpečené správy, overte toto zariadenie."
+ "Potvrďte, že ste to vy"
+ "Použite iné zariadenie"
+ "Použiť kľúč na obnovenie"
+ "Teraz môžete bezpečne čítať alebo odosielať správy a tomuto zariadeniu môže dôverovať aj ktokoľvek, s kým konverzujete."
+ "Zariadenie overené"
+ "Použite iné zariadenie"
+ "Čaká sa na druhom zariadení…"
"Svoje nastavenia môžete neskôr zmeniť."
"Povoľte oznámenia a nikdy nezmeškajte žiadnu správu"
+ "Zadajte kľúč na obnovenie"
"Hovory, ankety, vyhľadávanie a ďalšie funkcie pribudnú neskôr v tomto roku."
"História správ pre zašifrované miestnosti nebude v tejto aktualizácii k dispozícii."
"Radi by sme od vás počuli, dajte nám vedieť, čo si myslíte, prostredníctvom stránky nastavení."
diff --git a/features/ftue/impl/src/main/res/values-sv/translations.xml b/features/ftue/impl/src/main/res/values-sv/translations.xml
index 31823cfaab3..ffaf682393f 100644
--- a/features/ftue/impl/src/main/res/values-sv/translations.xml
+++ b/features/ftue/impl/src/main/res/values-sv/translations.xml
@@ -1,7 +1,18 @@
+ "Kan du inte bekräfta?"
+ "Skapa en ny återställningsnyckel"
+ "Verifiera den här enheten för att konfigurera säkra meddelanden."
+ "Bekräfta att det är du"
+ "Använd en annan enhet"
+ "Använd återställningsnyckel"
+ "Nu kan du läsa eller skicka meddelanden säkert, och alla du chattar med kan också lita på den här enheten."
+ "Enhet verifierad"
+ "Använd en annan enhet"
+ "Väntar på annan enhet …"
"Du kan ändra dina inställningar senare."
"Tillåt aviseringar och missa aldrig ett meddelande"
+ "Ange återställningsnyckel"
"Samtal, omröstningar, sökning och mer kommer att läggas till senare i år."
"Meddelandehistorik för krypterade rum är inte tillgänglig än."
"Vi vill gärna höra från dig, låt oss veta vad du tycker via inställningssidan."
diff --git a/features/ftue/impl/src/main/res/values-tr/translations.xml b/features/ftue/impl/src/main/res/values-tr/translations.xml
new file mode 100644
index 00000000000..dbeb65832cc
--- /dev/null
+++ b/features/ftue/impl/src/main/res/values-tr/translations.xml
@@ -0,0 +1,22 @@
+
+
+ "Onaylayamıyor musunuz?"
+ "Yeni bir kurtarma anahtarı oluştur"
+ "Güvenli mesajlaşmayı ayarlamak için bu cihazı doğrulayın."
+ "Kimliğinizi doğrulayın"
+ "Başka bir cihaz kullan"
+ "Kurtarma anahtarı kullan"
+ "Artık mesajları güvenli bir şekilde okuyabilir veya gönderebilirsiniz ve sohbet ettiğiniz herkes de bu cihaza güvenebilir."
+ "Cihaz doğrulandı"
+ "Başka bir cihaz kullan"
+ "Diğer cihazda bekleniyor…"
+ "Ayarlarınızı daha sonra değiştirebilirsiniz."
+ "Bildirimlere izin verin ve hiçbir mesajı kaçırmayın"
+ "Kurtarma anahtarını girin"
+ "Çağrılar, anketler, arama ve daha fazlası bu yıl içinde eklenecek."
+ "Şifrelenmiş odalar için mesaj geçmişi henüz mevcut değil."
+ "Düşüncelerinizi bizimle paylaşmanızı çok isteriz. Ayarlar sayfasından düşüncelerinizi bize iletin."
+ "Hadi başlayalım!"
+ "İşte bilmeniz gerekenler:"
+ "%1$s hoş geldiniz!"
+
diff --git a/features/ftue/impl/src/main/res/values-uk/translations.xml b/features/ftue/impl/src/main/res/values-uk/translations.xml
index 920f6d3d1c0..6dc00561499 100644
--- a/features/ftue/impl/src/main/res/values-uk/translations.xml
+++ b/features/ftue/impl/src/main/res/values-uk/translations.xml
@@ -1,7 +1,18 @@
+ "Не можете підтвердити?"
+ "Створити новий ключ відновлення"
+ "Верифікуйте цей пристрій, щоб налаштувати безпечний обмін повідомленнями."
+ "Підтвердьте, що це ви"
+ "Використовуйте інший пристрій"
+ "Використовуйте ключ відновлення"
+ "Тепер ви можете безпечно читати або надсилати повідомлення, і кожен, з ким ви спілкуєтесь, також може довіряти цьому пристрою."
+ "Пристрій перевірено"
+ "Використовуйте інший пристрій"
+ "Чекає на інше пристрій…"
"Ви можете змінити свої налаштування пізніше."
"Дозволити сповіщення і ніколи не пропускати повідомлення"
+ "Введіть ключ відновлення"
"Виклики, опитування, пошук тощо будуть додані пізніше цього року."
"Історія повідомлень для зашифрованих кімнат ще недоступна."
"Ми хотіли б почути вас, розкажіть нам ваші враження та ідеї щодо застосунку на сторінці налаштувань."
diff --git a/features/ftue/impl/src/main/res/values-ur/translations.xml b/features/ftue/impl/src/main/res/values-ur/translations.xml
new file mode 100644
index 00000000000..69882e87d0f
--- /dev/null
+++ b/features/ftue/impl/src/main/res/values-ur/translations.xml
@@ -0,0 +1,22 @@
+
+
+ "تصدیق نہیں کر سکتے؟"
+ "ایک نئی بازیابی کلید تخلیق کریں"
+ "محفوظ پیغام رسانی ترتیب دینے کیلئے اس آلے کی توثیق کریں۔"
+ "اپنی شناخت کی تصدیق کریں"
+ "دوسرا آلہ استعمال کریں"
+ "بازیابی کلید استعمال کریں"
+ "اب آپ محفوظ طریقے سے پیغامات پڑھ یا بھیج سکتے ہیں، اور جسکے ساتھ آپ گفتگو کرتے ہیں وہ بھی اس آلہ پر بھروسہ کر سکتا ہے۔"
+ "آلہ توثیق شدہ"
+ "دوسرا آلہ استعمال کریں"
+ "دوسرے آلہ پر منتظر…"
+ "آپ بعد میں اپنی ترتیبات تبدیل کر سکتے ہیں۔"
+ "اطلاعات کی اجازت دیں اور کبھی بھی کسی پیغام سے محروم نہ ہوں۔"
+ "بازیابی کلید درج کریں"
+ "مکالمات، رائے شماری، تلاش اور مزید بعد میں اس سال شامل کیا جائے گا۔"
+ "مرموز کردہ کمروں کیلئے سرگزشتِ پیغام ابھی دستیاب نہیں ہے"
+ "ہم آپ سے سننا پسند کریں گے، ترتیبات کے صفحہ کے ذریعے ہمیں بتائیں کہ آپ کیا سوچتے ہیں۔"
+ "چلیں!"
+ "یہ ہے جو آپ کو جاننے کی ضرورت ہے:"
+ "%1$s میں خوش آمدید!"
+
diff --git a/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml b/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml
index 34fb4fbe35c..5658f306160 100644
--- a/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml
@@ -1,7 +1,18 @@
+ "無法確認?"
+ "建立新的復原金鑰"
+ "驗證這部裝置以設定安全通訊。"
+ "確認這是你本人"
+ "使用另一部裝置"
+ "使用復原金鑰"
+ "您可以安全地讀取和發送訊息了,與您聊天的人也可以信任這部裝置。"
+ "裝置已驗證"
+ "使用另一部裝置"
+ "正在等待其他裝置…"
"您稍後仍可變更設定。"
"允許通知,永遠不會錯誤任何訊息"
+ "輸入復原金鑰"
"通話、投票、搜尋等更多功能將在今年登場。"
"在這次的更新,您無法查看聊天室內被加密的歷史訊息。"
"我們很樂意聽取您的意見,請到設定頁面告訴我們您的想法。"
diff --git a/features/ftue/impl/src/main/res/values-zh/translations.xml b/features/ftue/impl/src/main/res/values-zh/translations.xml
index c98be668674..88b8b4252e7 100644
--- a/features/ftue/impl/src/main/res/values-zh/translations.xml
+++ b/features/ftue/impl/src/main/res/values-zh/translations.xml
@@ -1,7 +1,18 @@
+ "无法确认?"
+ "创建新的恢复密钥"
+ "验证此设备以开始安全地收发消息。"
+ "确认这是你"
+ "使用其他设备"
+ "使用恢复密钥"
+ "现在,您可以安全地阅读或发送消息,与您聊天的人也会信任此设备。"
+ "设备已验证"
+ "使用其他设备"
+ "正在等待其他设备……"
"您可以稍后更改设置。"
"允许通知,绝不错过任何消息"
+ "输入恢复密钥"
"今年晚些时候将增加通话、投票、搜索等功能。"
"加密聊天室的消息历史记录尚不可用。"
"我们很乐意听取您的意见,请通过设置页面告诉我们您的想法。"
diff --git a/features/ftue/impl/src/main/res/values/localazy.xml b/features/ftue/impl/src/main/res/values/localazy.xml
index 3e8c86b7614..47214a93041 100644
--- a/features/ftue/impl/src/main/res/values/localazy.xml
+++ b/features/ftue/impl/src/main/res/values/localazy.xml
@@ -1,7 +1,18 @@
+ "Can\'t confirm?"
+ "Create a new recovery key"
+ "Verify this device to set up secure messaging."
+ "Confirm your identity"
+ "Use another device"
+ "Use recovery key"
+ "Now you can read or send messages securely, and anyone you chat with can also trust this device."
+ "Device verified"
+ "Use another device"
+ "Waiting on other device…"
"You can change your settings later."
"Allow notifications and never miss a message"
+ "Enter recovery key"
"Calls, polls, search and more will be added later this year."
"Message history for encrypted rooms isn’t available yet."
"We’d love to hear from you, let us know what you think via the settings page."
diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt
index 9ee2f519946..4a05433e5e3 100644
--- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt
+++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt
@@ -19,7 +19,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.permissions.api.PermissionStateProvider
-import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
+import io.element.android.libraries.permissions.test.FakePermissionStateProvider
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.services.analytics.api.AnalyticsService
diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenterTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenterTest.kt
index 55bc1b92438..fbdc9d1dd17 100644
--- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenterTest.kt
+++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenterTest.kt
@@ -14,7 +14,7 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.libraries.permissions.api.PermissionsPresenter
-import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
+import io.element.android.libraries.permissions.test.FakePermissionStateProvider
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt
new file mode 100644
index 00000000000..3801001ed41
--- /dev/null
+++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2025 New Vector 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.ftue.impl.sessionverification.choosemode
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.logout.api.direct.DirectLogoutEvents
+import io.element.android.features.logout.api.direct.DirectLogoutState
+import io.element.android.features.logout.api.direct.aDirectLogoutState
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.matrix.api.encryption.RecoveryState
+import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class ChooseSessionVerificationModePresenterTest {
+ @Test
+ fun `initial state - is relayed from EncryptionService`() = runTest {
+ val encryptionService = FakeEncryptionService().apply {
+ // Is last device
+ emitIsLastDevice(true)
+ // Can enter recovery key
+ emitRecoveryState(RecoveryState.INCOMPLETE)
+ }
+ val presenter = createPresenter(encryptionService = encryptionService)
+ presenter.test {
+ awaitItem().run {
+ assertThat(isLastDevice).isTrue()
+ assertThat(canEnterRecoveryKey).isTrue()
+ assertThat(directLogoutState.logoutAction.isUninitialized()).isTrue()
+ }
+ }
+ }
+
+ @Test
+ fun `sing out action triggers a direct logout`() = runTest {
+ val logoutEventRecorder = lambdaRecorder {}
+ val logoutPresenter = Presenter {
+ aDirectLogoutState(eventSink = logoutEventRecorder)
+ }
+ val presenter = createPresenter(directLogoutPresenter = logoutPresenter)
+ presenter.test {
+ val initial = awaitItem()
+ initial.eventSink(ChooseSelfVerificationModeEvent.SignOut)
+
+ logoutEventRecorder.assertions().isCalledOnce().with(value(DirectLogoutEvents.Logout(ignoreSdkError = false)))
+ }
+ }
+
+ private fun createPresenter(
+ encryptionService: FakeEncryptionService = FakeEncryptionService(),
+ directLogoutPresenter: Presenter = Presenter { aDirectLogoutState() }
+ ) = ChooseSelfVerificationModePresenter(
+ encryptionService = encryptionService,
+ directLogoutPresenter = directLogoutPresenter,
+ )
+}
diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt
new file mode 100644
index 00000000000..b89e3e42bd9
--- /dev/null
+++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2025 New Vector 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.ftue.impl.sessionverification.choosemode
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.ftue.impl.R
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+
+@RunWith(AndroidJUnit4::class)
+class ChooseSessionVerificationModeViewTest {
+ @get:Rule val rule = createAndroidComposeRule()
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on learn more invokes the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setChooseSelfVerificationModeView(
+ aChooseSelfVerificationModeState(),
+ onLearnMoreClick = callback,
+ )
+ rule.clickOn(CommonStrings.action_learn_more)
+ }
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on use another device calls the callback`() {
+ ensureCalledOnce { callback ->
+ rule.setChooseSelfVerificationModeView(
+ aChooseSelfVerificationModeState(isLastDevice = false),
+ onUseAnotherDevice = callback,
+ )
+ rule.clickOn(R.string.screen_identity_use_another_device)
+ }
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on enter recovery key calls the callback`() {
+ ensureCalledOnce { callback ->
+ rule.setChooseSelfVerificationModeView(
+ aChooseSelfVerificationModeState(canEnterRecoveryKey = true),
+ onEnterRecoveryKey = callback,
+ )
+ rule.clickOn(R.string.screen_session_verification_enter_recovery_key)
+ }
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on cannot confirm calls the reset keys callback`() {
+ ensureCalledOnce { callback ->
+ rule.setChooseSelfVerificationModeView(
+ aChooseSelfVerificationModeState(),
+ onResetKey = callback,
+ )
+ rule.clickOn(R.string.screen_identity_confirmation_cannot_confirm)
+ }
+ }
+
+ private fun AndroidComposeTestRule.setChooseSelfVerificationModeView(
+ state: ChooseSelfVerificationModeState,
+ onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
+ onUseAnotherDevice: () -> Unit = EnsureNeverCalled(),
+ onResetKey: () -> Unit = EnsureNeverCalled(),
+ onEnterRecoveryKey: () -> Unit = EnsureNeverCalled(),
+ ) {
+ setContent {
+ ChooseSelfVerificationModeView(
+ state = state,
+ onLearnMore = onLearnMoreClick,
+ onUseAnotherDevice = onUseAnotherDevice,
+ onResetKey = onResetKey,
+ onUseRecoveryKey = onEnterRecoveryKey,
+ )
+ }
+ }
+}
diff --git a/features/home/api/build.gradle.kts b/features/home/api/build.gradle.kts
new file mode 100644
index 00000000000..c1aee95e670
--- /dev/null
+++ b/features/home/api/build.gradle.kts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2023, 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.home.api"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+}
diff --git a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt
new file mode 100644
index 00000000000..0f6ee581bba
--- /dev/null
+++ b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2023, 2024 New Vector 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.home.api
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import io.element.android.libraries.architecture.FeatureEntryPoint
+import io.element.android.libraries.matrix.api.core.RoomId
+
+interface HomeEntryPoint : FeatureEntryPoint {
+ fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
+ interface NodeBuilder {
+ fun callback(callback: Callback): NodeBuilder
+ fun build(): Node
+ }
+
+ interface Callback : Plugin {
+ fun onRoomClick(roomId: RoomId)
+ fun onCreateRoomClick()
+ fun onSettingsClick()
+ fun onSetUpRecoveryClick()
+ fun onSessionConfirmRecoveryKeyClick()
+ fun onRoomSettingsClick(roomId: RoomId)
+ fun onReportBugClick()
+ fun onLogoutForNativeSlidingSyncMigrationNeeded()
+ }
+}
diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts
new file mode 100644
index 00000000000..88184bcbcd9
--- /dev/null
+++ b/features/home/impl/build.gradle.kts
@@ -0,0 +1,82 @@
+import extension.setupAnvil
+
+/*
+ * Copyright 2022-2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ id("kotlin-parcelize")
+}
+
+android {
+ namespace = "io.element.android.features.home.impl"
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+}
+
+setupAnvil()
+
+dependencies {
+ implementation(projects.appconfig)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.featureflag.api)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.matrixui)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.testtags)
+ implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.dateformatter.api)
+ implementation(projects.libraries.eventformatter.api)
+ implementation(projects.libraries.indicator.api)
+ implementation(projects.libraries.deeplink)
+ implementation(projects.libraries.fullscreenintent.api)
+ implementation(projects.libraries.permissions.api)
+ implementation(projects.libraries.permissions.noop)
+ implementation(projects.libraries.preferences.api)
+ implementation(projects.libraries.push.api)
+ implementation(projects.features.invite.api)
+ implementation(projects.features.networkmonitor.api)
+ implementation(projects.features.logout.api)
+ implementation(projects.features.leaveroom.api)
+ implementation(projects.features.rageshake.api)
+ implementation(projects.services.analytics.api)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.haze)
+ implementation(libs.haze.materials)
+ implementation(projects.features.reportroom.api)
+ api(projects.features.home.api)
+
+ testImplementation(libs.androidx.compose.ui.test.junit)
+ testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
+ testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
+ testImplementation(libs.test.robolectric)
+ testImplementation(projects.features.invite.test)
+ testImplementation(projects.features.logout.test)
+ testImplementation(projects.features.networkmonitor.test)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.libraries.featureflag.test)
+ testImplementation(projects.libraries.dateformatter.test)
+ testImplementation(projects.libraries.eventformatter.test)
+ testImplementation(projects.libraries.indicator.test)
+ testImplementation(projects.libraries.permissions.noop)
+ testImplementation(projects.libraries.permissions.test)
+ testImplementation(projects.libraries.preferences.test)
+ testImplementation(projects.libraries.push.test)
+ testImplementation(projects.services.analytics.test)
+ testImplementation(projects.services.toolbox.test)
+ testImplementation(projects.tests.testutils)
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPoint.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPoint.kt
new file mode 100644
index 00000000000..3fe5c533d74
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPoint.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2023, 2024 New Vector 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.home.impl
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.home.api.HomeEntryPoint
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultHomeEntryPoint @Inject constructor() : HomeEntryPoint {
+ override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): HomeEntryPoint.NodeBuilder {
+ val plugins = ArrayList()
+
+ return object : HomeEntryPoint.NodeBuilder {
+ override fun callback(callback: HomeEntryPoint.Callback): HomeEntryPoint.NodeBuilder {
+ plugins += callback
+ return this
+ }
+
+ override fun build(): Node {
+ return parentNode.createNode(buildContext, plugins)
+ }
+ }
+ }
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvents.kt
new file mode 100644
index 00000000000..4632e40d5a9
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvents.kt
@@ -0,0 +1,12 @@
+/*
+ * Copyright 2025 New Vector 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.home.impl
+
+sealed interface HomeEvents {
+ data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvents
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt
new file mode 100644
index 00000000000..4a7e5eb258c
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt
@@ -0,0 +1,166 @@
+/*
+ * Copyright 2023, 2024 New Vector 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.home.impl
+
+import android.app.Activity
+import android.os.Parcelable
+import androidx.activity.compose.LocalActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.lifecycle.subscribe
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.node.node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.push
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import im.vector.app.features.analytics.plan.MobileScreen
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.home.api.HomeEntryPoint
+import io.element.android.features.home.impl.components.RoomListMenuAction
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.features.invite.api.InviteData
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView
+import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
+import io.element.android.features.logout.api.direct.DirectLogoutView
+import io.element.android.features.reportroom.api.ReportRoomEntryPoint
+import io.element.android.libraries.architecture.BackstackView
+import io.element.android.libraries.architecture.BaseFlowNode
+import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.services.analytics.api.AnalyticsService
+import kotlinx.parcelize.Parcelize
+
+@ContributesNode(SessionScope::class)
+class HomeFlowNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: HomePresenter,
+ private val inviteFriendsUseCase: InviteFriendsUseCase,
+ private val analyticsService: AnalyticsService,
+ private val acceptDeclineInviteView: AcceptDeclineInviteView,
+ private val directLogoutView: DirectLogoutView,
+ private val reportRoomEntryPoint: ReportRoomEntryPoint,
+ private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint,
+) : BaseFlowNode(
+ backstack = BackStack(
+ initialElement = NavTarget.Root,
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins
+) {
+ init {
+ lifecycle.subscribe(
+ onResume = {
+ analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.Home))
+ }
+ )
+ }
+
+ sealed interface NavTarget : Parcelable {
+ @Parcelize
+ data object Root : NavTarget
+
+ @Parcelize
+ data class ReportRoom(val roomId: RoomId) : NavTarget
+
+ @Parcelize
+ data class DeclineInviteAndBlockUser(val inviteData: InviteData) : NavTarget
+ }
+
+ private fun onRoomClick(roomId: RoomId) {
+ plugins().forEach { it.onRoomClick(roomId) }
+ }
+
+ private fun onOpenSettings() {
+ plugins().forEach { it.onSettingsClick() }
+ }
+
+ private fun onCreateRoomClick() {
+ plugins().forEach { it.onCreateRoomClick() }
+ }
+
+ private fun onSetUpRecoveryClick() {
+ plugins().forEach { it.onSetUpRecoveryClick() }
+ }
+
+ private fun onSessionConfirmRecoveryKeyClick() {
+ plugins().forEach { it.onSessionConfirmRecoveryKeyClick() }
+ }
+
+ private fun onRoomSettingsClick(roomId: RoomId) {
+ plugins().forEach { it.onRoomSettingsClick(roomId) }
+ }
+
+ private fun onReportRoomClick(roomId: RoomId) {
+ backstack.push(NavTarget.ReportRoom(roomId))
+ }
+
+ private fun onDeclineInviteAndBlockUserClick(roomSummary: RoomListRoomSummary) {
+ backstack.push(NavTarget.DeclineInviteAndBlockUser(roomSummary.toInviteData()))
+ }
+
+ private fun onMenuActionClick(activity: Activity, roomListMenuAction: RoomListMenuAction) {
+ when (roomListMenuAction) {
+ RoomListMenuAction.InviteFriends -> {
+ inviteFriendsUseCase.execute(activity)
+ }
+ RoomListMenuAction.ReportBug -> {
+ plugins().forEach { it.onReportBugClick() }
+ }
+ }
+ }
+
+ fun rootNode(buildContext: BuildContext): Node {
+ return node(buildContext) { modifier ->
+ val state = presenter.present()
+ val activity = requireNotNull(LocalActivity.current)
+
+ HomeView(
+ homeState = state,
+ onRoomClick = this::onRoomClick,
+ onSettingsClick = this::onOpenSettings,
+ onCreateRoomClick = this::onCreateRoomClick,
+ onSetUpRecoveryClick = this::onSetUpRecoveryClick,
+ onConfirmRecoveryKeyClick = this::onSessionConfirmRecoveryKeyClick,
+ onRoomSettingsClick = this::onRoomSettingsClick,
+ onMenuActionClick = { onMenuActionClick(activity, it) },
+ onReportRoomClick = this::onReportRoomClick,
+ onDeclineInviteAndBlockUser = this::onDeclineInviteAndBlockUserClick,
+ modifier = modifier,
+ ) {
+ acceptDeclineInviteView.Render(
+ state = state.roomListState.acceptDeclineInviteState,
+ onAcceptInviteSuccess = this::onRoomClick,
+ onDeclineInviteSuccess = { },
+ modifier = Modifier
+ )
+ }
+
+ directLogoutView.Render(state.directLogoutState)
+ }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ BackstackView()
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ is NavTarget.ReportRoom -> reportRoomEntryPoint.createNode(this, buildContext, navTarget.roomId)
+ is NavTarget.DeclineInviteAndBlockUser -> declineInviteAndBlockUserEntryPoint.createNode(this, buildContext, navTarget.inviteData)
+ NavTarget.Root -> rootNode(buildContext)
+ }
+ }
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeNavigationBarItem.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeNavigationBarItem.kt
new file mode 100644
index 00000000000..5254648f24e
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeNavigationBarItem.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2025 New Vector 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.home.impl
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import io.element.android.compound.tokens.generated.CompoundIcons
+
+enum class HomeNavigationBarItem(
+ @StringRes
+ val labelRes: Int,
+) {
+ Chats(
+ labelRes = R.string.screen_roomlist_main_space_title
+ ),
+ Spaces(
+ // TODO Create a new entry in Localazy
+ labelRes = R.string.screen_roomlist_main_space_title
+ );
+
+ @Composable
+ fun icon() = when (this) {
+ Chats -> CompoundIcons.ChatSolid()
+ // TODO Spaces -> CompoundIcons.Workspace()
+ Spaces -> CompoundIcons.Code()
+ }
+
+ companion object {
+ fun from(index: Int): HomeNavigationBarItem {
+ return entries.getOrElse(index) { Chats }
+ }
+ }
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt
new file mode 100644
index 00000000000..894ed29e8d8
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2025 New Vector 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.home.impl
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import io.element.android.features.home.impl.roomlist.RoomListState
+import io.element.android.features.logout.api.direct.DirectLogoutState
+import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
+import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.indicator.api.IndicatorService
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.sync.SyncService
+import javax.inject.Inject
+
+class HomePresenter @Inject constructor(
+ private val client: MatrixClient,
+ private val syncService: SyncService,
+ private val snackbarDispatcher: SnackbarDispatcher,
+ private val indicatorService: IndicatorService,
+ private val roomListPresenter: Presenter,
+ private val logoutPresenter: Presenter,
+ private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
+ private val featureFlagService: FeatureFlagService,
+) : Presenter {
+ @Composable
+ override fun present(): HomeState {
+ val matrixUser = client.userProfile.collectAsState()
+ val isOnline by syncService.isOnline.collectAsState()
+ val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
+ val roomListState = roomListPresenter.present()
+ val isSpaceFeatureEnabled by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.Space)
+ }.collectAsState(initial = false)
+ var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) }
+ val currentHomeNavigationBarItem by remember {
+ derivedStateOf {
+ HomeNavigationBarItem.from(currentHomeNavigationBarItemOrdinal)
+ }
+ }
+ LaunchedEffect(Unit) {
+ // Force a refresh of the profile
+ client.getUserProfile()
+ }
+ // Avatar indicator
+ val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
+ val directLogoutState = logoutPresenter.present()
+
+ fun handleEvents(event: HomeEvents) {
+ when (event) {
+ is HomeEvents.SelectHomeNavigationBarItem -> {
+ currentHomeNavigationBarItemOrdinal = event.item.ordinal
+ }
+ }
+ }
+
+ val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
+ return HomeState(
+ matrixUser = matrixUser.value,
+ showAvatarIndicator = showAvatarIndicator,
+ hasNetworkConnection = isOnline,
+ currentHomeNavigationBarItem = currentHomeNavigationBarItem,
+ roomListState = roomListState,
+ snackbarMessage = snackbarMessage,
+ canReportBug = canReportBug,
+ directLogoutState = directLogoutState,
+ isSpaceFeatureEnabled = isSpaceFeatureEnabled,
+ eventSink = ::handleEvents,
+ )
+ }
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt
new file mode 100644
index 00000000000..5e6c16d2e42
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2023, 2024 New Vector 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.home.impl
+
+import androidx.compose.runtime.Immutable
+import io.element.android.features.home.impl.roomlist.RoomListState
+import io.element.android.features.logout.api.direct.DirectLogoutState
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
+import io.element.android.libraries.matrix.api.user.MatrixUser
+
+@Immutable
+data class HomeState(
+ val matrixUser: MatrixUser,
+ val showAvatarIndicator: Boolean,
+ val hasNetworkConnection: Boolean,
+ val currentHomeNavigationBarItem: HomeNavigationBarItem,
+ val roomListState: RoomListState,
+ val snackbarMessage: SnackbarMessage?,
+ val canReportBug: Boolean,
+ val directLogoutState: DirectLogoutState,
+ val isSpaceFeatureEnabled: Boolean,
+ val eventSink: (HomeEvents) -> Unit,
+) {
+ val displayActions = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt
new file mode 100644
index 00000000000..7a50296e170
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2025 New Vector 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.home.impl
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.home.impl.roomlist.RoomListState
+import io.element.android.features.home.impl.roomlist.RoomListStateProvider
+import io.element.android.features.home.impl.roomlist.aRoomListState
+import io.element.android.features.home.impl.roomlist.aRoomsContentState
+import io.element.android.features.home.impl.roomlist.generateRoomListRoomSummaryList
+import io.element.android.features.logout.api.direct.DirectLogoutState
+import io.element.android.features.logout.api.direct.aDirectLogoutState
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.ui.strings.CommonStrings
+
+open class HomeStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aHomeState(),
+ aHomeState(hasNetworkConnection = false),
+ aHomeState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)),
+ aHomeState(
+ isSpaceFeatureEnabled = true,
+ roomListState = aRoomListState(
+ // Add more rooms to see the blur effect under the NavigationBar
+ contentState = aRoomsContentState(
+ summaries = generateRoomListRoomSummaryList(),
+ )
+ ),
+ ),
+ aHomeState(
+ isSpaceFeatureEnabled = true,
+ currentHomeNavigationBarItem = HomeNavigationBarItem.Spaces,
+ ),
+ ) + RoomListStateProvider().values.map {
+ aHomeState(roomListState = it)
+ }
+}
+
+internal fun aHomeState(
+ matrixUser: MatrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
+ showAvatarIndicator: Boolean = false,
+ hasNetworkConnection: Boolean = true,
+ snackbarMessage: SnackbarMessage? = null,
+ currentHomeNavigationBarItem: HomeNavigationBarItem = HomeNavigationBarItem.Chats,
+ roomListState: RoomListState = aRoomListState(),
+ canReportBug: Boolean = true,
+ isSpaceFeatureEnabled: Boolean = false,
+ directLogoutState: DirectLogoutState = aDirectLogoutState(),
+ eventSink: (HomeEvents) -> Unit = {}
+) = HomeState(
+ matrixUser = matrixUser,
+ showAvatarIndicator = showAvatarIndicator,
+ hasNetworkConnection = hasNetworkConnection,
+ snackbarMessage = snackbarMessage,
+ canReportBug = canReportBug,
+ directLogoutState = directLogoutState,
+ currentHomeNavigationBarItem = currentHomeNavigationBarItem,
+ roomListState = roomListState,
+ isSpaceFeatureEnabled = isSpaceFeatureEnabled,
+ eventSink = eventSink,
+)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt
new file mode 100644
index 00000000000..fb5b9c9ae37
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt
@@ -0,0 +1,293 @@
+/*
+ * Copyright 2023, 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalHazeMaterialsApi::class)
+
+package io.element.android.features.home.impl
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberTopAppBarState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import dev.chrisbanes.haze.hazeEffect
+import dev.chrisbanes.haze.hazeSource
+import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
+import dev.chrisbanes.haze.materials.HazeMaterials
+import dev.chrisbanes.haze.rememberHazeState
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.home.impl.components.RoomListContentView
+import io.element.android.features.home.impl.components.RoomListMenuAction
+import io.element.android.features.home.impl.components.RoomListTopBar
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.features.home.impl.roomlist.RoomListContextMenu
+import io.element.android.features.home.impl.roomlist.RoomListDeclineInviteMenu
+import io.element.android.features.home.impl.roomlist.RoomListEvents
+import io.element.android.features.home.impl.roomlist.RoomListState
+import io.element.android.features.home.impl.search.RoomListSearchView
+import io.element.android.features.leaveroom.api.LeaveRoomView
+import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
+import io.element.android.libraries.androidutils.throttler.FirstThrottler
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.NavigationBar
+import io.element.android.libraries.designsystem.theme.components.NavigationBarItem
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
+import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
+import io.element.android.libraries.matrix.api.core.RoomId
+
+@Composable
+fun HomeView(
+ homeState: HomeState,
+ onRoomClick: (RoomId) -> Unit,
+ onSettingsClick: () -> Unit,
+ onSetUpRecoveryClick: () -> Unit,
+ onConfirmRecoveryKeyClick: () -> Unit,
+ onCreateRoomClick: () -> Unit,
+ onRoomSettingsClick: (roomId: RoomId) -> Unit,
+ onMenuActionClick: (RoomListMenuAction) -> Unit,
+ onReportRoomClick: (roomId: RoomId) -> Unit,
+ onDeclineInviteAndBlockUser: (roomSummary: RoomListRoomSummary) -> Unit,
+ modifier: Modifier = Modifier,
+ acceptDeclineInviteView: @Composable () -> Unit,
+) {
+ val state: RoomListState = homeState.roomListState
+ val coroutineScope = rememberCoroutineScope()
+ val firstThrottler = remember { FirstThrottler(300, coroutineScope) }
+
+ ConnectivityIndicatorContainer(
+ modifier = modifier,
+ isOnline = homeState.hasNetworkConnection,
+ ) { topPadding ->
+ Box {
+ if (state.contextMenu is RoomListState.ContextMenu.Shown) {
+ RoomListContextMenu(
+ contextMenu = state.contextMenu,
+ canReportRoom = state.canReportRoom,
+ eventSink = state.eventSink,
+ onRoomSettingsClick = onRoomSettingsClick,
+ onReportRoomClick = onReportRoomClick,
+ )
+ }
+ if (state.declineInviteMenu is RoomListState.DeclineInviteMenu.Shown) {
+ RoomListDeclineInviteMenu(
+ menu = state.declineInviteMenu,
+ canReportRoom = state.canReportRoom,
+ eventSink = state.eventSink,
+ onDeclineAndBlockClick = onDeclineInviteAndBlockUser,
+ )
+ }
+
+ LeaveRoomView(state = state.leaveRoomState)
+
+ HomeScaffold(
+ state = homeState,
+ onSetUpRecoveryClick = onSetUpRecoveryClick,
+ onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
+ onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) },
+ onOpenSettings = { if (firstThrottler.canHandle()) onSettingsClick() },
+ onCreateRoomClick = { if (firstThrottler.canHandle()) onCreateRoomClick() },
+ onMenuActionClick = onMenuActionClick,
+ modifier = Modifier.padding(top = topPadding),
+ )
+ // This overlaid view will only be visible when state.displaySearchResults is true
+ RoomListSearchView(
+ state = state.searchState,
+ eventSink = state.eventSink,
+ hideInvitesAvatars = state.hideInvitesAvatars,
+ onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) },
+ modifier = Modifier
+ .statusBarsPadding()
+ .padding(top = topPadding)
+ .fillMaxSize()
+ .background(ElementTheme.colors.bgCanvasDefault)
+ )
+ acceptDeclineInviteView()
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun HomeScaffold(
+ state: HomeState,
+ onSetUpRecoveryClick: () -> Unit,
+ onConfirmRecoveryKeyClick: () -> Unit,
+ onRoomClick: (RoomId) -> Unit,
+ onOpenSettings: () -> Unit,
+ onCreateRoomClick: () -> Unit,
+ onMenuActionClick: (RoomListMenuAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ fun onRoomClick(room: RoomListRoomSummary) {
+ onRoomClick(room.roomId)
+ }
+
+ val appBarState = rememberTopAppBarState()
+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(appBarState)
+ val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
+ val roomListState: RoomListState = state.roomListState
+
+ BackHandler(
+ enabled = state.currentHomeNavigationBarItem != HomeNavigationBarItem.Chats,
+ ) {
+ state.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats))
+ }
+
+ val hazeState = rememberHazeState()
+
+ Scaffold(
+ modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = {
+ RoomListTopBar(
+ title = stringResource(state.currentHomeNavigationBarItem.labelRes),
+ matrixUser = state.matrixUser,
+ showAvatarIndicator = state.showAvatarIndicator,
+ areSearchResultsDisplayed = roomListState.searchState.isSearchActive,
+ onToggleSearch = { roomListState.eventSink(RoomListEvents.ToggleSearchResults) },
+ onMenuActionClick = onMenuActionClick,
+ onOpenSettings = onOpenSettings,
+ scrollBehavior = scrollBehavior,
+ displayMenuItems = state.displayActions,
+ displayFilters = roomListState.displayFilters && state.currentHomeNavigationBarItem == HomeNavigationBarItem.Chats,
+ filtersState = roomListState.filtersState,
+ canReportBug = state.canReportBug,
+ )
+ },
+ bottomBar = {
+ if (state.isSpaceFeatureEnabled) {
+ NavigationBar(
+ containerColor = Color.Transparent,
+ modifier = Modifier
+ .hazeEffect(
+ state = hazeState,
+ style = HazeMaterials.regular(),
+ )
+ ) {
+ HomeNavigationBarItem.entries.forEach { item ->
+ NavigationBarItem(
+ selected = state.currentHomeNavigationBarItem == item,
+ onClick = {
+ state.eventSink(HomeEvents.SelectHomeNavigationBarItem(item))
+ },
+ icon = {
+ Icon(
+ imageVector = item.icon(),
+ contentDescription = null
+ )
+ },
+ label = {
+ Text(stringResource(item.labelRes))
+ }
+ )
+ }
+ }
+ }
+ },
+ content = { padding ->
+ when (state.currentHomeNavigationBarItem) {
+ HomeNavigationBarItem.Chats -> {
+ RoomListContentView(
+ contentState = roomListState.contentState,
+ filtersState = roomListState.filtersState,
+ hideInvitesAvatars = roomListState.hideInvitesAvatars,
+ eventSink = roomListState.eventSink,
+ onSetUpRecoveryClick = onSetUpRecoveryClick,
+ onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
+ onRoomClick = ::onRoomClick,
+ onCreateRoomClick = onCreateRoomClick,
+ // FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80,
+ // and include provided bottom padding
+ contentBottomPadding = 80.dp + padding.calculateBottomPadding(),
+ modifier = Modifier
+ .padding(
+ top = padding.calculateTopPadding(),
+ bottom = 0.dp,
+ start = padding.calculateStartPadding(LocalLayoutDirection.current),
+ end = padding.calculateEndPadding(LocalLayoutDirection.current),
+ )
+ .consumeWindowInsets(padding)
+ .hazeSource(state = hazeState)
+ )
+ }
+ HomeNavigationBarItem.Spaces -> {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .consumeWindowInsets(padding)
+ ) {
+ Text(
+ modifier = Modifier.align(Alignment.Center),
+ text = "Spaces are coming soon!",
+ style = ElementTheme.typography.fontBodyLgRegular,
+ color = ElementTheme.colors.textPrimary,
+ )
+ }
+ }
+ }
+ },
+ floatingActionButton = {
+ if (state.displayActions) {
+ FloatingActionButton(
+ containerColor = ElementTheme.colors.iconPrimary,
+ onClick = onCreateRoomClick
+ ) {
+ Icon(
+ imageVector = CompoundIcons.Plus(),
+ contentDescription = stringResource(id = R.string.screen_roomlist_a11y_create_message),
+ tint = ElementTheme.colors.iconOnSolidPrimary,
+ )
+ }
+ }
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ )
+}
+
+internal fun RoomListRoomSummary.contentType() = displayType.ordinal
+
+@PreviewsDayNight
+@Composable
+internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state: HomeState) = ElementPreview {
+ HomeView(
+ homeState = state,
+ onRoomClick = {},
+ onSettingsClick = {},
+ onSetUpRecoveryClick = {},
+ onConfirmRecoveryKeyClick = {},
+ onCreateRoomClick = {},
+ onRoomSettingsClick = {},
+ onReportRoomClick = {},
+ onMenuActionClick = {},
+ onDeclineInviteAndBlockUser = {},
+ acceptDeclineInviteView = {},
+ )
+}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/BannerPadding.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BannerPadding.kt
similarity index 88%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/BannerPadding.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BannerPadding.kt
index a609d5dc7d2..389c2aecfb4 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/BannerPadding.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BannerPadding.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.components
+package io.element.android.features.home.impl.components
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt
new file mode 100644
index 00000000000..017ca9592d6
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2025 New Vector 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.home.impl.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import io.element.android.features.home.impl.R
+import io.element.android.libraries.designsystem.components.Announcement
+import io.element.android.libraries.designsystem.components.AnnouncementType
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.push.api.battery.BatteryOptimizationEvents
+import io.element.android.libraries.push.api.battery.BatteryOptimizationState
+import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
+
+@Composable
+internal fun BatteryOptimizationBanner(
+ state: BatteryOptimizationState,
+ modifier: Modifier = Modifier,
+) {
+ Announcement(
+ modifier = modifier.roomListBannerPadding(),
+ title = stringResource(R.string.banner_battery_optimization_title_android),
+ description = stringResource(R.string.banner_battery_optimization_content_android),
+ type = AnnouncementType.Actionable(
+ actionText = stringResource(R.string.banner_battery_optimization_submit_android),
+ onActionClick = { state.eventSink(BatteryOptimizationEvents.RequestDisableOptimizations) },
+ onDismissClick = { state.eventSink(BatteryOptimizationEvents.Dismiss) },
+ ),
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun BatteryOptimizationBannerPreview() = ElementPreview {
+ BatteryOptimizationBanner(
+ state = aBatteryOptimizationState(),
+ )
+}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/ConfirmRecoveryKeyBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/ConfirmRecoveryKeyBanner.kt
similarity index 92%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/ConfirmRecoveryKeyBanner.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/ConfirmRecoveryKeyBanner.kt
index 3ae7750fa35..c833e2914a5 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/ConfirmRecoveryKeyBanner.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/ConfirmRecoveryKeyBanner.kt
@@ -5,12 +5,12 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.components
+package io.element.android.features.home.impl.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
-import io.element.android.features.roomlist.impl.R
+import io.element.android.features.home.impl.R
import io.element.android.libraries.designsystem.components.Announcement
import io.element.android.libraries.designsystem.components.AnnouncementType
import io.element.android.libraries.designsystem.preview.ElementPreview
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/FullScreenIntentPermissionBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt
similarity index 80%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/FullScreenIntentPermissionBanner.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt
index 7e84a26ab3e..e89392da62b 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/FullScreenIntentPermissionBanner.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt
@@ -5,16 +5,17 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.components
+package io.element.android.features.home.impl.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
-import io.element.android.features.roomlist.impl.R
+import io.element.android.features.home.impl.R
import io.element.android.libraries.designsystem.components.Announcement
import io.element.android.libraries.designsystem.components.AnnouncementType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.ui.strings.CommonStrings
@@ -29,8 +30,8 @@ fun FullScreenIntentPermissionBanner(
description = stringResource(R.string.full_screen_intent_banner_message),
type = AnnouncementType.Actionable(
actionText = stringResource(CommonStrings.action_continue),
- onDismissClick = state.dismissFullScreenIntentBanner,
- onActionClick = state.openFullScreenIntentSettings,
+ onDismissClick = { state.eventSink(FullScreenIntentPermissionsEvents.Dismiss) },
+ onActionClick = { state.eventSink(FullScreenIntentPermissionsEvents.OpenSettings) },
),
modifier = modifier.roomListBannerPadding(),
)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt
similarity index 83%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt
index 525b1413a92..f86bd400f83 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.components
+package io.element.android.features.home.impl.components
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
@@ -31,21 +31,23 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
-import io.element.android.features.roomlist.impl.R
-import io.element.android.features.roomlist.impl.RoomListContentState
-import io.element.android.features.roomlist.impl.RoomListContentStateProvider
-import io.element.android.features.roomlist.impl.RoomListEvents
-import io.element.android.features.roomlist.impl.SecurityBannerState
-import io.element.android.features.roomlist.impl.contentType
-import io.element.android.features.roomlist.impl.filters.RoomListFilter
-import io.element.android.features.roomlist.impl.filters.RoomListFiltersEmptyStateResources
-import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
-import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
-import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
-import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
+import io.element.android.features.home.impl.R
+import io.element.android.features.home.impl.contentType
+import io.element.android.features.home.impl.filters.RoomListFilter
+import io.element.android.features.home.impl.filters.RoomListFiltersEmptyStateResources
+import io.element.android.features.home.impl.filters.RoomListFiltersState
+import io.element.android.features.home.impl.filters.aRoomListFiltersState
+import io.element.android.features.home.impl.filters.selection.FilterSelectionState
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.features.home.impl.model.RoomSummaryDisplayType
+import io.element.android.features.home.impl.roomlist.RoomListContentState
+import io.element.android.features.home.impl.roomlist.RoomListContentStateProvider
+import io.element.android.features.home.impl.roomlist.RoomListEvents
+import io.element.android.features.home.impl.roomlist.SecurityBannerState
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
@@ -59,12 +61,13 @@ import kotlinx.collections.immutable.ImmutableList
fun RoomListContentView(
contentState: RoomListContentState,
filtersState: RoomListFiltersState,
+ hideInvitesAvatars: Boolean,
eventSink: (RoomListEvents) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit,
onCreateRoomClick: () -> Unit,
- onMigrateToNativeSlidingSyncClick: () -> Unit,
+ contentBottomPadding: Dp,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
@@ -86,12 +89,13 @@ fun RoomListContentView(
is RoomListContentState.Rooms -> {
RoomsView(
state = contentState,
+ hideInvitesAvatars = hideInvitesAvatars,
filtersState = filtersState,
eventSink = eventSink,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
- onMigrateToNativeSlidingSyncClick = onMigrateToNativeSlidingSyncClick,
onRoomClick = onRoomClick,
+ contentBottomPadding = contentBottomPadding,
)
}
}
@@ -148,7 +152,7 @@ private fun EmptyView(
onDismissClick = { eventSink(RoomListEvents.DismissBanner) },
)
}
- else -> Unit
+ SecurityBannerState.None -> Unit
}
}
}
@@ -157,12 +161,13 @@ private fun EmptyView(
@Composable
private fun RoomsView(
state: RoomListContentState.Rooms,
+ hideInvitesAvatars: Boolean,
filtersState: RoomListFiltersState,
eventSink: (RoomListEvents) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit,
- onMigrateToNativeSlidingSyncClick: () -> Unit,
+ contentBottomPadding: Dp,
modifier: Modifier = Modifier,
) {
if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) {
@@ -173,11 +178,12 @@ private fun RoomsView(
} else {
RoomsViewList(
state = state,
+ hideInvitesAvatars = hideInvitesAvatars,
eventSink = eventSink,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = onRoomClick,
- onMigrateToNativeSlidingSyncClick = onMigrateToNativeSlidingSyncClick,
+ contentBottomPadding = contentBottomPadding,
modifier = modifier.fillMaxSize(),
)
}
@@ -186,11 +192,12 @@ private fun RoomsView(
@Composable
private fun RoomsViewList(
state: RoomListContentState.Rooms,
+ hideInvitesAvatars: Boolean,
eventSink: (RoomListEvents) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit,
- onMigrateToNativeSlidingSyncClick: () -> Unit,
+ contentBottomPadding: Dp,
modifier: Modifier = Modifier,
) {
val lazyListState = rememberLazyListState()
@@ -209,8 +216,7 @@ private fun RoomsViewList(
LazyColumn(
state = lazyListState,
modifier = modifier,
- // FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80
- contentPadding = PaddingValues(bottom = 80.dp)
+ contentPadding = PaddingValues(bottom = contentBottomPadding)
) {
when (state.securityBannerState) {
SecurityBannerState.SetUpRecovery -> {
@@ -230,18 +236,14 @@ private fun RoomsViewList(
)
}
}
- SecurityBannerState.NeedsNativeSlidingSyncMigration -> {
- item {
- NativeSlidingSyncMigrationBanner(
- onContinueClick = onMigrateToNativeSlidingSyncClick,
- onDismissClick = { updatedEventSink(RoomListEvents.DismissBanner) },
- )
- }
- }
SecurityBannerState.None -> if (state.fullScreenIntentPermissionsState.shouldDisplayBanner) {
item {
FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState)
}
+ } else if (state.batteryOptimizationState.shouldDisplayBanner) {
+ item {
+ BatteryOptimizationBanner(state = state.batteryOptimizationState)
+ }
}
}
@@ -253,6 +255,9 @@ private fun RoomsViewList(
) { index, room ->
RoomSummaryRow(
room = room,
+ hideInviteAvatars = hideInvitesAvatars,
+ isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE &&
+ state.seenRoomInvites.contains(room.roomId),
onClick = onRoomClick,
eventSink = eventSink,
)
@@ -312,13 +317,19 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
RoomListContentView(
contentState = state,
filtersState = aRoomListFiltersState(
- filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) }
+ filterSelectionStates = RoomListFilter.entries.map {
+ FilterSelectionState(
+ filter = it,
+ isSelected = true
+ )
+ }
),
+ hideInvitesAvatars = false,
eventSink = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onRoomClick = {},
onCreateRoomClick = {},
- onMigrateToNativeSlidingSyncClick = {},
+ contentBottomPadding = 0.dp,
)
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListMenuAction.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListMenuAction.kt
similarity index 81%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListMenuAction.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListMenuAction.kt
index 14f46098acb..9f17fc835e7 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListMenuAction.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListMenuAction.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.components
+package io.element.android.features.home.impl.components
enum class RoomListMenuAction {
InviteFriends,
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListTopBar.kt
similarity index 91%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListTopBar.kt
index f196ba3c196..8071dead3dd 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListTopBar.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.components
+package io.element.android.features.home.impl.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -34,20 +34,23 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
-import io.element.android.features.roomlist.impl.R
-import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
-import io.element.android.features.roomlist.impl.filters.RoomListFiltersView
-import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
+import io.element.android.features.home.impl.R
+import io.element.android.features.home.impl.filters.RoomListFiltersState
+import io.element.android.features.home.impl.filters.RoomListFiltersView
+import io.element.android.features.home.impl.filters.aRoomListFiltersState
import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatarBloom
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -75,6 +78,7 @@ private val avatarBloomSize = 430.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomListTopBar(
+ title: String,
matrixUser: MatrixUser,
showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean,
@@ -85,9 +89,11 @@ fun RoomListTopBar(
displayMenuItems: Boolean,
displayFilters: Boolean,
filtersState: RoomListFiltersState,
+ canReportBug: Boolean,
modifier: Modifier = Modifier,
) {
DefaultRoomListTopBar(
+ title = title,
matrixUser = matrixUser,
showAvatarIndicator = showAvatarIndicator,
areSearchResultsDisplayed = areSearchResultsDisplayed,
@@ -98,6 +104,7 @@ fun RoomListTopBar(
displayMenuItems = displayMenuItems,
displayFilters = displayFilters,
filtersState = filtersState,
+ canReportBug = canReportBug,
modifier = modifier,
)
}
@@ -105,6 +112,7 @@ fun RoomListTopBar(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DefaultRoomListTopBar(
+ title: String,
matrixUser: MatrixUser,
showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean,
@@ -115,6 +123,7 @@ private fun DefaultRoomListTopBar(
displayMenuItems: Boolean,
displayFilters: Boolean,
filtersState: RoomListFiltersState,
+ canReportBug: Boolean,
modifier: Modifier = Modifier,
) {
// We need this to manually clip the top app bar in preview mode
@@ -162,7 +171,7 @@ private fun DefaultRoomListTopBar(
// Workaround to display a very subtle bloom for avatars with very soft colors
Color(0xFFF9F9F9)
} else {
- ElementTheme.materialColors.background
+ ElementTheme.colors.bgCanvasDefault
},
blurSize = DpSize(avatarBloomSize, avatarBloomSize),
offset = DpOffset(24.dp, 24.dp + statusBarPadding),
@@ -174,7 +183,7 @@ private fun DefaultRoomListTopBar(
} else {
DpSize.Unspecified
},
- bottomSoftEdgeColor = ElementTheme.materialColors.background,
+ bottomSoftEdgeColor = ElementTheme.colors.bgCanvasDefault,
bottomSoftEdgeAlpha = if (displayFilters) {
1f
} else {
@@ -190,7 +199,12 @@ private fun DefaultRoomListTopBar(
scrolledContainerColor = Color.Transparent,
),
title = {
- Text(text = stringResource(id = R.string.screen_roomlist_main_space_title))
+ Text(
+ modifier = Modifier.semantics {
+ heading()
+ },
+ text = title,
+ )
},
navigationIcon = {
NavigationIcon(
@@ -233,13 +247,13 @@ private fun DefaultRoomListTopBar(
leadingIcon = {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
- tint = ElementTheme.materialColors.secondary,
+ tint = ElementTheme.colors.iconSecondary,
contentDescription = null,
)
}
)
}
- if (RoomListConfig.SHOW_REPORT_PROBLEM_MENU_ITEM) {
+ if (RoomListConfig.SHOW_REPORT_PROBLEM_MENU_ITEM && canReportBug) {
DropdownMenuItem(
onClick = {
showMenu = false
@@ -249,7 +263,7 @@ private fun DefaultRoomListTopBar(
leadingIcon = {
Icon(
imageVector = CompoundIcons.ChatProblem(),
- tint = ElementTheme.materialColors.secondary,
+ tint = ElementTheme.colors.iconSecondary,
contentDescription = null,
)
}
@@ -294,6 +308,7 @@ private fun NavigationIcon(
Box {
Avatar(
avatarData = avatarData,
+ avatarType = AvatarType.User,
contentDescription = stringResource(CommonStrings.common_settings),
)
if (showAvatarIndicator) {
@@ -310,6 +325,7 @@ private fun NavigationIcon(
@Composable
internal fun DefaultRoomListTopBarPreview() = ElementPreview {
DefaultRoomListTopBar(
+ title = stringResource(R.string.screen_roomlist_main_space_title),
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
@@ -319,6 +335,7 @@ internal fun DefaultRoomListTopBarPreview() = ElementPreview {
displayMenuItems = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
+ canReportBug = true,
onMenuActionClick = {},
)
}
@@ -328,6 +345,7 @@ internal fun DefaultRoomListTopBarPreview() = ElementPreview {
@Composable
internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview {
DefaultRoomListTopBar(
+ title = stringResource(R.string.screen_roomlist_main_space_title),
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
showAvatarIndicator = true,
areSearchResultsDisplayed = false,
@@ -337,6 +355,7 @@ internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview {
displayMenuItems = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
+ canReportBug = true,
onMenuActionClick = {},
)
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryPlaceholderRow.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryPlaceholderRow.kt
similarity index 98%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryPlaceholderRow.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryPlaceholderRow.kt
index af980c1b092..f07fe880b35 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryPlaceholderRow.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryPlaceholderRow.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.components
+package io.element.android.features.home.impl.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt
similarity index 84%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt
index 05ff1042e58..e5a85a7442d 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt
@@ -5,9 +5,8 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.components
+package io.element.android.features.home.impl.components
-import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -24,7 +23,6 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
-import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -39,14 +37,15 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
-import io.element.android.features.roomlist.impl.R
-import io.element.android.features.roomlist.impl.RoomListEvents
-import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
-import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryProvider
-import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
+import io.element.android.features.home.impl.R
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.features.home.impl.model.RoomListRoomSummaryProvider
+import io.element.android.features.home.impl.model.RoomSummaryDisplayType
+import io.element.android.features.home.impl.roomlist.RoomListEvents
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
-import io.element.android.libraries.designsystem.components.avatar.CompositeAvatar
+import io.element.android.libraries.designsystem.components.avatar.Avatar
+import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
@@ -58,7 +57,6 @@ import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.unreadIndicator
-import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.ui.components.InviteSenderView
import io.element.android.libraries.matrix.ui.model.InviteSender
@@ -70,6 +68,8 @@ internal val minHeight = 84.dp
@Composable
internal fun RoomSummaryRow(
room: RoomListRoomSummary,
+ hideInviteAvatars: Boolean,
+ isInviteSeen: Boolean,
onClick: (RoomListRoomSummary) -> Unit,
eventSink: (RoomListEvents) -> Unit,
modifier: Modifier = Modifier,
@@ -82,18 +82,20 @@ internal fun RoomSummaryRow(
RoomSummaryDisplayType.INVITE -> {
RoomSummaryScaffoldRow(
room = room,
+ hideAvatarImage = hideInviteAvatars,
onClick = onClick,
onLongClick = {
Timber.d("Long click on invite room")
},
) {
- InviteNameAndIndicatorRow(name = room.name)
- InviteSubtitle(isDm = room.isDm, inviteSender = room.inviteSender, canonicalAlias = room.canonicalAlias)
+ InviteNameAndIndicatorRow(name = room.name, isInviteSeen = isInviteSeen)
+ InviteSubtitle(isDm = room.isDm, inviteSender = room.inviteSender)
if (!room.isDm && room.inviteSender != null) {
Spacer(modifier = Modifier.height(4.dp))
InviteSenderView(
modifier = Modifier.fillMaxWidth(),
inviteSender = room.inviteSender,
+ hideAvatarImage = hideInviteAvatars
)
}
Spacer(modifier = Modifier.height(12.dp))
@@ -102,7 +104,7 @@ internal fun RoomSummaryRow(
eventSink(RoomListEvents.AcceptInvite(room))
},
onDeclineClick = {
- eventSink(RoomListEvents.DeclineInvite(room))
+ eventSink(RoomListEvents.ShowDeclineInviteMenu(room))
}
)
}
@@ -120,7 +122,7 @@ internal fun RoomSummaryRow(
timestamp = room.timestamp,
isHighlighted = room.isHighlighted
)
- LastMessageAndIndicatorRow(room = room)
+ MessagePreviewAndIndicatorRow(room = room)
}
}
RoomSummaryDisplayType.KNOCKED -> {
@@ -147,7 +149,7 @@ internal fun RoomSummaryRow(
Spacer(modifier = Modifier.height(4.dp))
}
Text(
- text = stringResource(id = R.string.screen_join_room_knock_sent_title),
+ text = stringResource(id = R.string.screen_roomlist_knock_event_sent_description),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
@@ -159,18 +161,19 @@ internal fun RoomSummaryRow(
}
}
-@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RoomSummaryScaffoldRow(
room: RoomListRoomSummary,
onClick: (RoomListRoomSummary) -> Unit,
onLongClick: (RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier,
+ hideAvatarImage: Boolean = false,
content: @Composable ColumnScope.() -> Unit
) {
val clickModifier = Modifier.combinedClickable(
onClick = { onClick(room) },
onLongClick = { onLongClick(room) },
+ onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
indication = ripple(),
interactionSource = remember { MutableInteractionSource() }
)
@@ -183,9 +186,13 @@ private fun RoomSummaryScaffoldRow(
.padding(horizontal = 16.dp, vertical = 11.dp)
.height(IntrinsicSize.Min),
) {
- CompositeAvatar(
+ Avatar(
avatarData = room.avatarData,
- heroes = room.heroes,
+ avatarType = AvatarType.Room(
+ heroes = room.heroes,
+ isTombstoned = room.isTombstoned,
+ ),
+ hideImage = hideAvatarImage,
)
Spacer(modifier = Modifier.width(16.dp))
Column(
@@ -212,7 +219,7 @@ private fun NameAndTimestampRow(
style = ElementTheme.typography.fontBodyLgMedium,
text = name ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
- color = MaterialTheme.roomListRoomName(),
+ color = ElementTheme.roomListRoomName(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -223,7 +230,7 @@ private fun NameAndTimestampRow(
color = if (isHighlighted) {
ElementTheme.colors.unreadIndicator
} else {
- MaterialTheme.roomListRoomMessageDate()
+ ElementTheme.roomListRoomMessageDate()
},
)
}
@@ -233,13 +240,12 @@ private fun NameAndTimestampRow(
private fun InviteSubtitle(
isDm: Boolean,
inviteSender: InviteSender?,
- canonicalAlias: RoomAlias?,
modifier: Modifier = Modifier
) {
val subtitle = if (isDm) {
inviteSender?.userId?.value
} else {
- canonicalAlias?.value
+ null
}
if (subtitle != null) {
Text(
@@ -247,14 +253,14 @@ private fun InviteSubtitle(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
- color = MaterialTheme.roomListRoomMessage(),
+ color = ElementTheme.roomListRoomMessage(),
modifier = modifier,
)
}
}
@Composable
-private fun LastMessageAndIndicatorRow(
+private fun MessagePreviewAndIndicatorRow(
room: RoomListRoomSummary,
modifier: Modifier = Modifier,
) {
@@ -262,13 +268,16 @@ private fun LastMessageAndIndicatorRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(28.dp)
) {
- // Last Message
- val attributedLastMessage = room.lastMessage as? AnnotatedString
- ?: AnnotatedString(room.lastMessage.orEmpty().toString())
+ val messagePreview = if (room.isTombstoned) {
+ stringResource(R.string.screen_roomlist_tombstoned_room_description)
+ } else {
+ room.lastMessage.orEmpty()
+ }
+ val annotatedMessagePreview = messagePreview as? AnnotatedString ?: AnnotatedString(text = messagePreview.toString())
Text(
modifier = Modifier.weight(1f),
- text = attributedLastMessage,
- color = MaterialTheme.roomListRoomMessage(),
+ text = annotatedMessagePreview,
+ color = ElementTheme.roomListRoomMessage(),
style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2,
maxLines = 2,
@@ -303,6 +312,7 @@ private fun LastMessageAndIndicatorRow(
@Composable
private fun InviteNameAndIndicatorRow(
name: String?,
+ isInviteSeen: Boolean,
modifier: Modifier = Modifier,
) {
Row(
@@ -315,13 +325,15 @@ private fun InviteNameAndIndicatorRow(
style = ElementTheme.typography.fontBodyLgMedium,
text = name ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
- color = MaterialTheme.roomListRoomName(),
+ color = ElementTheme.roomListRoomName(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
- UnreadIndicatorAtom(
- color = ElementTheme.colors.unreadIndicator
- )
+ if (!isInviteSeen) {
+ UnreadIndicatorAtom(
+ color = ElementTheme.colors.unreadIndicator
+ )
+ }
}
}
@@ -387,6 +399,9 @@ private fun MentionIndicatorAtom() {
internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryProvider::class) data: RoomListRoomSummary) = ElementPreview {
RoomSummaryRow(
room = data,
+ hideInviteAvatars = false,
+ // Set isInviteSeen to true for the preview when the room has name "Bob"
+ isInviteSeen = data.name == "Bob",
onClick = {},
eventSink = {},
)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/SetUpRecoveryKeyBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/SetUpRecoveryKeyBanner.kt
similarity index 92%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/SetUpRecoveryKeyBanner.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/SetUpRecoveryKeyBanner.kt
index e1d251de8b8..37aed6d1302 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/SetUpRecoveryKeyBanner.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/SetUpRecoveryKeyBanner.kt
@@ -5,12 +5,12 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.components
+package io.element.android.features.home.impl.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
-import io.element.android.features.roomlist.impl.R
+import io.element.android.features.home.impl.R
import io.element.android.libraries.designsystem.components.Announcement
import io.element.android.libraries.designsystem.components.AnnouncementType
import io.element.android.libraries.designsystem.preview.ElementPreview
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt
similarity index 92%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt
index e81c8828ae7..dcda64ca9cc 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt
@@ -5,13 +5,14 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.datasource
+package io.element.android.features.home.impl.datasource
-import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
+import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.libraries.androidutils.diff.DiffCacheUpdater
import io.element.android.libraries.androidutils.diff.MutableListDiffCache
import io.element.android.libraries.androidutils.system.DateTimeObserver
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
@@ -36,7 +37,8 @@ class RoomListDataSource @Inject constructor(
private val roomListRoomSummaryFactory: RoomListRoomSummaryFactory,
private val coroutineDispatchers: CoroutineDispatchers,
private val notificationSettingsService: NotificationSettingsService,
- private val appScope: CoroutineScope,
+ @SessionCoroutineScope
+ private val sessionCoroutineScope: CoroutineScope,
private val dateTimeObserver: DateTimeObserver,
) {
init {
@@ -77,7 +79,7 @@ class RoomListDataSource @Inject constructor(
.onEach {
roomListService.allRooms.rebuildSummaries()
}
- .launchIn(appScope)
+ .launchIn(sessionCoroutineScope)
}
private fun observeDateTimeChanges() {
@@ -88,7 +90,7 @@ class RoomListDataSource @Inject constructor(
is DateTimeObserver.Event.DateChanged -> rebuildAllRoomSummaries()
}
}
- .launchIn(appScope)
+ .launchIn(sessionCoroutineScope)
}
private suspend fun replaceWith(roomSummaries: List) = withContext(coroutineDispatchers.computation) {
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt
similarity index 92%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt
index d1581f545b1..3d776621887 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt
@@ -5,10 +5,10 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.datasource
+package io.element.android.features.home.impl.datasource
-import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
-import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
@@ -67,6 +67,7 @@ class RoomListRoomSummaryFactory @Inject constructor(
heroes = roomInfo.heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomListItem)
}.toImmutableList(),
+ isTombstoned = roomInfo.successorRoom != null,
)
}
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt
new file mode 100644
index 00000000000..ef645124622
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2024 New Vector 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.home.impl.di
+
+import com.squareup.anvil.annotations.ContributesTo
+import dagger.Binds
+import dagger.Module
+import io.element.android.features.home.impl.filters.RoomListFiltersPresenter
+import io.element.android.features.home.impl.filters.RoomListFiltersState
+import io.element.android.features.home.impl.roomlist.RoomListPresenter
+import io.element.android.features.home.impl.roomlist.RoomListState
+import io.element.android.features.home.impl.search.RoomListSearchPresenter
+import io.element.android.features.home.impl.search.RoomListSearchState
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.di.SessionScope
+
+@ContributesTo(SessionScope::class)
+@Module
+interface RoomListModule {
+ @Binds
+ fun bindRoomListPresenter(presenter: RoomListPresenter): Presenter
+
+ @Binds
+ fun bindSearchPresenter(presenter: RoomListSearchPresenter): Presenter
+
+ @Binds
+ fun bindFiltersPresenter(presenter: RoomListFiltersPresenter): Presenter
+}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFilter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFilter.kt
similarity index 90%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFilter.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFilter.kt
index 324dd1eb3d5..2c7ebc35736 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFilter.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFilter.kt
@@ -5,9 +5,9 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.filters
+package io.element.android.features.home.impl.filters
-import io.element.android.features.roomlist.impl.R
+import io.element.android.features.home.impl.R
/**
* Enum class representing the different filters that can be applied to the room list.
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersEmptyStateResources.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResources.kt
similarity index 96%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersEmptyStateResources.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResources.kt
index a0c48787156..61436a0054e 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersEmptyStateResources.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResources.kt
@@ -5,10 +5,10 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.filters
+package io.element.android.features.home.impl.filters
import androidx.annotation.StringRes
-import io.element.android.features.roomlist.impl.R
+import io.element.android.features.home.impl.R
/**
* Holds the resources for the empty state when filters are applied to the room list.
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEvents.kt
similarity index 86%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersEvents.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEvents.kt
index 42559995f6b..13fcc656e81 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersEvents.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEvents.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.filters
+package io.element.android.features.home.impl.filters
sealed interface RoomListFiltersEvents {
data class ToggleFilter(val filter: RoomListFilter) : RoomListFiltersEvents
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenter.kt
similarity index 94%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenter.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenter.kt
index 0213d3fc15e..de3f7eaa0bc 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenter.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenter.kt
@@ -5,12 +5,12 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.filters
+package io.element.android.features.home.impl.filters
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
-import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionStrategy
+import io.element.android.features.home.impl.filters.selection.FilterSelectionStrategy
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import kotlinx.collections.immutable.toPersistentList
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersState.kt
similarity index 83%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersState.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersState.kt
index 3e72aa680bf..bb4146ce84b 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersState.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersState.kt
@@ -5,9 +5,9 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.filters
+package io.element.android.features.home.impl.filters
-import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
+import io.element.android.features.home.impl.filters.selection.FilterSelectionState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersStateProvider.kt
similarity index 87%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersStateProvider.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersStateProvider.kt
index e104651af93..8cba01163c1 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersStateProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersStateProvider.kt
@@ -5,10 +5,10 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.filters
+package io.element.android.features.home.impl.filters
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
+import io.element.android.features.home.impl.filters.selection.FilterSelectionState
import kotlinx.collections.immutable.toImmutableList
class RoomListFiltersStateProvider : PreviewParameterProvider {
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt
similarity index 96%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt
index 81bae26b4ac..91b00b1180d 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt
@@ -5,12 +5,11 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.filters
+package io.element.android.features.home.impl.filters
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
-import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.animateScrollBy
@@ -41,6 +40,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.home.impl.R
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
@@ -48,7 +48,6 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
-@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RoomListFiltersView(
state: RoomListFiltersState,
@@ -149,7 +148,7 @@ private fun RoomListClearFiltersButton(
modifier = Modifier.align(Alignment.Center),
imageVector = CompoundIcons.Close(),
tint = ElementTheme.colors.iconOnSolidPrimary,
- contentDescription = stringResource(id = io.element.android.libraries.ui.strings.R.string.action_clear),
+ contentDescription = stringResource(id = R.string.screen_roomlist_clear_filters),
)
}
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/selection/DefaultFilterSelectionStrategy.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt
similarity index 92%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/selection/DefaultFilterSelectionStrategy.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt
index c89615fea72..26a77da5c78 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/selection/DefaultFilterSelectionStrategy.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt
@@ -5,10 +5,10 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.filters.selection
+package io.element.android.features.home.impl.filters.selection
import com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.features.roomlist.impl.filters.RoomListFilter
+import io.element.android.features.home.impl.filters.RoomListFilter
import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/FilterSelectionState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/FilterSelectionState.kt
new file mode 100644
index 00000000000..d604e51f840
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/FilterSelectionState.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright 2024 New Vector 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.home.impl.filters.selection
+
+import io.element.android.features.home.impl.filters.RoomListFilter
+
+data class FilterSelectionState(
+ val filter: RoomListFilter,
+ val isSelected: Boolean,
+)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/selection/FilterSelectionStrategy.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/FilterSelectionStrategy.kt
similarity index 82%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/selection/FilterSelectionStrategy.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/FilterSelectionStrategy.kt
index c8d3c09fec1..8396a4e2a22 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/selection/FilterSelectionStrategy.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/FilterSelectionStrategy.kt
@@ -5,9 +5,9 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.filters.selection
+package io.element.android.features.home.impl.filters.selection
-import io.element.android.features.roomlist.impl.filters.RoomListFilter
+import io.element.android.features.home.impl.filters.RoomListFilter
import kotlinx.coroutines.flow.StateFlow
interface FilterSelectionStrategy {
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummary.kt
similarity index 83%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummary.kt
index 498ad762a30..3f166a66b44 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummary.kt
@@ -5,9 +5,10 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.model
+package io.element.android.features.home.impl.model
import androidx.compose.runtime.Immutable
+import io.element.android.features.invite.api.InviteData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
@@ -35,16 +36,21 @@ data class RoomListRoomSummary(
val isDm: Boolean,
val isFavorite: Boolean,
val inviteSender: InviteSender?,
+ val isTombstoned: Boolean,
val heroes: ImmutableList,
) {
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&
(numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) ||
- isMarkedUnread ||
- displayType == RoomSummaryDisplayType.INVITE
+ isMarkedUnread
val hasNewContent = numberOfUnreadMessages > 0 ||
numberOfUnreadMentions > 0 ||
numberOfUnreadNotifications > 0 ||
- isMarkedUnread ||
- displayType == RoomSummaryDisplayType.INVITE
+ isMarkedUnread
+
+ fun toInviteData() = InviteData(
+ roomId = roomId,
+ roomName = name ?: roomId.value,
+ isDm = isDm,
+ )
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryProvider.kt
similarity index 95%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryProvider.kt
index d293951f89f..f06e5a1a278 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryProvider.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.model
+package io.element.android.features.home.impl.model
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@@ -110,6 +110,11 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider = emptyList(),
+ isTombstoned: Boolean = false,
) = RoomListRoomSummary(
id = id,
roomId = RoomId(id),
@@ -164,4 +171,5 @@ internal fun aRoomListRoomSummary(
displayType = displayType,
canonicalAlias = canonicalAlias,
heroes = heroes.toImmutableList(),
+ isTombstoned = isTombstoned,
)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomSummaryDisplayType.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomSummaryDisplayType.kt
similarity index 86%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomSummaryDisplayType.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomSummaryDisplayType.kt
index 4b472d88479..8fcef46faf3 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomSummaryDisplayType.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomSummaryDisplayType.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.model
+package io.element.android.features.home.impl.model
/**
* Represents the type of display for a room list item.
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStateProvider.kt
new file mode 100644
index 00000000000..a421c239cb4
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStateProvider.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2023, 2024 New Vector 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.home.impl.roomlist
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
+import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.push.api.battery.BatteryOptimizationState
+import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toPersistentSet
+
+open class RoomListContentStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aRoomsContentState(),
+ aRoomsContentState(summaries = persistentListOf()),
+ aSkeletonContentState(),
+ anEmptyContentState(),
+ anEmptyContentState(securityBannerState = SecurityBannerState.SetUpRecovery),
+ )
+}
+
+internal fun aRoomsContentState(
+ securityBannerState: SecurityBannerState = SecurityBannerState.None,
+ summaries: ImmutableList = aRoomListRoomSummaryList(),
+ fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
+ batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(),
+ seenRoomInvites: Set = emptySet(),
+) = RoomListContentState.Rooms(
+ securityBannerState = securityBannerState,
+ fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
+ batteryOptimizationState = batteryOptimizationState,
+ summaries = summaries,
+ seenRoomInvites = seenRoomInvites.toPersistentSet(),
+)
+
+internal fun aSkeletonContentState() = RoomListContentState.Skeleton(16)
+
+internal fun anEmptyContentState(
+ securityBannerState: SecurityBannerState = SecurityBannerState.None,
+) = RoomListContentState.Empty(
+ securityBannerState = securityBannerState,
+)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt
similarity index 86%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt
index 661beb64187..27883e88d59 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl
+package io.element.android.features.home.impl.roomlist
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@@ -19,6 +19,7 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.home.impl.R
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -34,14 +35,17 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun RoomListContextMenu(
contextMenu: RoomListState.ContextMenu.Shown,
+ canReportRoom: Boolean,
eventSink: (RoomListEvents.ContextMenuEvents) -> Unit,
onRoomSettingsClick: (roomId: RoomId) -> Unit,
+ onReportRoomClick: (roomId: RoomId) -> Unit
) {
ModalBottomSheet(
onDismissRequest = { eventSink(RoomListEvents.HideContextMenu) },
) {
RoomListModalBottomSheetContent(
contextMenu = contextMenu,
+ canReportRoom = canReportRoom,
onRoomMarkReadClick = {
eventSink(RoomListEvents.HideContextMenu)
eventSink(RoomListEvents.MarkAsRead(contextMenu.roomId))
@@ -65,6 +69,10 @@ fun RoomListContextMenu(
eventSink(RoomListEvents.HideContextMenu)
eventSink(RoomListEvents.ClearCacheOfRoom(contextMenu.roomId))
},
+ onReportRoomClick = {
+ eventSink(RoomListEvents.HideContextMenu)
+ onReportRoomClick(contextMenu.roomId)
+ },
)
}
}
@@ -72,12 +80,14 @@ fun RoomListContextMenu(
@Composable
private fun RoomListModalBottomSheetContent(
contextMenu: RoomListState.ContextMenu.Shown,
+ canReportRoom: Boolean,
onRoomSettingsClick: () -> Unit,
onLeaveRoomClick: () -> Unit,
onFavoriteChange: (isFavorite: Boolean) -> Unit,
onRoomMarkReadClick: () -> Unit,
onRoomMarkUnreadClick: () -> Unit,
onClearCacheRoomClick: () -> Unit,
+ onReportRoomClick: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth()
@@ -132,14 +142,10 @@ private fun RoomListModalBottomSheetContent(
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(
CompoundIcons.Favourite(),
- contentDescription = stringResource(id = CommonStrings.common_favourite),
)
),
trailingContent = ListItemContent.Switch(
checked = contextMenu.isFavorite,
- onChange = { isFavorite ->
- onFavoriteChange(isFavorite)
- },
),
onClick = {
onFavoriteChange(!contextMenu.isFavorite)
@@ -157,32 +163,38 @@ private fun RoomListModalBottomSheetContent(
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(
CompoundIcons.Settings(),
- contentDescription = stringResource(id = CommonStrings.common_settings)
)
),
style = ListItemStyle.Primary,
)
+ if (canReportRoom) {
+ ListItem(
+ headlineContent = {
+ Text(text = stringResource(CommonStrings.action_report_room))
+ },
+ modifier = Modifier.clickable { onReportRoomClick() },
+ leadingContent = ListItemContent.Icon(
+ iconSource = IconSource.Vector(
+ CompoundIcons.ChatProblem(),
+ contentDescription = stringResource(CommonStrings.action_report_room),
+ )
+ ),
+ style = ListItemStyle.Destructive,
+ )
+ }
ListItem(
headlineContent = {
- val leaveText = stringResource(
- id = if (contextMenu.isDm) {
- CommonStrings.action_leave_conversation
- } else {
- CommonStrings.action_leave_room
- }
- )
- Text(text = leaveText)
+ Text(text = stringResource(CommonStrings.action_leave_room))
},
modifier = Modifier.clickable { onLeaveRoomClick() },
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(
CompoundIcons.Leave(),
- contentDescription = stringResource(id = CommonStrings.action_leave_room)
)
),
style = ListItemStyle.Destructive,
)
- if (contextMenu.eventCacheFeatureFlagEnabled) {
+ if (contextMenu.displayClearRoomCacheAction) {
ListItem(
headlineContent = {
Text(text = "Clear cache for this room")
@@ -207,11 +219,13 @@ internal fun RoomListModalBottomSheetContentPreview(
) = ElementPreview {
RoomListModalBottomSheetContent(
contextMenu = contextMenu,
+ canReportRoom = true,
onRoomMarkReadClick = {},
onRoomMarkUnreadClick = {},
onRoomSettingsClick = {},
onLeaveRoomClick = {},
onFavoriteChange = {},
onClearCacheRoomClick = {},
+ onReportRoomClick = {},
)
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenu.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenu.kt
new file mode 100644
index 00000000000..c6b91a02fce
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenu.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2023, 2024 New Vector 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.home.impl.roomlist
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.features.home.impl.R
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
+import io.element.android.libraries.designsystem.theme.components.OutlinedButton
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TextButton
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RoomListDeclineInviteMenu(
+ menu: RoomListState.DeclineInviteMenu.Shown,
+ canReportRoom: Boolean,
+ onDeclineAndBlockClick: (RoomListRoomSummary) -> Unit,
+ eventSink: (RoomListEvents) -> Unit,
+) {
+ ModalBottomSheet(
+ onDismissRequest = { eventSink(RoomListEvents.HideDeclineInviteMenu) },
+ ) {
+ RoomListDeclineInviteMenuContent(
+ roomName = menu.roomSummary.name ?: menu.roomSummary.roomId.value,
+ onDeclineClick = {
+ eventSink(RoomListEvents.HideDeclineInviteMenu)
+ eventSink(RoomListEvents.DeclineInvite(menu.roomSummary, false))
+ },
+ onDeclineAndBlockClick = {
+ eventSink(RoomListEvents.HideDeclineInviteMenu)
+ if (canReportRoom) {
+ onDeclineAndBlockClick(menu.roomSummary)
+ } else {
+ eventSink(RoomListEvents.DeclineInvite(menu.roomSummary, true))
+ }
+ },
+ onCancelClick = {
+ eventSink(RoomListEvents.HideDeclineInviteMenu)
+ }
+ )
+ }
+}
+
+@Composable
+private fun RoomListDeclineInviteMenuContent(
+ roomName: String,
+ onDeclineClick: () -> Unit,
+ onDeclineAndBlockClick: () -> Unit,
+ onCancelClick: () -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(all = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(R.string.screen_invites_decline_chat_title),
+ style = ElementTheme.typography.fontHeadingSmMedium,
+ color = ElementTheme.colors.textPrimary,
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.screen_invites_decline_chat_message, roomName),
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = ElementTheme.colors.textSecondary,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ )
+ Spacer(Modifier.height(22.dp))
+ Button(
+ text = stringResource(CommonStrings.action_decline),
+ modifier = Modifier.fillMaxWidth(),
+ onClick = onDeclineClick,
+ )
+ Spacer(Modifier.height(16.dp))
+ OutlinedButton(
+ text = stringResource(CommonStrings.action_decline_and_block),
+ modifier = Modifier.fillMaxWidth(),
+ destructive = true,
+ onClick = onDeclineAndBlockClick
+ )
+ Spacer(Modifier.height(16.dp))
+ TextButton(
+ text = stringResource(CommonStrings.action_cancel),
+ modifier = Modifier.fillMaxWidth(),
+ onClick = onCancelClick
+ )
+ }
+}
+
+// TODO This component should be seen in [RoomListView] @Preview but it doesn't show up.
+// see: https://issuetracker.google.com/issues/283843380
+// Remove this preview when the issue is fixed.
+@PreviewsDayNight
+@Composable
+internal fun RoomListDeclineInviteMenuContentPreview() = ElementPreview {
+ RoomListDeclineInviteMenuContent(
+ roomName = "Room name",
+ onCancelClick = {},
+ onDeclineClick = {},
+ onDeclineAndBlockClick = {},
+ )
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvents.kt
new file mode 100644
index 00000000000..4ba37d810b8
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvents.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2023, 2024 New Vector 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.home.impl.roomlist
+
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.libraries.matrix.api.core.RoomId
+
+sealed interface RoomListEvents {
+ data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
+ data object DismissRequestVerificationPrompt : RoomListEvents
+ data object DismissBanner : RoomListEvents
+ data object ToggleSearchResults : RoomListEvents
+ data class ShowContextMenu(val roomSummary: RoomListRoomSummary) : RoomListEvents
+
+ data class AcceptInvite(val roomSummary: RoomListRoomSummary) : RoomListEvents
+ data class DeclineInvite(val roomSummary: RoomListRoomSummary, val blockUser: Boolean) : RoomListEvents
+ data class ShowDeclineInviteMenu(val roomSummary: RoomListRoomSummary) : RoomListEvents
+ data object HideDeclineInviteMenu : RoomListEvents
+
+ sealed interface ContextMenuEvents : RoomListEvents
+ data object HideContextMenu : ContextMenuEvents
+ data class LeaveRoom(val roomId: RoomId) : ContextMenuEvents
+ data class MarkAsRead(val roomId: RoomId) : ContextMenuEvents
+ data class MarkAsUnread(val roomId: RoomId) : ContextMenuEvents
+ data class SetRoomIsFavorite(val roomId: RoomId, val isFavorite: Boolean) : ContextMenuEvents
+ data class ClearCacheOfRoom(val roomId: RoomId) : ContextMenuEvents
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt
new file mode 100644
index 00000000000..64a363d73ff
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt
@@ -0,0 +1,328 @@
+/*
+ * Copyright 2023, 2024 New Vector 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.home.impl.roomlist
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import im.vector.app.features.analytics.plan.Interaction
+import io.element.android.features.home.impl.datasource.RoomListDataSource
+import io.element.android.features.home.impl.filters.RoomListFiltersState
+import io.element.android.features.home.impl.search.RoomListSearchEvents
+import io.element.android.features.home.impl.search.RoomListSearchState
+import io.element.android.features.invite.api.SeenInvitesStore
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.AcceptInvite
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.DeclineInvite
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
+import io.element.android.features.leaveroom.api.LeaveRoomEvent.ShowConfirmation
+import io.element.android.features.leaveroom.api.LeaveRoomState
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.coroutine.mapState
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomId
+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.roomlist.RoomList
+import io.element.android.libraries.matrix.api.timeline.ReceiptType
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
+import io.element.android.libraries.push.api.battery.BatteryOptimizationState
+import io.element.android.libraries.push.api.notifications.NotificationCleaner
+import io.element.android.services.analytics.api.AnalyticsService
+import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.collections.immutable.toPersistentSet
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.takeWhile
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+private const val EXTENDED_RANGE_SIZE = 40
+private const val SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS = 300L
+
+class RoomListPresenter @Inject constructor(
+ private val client: MatrixClient,
+ private val leaveRoomPresenter: Presenter,
+ private val roomListDataSource: RoomListDataSource,
+ private val featureFlagService: FeatureFlagService,
+ private val filtersPresenter: Presenter,
+ private val searchPresenter: Presenter,
+ private val sessionPreferencesStore: SessionPreferencesStore,
+ private val analyticsService: AnalyticsService,
+ private val acceptDeclineInvitePresenter: Presenter,
+ private val fullScreenIntentPermissionsPresenter: Presenter,
+ private val batteryOptimizationPresenter: Presenter,
+ private val notificationCleaner: NotificationCleaner,
+ private val appPreferencesStore: AppPreferencesStore,
+ private val seenInvitesStore: SeenInvitesStore,
+) : Presenter {
+ private val encryptionService: EncryptionService = client.encryptionService()
+
+ @Composable
+ override fun present(): RoomListState {
+ val coroutineScope = rememberCoroutineScope()
+ val leaveRoomState = leaveRoomPresenter.present()
+ val filtersState = filtersPresenter.present()
+ val searchState = searchPresenter.present()
+ val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
+
+ LaunchedEffect(Unit) {
+ roomListDataSource.launchIn(this)
+ }
+
+ var securityBannerDismissed by rememberSaveable { mutableStateOf(true) }
+
+ // Avatar indicator
+ val hideInvitesAvatar by remember {
+ client
+ .mediaPreviewService()
+ .mediaPreviewConfigFlow
+ .mapState { config -> config.hideInviteAvatar }
+ }.collectAsState()
+
+ val contextMenu = remember { mutableStateOf(RoomListState.ContextMenu.Hidden) }
+ val declineInviteMenu = remember { mutableStateOf(RoomListState.DeclineInviteMenu.Hidden) }
+
+ fun handleEvents(event: RoomListEvents) {
+ when (event) {
+ is RoomListEvents.UpdateVisibleRange -> coroutineScope.launch {
+ updateVisibleRange(event.range)
+ }
+ RoomListEvents.DismissRequestVerificationPrompt -> securityBannerDismissed = true
+ RoomListEvents.DismissBanner -> securityBannerDismissed = true
+ RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
+ is RoomListEvents.ShowContextMenu -> {
+ coroutineScope.showContextMenu(event, contextMenu)
+ }
+ is RoomListEvents.HideContextMenu -> {
+ contextMenu.value = RoomListState.ContextMenu.Hidden
+ }
+ is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(ShowConfirmation(event.roomId))
+ is RoomListEvents.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite)
+ is RoomListEvents.MarkAsRead -> coroutineScope.markAsRead(event.roomId)
+ is RoomListEvents.MarkAsUnread -> coroutineScope.markAsUnread(event.roomId)
+ is RoomListEvents.AcceptInvite -> {
+ acceptDeclineInviteState.eventSink(
+ AcceptInvite(event.roomSummary.toInviteData())
+ )
+ }
+ is RoomListEvents.DeclineInvite -> {
+ acceptDeclineInviteState.eventSink(
+ DeclineInvite(event.roomSummary.toInviteData(), blockUser = event.blockUser, shouldConfirm = false)
+ )
+ }
+ is RoomListEvents.ShowDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Shown(event.roomSummary)
+ RoomListEvents.HideDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Hidden
+ is RoomListEvents.ClearCacheOfRoom -> coroutineScope.clearCacheOfRoom(event.roomId)
+ }
+ }
+
+ val contentState = roomListContentState(securityBannerDismissed)
+
+ val canReportRoom by produceState(false) { value = client.canReportRoom() }
+
+ return RoomListState(
+ contextMenu = contextMenu.value,
+ declineInviteMenu = declineInviteMenu.value,
+ leaveRoomState = leaveRoomState,
+ filtersState = filtersState,
+ searchState = searchState,
+ contentState = contentState,
+ acceptDeclineInviteState = acceptDeclineInviteState,
+ hideInvitesAvatars = hideInvitesAvatar,
+ canReportRoom = canReportRoom,
+ eventSink = ::handleEvents,
+ )
+ }
+
+ @Composable
+ private fun rememberSecurityBannerState(
+ securityBannerDismissed: Boolean,
+ ): State {
+ val currentSecurityBannerDismissed by rememberUpdatedState(securityBannerDismissed)
+ val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
+ return remember {
+ derivedStateOf {
+ calculateBannerState(
+ securityBannerDismissed = currentSecurityBannerDismissed,
+ recoveryState = recoveryState,
+ )
+ }
+ }
+ }
+
+ private fun calculateBannerState(
+ securityBannerDismissed: Boolean,
+ recoveryState: RecoveryState,
+ ): SecurityBannerState {
+ if (securityBannerDismissed) {
+ return SecurityBannerState.None
+ }
+
+ when (recoveryState) {
+ RecoveryState.DISABLED -> return SecurityBannerState.SetUpRecovery
+ RecoveryState.INCOMPLETE -> return SecurityBannerState.RecoveryKeyConfirmation
+ RecoveryState.UNKNOWN,
+ RecoveryState.WAITING_FOR_SYNC,
+ RecoveryState.ENABLED -> Unit
+ }
+
+ return SecurityBannerState.None
+ }
+
+ @Composable
+ private fun roomListContentState(
+ securityBannerDismissed: Boolean,
+ ): RoomListContentState {
+ val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
+ roomListDataSource.allRooms.collect { value = AsyncData.Success(it) }
+ }
+ val loadingState by roomListDataSource.loadingState.collectAsState()
+ val showEmpty by remember {
+ derivedStateOf {
+ (loadingState as? RoomList.LoadingState.Loaded)?.numberOfRooms == 0
+ }
+ }
+ val showSkeleton by remember {
+ derivedStateOf {
+ loadingState == RoomList.LoadingState.NotLoaded || roomSummaries is AsyncData.Loading
+ }
+ }
+ val seenRoomInvites by remember { seenInvitesStore.seenRoomIds() }.collectAsState(emptySet())
+ val securityBannerState by rememberSecurityBannerState(securityBannerDismissed)
+ return when {
+ showEmpty -> RoomListContentState.Empty(securityBannerState = securityBannerState)
+ showSkeleton -> RoomListContentState.Skeleton(count = 16)
+ else -> {
+ RoomListContentState.Rooms(
+ securityBannerState = securityBannerState,
+ fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
+ batteryOptimizationState = batteryOptimizationPresenter.present(),
+ summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList(),
+ seenRoomInvites = seenRoomInvites.toPersistentSet(),
+ )
+ }
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private fun CoroutineScope.showContextMenu(event: RoomListEvents.ShowContextMenu, contextMenuState: MutableState) = launch {
+ val initialState = RoomListState.ContextMenu.Shown(
+ roomId = event.roomSummary.roomId,
+ roomName = event.roomSummary.name,
+ isDm = event.roomSummary.isDm,
+ isFavorite = event.roomSummary.isFavorite,
+ markAsUnreadFeatureFlagEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.MarkAsUnread),
+ hasNewContent = event.roomSummary.hasNewContent,
+ displayClearRoomCacheAction = appPreferencesStore.isDeveloperModeEnabledFlow().first(),
+ )
+ contextMenuState.value = initialState
+
+ client.getRoom(event.roomSummary.roomId)?.use { room ->
+
+ val isShowingContextMenuFlow = snapshotFlow { contextMenuState.value is RoomListState.ContextMenu.Shown }
+ .distinctUntilChanged()
+
+ val isFavoriteFlow = room.roomInfoFlow
+ .map { it.isFavorite }
+ .distinctUntilChanged()
+
+ isFavoriteFlow
+ .onEach { isFavorite ->
+ contextMenuState.value = initialState.copy(isFavorite = isFavorite)
+ }
+ .flatMapLatest { isShowingContextMenuFlow }
+ .takeWhile { isShowingContextMenu -> isShowingContextMenu }
+ .collect()
+ }
+ }
+
+ private fun CoroutineScope.setRoomIsFavorite(roomId: RoomId, isFavorite: Boolean) = launch {
+ client.getRoom(roomId)?.use { room ->
+ room.setIsFavorite(isFavorite)
+ .onSuccess {
+ analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuFavouriteToggle)
+ }
+ }
+ }
+
+ private fun CoroutineScope.markAsRead(roomId: RoomId) = launch {
+ notificationCleaner.clearMessagesForRoom(client.sessionId, roomId)
+ client.getRoom(roomId)?.use { room ->
+ room.setUnreadFlag(isUnread = false)
+ val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) {
+ ReceiptType.READ
+ } else {
+ ReceiptType.READ_PRIVATE
+ }
+ room.markAsRead(receiptType)
+ .onSuccess {
+ analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle)
+ }
+ }
+ }
+
+ private fun CoroutineScope.markAsUnread(roomId: RoomId) = launch {
+ client.getRoom(roomId)?.use { room ->
+ room.setUnreadFlag(isUnread = true)
+ .onSuccess {
+ analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle)
+ }
+ }
+ }
+
+ private fun CoroutineScope.clearCacheOfRoom(roomId: RoomId) = launch {
+ client.getRoom(roomId)?.use { room ->
+ room.clearEventCacheStorage()
+ }
+ }
+
+ private var currentUpdateVisibleRangeJob: Job? = null
+ private fun CoroutineScope.updateVisibleRange(range: IntRange) {
+ currentUpdateVisibleRangeJob?.cancel()
+ currentUpdateVisibleRangeJob = launch {
+ // Debounce the subscription to avoid subscribing to too many rooms
+ delay(SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS)
+
+ if (range.isEmpty()) return@launch
+ val currentRoomList = roomListDataSource.allRooms.first()
+ // Use extended range to 'prefetch' the next rooms info
+ val midExtendedRangeSize = EXTENDED_RANGE_SIZE / 2
+ val extendedRange = range.first until range.last + midExtendedRangeSize
+ val roomIds = extendedRange.mapNotNull { index ->
+ currentRoomList.getOrNull(index)?.roomId
+ }
+ roomListDataSource.subscribeToVisibleRooms(roomIds)
+ }
+ }
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt
new file mode 100644
index 00000000000..4cfa82fbfcd
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2023, 2024 New Vector 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.home.impl.roomlist
+
+import androidx.compose.runtime.Immutable
+import io.element.android.features.home.impl.filters.RoomListFiltersState
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.features.home.impl.search.RoomListSearchState
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
+import io.element.android.features.leaveroom.api.LeaveRoomState
+import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.push.api.battery.BatteryOptimizationState
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableSet
+
+@Immutable
+data class RoomListState(
+ val contextMenu: ContextMenu,
+ val declineInviteMenu: DeclineInviteMenu,
+ val leaveRoomState: LeaveRoomState,
+ val filtersState: RoomListFiltersState,
+ val searchState: RoomListSearchState,
+ val contentState: RoomListContentState,
+ val acceptDeclineInviteState: AcceptDeclineInviteState,
+ val hideInvitesAvatars: Boolean,
+ val canReportRoom: Boolean,
+ val eventSink: (RoomListEvents) -> Unit,
+) {
+ val displayFilters = contentState is RoomListContentState.Rooms
+
+ sealed interface ContextMenu {
+ data object Hidden : ContextMenu
+ data class Shown(
+ val roomId: RoomId,
+ val roomName: String?,
+ val isDm: Boolean,
+ val isFavorite: Boolean,
+ val markAsUnreadFeatureFlagEnabled: Boolean,
+ val hasNewContent: Boolean,
+ val displayClearRoomCacheAction: Boolean,
+ ) : ContextMenu
+ }
+
+ sealed interface DeclineInviteMenu {
+ data object Hidden : DeclineInviteMenu
+ data class Shown(val roomSummary: RoomListRoomSummary) : DeclineInviteMenu
+ }
+}
+
+enum class SecurityBannerState {
+ None,
+ SetUpRecovery,
+ RecoveryKeyConfirmation,
+}
+
+@Immutable
+sealed interface RoomListContentState {
+ data class Skeleton(val count: Int) : RoomListContentState
+ data class Empty(
+ val securityBannerState: SecurityBannerState,
+ ) : RoomListContentState
+
+ data class Rooms(
+ val securityBannerState: SecurityBannerState,
+ val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
+ val batteryOptimizationState: BatteryOptimizationState,
+ val summaries: ImmutableList,
+ val seenRoomInvites: ImmutableSet,
+ ) : RoomListContentState
+}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateContextMenuShownProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownProvider.kt
similarity index 91%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateContextMenuShownProvider.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownProvider.kt
index 47927956778..0cd3117de4c 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateContextMenuShownProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownProvider.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl
+package io.element.android.features.home.impl.roomlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomId
@@ -31,5 +31,5 @@ internal fun aContextMenuShown(
markAsUnreadFeatureFlagEnabled = true,
hasNewContent = hasNewContent,
isFavorite = isFavorite,
- eventCacheFeatureFlagEnabled = false,
+ displayClearRoomCacheAction = false,
)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt
new file mode 100644
index 00000000000..0e4600c16ad
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2023, 2024 New Vector 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.home.impl.roomlist
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.home.impl.filters.RoomListFiltersState
+import io.element.android.features.home.impl.filters.aRoomListFiltersState
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.features.home.impl.model.RoomSummaryDisplayType
+import io.element.android.features.home.impl.model.aRoomListRoomSummary
+import io.element.android.features.home.impl.model.anInviteSender
+import io.element.android.features.home.impl.search.RoomListSearchState
+import io.element.android.features.home.impl.search.aRoomListSearchState
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
+import io.element.android.features.leaveroom.api.LeaveRoomState
+import io.element.android.features.leaveroom.api.aLeaveRoomState
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toPersistentList
+
+open class RoomListStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aRoomListState(),
+ aRoomListState(contextMenu = aContextMenuShown(roomName = null)),
+ aRoomListState(contextMenu = aContextMenuShown(roomName = "A nice room name")),
+ aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)),
+ aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
+ aRoomListState(contentState = anEmptyContentState()),
+ aRoomListState(contentState = aSkeletonContentState()),
+ aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
+ aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery)),
+ aRoomListState(contentState = aRoomsContentState(batteryOptimizationState = aBatteryOptimizationState(shouldDisplayBanner = true))),
+ )
+}
+
+internal fun aRoomListState(
+ contextMenu: RoomListState.ContextMenu = RoomListState.ContextMenu.Hidden,
+ declineInviteMenu: RoomListState.DeclineInviteMenu = RoomListState.DeclineInviteMenu.Hidden,
+ leaveRoomState: LeaveRoomState = aLeaveRoomState(),
+ searchState: RoomListSearchState = aRoomListSearchState(),
+ filtersState: RoomListFiltersState = aRoomListFiltersState(),
+ contentState: RoomListContentState = aRoomsContentState(),
+ acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
+ hideInvitesAvatars: Boolean = false,
+ canReportRoom: Boolean = true,
+ eventSink: (RoomListEvents) -> Unit = {}
+) = RoomListState(
+ contextMenu = contextMenu,
+ declineInviteMenu = declineInviteMenu,
+ leaveRoomState = leaveRoomState,
+ filtersState = filtersState,
+ searchState = searchState,
+ contentState = contentState,
+ acceptDeclineInviteState = acceptDeclineInviteState,
+ hideInvitesAvatars = hideInvitesAvatars,
+ canReportRoom = canReportRoom,
+ eventSink = eventSink,
+)
+
+internal fun anAcceptDeclineInviteState(
+ acceptAction: AsyncAction = AsyncAction.Uninitialized,
+ declineAction: AsyncAction = AsyncAction.Uninitialized,
+ eventSink: (AcceptDeclineInviteEvents) -> Unit = {}
+) = AcceptDeclineInviteState(
+ acceptAction = acceptAction,
+ declineAction = declineAction,
+ eventSink = eventSink,
+)
+
+internal fun aRoomListRoomSummaryList(): ImmutableList {
+ return persistentListOf(
+ aRoomListRoomSummary(
+ name = "Room Invited",
+ avatarData = AvatarData("!roomId", "Room with Alice and Bob", size = AvatarSize.RoomListItem),
+ id = "!roomId:domain",
+ inviteSender = anInviteSender(),
+ displayType = RoomSummaryDisplayType.INVITE,
+ ),
+ aRoomListRoomSummary(
+ name = "Room",
+ numberOfUnreadMessages = 1,
+ timestamp = "14:18",
+ lastMessage = "A very very very very long message which suites on two lines",
+ avatarData = AvatarData("!id", "R", size = AvatarSize.RoomListItem),
+ id = "!roomId:domain",
+ ),
+ aRoomListRoomSummary(
+ name = "Room#2",
+ numberOfUnreadMessages = 0,
+ timestamp = "14:16",
+ lastMessage = "A short message",
+ avatarData = AvatarData("!id", "Z", size = AvatarSize.RoomListItem),
+ id = "!roomId2:domain",
+ ),
+ aRoomListRoomSummary(
+ id = "!roomId3:domain",
+ displayType = RoomSummaryDisplayType.PLACEHOLDER,
+ ),
+ aRoomListRoomSummary(
+ id = "!roomId4:domain",
+ displayType = RoomSummaryDisplayType.PLACEHOLDER,
+ ),
+ )
+}
+
+internal fun generateRoomListRoomSummaryList(
+ numberOfRooms: Int = 10,
+): ImmutableList {
+ return List(numberOfRooms) { index ->
+ aRoomListRoomSummary(
+ name = "Room#$index",
+ numberOfUnreadMessages = 0,
+ timestamp = "14:16",
+ lastMessage = "A message",
+ avatarData = AvatarData("!id$index", "${(65 + index % 26).toChar()}", size = AvatarSize.RoomListItem),
+ id = "!roomId$index:domain",
+ )
+ }.toPersistentList()
+}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchDataSource.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt
similarity index 90%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchDataSource.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt
index 34152a7e65d..6840809480c 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchDataSource.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt
@@ -5,10 +5,10 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.search
+package io.element.android.features.home.impl.search
-import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
-import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
+import io.element.android.features.home.impl.datasource.RoomListRoomSummaryFactory
+import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt
similarity index 87%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchEvents.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt
index b8248d09f5c..26f713da819 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchEvents.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.search
+package io.element.android.features.home.impl.search
sealed interface RoomListSearchEvents {
data object ToggleSearchVisibility : RoomListSearchEvents
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt
similarity index 81%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenter.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt
index 62b5fa524f5..b4d151fe028 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenter.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.search
+package io.element.android.features.home.impl.search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -15,14 +15,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.featureflag.api.FeatureFlagService
-import io.element.android.libraries.featureflag.api.FeatureFlags
import kotlinx.collections.immutable.persistentListOf
import javax.inject.Inject
class RoomListSearchPresenter @Inject constructor(
private val dataSource: RoomListSearchDataSource,
- private val featureFlagService: FeatureFlagService,
) : Presenter {
@Composable
override fun present(): RoomListSearchState {
@@ -57,14 +54,12 @@ class RoomListSearchPresenter @Inject constructor(
}
}
- val isRoomDirectorySearchEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch).collectAsState(initial = false)
val searchResults by dataSource.roomSummaries.collectAsState(initial = persistentListOf())
return RoomListSearchState(
isSearchActive = isSearchActive,
query = searchQuery,
results = searchResults,
- isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
eventSink = ::handleEvents
)
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt
new file mode 100644
index 00000000000..9d50f52a339
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2024 New Vector 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.home.impl.search
+
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import kotlinx.collections.immutable.ImmutableList
+
+data class RoomListSearchState(
+ val isSearchActive: Boolean,
+ val query: String,
+ val results: ImmutableList,
+ val eventSink: (RoomListSearchEvents) -> Unit
+)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt
new file mode 100644
index 00000000000..503e334418e
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2024 New Vector 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.home.impl.search
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.features.home.impl.roomlist.aRoomListRoomSummaryList
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+class RoomListSearchStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aRoomListSearchState(),
+ aRoomListSearchState(
+ isSearchActive = true,
+ query = "Test",
+ results = aRoomListRoomSummaryList()
+ ),
+ )
+}
+
+fun aRoomListSearchState(
+ isSearchActive: Boolean = false,
+ query: String = "",
+ results: ImmutableList = persistentListOf(),
+ eventSink: (RoomListSearchEvents) -> Unit = { },
+) = RoomListSearchState(
+ isSearchActive = isSearchActive,
+ query = query,
+ results = results,
+ eventSink = eventSink,
+)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt
similarity index 78%
rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt
index d81f5442762..29c8078c09b 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt
@@ -5,19 +5,16 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.search
+package io.element.android.features.home.impl.search
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -26,7 +23,6 @@ import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
@@ -38,22 +34,18 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
-import io.element.android.features.roomlist.impl.R
-import io.element.android.features.roomlist.impl.RoomListEvents
-import io.element.android.features.roomlist.impl.components.RoomSummaryRow
-import io.element.android.features.roomlist.impl.contentType
-import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
+import io.element.android.features.home.impl.components.RoomSummaryRow
+import io.element.android.features.home.impl.contentType
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.features.home.impl.roomlist.RoomListEvents
import io.element.android.libraries.designsystem.components.button.BackButton
-import io.element.android.libraries.designsystem.components.button.SuperButton
import io.element.android.libraries.designsystem.modifiers.applyIf
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.FilledTextField
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
-import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.copy
import io.element.android.libraries.matrix.api.core.RoomId
@@ -62,9 +54,9 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun RoomListSearchView(
state: RoomListSearchState,
+ hideInvitesAvatars: Boolean,
eventSink: (RoomListEvents) -> Unit,
onRoomClick: (RoomId) -> Unit,
- onRoomDirectorySearchClick: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(enabled = state.isSearchActive) {
@@ -89,9 +81,9 @@ internal fun RoomListSearchView(
if (state.isSearchActive) {
RoomListSearchContent(
state = state,
+ hideInvitesAvatars = hideInvitesAvatars,
onRoomClick = onRoomClick,
eventSink = eventSink,
- onRoomDirectorySearchClick = onRoomDirectorySearchClick,
)
}
}
@@ -102,9 +94,9 @@ internal fun RoomListSearchView(
@Composable
private fun RoomListSearchContent(
state: RoomListSearchState,
+ hideInvitesAvatars: Boolean,
eventSink: (RoomListEvents) -> Unit,
onRoomClick: (RoomId) -> Unit,
- onRoomDirectorySearchClick: () -> Unit,
) {
val borderColor = MaterialTheme.colorScheme.tertiary
val strokeWidth = 1.dp
@@ -175,14 +167,6 @@ private fun RoomListSearchContent(
.padding(padding)
.consumeWindowInsets(padding)
) {
- if (state.displayRoomDirectorySearch) {
- RoomDirectorySearchButton(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 24.dp, horizontal = 16.dp),
- onClick = onRoomDirectorySearchClick
- )
- }
LazyColumn(
modifier = Modifier.weight(1f),
) {
@@ -192,6 +176,9 @@ private fun RoomListSearchContent(
) { room ->
RoomSummaryRow(
room = room,
+ hideInviteAvatars = hideInvitesAvatars,
+ // TODO
+ isInviteSeen = false,
onClick = ::onRoomClick,
eventSink = eventSink,
)
@@ -201,38 +188,13 @@ private fun RoomListSearchContent(
}
}
-@Composable
-private fun RoomDirectorySearchButton(
- onClick: () -> Unit,
- modifier: Modifier = Modifier
-) {
- SuperButton(
- onClick = onClick,
- modifier = modifier,
- buttonSize = ButtonSize.Large,
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(
- imageVector = CompoundIcons.ListBulleted(),
- contentDescription = null,
- )
- Spacer(modifier = Modifier.width(8.dp))
- Text(
- text = stringResource(R.string.screen_roomlist_room_directory_button_title),
- )
- }
- }
-}
-
@PreviewsDayNight
@Composable
internal fun RoomListSearchContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview {
RoomListSearchContent(
state = state,
+ hideInvitesAvatars = false,
onRoomClick = {},
eventSink = {},
- onRoomDirectorySearchClick = {},
)
}
diff --git a/features/home/impl/src/main/res/values-be/translations.xml b/features/home/impl/src/main/res/values-be/translations.xml
new file mode 100644
index 00000000000..b34671ed861
--- /dev/null
+++ b/features/home/impl/src/main/res/values-be/translations.xml
@@ -0,0 +1,42 @@
+
+
+ "Стварыце новы ключ аднаўлення, які можна выкарыстоўваць для аднаўлення зашыфраванай гісторыі паведамленняў у выпадку страты доступу да вашых прылад."
+ "Наладзьце аднаўленне"
+ "Наладзіць аднаўленне"
+ "Пацвердзіце свой ключ аднаўлення, каб захаваць доступ да сховішча ключоў і гісторыі паведамленняў."
+ "Ваша сховішча ключоў не сінхранізавана"
+ "Каб не прапусціць важны званок, зменіце налады, каб дазволіць поўнаэкранныя апавяшчэнні, калі тэлефон заблакіраваны."
+ "Палепшыце якасць званкоў"
+ "Вы ўпэўненыя, што хочаце адхіліць запрашэнне ў %1$s?"
+ "Адхіліць запрашэнне"
+ "Вы ўпэўненыя, што хочаце адмовіцца ад прыватных зносін з %1$s?"
+ "Адхіліць чат"
+ "Няма запрашэнняў"
+ "%1$s (%2$s) запрасіў(-ла) вас"
+ "Гэта аднаразовы працэс, дзякуем за чаканне."
+ "Налада ўліковага запісу."
+ "Стварыце новую размову або пакой"
+ "Пачніце з паведамлення каму-небудзь."
+ "Пакуль няма чатаў."
+ "Абранае"
+ "Дадаць чат у абранае можна ў наладах чата.
+На дадзены момант вы можаце прыбраць фільтры, каб убачыць іншыя вашыя чаты."
+ "У вас пакуль няма абраных чатаў"
+ "Запрашэнні"
+ "У вас няма непрынятых запрашэнняў."
+ "Нізкі прыярытэт"
+ "Вы можаце прыбраць фільтры, каб убачыць іншыя вашыя чаты."
+ "У вас няма чатаў для гэтай катэгорыі"
+ "Людзі"
+ "У вас пакуль няма асабістых паведамленняў"
+ "Пакоі"
+ "Вас пакуль няма ў ніводным пакоі"
+ "Непрачытаныя"
+ "Віншуем!
+У вас няма непрачытаных паведамленняў!"
+ "Усе чаты"
+ "Пазначыць як прачытанае"
+ "Пазначыць як непрачытанае"
+ "Здаецца, вы карыстаецеся новай прыладай. Праверце з дапамогай іншай прылады, каб атрымаць доступ да зашыфраваных паведамленняў."
+ "Пацвердзіце, што гэта вы"
+
diff --git a/features/home/impl/src/main/res/values-bg/translations.xml b/features/home/impl/src/main/res/values-bg/translations.xml
new file mode 100644
index 00000000000..8a555ebbb8d
--- /dev/null
+++ b/features/home/impl/src/main/res/values-bg/translations.xml
@@ -0,0 +1,33 @@
+
+
+ "Потвърдете ключа си за възстановяване, за да запазите достъп до хранилището за ключове и историята на съобщенията си."
+ "Въведете ключа си за възстановяване"
+ "Хранилището ви за ключове не е синхронизирано"
+ "Сигурни ли сте, че искате да отхвърлите поканата за присъединяване в %1$s?"
+ "Отказване на покана"
+ "Няма покани"
+ "%1$s (%2$s) ви покани"
+ "Създаване на нов разговор или стая"
+ "Започнете, като изпратите съобщение на някого."
+ "Все още няма чатове."
+ "Любими"
+ "Можете да добавите чат към фаворизираните си в настройките на чата.
+Засега можете да премахнете избора на филтрите, за да видите другите си чатове."
+ "Все още нямате фаворизирани чатове"
+ "Покани"
+ "Нямате чакащи покани."
+ "Нисък приоритет"
+ "Можете да премахнете избора на филтрите, за да видите другите си чатове"
+ "Хора"
+ "Все още нямате директни съобщения"
+ "Стаи"
+ "Все още не сте в никоя стая"
+ "Непрочетени"
+ "Поздравления!
+Нямате непрочетени съобщения!"
+ "Всички чатове"
+ "Отбелязване като прочетено"
+ "Отбелязване като непрочетено"
+ "Изглежда, че използвате ново устройство. Потвърдете с друго устройство за достъп до вашите шифровани съобщения."
+ "Потвърдете, че сте вие"
+
diff --git a/features/home/impl/src/main/res/values-cs/translations.xml b/features/home/impl/src/main/res/values-cs/translations.xml
new file mode 100644
index 00000000000..9fcea2887a1
--- /dev/null
+++ b/features/home/impl/src/main/res/values-cs/translations.xml
@@ -0,0 +1,49 @@
+
+
+ "Zakažte optimalizaci baterie pro tuto aplikaci, abyste měli jistotu, že budou přijata všechna oznámení."
+ "Zakázat optimalizaci"
+ "Nepřicházejí vám oznámení?"
+ "Vygenerujte nový klíč pro obnovení, který lze použít k obnovení historie šifrovaných zpráv v případě, že ztratíte přístup ke svým zařízením."
+ "Nastavení obnovy"
+ "Nastavení obnovy"
+ "Potvrďte klíč pro obnovení, abyste zachovali přístup k úložišti klíčů a historii zpráv."
+ "Zadejte klíč pro obnovení"
+ "Zapomněli jste klíč pro obnovení?"
+ "Vaše úložiště klíčů není synchronizováno"
+ "Abyste nikdy nezmeškali důležitý hovor, změňte nastavení tak, abyste povolili oznámení na celé obrazovce, když je telefon uzamčen."
+ "Vylepšete si zážitek z volání"
+ "Opravdu chcete odmítnout pozvánku do %1$s?"
+ "Odmítnout pozvání"
+ "Opravdu chcete odmítnout tuto soukromou konverzaci s %1$s?"
+ "Odmítnout chat"
+ "Žádné pozvánky"
+ "%1$s (%2$s) vás pozval(a)"
+ "Jedná se o jednorázový proces, prosíme o strpení."
+ "Nastavení vašeho účtu"
+ "Vytvořte novou konverzaci nebo místnost"
+ "Začněte tím, že někomu pošnete zprávu."
+ "Zatím žádné konverzace."
+ "Oblíbené"
+ "V nastavení chatu můžete přidat chat k oblíbeným.
+Prozatím můžete zrušit výběr filtrů, abyste viděli své další chaty"
+ "Zatím nemáte oblíbené chaty"
+ "Pozvánky"
+ "Nemáte žádné nevyřízené pozvánky."
+ "Nízká priorita"
+ "Můžete zrušit výběr filtrů, abyste viděli své další chaty"
+ "Nemáte chaty pro tento výběr"
+ "Lidé"
+ "Zatím nemáte žádné přímé zprávy"
+ "Místnosti"
+ "Ještě nejste v žádné místnosti"
+ "Nepřečtené"
+ "Gratulujeme!
+Nemáte žádné nepřečtené zprávy!"
+ "Žádost o vstup odeslána"
+ "Všechny chaty"
+ "Označit jako přečtené"
+ "Označit jako nepřečtené"
+ "Tato místnost byla aktualizována"
+ "Zdá se, že používáte nové zařízení. Ověřte přihlášení, abyste měli přístup k zašifrovaným zprávám."
+ "Ověřte, že jste to vy"
+
diff --git a/features/home/impl/src/main/res/values-cy/translations.xml b/features/home/impl/src/main/res/values-cy/translations.xml
new file mode 100644
index 00000000000..9b44b0fc3fa
--- /dev/null
+++ b/features/home/impl/src/main/res/values-cy/translations.xml
@@ -0,0 +1,50 @@
+
+
+ "Analluogwch optimeiddio batri ar gyfer yr ap hwn, er mwyn sicrhau bod pob hysbysiad yn cael ei dderbyn."
+ "Analluogi optimeiddio"
+ "Hysbysiadau ddim yn cyrraedd?"
+ "Adferwch eich hunaniaeth cryptograffig a hanes negeseuon gydag allwedd adfer os ydych wedi colli eich holl ddyfeisiau presennol."
+ "Gosod adfer"
+ "Gosodwch adferiad i ddiogelu eich cyfrif"
+ "Cadarnhewch eich allwedd adfer i gynnal mynediad i\'ch storfa allweddi a\'ch hanes negeseuon."
+ "Rhowch eich allwedd adfer"
+ "Wedi anghofio\'ch allwedd adfer?"
+ "Dyw eich allwedd storfa heb ei gydweddu"
+ "Er mwyn sicrhau fyddwch chi ddim yn colli galwad bwysig, newidiwch eich gosodiadau i ganiatáu hysbysiadau sgrin lawn pan fydd eich ffôn wedi\'i gloi."
+ "Gwella profiad eich galwadau"
+ "Ydych chi\'n siŵr eich bod am wrthod y gwahoddiad i ymuno â %1$s?"
+ "Gwrthod y gwahoddiad"
+ "Ydych chi\'n siŵr eich bod am wrthod y sgwrs breifat hon gyda %1$s?"
+ "Gwrthod sgwrs"
+ "Dim Gwahoddiadau"
+ "Mae %1$s (%2$s) wedi eich gwahodd"
+ "Mae hon yn broses un tro, diolch am aros."
+ "Creu eich cyfrif."
+ "Crëwch sgwrs neu ystafell newydd"
+ "Clirio\'r hidlau"
+ "Cychwynnwch arni trwy anfon neges at rywun."
+ "Dim sgyrsiau eto."
+ "Ffefrynnau"
+ "Gallwch ychwanegu sgwrs at eich ffefrynnau yn y gosodiadau sgwrsio.
+Am y tro, gallwch ddad-ddewis hidlwyr er mwyn gweld eich sgyrsiau eraill"
+ "Does gennych chi ddim hoff sgyrsiau eto"
+ "Gwahoddiadau"
+ "Does gennych chi ddim gwahoddiadau yn aros."
+ "Blaenoriaeth Isel"
+ "Gallwch ddad-ddewis hidlwyr er mwyn gweld eich sgyrsiau eraill"
+ "Does gennych chi ddim sgyrsiau ar gyfer y dewis hwn"
+ "Pobl"
+ "Does gennych chi ddim unrhyw DMs eto"
+ "Ystafelloedd"
+ "Dydych chi ddim mewn unrhyw ystafell eto"
+ "Heb ei ddarllen"
+ "Llongyfarchiadau!
+Does gennych chi ddim negeseuon heb eu darllen!"
+ "Anfonwyd y cais i ymuno"
+ "Sgyrsiau"
+ "Marcio fel wedi\'i ddarllen"
+ "Marcio fel heb ei ddarllen"
+ "Mae\'r ystafell hon wedi\'i huwchraddio"
+ "Mae\'n debyg eich bod chi\'n defnyddio dyfais newydd. Dilyswch gyda dyfais arall i gael mynediad at eich negeseuon wedi\'u hamgryptio."
+ "Gwiriwch mai chi sydd yna"
+
diff --git a/features/home/impl/src/main/res/values-da/translations.xml b/features/home/impl/src/main/res/values-da/translations.xml
new file mode 100644
index 00000000000..ae32f7ab667
--- /dev/null
+++ b/features/home/impl/src/main/res/values-da/translations.xml
@@ -0,0 +1,50 @@
+
+
+ "Deaktiver batterioptimering for denne app for at sikre, at alle notifikationer dukker op."
+ "Deaktivér optimering"
+ "Modtager du ikke notifikationer?"
+ "Gendan din kryptografiske identitet og meddelelseshistorik med en gendannelsesnøgle, hvis du har mistet alle dine eksisterende enheder."
+ "Opsæt gendannelse"
+ "Konfigurer gendannelse for at beskytte din konto"
+ "Bekræft din gendannelsesnøgle for at bevare adgangen til nøglelager og meddelelseshistorik."
+ "Indtast din gendannelsesnøgle"
+ "Har du glemt din gendannelsesnøgle?"
+ "Dit nøglelager er ikke synkroniseret"
+ "For at sikre, at du aldrig går glip af et vigtigt opkald, skal du ændre dine indstillinger til at tillade underretninger i fuld skærm, når din telefon er låst."
+ "Gør din opkaldsoplevelse bedre"
+ "Er du sikker på, at du vil afvise invitationen til at deltage i %1$s?"
+ "Afvis invitation"
+ "Er du sikker på, at du vil afvise denne private samtale med %1$s?"
+ "Afvis samtale"
+ "Ingen invitationer"
+ "%1$s(%2$s ) inviterede dig"
+ "Dette er en engangsproces, tak for din tålmodighed."
+ "Sætter din konto op."
+ "Opret en ny samtale eller et nyt rum"
+ "Ryd filtre"
+ "Kom i gang ved at sende en besked til nogen."
+ "Ingen samtaler endnu."
+ "Favoritter"
+ "Du kan tilføje en samtale til dine favoritter i samtaleindstillingerne.
+For nu kan du fravælge filtre for at se dine andre samtaler"
+ "Du har endnu ingen foretrukne samtaler"
+ "Invitationer"
+ "Du har ingen afventende invitationer."
+ "Lav prioritet"
+ "Du kan fravælge filtre for at se dine andre samtaler"
+ "Du har ingen samtaler til dette valg"
+ "Mennesker"
+ "Du har ingen DM\'er endnu"
+ "Rum"
+ "Du er ikke i noget rum endnu"
+ "Ulæste"
+ "Tillykke!
+Du har ingen ulæste beskeder!"
+ "Anmodning om at deltage sendt"
+ "Samtaler"
+ "Marker som læst"
+ "Marker som ulæst"
+ "Dette rum er blevet opgraderet"
+ "Det ser ud til, at du bruger en ny enhed. Bekræft med en anden enhed for at få adgang til dine krypterede meddelelser."
+ "Bekræft, at det er dig"
+
diff --git a/features/home/impl/src/main/res/values-de/translations.xml b/features/home/impl/src/main/res/values-de/translations.xml
new file mode 100644
index 00000000000..7f2613809cd
--- /dev/null
+++ b/features/home/impl/src/main/res/values-de/translations.xml
@@ -0,0 +1,50 @@
+
+
+ "Deaktiviere die Batterieoptimierung für diese App, um sicherzustellen, dass alle Benachrichtigungen empfangen werden."
+ "Optimierung deaktivieren"
+ "Kommen die Benachrichtigungen nicht an?"
+ "Falls Sie alle vorhandenen Geräte verloren haben, stellen Sie Ihre kryptografische Identität und Ihren Nachrichtenverlauf mit einem Wiederherstellungsschlüssel wieder her."
+ "Wiederherstellung einrichten"
+ "Wiederherstellung einrichten"
+ "Bestätigen Sie die Validität Ihres Wiederherstellungsschlüssels, um weiterhin auf Ihren Schlüsselspeicher und den Nachrichtenverlauf zugreifen zu können."
+ "Geben Sie Ihren Wiederherstellungsschlüssel ein"
+ "Haben Sie Ihren Wiederherstellungsschlüssel vergessen?"
+ "Ihr Schlüsselspeicher ist nicht synchronisiert"
+ "Damit Sie keine wichtigen Anrufe verpassen, ändern Sie bitte Ihre Einstellungen, so dass das gesperrte Telefon auch Benachrichtigungen im Vollbildmodus erhalten darf."
+ "Verbessere dein Anruferlebnis"
+ "Möchten Sie die Einladung zum Betreten von %1$s wirklich ablehnen?"
+ "Einladung ablehnen"
+ "Möchten Sie diesen privaten Chat mit %1$s wirklich ablehnen?"
+ "Einladung ablehnen"
+ "Keine Einladungen"
+ "%1$s (%2$s) hat dich eingeladen"
+ "Dies ist ein einmaliger Vorgang, danke fürs Warten."
+ "Dein Konto wird eingerichtet."
+ "Eine Unterthaltung oder Raum erstellen"
+ "Filter zurücksetzen"
+ "Beginnen Sie, indem Sie jemandem eine Nachricht senden."
+ "Noch keine Chats."
+ "Favoriten"
+ "In den Chatroomeinstellungen können Sie einen Chatroom als Favorit markieren.
+Deaktivieren Sie den entsprechenden Filter, um Ihre anderen Chatrooms zu sehen"
+ "Sie haben noch keine Chatrooms als Favorit markiert"
+ "Einladungen"
+ "Sie haben keine ausstehenden Einladungen."
+ "Niedrige Priorität"
+ "Wähle Filter ab, um Deine Chats zu sehen."
+ "Diese Chats entsprechen diesen Kriterien nicht."
+ "Personen"
+ "Sie haben noch keine Direktnachrichten"
+ "Räume"
+ "Sie sind noch in keinem Raum"
+ "Ungelesen"
+ "Glückwunsch!
+Sie haben keine ungelesenen Nachrichten!"
+ "Beitrittsanfrage geschickt"
+ "Chats"
+ "Als gelesen markieren"
+ "Als ungelesen markieren"
+ "Dieser Raum wurde aktualisiert."
+ "Sie verwenden anscheinend ein neues Gerät. Verifizieren Sie es mit einem anderen Gerät, um Zugriff auf ihre verschlüsselten Nachrichten zu erhalten."
+ "Bestätigen Sie ihre Identität"
+
diff --git a/features/home/impl/src/main/res/values-el/translations.xml b/features/home/impl/src/main/res/values-el/translations.xml
new file mode 100644
index 00000000000..692aace0ab2
--- /dev/null
+++ b/features/home/impl/src/main/res/values-el/translations.xml
@@ -0,0 +1,49 @@
+
+
+ "Απενεργοποίησε τη βελτιστοποίηση μπαταρίας για αυτήν την εφαρμογή, για να βεβαιωθείς ότι λαμβάνονται όλες οι ειδοποιήσεις."
+ "Απενεργοποίηση βελτιστοποίησης"
+ "Δεν φτάνουν οι ειδοποιήσεις;"
+ "Δημιούργησε ένα νέο κλειδί ανάκτησης που μπορεί να χρησιμοποιηθεί για την επαναφορά του ιστορικού των κρυπτογραφημένων μηνυμάτων σου σε περίπτωση που χάσεις την πρόσβαση στις συσκευές σου."
+ "Ρύθμιση ανάκτησης"
+ "Ρύθμιση ανάκτησης"
+ "Επιβεβαίωσε το κλειδί ανάκτησης για να διατηρήσεις την πρόσβαση στο χώρο αποθήκευσης κλειδιών και στο ιστορικό μηνυμάτων."
+ "Εισήγαγε το κλειδί ανάκτησης"
+ "Ξέχασες το κλειδί ανάκτησης;"
+ "Ο χώρος αποθήκευσης κλειδιών σου δεν είναι συγχρονισμένος"
+ "Για να διασφαλίσετε ότι δεν θα χάσετε ποτέ μια σημαντική κλήση, αλλάξτε τις ρυθμίσεις σας ώστε να επιτρέπονται οι ειδοποιήσεις πλήρους οθόνης όταν το τηλέφωνό σας είναι κλειδωμένο."
+ "Βελτίωσε την εμπειρία κλήσεων"
+ "Σίγουρα θες να απορρίψεις την πρόσκληση συμμετοχής στο %1$s;"
+ "Απόρριψη πρόσκλησης"
+ "Σίγουρα θες να απορρίψεις την ιδιωτική συνομιλία με τον χρήστη %1$s;"
+ "Απόρριψη συνομιλίας"
+ "Χωρίς προσκλήσεις"
+ "%1$s (%2$s) σέ προσκάλεσε"
+ "Αυτή είναι μια εφάπαξ διαδικασία, ευχαριστώ που περίμενες."
+ "Ρύθμιση του λογαριασμού σου."
+ "Δημιουργία νέας συνομιλίας ή αίθουσας"
+ "Ξεκίνησε στέλνοντας μηνύματα σε κάποιον."
+ "Δεν υπάρχουν συνομιλίες ακόμα."
+ "Αγαπημένα"
+ "Μπορείς να προσθέσεις μια συνομιλία στα αγαπημένα σου στις ρυθμίσεις συνομιλίας.
+Προς το παρόν, μπορείς να καταργήσεις την επιλογή φίλτρων για να δεις τις άλλες συνομιλίες σου"
+ "Δεν έχεις ακόμα αγαπημένες συνομιλίες"
+ "Προσκλήσεις"
+ "Δεν έχεις εκκρεμείς προσκλήσεις."
+ "Χαμηλής Προτεραιότητας"
+ "Μπορείς να καταργήσεις την επιλογή φίλτρων για να δεις τις άλλες συνομιλίες σου"
+ "Δεν έχεις συνομιλίες για αυτήν την επιλογή"
+ "Άτομα"
+ "Δεν έχεις ακόμα ΠΜ"
+ "Αίθουσες"
+ "Δεν είστε ακόμα σε κάποια αίθουσα"
+ "Μη αναγνωσμένα"
+ "Συγχαρητήρια!
+Δεν έχεις μη αναγνωσμένα μηνύματα!"
+ "Το αίτημα συμμετοχής στάλθηκε"
+ "Συνομιλίες"
+ "Επισήμανση ως αναγνωσμένου"
+ "Επισήμανση ως μη αναγνωσμένου"
+ "Αυτή η αίθουσα έχει αναβαθμιστεί"
+ "Φαίνεται ότι χρησιμοποιείς μια νέα συσκευή. Επαλήθευσε με άλλη συσκευή για πρόσβαση στα κρυπτογραφημένα σου μηνύματα."
+ "Επαλήθευσε ότι είσαι εσύ"
+
diff --git a/features/roomlist/impl/src/main/res/values-en-rUS/translations.xml b/features/home/impl/src/main/res/values-en-rUS/translations.xml
similarity index 100%
rename from features/roomlist/impl/src/main/res/values-en-rUS/translations.xml
rename to features/home/impl/src/main/res/values-en-rUS/translations.xml
diff --git a/features/home/impl/src/main/res/values-es/translations.xml b/features/home/impl/src/main/res/values-es/translations.xml
new file mode 100644
index 00000000000..a58f9e743ae
--- /dev/null
+++ b/features/home/impl/src/main/res/values-es/translations.xml
@@ -0,0 +1,45 @@
+
+
+ "Recupera tu identidad criptográfica y tu historial de mensajes con una clave de recuperación si has perdido todos tus dispositivos actuales."
+ "Configurar la recuperación"
+ "Configura la recuperación para proteger tu cuenta"
+ "Confirma tu clave de recuperación para mantener el acceso a tu almacén de claves y al historial de mensajes."
+ "Introduce tu clave de recuperación"
+ "¿Olvidaste tu clave de recuperación?"
+ "Tu almacén de claves no está sincronizado"
+ "Para asegurarte de que nunca te pierdas una llamada importante, modifica tus ajustes para permitir notificaciones a pantalla completa cuando el teléfono esté bloqueado."
+ "Mejora tu experiencia de llamada"
+ "¿Estás seguro de que quieres rechazar la invitación a unirte a %1$s?"
+ "Rechazar la invitación"
+ "¿Estás seguro de que quieres rechazar este chat privado con %1$s?"
+ "Rechazar el chat"
+ "Sin invitaciones"
+ "%1$s (%2$s) te invitó"
+ "Este proceso solo se hace una vez, gracias por esperar."
+ "Configura tu cuenta"
+ "Crear una nueva conversación o sala"
+ "Empieza enviando un mensaje a alguien."
+ "Aún no hay chats."
+ "Favoritos"
+ "Puedes añadir un chat a tus favoritos en la configuración del chat.
+Por ahora, puedes deseleccionar los filtros para ver tus otros chats"
+ "Aún no tienes chats favoritos"
+ "Invitaciones"
+ "No tienes ninguna invitación pendiente."
+ "Prioridad baja"
+ "Puedes deseleccionar filtros para ver tus otros chats."
+ "No tienes chats para esta selección"
+ "Personas"
+ "Todavía no tienes ningún mensaje directo"
+ "Salas"
+ "Todavía no estás en ninguna sala"
+ "No leídos"
+ "¡Felicidades!
+¡No tienes ningún mensaje sin leer!"
+ "Solicitud de unión enviada"
+ "Chats"
+ "Marcar como leído"
+ "Marcar como no leído"
+ "Parece que estás usando un nuevo dispositivo. Verifica que eres tú para acceder a tus mensajes cifrados."
+ "Verifica que eres tú"
+
diff --git a/features/home/impl/src/main/res/values-et/translations.xml b/features/home/impl/src/main/res/values-et/translations.xml
new file mode 100644
index 00000000000..f8dd7fc086a
--- /dev/null
+++ b/features/home/impl/src/main/res/values-et/translations.xml
@@ -0,0 +1,50 @@
+
+
+ "Kui tahad olla kindel, et näed õigel ajal kõiki teavitusi, siis palun lülita akukasutuse optimeerimine välja."
+ "Lülita akukasutuse optimeerimine välja"
+ "Sa ei näe kõiki teavitusi?"
+ "Loo uus taastevõti, mida saad kasutada oma krüptitud sõnumite ajaloo taastamisel olukorras, kus kaotad ligipääsu oma seadmetele."
+ "Seadista andmete taastamine"
+ "Seadista taastamine"
+ "Säilitamaks ligipääsu vestluste ja krüptovõtmete varukoopiale, palun sisesta kinnituseks oma taastevõti."
+ "Sisesta oma taastevõti"
+ "Kas unustasid oma taastevõtme?"
+ "Sinu võtmehoidla pole sünkroonis"
+ "Selleks, et sul ainsamgi tähtis kõne ei jääks märkamata, siis palun muuda oma nutiseadme seadistusi nii, et lukustusvaates oleksid täisekraani mõõtu teavitused."
+ "Sinu tõhusad telefonikõned"
+ "Kas sa oled kindel, et soovid keelduda liitumiskutsest: %1$s?"
+ "Lükka kutse tagasi"
+ "Kas sa oled kindel, et soovid keelduda privaatsest vestlusest kasutajaga %1$s?"
+ "Keeldu vestlusest"
+ "Kutseid pole"
+ "%1$s (%2$s) saatis sulle kutse"
+ "Tänud, et ootad - seda toimingut on vaja teha vaid üks kord."
+ "Seadistame sinu kasutajakontot."
+ "Loo uus vestlus või jututuba"
+ "Tühjenda filtrid"
+ "Alustamiseks saada kellelegi sõnum."
+ "Veel pole vestlusi."
+ "Lemmikud"
+ "Vestluse seadistusest saad ta määrata lemmikuks.
+Aga seni… oma teiste vestluste nägemiseks pead eemaldama filtrid"
+ "Sul veel pole lemmikvestlusi"
+ "Kutsed"
+ "Sul pole ootel kutseid."
+ "Vähetähtis"
+ "Oma teiste vestluste nägemiseks sa pead filtrid eemaldama"
+ "Selle valiku jaoks sul veel pole vestlusi"
+ "Inimesed"
+ "Sul pole veel otsevestlusi"
+ "Jututoad"
+ "Sa veel ei osale mitte üheski jututoas"
+ "Lugemata"
+ "Õnnitleme!
+Sul pole ühtegi lugemata sõnumit!"
+ "Liitumispalve on saadetud"
+ "Vestlused"
+ "Märgi loetuks"
+ "Märgi mitteloetuks"
+ "See jututuba on uuendatud"
+ "Tundub, et kasutad uut seadet. Oma krüptitud sõnumite lugemiseks verifitseeri ta mõne muu oma seadmega."
+ "Verifitseeri, et see oled sina"
+
diff --git a/features/home/impl/src/main/res/values-eu/translations.xml b/features/home/impl/src/main/res/values-eu/translations.xml
new file mode 100644
index 00000000000..8289dc97d47
--- /dev/null
+++ b/features/home/impl/src/main/res/values-eu/translations.xml
@@ -0,0 +1,41 @@
+
+
+ "Konfiguratu berreskurapena"
+ "Sartu zure berreskuratze-gakoa"
+ "Berreskuratze-gakoa ahaztu al duzu?"
+ "Dei garrantzitsurik galduko ez duzula ziurtatzeko, aldatu ezarpenak telefonoa blokeatuta dagoenean pantaila osoko jakinarazpenak baimentzeko."
+ "Hobetu deien esperientzia"
+ "Ziur %1$s(e)ra batzeko gonbidapena baztertu nahi duzula?"
+ "Baztertu gonbidapena"
+ "Ziur %1$s(r)en txat pribatua baztertu nahi duzula?"
+ "Baztertu txata"
+ "Ez dago gonbidapenik"
+ "%1$s(e)k (%2$s) gonbidatu zaitu"
+ "Behin egin beharreko prozesua da; eskerrik asko itxaroteagatik."
+ "Zure kontua konfiguratzen."
+ "Sortu elkarrizketa edo gela berria"
+ "Hasi norbaiti mezuak bidaltzen."
+ "Oraindik ez dago txatik."
+ "Gogokoak"
+ "Txatak gogokoetara gehi dezakezu txaten ezarpenetan.
+Oraingoz, iragazkiak desautatu ditzakezu zure gainerako txatak ikusteko"
+ "Oraindik ez duzu gogoko txatik"
+ "Gonbidapenak"
+ "Ez duzu gonbidapenik zain."
+ "Lehentasun baxua"
+ "Iragazkiak desautatu ditzakezu gainerako txatak ikusteko"
+ "Ez duzu hautaketa betetzen duen txatik"
+ "Jendea"
+ "Oraindik ez duzu Mezu Pribaturik"
+ "Gelak"
+ "Oraindik ez zaude inolako gelatan"
+ "Irakurri gabeak"
+ "Bejondeizula!
+Ez duzu irakurri gabeko mezurik!"
+ "Sartzeko eskaera bidali da"
+ "Txatak"
+ "Markatu irakurritzat"
+ "Markatu irakurri gabetzat"
+ "Gailu berri bat erabiltzen ari zarela dirudi. Egiaztatu beste gailu batekin enkriptatutako mezuak atzitzeko."
+ "Egiaztatu zu zarela"
+
diff --git a/features/home/impl/src/main/res/values-fa/translations.xml b/features/home/impl/src/main/res/values-fa/translations.xml
new file mode 100644
index 00000000000..ef436cbe90d
--- /dev/null
+++ b/features/home/impl/src/main/res/values-fa/translations.xml
@@ -0,0 +1,39 @@
+
+
+ "برپایی بازیابی"
+ "برپایی بازیابی"
+ "ورود کلید بازیابیتان"
+ "ذخیرهساز کلیدتان از همگام بودن در آمده"
+ "بهبود تجریهٔ تماستان"
+ "مطمئنید که میخواهید دعوت پیوستن به %1$s را رد کنید؟"
+ "رد دعوت"
+ "مطمئنید که میخواهید این گپ خصوصی با %1$s را رد کنید؟"
+ "رد گپ"
+ "بدون دعوت"
+ "%1$s (%2$s) دعوتتان کرد"
+ "فرایندی یک باره است. ممنون از شکیباییتان."
+ "برپایی حسابتان."
+ "ایجاد اتاق یا گفتوگویی جدید"
+ "آغاز با پیام دادن به کسی."
+ "هنوز گپی وجود ندارد."
+ "علاقهمندیها"
+ "هنوز هیچ گپ مورد علاقهای ندارید"
+ "دعوتها"
+ "هیچ دعوت منتظری ندارید."
+ "اولویت کم"
+ "می توانید پالایهها را برای دیدن دیگر گپهایتان بردارید"
+ "هیچ گپی برای این گزینش ندارید"
+ "افراد"
+ "هنوز هیچ پیام مستقیمی ندارید"
+ "اتاقها"
+ "هنوز در هیچ اتاقی نیستید"
+ "نخواندهها"
+ "تبریک!
+هیچ پیام نخواندهای ندارید!"
+ "درخواست پیوستن فرستاده شد"
+ "گپها"
+ "علامتگذاری به عنوان خوانده شده"
+ "نشان به ناخوانده"
+ "به نظر می رسد از دستگاه جدیدی استفاده می کنید. برای دسترسی به پیام های رمزگذاری شده خود، با دستگاه دیگری این دستگاه را تأیید کنید."
+ "تأیید کنید که خودتانید"
+
diff --git a/features/home/impl/src/main/res/values-fi/translations.xml b/features/home/impl/src/main/res/values-fi/translations.xml
new file mode 100644
index 00000000000..10da41c88b1
--- /dev/null
+++ b/features/home/impl/src/main/res/values-fi/translations.xml
@@ -0,0 +1,46 @@
+
+
+ "Palauta kryptografinen identiteettisi ja viestihistoriasi palautusavaimella, mikäli menetät pääsyn kaikkiin laitteisiisi."
+ "Ota palautus käyttöön"
+ "Ota palautus käyttöön tilisi suojaamiseksi"
+ "Vahvista palautusavaimesi, jotta pääset edelleen käyttämään avainten säilytystä ja viestihistoriaa."
+ "Syötä palautusavaimesi"
+ "Unohditko palautusavaimesi?"
+ "Avainten säilytys ei ole synkronoitu"
+ "Salli koko näytön ilmoitukset, kun laite on lukittu, jos et halua koskaan missata tärkeää puhelua."
+ "Paranna puhelukokemustasi"
+ "Haluatko varmasti hylätä kutsun liittyä %1$s -huoneeseen?"
+ "Hylkää kutsu"
+ "Haluatko varmasti hylätä kutsun yksityiseen keskusteluun käyttäjän %1$s kanssa?"
+ "Hylkää keskustelu"
+ "Ei kutsuja"
+ "%1$s (%2$s) kutsui sinut"
+ "Tämä on kertaluonteinen prosessi, kiitos odottamisesta."
+ "Tiliä määritetään."
+ "Luo uusi keskustelu tai huone"
+ "Aloita lähettämällä viesti jollekin."
+ "Sinulla ei ole vielä keskusteluja."
+ "Suosikit"
+ "Voit lisätä keskustelun suosikkeihisi keskustelun asetuksissa.
+Toistaiseksi voit poistaa suodattimien valinnan, jotta näet muut keskustelut."
+ "Sinulla ei ole vielä suosikkikeskusteluja"
+ "Kutsut"
+ "Sinulla ei ole yhtään odottavaa kutsua."
+ "Matala prioriteetti"
+ "Voit poistaa suodattimien valinnan nähdäksesi muut keskustelusi."
+ "Sinulla ei ole sopivia keskusteluja tähän valintaan"
+ "Ihmiset"
+ "Sinulla ei ole vielä yhtään yksityisviestiä"
+ "Huoneet"
+ "Et ole vielä missään huoneessa"
+ "Lukemattomat"
+ "Onnittelut!
+Sinulla ei ole lukemattomia viestejä!"
+ "Liittymispyyntö lähetetty"
+ "Keskustelut"
+ "Merkitse luetuksi"
+ "Merkitse lukemattomaksi"
+ "Tämä huone on päivitetty"
+ "Vaikuttaisi siltä, että käytät uutta laitetta. Vahvista toisella laitteella nähdäksesi salatut viestit."
+ "Vahvista, että se olet sinä"
+
diff --git a/features/home/impl/src/main/res/values-fr/translations.xml b/features/home/impl/src/main/res/values-fr/translations.xml
new file mode 100644
index 00000000000..3f86a648105
--- /dev/null
+++ b/features/home/impl/src/main/res/values-fr/translations.xml
@@ -0,0 +1,50 @@
+
+
+ "Désactivez l’optimisation de la batterie pour cette application afin de vous assurer que toutes les notifications sont reçues."
+ "Désactiver l’optimisation"
+ "Ils vous manque des notifications?"
+ "Générez une nouvelle clé de récupération qui peut être utilisée pour restaurer l’historique de vos messages chiffrés au cas où vous perdriez l’accès à vos appareils."
+ "Configurer la sauvegarde"
+ "Configurer la récupération"
+ "Confirmez votre clé de récupération pour conserver l’accès à votre stockage de clés et à l’historique des messages."
+ "Saisissez votre clé de récupération"
+ "Clé de récupération oubliée ?"
+ "Le stockage de vos clés n’est pas synchronisé"
+ "Afin de ne jamais manquer un appel important, veuillez modifier vos paramètres pour autoriser les notifications en plein écran lorsque votre appareil est verrouillé."
+ "Améliorez votre expérience d’appel"
+ "Êtes-vous sûr de vouloir décliner l’invitation à rejoindre %1$s ?"
+ "Refuser l’invitation"
+ "Êtes-vous sûr de vouloir refuser cette discussion privée avec %1$s ?"
+ "Refuser l’invitation"
+ "Aucune invitation"
+ "%1$s (%2$s) vous a invité(e)"
+ "Il s’agit d’une opération ponctuelle, merci d’attendre quelques instants."
+ "Configuration de votre compte."
+ "Créer une nouvelle discussion ou un nouveau salon"
+ "Supprimer les filtres"
+ "Commencez par envoyer un message à quelqu’un."
+ "Aucune discussion pour le moment."
+ "Favoris"
+ "Vous pouvez ajouter une discussion aux favoris depuis les paramètres de la discussion.
+En attendant, vous pouvez désélectionner des filtres pour voir vos autres salons."
+ "Vous n’avez pas encore de discussions favorites"
+ "Invitations"
+ "Vous n’avez aucune invitation en attente."
+ "Priorité basse"
+ "Veuillez désélectionner des filtres pour voir vos discussions"
+ "Vous n’avez pas de discussions pour cette sélection"
+ "Personnes"
+ "Vous n’avez pas encore de discussions"
+ "Salons"
+ "Vous n’êtes membre d’aucun salon"
+ "Non-lus"
+ "Félicitations !
+Vous n’avez plus de messages non-lus !"
+ "Demande de rejoindre le salon envoyée"
+ "Conversations"
+ "Marquer comme lu"
+ "Marquer comme non lu"
+ "Ce salon a été mis à niveau."
+ "Il semblerait que vous utilisiez un nouvel appareil. Vérifiez la session avec un autre de vos appareils pour accéder à vos messages chiffrés."
+ "Vérifier que c’est bien vous"
+
diff --git a/features/home/impl/src/main/res/values-hu/translations.xml b/features/home/impl/src/main/res/values-hu/translations.xml
new file mode 100644
index 00000000000..e0763e4c6fd
--- /dev/null
+++ b/features/home/impl/src/main/res/values-hu/translations.xml
@@ -0,0 +1,50 @@
+
+
+ "Kapcsolja ki az alkalmazás akkumulátor-optimalizálását, hogy biztosan megkapja az összes értesítést."
+ "Optimalizálás letiltása"
+ "Nem érkeznek meg az értesítések?"
+ "Hozzon létre egy új helyreállítási kulcsot, amellyel visszaállíthatja a titkosított üzenetek előzményeit, ha elveszíti az eszközökhöz való hozzáférést."
+ "Helyreállítás beállítása"
+ "Helyreállítás beállítása a fiókja védelméhez"
+ "Erősítse meg a helyreállítási kulcsát, hogy továbbra is hozzáférjen a kulcstárolójához és az üzenetelőzményekhez."
+ "Adja meg a helyreállítási kulcsot"
+ "Elfelejtette a helyreállítási kulcsot?"
+ "A kulcstároló nincs szinkronizálva"
+ "Hogy sose maradjon le egyetlen fontos hívásról sem, a beállításokban engedélyezze a teljes képernyős értesítéseket, amikor a telefon zárolva van."
+ "Fokozza a hívásélményét"
+ "Biztos, hogy elutasítja a meghívást, hogy csatlakozzon ehhez: %1$s?"
+ "Meghívás elutasítása"
+ "Biztos, hogy elutasítja ezt a privát csevegést vele: %1$s?"
+ "Csevegés elutasítása"
+ "Nincsenek meghívások"
+ "%1$s (%2$s) meghívta"
+ "Ez egy egyszeri folyamat, köszönjük a türelmét."
+ "A fiók beállítása."
+ "Új beszélgetés vagy szoba létrehozása"
+ "Szűrők törlése"
+ "Kezdje azzal, hogy üzenetet küld valakinek."
+ "Még nincsenek csevegések."
+ "Kedvencek"
+ "A csevegési beállításokban csevegéseket adhat hozzá a kedvencekhez.
+Egyelőre törölheti a szűrőket a többi csevegés megtekintéséhez."
+ "Még nincsenek kedvenc csevegései"
+ "Meghívások"
+ "Nincsenek függőben lévő meghívásai."
+ "Alacsony prioritás"
+ "Kikapcsolhatja a szűrőket a többi csevegés megtekintéséhez"
+ "Ehhez a kiválasztáshoz nem tartoznak csevegések"
+ "Emberek"
+ "Még nincsenek privát üzenetei"
+ "Szobák"
+ "Még nincs egy szobában sem"
+ "Olvasatlan"
+ "Gratulálunk!
+Nincs olvasatlan üzenete!"
+ "Csatlakozási kérés elküldve"
+ "Összes csevegés"
+ "Megjelölés olvasottként"
+ "Megjelölés olvasatlanként"
+ "A szoba verzióját frissítették"
+ "Úgy tűnik, hogy új eszközt használ. Ellenőrizze egy másik eszközzel, hogy a továbbiakban elérje a titkosított üzeneteket."
+ "Ellenőrizze, hogy Ön az"
+
diff --git a/features/home/impl/src/main/res/values-in/translations.xml b/features/home/impl/src/main/res/values-in/translations.xml
new file mode 100644
index 00000000000..b3e16b4c25a
--- /dev/null
+++ b/features/home/impl/src/main/res/values-in/translations.xml
@@ -0,0 +1,50 @@
+
+
+ "Nonaktifkan pengoptimalan baterai untuk aplikasi ini, untuk memastikan semua notifikasi diterima."
+ "Nonaktifkan optimasi"
+ "Notifikasi tidak masuk?"
+ "Buat kunci pemulihan baru yang dapat digunakan untuk memulihkan riwayat pesan terenkripsi Anda jika Anda kehilangan akses ke perangkat Anda."
+ "Siapkan pemulihan"
+ "Siapkan pemulihan"
+ "Konfirmasikan kunci pemulihan Anda untuk mempertahankan akses ke penyimpanan kunci dan riwayat pesan Anda."
+ "Masukkan kunci pemulihan Anda"
+ "Lupa kunci pemulihan Anda?"
+ "Penyimpanan kunci Anda tidak sinkron"
+ "Untuk memastikan Anda tidak melewatkan panggilan penting, silakan ubah pengaturan Anda untuk memperbolehkan notifikasi layar penuh ketika ponsel Anda terkunci."
+ "Tingkatkan pengalaman panggilan Anda"
+ "Apakah Anda yakin ingin menolak undangan untuk bergabung ke %1$s?"
+ "Tolak undangan"
+ "Apakah Anda yakin ingin menolak obrolan pribadi dengan %1$s?"
+ "Tolak obrolan"
+ "Tidak ada undangan"
+ "%1$s (%2$s) mengundang Anda"
+ "Ini adalah proses satu kali, terima kasih telah menunggu."
+ "Menyiapkan akun Anda."
+ "Buat percakapan atau ruangan baru"
+ "Hapus filter"
+ "Mulailah dengan mengirim pesan kepada seseorang."
+ "Belum ada obrolan."
+ "Favorit"
+ "Anda dapat menambahkan percakapan ke favorit Anda dalam pengaturan percakapan.
+Untuk sementara, Anda dapat membatalkan pilihan saringan untuk melihat percakapan Anda yang lain"
+ "Anda belum memiliki percakapan favorit"
+ "Undangan"
+ "Anda tidak memiliki undangan yang tertunda."
+ "Prioritas Rendah"
+ "Anda dapat membatalkan pilihan saringan untuk melihat percakapan Anda yang lain"
+ "Anda tidak memiliki percakapan untuk pemilihan ini"
+ "Orang"
+ "Anda belum memiliki percakapan langsung"
+ "Ruangan"
+ "Anda belum berada dalam ruangan"
+ "Belum dibaca"
+ "Selamat!
+Anda tidak memiliki pesan yang belum dibaca!"
+ "Permintaan untuk bergabung dikirim"
+ "Semua Obrolan"
+ "Tandai sebagai dibaca"
+ "Tandai sebagai belum dibaca"
+ "Ruangan ini telah ditingkatkan"
+ "Sepertinya Anda menggunakan perangkat baru. Verifikasi dengan perangkat lain untuk mengakses pesan terenkripsi Anda selanjutnya."
+ "Verifikasi bahwa ini Anda"
+
diff --git a/features/home/impl/src/main/res/values-it/translations.xml b/features/home/impl/src/main/res/values-it/translations.xml
new file mode 100644
index 00000000000..88ff0eb3be4
--- /dev/null
+++ b/features/home/impl/src/main/res/values-it/translations.xml
@@ -0,0 +1,50 @@
+
+
+ "Disabilita l\'ottimizzazione della batteria per questa app, per assicurarti che tutte le notifiche vengano ricevute."
+ "Disabilita l\'ottimizzazione"
+ "Le notifiche non arrivano?"
+ "Genera una nuova chiave di recupero che può essere usata per ripristinare la cronologia dei messaggi crittografati nel caso in cui tu perda l\'accesso ai tuoi dispositivi."
+ "Configura il recupero"
+ "Configura il ripristino"
+ "Conferma la chiave di recupero per mantenere l\'accesso all\'archiviazione delle chiavi e alla cronologia dei messaggi."
+ "Inserisci la tua chiave di recupero"
+ "Hai dimenticato la chiave di recupero?"
+ "L\'archiviazione delle chiavi non è sincronizzata"
+ "Per non perdere mai una chiamata importante, modifica le impostazioni per consentire le notifiche a schermo intero quando il telefono è bloccato."
+ "Migliora la tua esperienza di chiamata"
+ "Vuoi davvero rifiutare l\'invito ad entrare in %1$s?"
+ "Rifiuta l\'invito"
+ "Vuoi davvero rifiutare questa conversazione privata con %1$s?"
+ "Rifiuta l\'invito alla conversazione"
+ "Nessun invito"
+ "%1$s (%2$s) ti ha invitato"
+ "Si tratta di una procedura che si effettua una sola volta, grazie per l\'attesa."
+ "Configurazione del tuo account."
+ "Crea una nuova conversazione o stanza"
+ "Elimina filtri"
+ "Inizia inviando un messaggio a qualcuno."
+ "Ancora nessuna conversazione."
+ "Preferiti"
+ "Puoi aggiungere una conversazione ai tuoi preferiti nelle impostazioni della stessa.
+Per il momento, puoi deselezionare i filtri per vedere le altre conversazioni."
+ "Non hai ancora conversazioni preferite"
+ "Inviti"
+ "Non hai nessun invito in sospeso."
+ "Bassa priorità"
+ "Puoi deselezionare i filtri per vedere le altre conversazioni."
+ "Non hai conversazioni per questa selezione"
+ "Persone"
+ "Non hai ancora nessuna conversazione diretta"
+ "Stanze"
+ "Non sei ancora in nessuna stanza"
+ "Non letti"
+ "Congratulazioni!
+Non hai messaggi non letti!"
+ "Richiesta di accesso inviata"
+ "Tutte le conversazioni"
+ "Segna come letto"
+ "Segna come non letto"
+ "Questa stanza è stata aggiornata"
+ "Sembra che tu stia usando un nuovo dispositivo. Verificati con un altro dispositivo per accedere ai tuoi messaggi cifrati."
+ "Verifica che sei tu"
+
diff --git a/features/home/impl/src/main/res/values-ka/translations.xml b/features/home/impl/src/main/res/values-ka/translations.xml
new file mode 100644
index 00000000000..bcedaeac607
--- /dev/null
+++ b/features/home/impl/src/main/res/values-ka/translations.xml
@@ -0,0 +1,37 @@
+
+
+ "აღდგენის დაყენება"
+ "დაადასტურეთ თქვენი აღდგენის გასაღები რათა გქონდეთ წვდომა გასაღებების დამგროვებელთან და შეტყობინებების ისტორიასთან."
+ "თქვენი გასაღების დამგროვებელი არაა სინქრონიზებული"
+ "დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ მოწვევაზე %1$s-ში?"
+ "მოწვევაზე უარის თქმა"
+ "დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ ჩატზე %1$s-თან?"
+ "ჩატზე უარის თქვა"
+ "მოწვევები არ არის"
+ "%1$s (%2$s) მოგიწვიათ"
+ "ეს არის ერთჯერადი პროცესი, მადლობა ლოდინისთვის."
+ "თქვენი ანგარიშის კონფიგურაცია"
+ "ახალი საუბრისა ან ოთახის შექმნა"
+ "დაიწყეთ ვინმესთვის შეტყობინების გაგზავნით."
+ "არც ერთი ჩატი ჯერ არაა."
+ "რჩეულები"
+ "თქვენ შეგიძლიათ ოთახის რჩეულებში დამატება ოთახების პარამეტრებში.
+ახლა კი შეგიძლიათ ფილტრების მოხსნა სხვა ოთახების გამოსაჩენად"
+ "თქვენ ჯერ არ გაქვთ რჩეული ჩატები"
+ "მოწვევები"
+ "დაბალი პრიორიტეტი"
+ "თქვენ შეგიძლიათ წაშალოთ ფილტრები სხვა ჩეთების გამოსაჩენად"
+ "თქვენ არ გაქვთ ოთახები ამ არჩევნისთვის"
+ "ხალხი"
+ "თქვენ ჯერ არ გაქვთ პირადი შეტყობინებები"
+ "ოთახები"
+ "თქვენ ჯერ არც ერთ ოთახში არ ხართ"
+ "წაუკითხავი"
+ "გილოცავთ!
+თქვენ არ გაქვთ წაუკითხავი შეტყობინებები!"
+ "ჩატები"
+ "წაკითხულად მონიშვნა"
+ "წაუკითხავად მონიშვნა"
+ "როგორც ჩანს, ახალ მოწყობილობას იყენებთ. დაადასტურეთ სხვა მოწყობილობით თქვენს დაშიფრულ შეტყობინებებზე წვდომისთვის."
+ "დაადასტურეთ, რომ ეს თქვენ ხართ"
+
diff --git a/features/home/impl/src/main/res/values-lt/translations.xml b/features/home/impl/src/main/res/values-lt/translations.xml
new file mode 100644
index 00000000000..37e9003c206
--- /dev/null
+++ b/features/home/impl/src/main/res/values-lt/translations.xml
@@ -0,0 +1,15 @@
+
+
+ "Ar tikrai norite atmesti kvietimą prisijungti prie %1$s?"
+ "Atmesti kvietimą"
+ "Ar tikrai norite atmesti šį privatų pokalbį su %1$s ?"
+ "Atmesti pokalbį"
+ "Jokių kvietimų"
+ "%1$s(%2$s) pakvietė Jus"
+ "Sukurti naują pokalbį arba kambarį"
+ "Kvietimai"
+ "Žmonės"
+ "Pokalbiai"
+ "Panašu, kad naudojate naują įrenginį. Patvirtinkite naudodami kitą įrenginį, kad galėtumėte pasiekti savo šifruotas žinutes."
+ "Patvirtinkite, kad tai Jūs"
+
diff --git a/features/home/impl/src/main/res/values-nb/translations.xml b/features/home/impl/src/main/res/values-nb/translations.xml
new file mode 100644
index 00000000000..2ce6733b8aa
--- /dev/null
+++ b/features/home/impl/src/main/res/values-nb/translations.xml
@@ -0,0 +1,49 @@
+
+
+ "Deaktiver batterioptimalisering for denne appen for å sikre at alle varsler mottas."
+ "Deaktiver optimalisering"
+ "Kommer ikke varslene frem?"
+ "Gjenopprett din kryptografiske identitet og meldingshistorikk med en gjenopprettingsnøkkel hvis du har mistet alle dine brukte enheter."
+ "Konfigurer gjenoppretting"
+ "Konfigurer gjenoppretting for å beskytte kontoen din"
+ "Verifiser gjenopprettingsnøkkelen for å opprettholde tilgangen til nøkkellageret og meldingshistorikken."
+ "Skriv inn gjenopprettingsnøkkelen din"
+ "Har du glemt din gjenopprettingsnøkkel?"
+ "Nøkkellagringen din er ikke synkronisert"
+ "For å sikre at du aldri går glipp av en viktig samtale, må du endre innstillingene dine for å tillate fullskjermvarsler når telefonen er låst."
+ "Forbedre samtaleopplevelsen din"
+ "Er du sikker på at du vil takke nei til invitasjonen til å bli med i %1$s?"
+ "Avvis invitasjon"
+ "Er du sikker på at du vil avslå denne private chatten med %1$s?"
+ "Avslå chat"
+ "Ingen invitasjoner"
+ "%1$s(%2$s) inviterte deg"
+ "Dette er en engangsprosess, takk for at du venter."
+ "Setter opp kontoen din."
+ "Opprett en ny samtale eller et nytt rom"
+ "Kom i gang med å sende meldinger til noen."
+ "Ingen chatter ennå."
+ "Favoritter"
+ "Du kan legge til en chat blant favorittene dine i chat-innstillingene.
+Inntil videre kan du velge bort filtre for å se de andre chattene dine"
+ "Du har ikke favorittchatter ennå"
+ "Invitasjoner"
+ "Du har ingen ventende invitasjoner."
+ "Lav prioritet"
+ "Du kan velge bort filtre for å se de andre chattene dine"
+ "Du har ikke chatter for dette utvalget"
+ "Personer"
+ "Du har ingen DM-er ennå"
+ "Rom"
+ "Du er ikke i noe rom ennå"
+ "Uleste"
+ "Gratulerer!
+Du har ingen uleste meldinger!"
+ "Forespørsel om å bli med sendt"
+ "Chatter"
+ "Marker som lest"
+ "Merk som ulest"
+ "Dette rommet har blitt oppgradert"
+ "Det ser ut til at du bruker en ny enhet. Bekreft med en annen enhet for å få tilgang til de krypterte meldingene dine."
+ "Bekreft at det er deg"
+
diff --git a/features/home/impl/src/main/res/values-nl/translations.xml b/features/home/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 00000000000..fc207da0167
--- /dev/null
+++ b/features/home/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,43 @@
+
+
+ "Herstel je cryptografische identiteit en berichtengeschiedenis met een herstelsleutel voor als je al je bestaande apparaten kwijt bent."
+ "Herstelmogelijkheid instellen"
+ "Herstel instellen om je account te beschermen"
+ "Bevestig je herstelsleutel om toegang te houden tot je sleutelopslag en berichtengeschiedenis."
+ "Je sleutelopslag is niet gesynchroniseerd"
+ "Pas je instellingen aan om meldingen op het volledige scherm toe te staan wanneer de telefoon is vergrendeld. Zo mis je nooit een belangrijk gesprek."
+ "Verbeter je gesprekservaring"
+ "Weet je zeker dat je de uitnodiging om toe te treden tot %1$s wilt weigeren?"
+ "Uitnodiging weigeren"
+ "Weet je zeker dat je deze privéchat met %1$s wilt weigeren?"
+ "Chat weigeren"
+ "Geen uitnodigingen"
+ "%1$s (%2$s) heeft je uitgenodigd"
+ "Dit is een eenmalig proces, bedankt voor het wachten."
+ "Je account instellen."
+ "Begin een nieuw gesprek of maak een nieuwe kamer"
+ "Ga aan de slag door iemand een bericht te sturen."
+ "Nog geen chats."
+ "Favorieten"
+ "Je kunt een chat toevoegen aan je favorieten in de chatinstellingen.
+Voor nu kun je filters deselecteren om je andere chats te zien"
+ "Je hebt nog geen favoriete chats"
+ "Uitnodigingen"
+ "Je hebt geen openstaande uitnodigingen."
+ "Lage prioriteit"
+ "Je kunt filters deselecteren om je andere chats te zien"
+ "Je hebt geen chats voor deze selectie"
+ "Personen"
+ "Je hebt nog geen directe chats"
+ "Kamers"
+ "Je zit nog niet in een kamer"
+ "Ongelezen"
+ "Gefeliciteerd!
+Je hebt geen ongelezen berichten!"
+ "Verzoek om toe te treden verzonden"
+ "Chats"
+ "Markeren als gelezen"
+ "Markeren als ongelezen"
+ "Het lijkt erop dat je een nieuw apparaat gebruikt. Verifieer met een ander apparaat om toegang te krijgen tot je versleutelde berichten."
+ "Verifieer dat jij het bent"
+
diff --git a/features/home/impl/src/main/res/values-pl/translations.xml b/features/home/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 00000000000..8bb236e71a5
--- /dev/null
+++ b/features/home/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,45 @@
+
+
+ "Wygeneruj nowy klucz przywracania, którego można użyć do przywrócenia historii wiadomości szyfrowanych w przypadku utraty dostępu do swoich urządzeń."
+ "Skonfiguruj przywracanie"
+ "Skonfiguruj przywracanie"
+ "Potwierdź klucz przywracania, aby zachować dostęp do magazynu kluczy i historii wiadomości."
+ "Wprowadź klucz przywracania"
+ "Zapomniałeś klucza przywracania?"
+ "Magazyn kluczy nie jest zsynchronizowany"
+ "Upewnij się, że nie pominiesz żadnego połączenia. Zmień swoje ustawienia i zezwól na powiadomienia na blokadzie ekranu."
+ "Popraw jakość swoich rozmów"
+ "Czy na pewno chcesz odrzucić zaproszenie dołączenia do %1$s?"
+ "Odrzuć zaproszenie"
+ "Czy na pewno chcesz odrzucić rozmowę prywatną z %1$s?"
+ "Odrzuć czat"
+ "Brak zaproszeń"
+ "%1$s (%2$s) zaprosił Cię"
+ "Jest to jednorazowy proces, dziękujemy za czekanie."
+ "Konfigurowanie Twojego konta."
+ "Utwórz nową rozmowę lub pokój"
+ "Wyślij komuś wiadomość, aby rozpocząć."
+ "Brak czatów."
+ "Ulubione"
+ "Możesz dodać czat do ulubionych w ustawieniach czatu.
+Na razie możesz wyczyścić filtry, aby zobaczyć pozostałe czaty"
+ "Nie masz jeszcze ulubionych czatów"
+ "Zaproszenia"
+ "Nie masz żadnych oczekujących zaproszeń."
+ "Niski priorytet"
+ "Wyczyść filtry, aby zobaczyć pozostałe czaty"
+ "Brak czatów dla podanych kryteriów"
+ "Osoby"
+ "Nie masz jeszcze żadnych PW"
+ "Pokoje"
+ "Nie jesteś jeszcze w żadnym pokoju"
+ "Nieprzeczytane"
+ "Gratulacje!
+Nie masz żadnych nieprzeczytanych wiadomości!"
+ "Wysłano prośbę o dołączenie"
+ "Wszystkie czaty"
+ "Oznacz jako przeczytane"
+ "Oznacz jako nieprzeczytane"
+ "Wygląda na to, że używasz nowego urządzenia. Zweryfikuj się innym urządzeniem, aby uzyskać dostęp do zaszyfrowanych wiadomości."
+ "Potwierdź, że to Ty"
+
diff --git a/features/home/impl/src/main/res/values-pt-rBR/translations.xml b/features/home/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 00000000000..871a2bcc5cc
--- /dev/null
+++ b/features/home/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,50 @@
+
+
+ "Desative a otimização de bateria para este app, para que tenha certeza que todas as notificações sejam recebidas."
+ "Desativar otimização"
+ "As notificações não chegam?"
+ "Recupere sua identidade criptográfica e o histórico de mensagens com uma chave de recuperação se você tiver perdido todos os dispositivos existentes."
+ "Configurar a recuperação"
+ "Configure a recuperação para proteger sua conta"
+ "Confirme sua chave de recuperação para manter o acesso ao seu armazenamento de chaves e histórico de mensagens."
+ "Digite sua chave de recuperação"
+ "Esqueceu sua chave de recuperação?"
+ "Seu armazenamento de chaves está fora de sincronia"
+ "Para garantir que você nunca perca uma chamada importante, por favor altere as suas configurações para permitir notificações em tela cheia enquanto o seu celular estiver bloqueado."
+ "Melhore a sua experiência de chamadas"
+ "Tem certeza de que deseja recusar o convite para ingressar em %1$s?"
+ "Recusar convite"
+ "Tem certeza de que deseja recusar esse chat privado com %1$s?"
+ "Recusar chat"
+ "Sem convites"
+ "%1$s(%2$s) convidou você"
+ "Este é um processo único, obrigado por esperar."
+ "Configurando sua conta."
+ "Criar uma nova conversa ou sala"
+ "Limpar filtros"
+ "Comece enviando uma mensagem para alguém."
+ "Ainda não há conversas."
+ "Favoritos"
+ "Você pode adicionar um bate-papo aos seus favoritos nas configurações de bate-papo.
+Por enquanto, você pode desmarcar os filtros para ver seus outros bate-papos"
+ "Você não tem nenhuma conversa favorita ainda"
+ "Convites"
+ "Você não tem nenhum convite pendente."
+ "Baixa prioridade"
+ "Você pode desmarcar filtros para ver suas outras conversas"
+ "Você não tem conversas para esta seleção"
+ "Pessoas"
+ "Você não tem nenhum conversa privada ainda"
+ "Salas"
+ "Você não está em nenhuma sala ainda"
+ "Não lidos"
+ "Parabéns!
+Você não tem nenhuma mensagem não lida!"
+ "Pedido de adesão enviado"
+ "Conversas"
+ "Marcar como lido"
+ "Marcar como não lido"
+ "Esta sala foi atualizada"
+ "Parece que você está usando um novo dispositivo. Verifique com outro dispositivo para acessar suas mensagens criptografadas."
+ "Verifique se é você"
+
diff --git a/features/home/impl/src/main/res/values-pt/translations.xml b/features/home/impl/src/main/res/values-pt/translations.xml
new file mode 100644
index 00000000000..daba977faac
--- /dev/null
+++ b/features/home/impl/src/main/res/values-pt/translations.xml
@@ -0,0 +1,50 @@
+
+
+ "Desativa as otimizações de bateria para esta aplicação, de modo a garantir que todas as notificações chegam."
+ "Desativar otimizações"
+ "As notificações não chegam?"
+ "Recupera a tua identidade criptográfica e o histórico de mensagens com uma chave de recuperação se tiveres perdido todos os teus dispositivos existentes."
+ "Configurar recuperação"
+ "Configurar a recuperação"
+ "Confirma a tua chave de recuperação para manteres o acesso ao teu armazenamento de chaves e ao histórico de mensagens."
+ "Introduz a tua chave de recuperação"
+ "Esqueceste-te da tua chave de recuperação?"
+ "O teu armazenamento de chaves não está sincronizado"
+ "Para garantir que nunca perdes uma chamada importante, altera as configurações para permitir notificações em ecrã inteiro quando o telemóvel está bloqueado."
+ "Melhora a tua experiência de chamada"
+ "Tens a certeza que queres rejeitar o convite para entra em %1$s?"
+ "Rejeitar convite"
+ "Tem a certeza que queres rejeitar esta conversa privada com %1$s?"
+ "Rejeitar conversa"
+ "Sem convites"
+ "%1$s (%2$s) convidou-te"
+ "Este processo só acontece uma única vez, obrigado por esperares."
+ "A configurar a tua conta…"
+ "Criar uma nova conversa ou sala"
+ "Limpar filtros"
+ "Começa por enviar uma mensagem a alguém."
+ "Ainda não tens conversas."
+ "Favoritas"
+ "Podes adicionar uma conversa às tuas favoritas nas suas configurações.
+Por enquanto, podes anular a seleção dos filtros para veres as tuas outras conversas"
+ "Ainda não tens nenhuma conversa favorita"
+ "Convites"
+ "Não tens nenhum convite pendente."
+ "Prioridade baixa"
+ "Podes anular a seleção dos filtros para veres as tuas outras conversas"
+ "Não tens nenhuma conversa selecionada"
+ "Pessoas"
+ "Ainda não tens nenhuma MD (mensagem direta)"
+ "Salas"
+ "Ainda não estás em nenhuma sala"
+ "Por ler"
+ "Parabéns!
+Não tens nenhuma mensagem por ler!"
+ "Pedido de adesão enviado"
+ "Conversas"
+ "Marcar como lida"
+ "Marcar como não lida"
+ "Esta sala foi atualizada"
+ "Parece que estás a utilizar um novo dispositivo. Verifica-o com um outro para poderes aceder às tuas mensagens cifradas."
+ "Verifica que és tu"
+
diff --git a/features/home/impl/src/main/res/values-ro/translations.xml b/features/home/impl/src/main/res/values-ro/translations.xml
new file mode 100644
index 00000000000..2d7bdd8a952
--- /dev/null
+++ b/features/home/impl/src/main/res/values-ro/translations.xml
@@ -0,0 +1,43 @@
+
+
+ "Recuperați-vă identitatea criptografică și istoricul mesajelor cu o cheie de recuperare dacă ați pierdut toate dispozitivele existente."
+ "Configurați recuperarea"
+ "Configurați recuperarea pentru a vă proteja contul"
+ "Backup-ul pentru chat nu este sincronizat în prezent. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup."
+ "Backup-ul nu este sincronizat"
+ "Pentru a vă asigura că nu pierdeți niciodată un apel important, vă rugăm să modificați setările pentru a permite notificări fullscreen atunci când telefonul este blocat."
+ "Îmbunătățiți-vă experiența in timpul unui apel"
+ "Sigur doriți să refuzați alăturarea la %1$s?"
+ "Refuzați invitația"
+ "Sigur doriți să refuzați conversațiile cu %1$s?"
+ "Refuzați conversația"
+ "Nicio invitație"
+ "%1$s (%2$s) v-a invitat."
+ "Acesta este un proces care se desfășoară o singură dată, vă mulțumim pentru așteptare."
+ "Contul dumneavoastră se configurează"
+ "Creați o conversație sau o cameră nouă"
+ "Începeți prin a trimite mesaje cuiva."
+ "Nu există încă discuții."
+ "Favorite"
+ "Puteți adăuga un chat la preferințele dvs. în setările de chat.
+Deocamdată, puteți deselecta filtrele pentru a vedea celelalte chat-uri"
+ "Încă nu aveți conversații preferate"
+ "Invitații"
+ "Nu aveți invitații în așteptare."
+ "Prioritate scăzută"
+ "Puteți deselecta filtrele pentru a vedea celelalte chat-uri"
+ "Nu aveți chat-uri pentru această selecție"
+ "Persoane"
+ "Încă nu aveți DM-uri"
+ "Camere"
+ "Nu sunteți încă în nicio cameră"
+ "Necitite"
+ "Felicitari!
+Nu aveți mesaje necitite!"
+ "Cererea de alăturare a fost trimisă"
+ "Toate conversatiile"
+ "Marcați ca citită"
+ "Marcați ca necitită"
+ "Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea cu un alt dispozitiv pentru a accesa mesajele dumneavoastră criptate."
+ "Verificați că sunteți dumneavoastră"
+
diff --git a/features/home/impl/src/main/res/values-ru/translations.xml b/features/home/impl/src/main/res/values-ru/translations.xml
new file mode 100644
index 00000000000..a70efcfdfdd
--- /dev/null
+++ b/features/home/impl/src/main/res/values-ru/translations.xml
@@ -0,0 +1,46 @@
+
+
+ "Создайте новый ключ восстановления, который можно использовать для восстановления зашифрованной истории сообщений в случае потери доступа к своим устройствам."
+ "Настроить восстановление"
+ "Для защиты вашего аккаунта рекомендуется настроить восстановление"
+ "Подтвердите ключ восстановления, чтобы сохранить доступ к хранилищу ключей и истории сообщений."
+ "Введите ключ восстановления"
+ "Забыли ключ восстановления?"
+ "Хранилище ключей не синхронизировано"
+ "Чтобы больше не пропускать важные звонки, разрешите приложению показывать полноэкранные уведомления на заблокированном экране телефона."
+ "Улучшите качество звонков"
+ "Вы уверены, что хотите отклонить приглашение в %1$s?"
+ "Отклонить приглашение"
+ "Вы уверены, что хотите отказаться от личного общения с %1$s?"
+ "Отклонить чат"
+ "Нет приглашений"
+ "%1$s (%2$s) пригласил вас"
+ "Это одноразовый процесс, спасибо, что подождали."
+ "Настройка учетной записи."
+ "Создайте новую беседу или комнату"
+ "Начните переписку с отправки сообщения."
+ "Пока нет доступных чатов."
+ "Избранное"
+ "Добавить чат в избранное можно в настройках чата.
+На данный момент вы можете убрать фильтры, чтобы увидеть другие ваши чаты."
+ "У вас пока нет избранных чатов"
+ "Приглашения"
+ "У вас нет отложенных приглашений."
+ "Низкий приоритет"
+ "Вы можете убрать фильтры, чтобы увидеть другие ваши чаты."
+ "У вас нет чатов для этой подборки"
+ "Пользователи"
+ "У вас пока нет личных сообщений"
+ "Комнаты"
+ "Вас пока нет ни в одной комнате"
+ "Непрочитанные"
+ "Поздравляем!
+У вас нет непрочитанных сообщений!"
+ "Запрос на присоединение отправлен"
+ "Все чаты"
+ "Пометить как прочитанное"
+ "Отметить как непрочитанное"
+ "Эта комната была обновлена"
+ "Похоже, вы используете новое устройство. Чтобы получить доступ к зашифрованным сообщениям пройдите подтверждение с другим устройством."
+ "Подтвердите, что это вы"
+
diff --git a/features/home/impl/src/main/res/values-sk/translations.xml b/features/home/impl/src/main/res/values-sk/translations.xml
new file mode 100644
index 00000000000..ade2c192c7b
--- /dev/null
+++ b/features/home/impl/src/main/res/values-sk/translations.xml
@@ -0,0 +1,50 @@
+
+
+ "Vypnite optimalizáciu batérie pre túto aplikáciu, aby ste sa uistili, že sú prijaté všetky upozornenia."
+ "Zakázať optimalizáciu"
+ "Oznámenia neprichádzajú?"
+ "Vytvorte nový kľúč na obnovenie, ktorý môžete použiť na obnovenie vašej histórie šifrovaných správ v prípade straty prístupu k vašim zariadeniam."
+ "Nastaviť obnovenie"
+ "Nastaviť obnovenie"
+ "Potvrďte svoj kľúč na obnovenie, aby ste zachovali prístup k úložisku kľúčov a histórii správ."
+ "Zadajte kľúč na obnovenie"
+ "Zabudli ste svoj kľúč na obnovenie?"
+ "Vaše úložisko kľúčov nie je synchronizované"
+ "Aby ste už nikdy nezmeškali dôležitý hovor, zmeňte svoje nastavenia a povoľte upozornenia na celú obrazovku, keď je váš telefón uzamknutý."
+ "Vylepšite svoj zážitok z hovoru"
+ "Naozaj chcete odmietnuť pozvánku na pripojenie do %1$s?"
+ "Odmietnuť pozvanie"
+ "Naozaj chcete odmietnuť túto súkromnú konverzáciu s %1$s?"
+ "Odmietnuť konverzáciu"
+ "Žiadne pozvánky"
+ "%1$s (%2$s) vás pozval/a"
+ "Ide o jednorazový proces, ďakujeme za trpezlivosť."
+ "Nastavenie vášho účtu."
+ "Vytvorte novú konverzáciu alebo miestnosť"
+ "Vyčistiť filtre"
+ "Začnite tým, že niekomu pošlete správu."
+ "Zatiaľ žiadne konverzácie."
+ "Obľúbené"
+ "Môžete pridať konverzáciu medzi obľúbené v nastaveniach konverzácie.
+Zatiaľ môžete zrušiť výber filtrov, aby ste videli ostatné konverzácie"
+ "Zatiaľ nemáte obľúbené konverzácie"
+ "Pozvánky"
+ "Nemáte žiadne čakajúce pozvánky."
+ "Nízka priorita"
+ "Môžete zrušiť výber filtrov, aby ste videli svoje ostatné konverzácie"
+ "Nemáte konverzácie pre tento výber"
+ "Ľudia"
+ "Zatiaľ nemáte žiadne priame správy"
+ "Miestnosti"
+ "Zatiaľ ešte nie ste v žiadnej miestnosti"
+ "Neprečítané"
+ "Gratulujeme!
+Nemáte žiadne neprečítané správy!"
+ "Žiadosť o pripojenie bola odoslaná"
+ "Všetky konverzácie"
+ "Označiť ako prečítané"
+ "Označiť ako neprečítané"
+ "Táto miestnosť bola aktualizovaná"
+ "Vyzerá to tak, že používate nové zariadenie. Overte svoj prístup k zašifrovaným správam pomocou vášho druhého zariadenia."
+ "Overte, že ste to vy"
+
diff --git a/features/home/impl/src/main/res/values-sv/translations.xml b/features/home/impl/src/main/res/values-sv/translations.xml
new file mode 100644
index 00000000000..ab00fd3dd3a
--- /dev/null
+++ b/features/home/impl/src/main/res/values-sv/translations.xml
@@ -0,0 +1,46 @@
+
+
+ "Skapa en ny återställningsnyckel som kan användas för att återställa din krypterade meddelandehistorik om du förlorar åtkomst till dina enheter."
+ "Ställ in återställning"
+ "Ställ in återställning"
+ "Bekräfta din återställningsnyckel för att behålla åtkomsten till din nyckellagring och meddelandehistorik."
+ "Ange din återställningsnyckel"
+ "Glömt din återställningsnyckel?"
+ "Din nyckellagring är inte synkroniserad"
+ "För att säkerställa att du aldrig missar ett viktigt samtal, ändra dina inställningar för att tillåta helskärmsmeddelanden när telefonen är låst."
+ "Förbättra din samtalsupplevelse"
+ "Är du säker på att du vill tacka nej till inbjudan att gå med%1$s?"
+ "Avböj inbjudan"
+ "Är du säker på att du vill avböja denna privata chatt med %1$s?"
+ "Avböj chatt"
+ "Inga inbjudningar"
+ "%1$s (%2$s) bjöd in dig"
+ "Detta är en engångsprocess, tack för att du väntar."
+ "Konfigurerar ditt konto"
+ "Skapa en ny konversation eller ett nytt rum"
+ "Kom igång genom att skicka meddelanden till någon."
+ "Inga chattar än."
+ "Favoriter"
+ "Du kan lägga till en chatt till dina favoriter i chattinställningarna.
+För tillfället kan du avmarkera filter för att se dina andra chattar"
+ "Du har inga favoritchattar än"
+ "Inbjudningar"
+ "Du har inga väntande inbjudningar."
+ "Låg prioritet"
+ "Du kan avmarkera filter för att se dina andra chattar"
+ "Du har inga chattar för det här valet"
+ "Personer"
+ "Du har inga DM:er än"
+ "Rum"
+ "Du är inte i något rum än"
+ "Olästa"
+ "Grattis!
+Du har inga olästa meddelanden!"
+ "Begäran om att gå med skickad"
+ "Alla chattar"
+ "Markera som läst"
+ "Markera som oläst"
+ "Det här rummet har uppgraderats"
+ "Det verkar som om du använder en ny enhet. Verifiera med en annan enhet för att komma åt dina krypterade meddelanden."
+ "Verifiera att det är du"
+
diff --git a/features/home/impl/src/main/res/values-tr/translations.xml b/features/home/impl/src/main/res/values-tr/translations.xml
new file mode 100644
index 00000000000..6f42809ef32
--- /dev/null
+++ b/features/home/impl/src/main/res/values-tr/translations.xml
@@ -0,0 +1,45 @@
+
+
+ "Mevcut tüm cihazlarınızı kaybettiyseniz şifreleme kimliğinizi ve mesaj geçmişinizi bir kurtarma anahtarıyla kurtarın."
+ "Kurtarmayı ayarlayın"
+ "Hesabınızı korumak için kurtarmayı ayarlayın"
+ "Anahtar depolama alanınıza ve mesaj geçmişinize erişimi sürdürmek için kurtarma anahtarınızı onaylayın."
+ "Kurtarma anahtarınızı girin"
+ "Kurtarma anahtarınızı mı unuttunuz?"
+ "Anahtar depolama alanınız senkronize değil"
+ "Önemli bir aramayı asla kaçırmamak için, telefonunuz kilitliyken tam ekran bildirimlere izin vermek üzere ayarlarınızı değiştirin."
+ "Arama deneyiminizi geliştirin"
+ "%1$s katılma davetini reddetmek istediğinizden emin misiniz?"
+ "Daveti reddet"
+ "%1$s ile bu özel sohbeti reddetmek istediğinizden emin misiniz?"
+ "Sohbeti reddet"
+ "Davet Yok"
+ "%1$s (%2$s) sizi davet etti"
+ "Bu tek seferlik bir işlemdir, beklediğiniz için teşekkürler."
+ "Hesabınızı ayarlanıyor."
+ "Yeni bir sohbet veya oda oluşturun"
+ "Birine mesaj göndererek başla."
+ "Henüz sohbet yok."
+ "Favoriler"
+ "Sohbet ayarlarından bir sohbeti favorilerinize ekleyebilirsiniz.
+Şimdilik, diğer sohbetlerinizi görmek için filtrelerin seçimini kaldırabilirsiniz"
+ "Henüz favori sohbetleriniz yok"
+ "Davetiyeler"
+ "Bekleyen davetiniz yok."
+ "Düşük Öncelikli"
+ "Diğer sohbetlerinizi görmek için filtrelerin seçimini kaldırabilirsiniz"
+ "Bu seçim için sohbetiniz yok"
+ "Kişiler"
+ "Henüz hiç DM\'niz yok"
+ "Odalar"
+ "Henüz herhangi bir odada değilsiniz"
+ "Okunmamış"
+ "Tebrikler!
+Okunmamış mesajınız yok!"
+ "Katılma isteği gönderildi"
+ "Sohbetler"
+ "Okundu olarak işaretle"
+ "Okunmamış olarak işaretle"
+ "Görünüşe göre yeni bir cihaz kullanıyorsunuz. Şifrelenmiş mesajlarınıza erişmek için başka bir cihazla doğrulayın."
+ "Siz olduğunuzu doğrulayın"
+
diff --git a/features/home/impl/src/main/res/values-uk/translations.xml b/features/home/impl/src/main/res/values-uk/translations.xml
new file mode 100644
index 00000000000..57d750d0293
--- /dev/null
+++ b/features/home/impl/src/main/res/values-uk/translations.xml
@@ -0,0 +1,49 @@
+
+
+ "Вимкніть оптимізацію акумулятора для цього застосунку, щоб надходили всі сповіщення."
+ "Вимкнути оптимізацію"
+ "Не надходять сповіщення?"
+ "Відновіть свою криптографічну ідентичність та історію повідомлень за допомогою ключа відновлення, якщо ви втратили всі наявні пристрої."
+ "Налаштувати відновлення"
+ "Налаштуйте відновлення для захисту свого облікового запису"
+ "Підтвердіть свій ключ відновлення, щоб мати доступ до сховища ключів та історії повідомлень."
+ "Введіть ключ відновлення"
+ "Забули ключ відновлення?"
+ "Ваше сховище ключів не синхронізовано"
+ "Щоб ніколи не пропустити важливий виклик, змініть налаштування, щоб увімкнути повноекранні сповіщення, коли телефон заблоковано."
+ "Покращуйте досвід дзвінків"
+ "Ви впевнені, що хочете відхилити запрошення приєднатися до %1$s?"
+ "Відхилити запрошення"
+ "Ви дійсно хочете відмовитися від приватної бесіди з %1$s?"
+ "Відхилити бесіду"
+ "Немає запрошень"
+ "%1$s (%2$s) запрошує вас"
+ "Це одноразовий процес, дякую за очікування."
+ "Налаштування облікового запису."
+ "Створити нову розмову або кімнату"
+ "Почніть з обміну повідомленнями з кимось."
+ "Ще немає бесід."
+ "Обране"
+ "Ви можете додати бесіду до обраних у налаштуваннях бесіди.
+Наразі ви можете зняти фільтри, щоб побачити інші ваші бесіди"
+ "Ви ще не маєте обраних бесід"
+ "Запрошення"
+ "У вас немає запрошень, що очікують на розгляд."
+ "Низький пріоритет"
+ "Ви можете зняти фільтри, щоб побачити інші ваші бесіди"
+ "Ви не маєте бесід для цієї категорії"
+ "Люди"
+ "Ви ще не маєте жодної особистої бесіди"
+ "Кімнати"
+ "Ви ще не учасник жодної кімнати"
+ "Непрочитані"
+ "Вітаємо!
+У вас немає непрочитаних повідомлень!"
+ "Запит на приєднання надіслано"
+ "Бесіди"
+ "Позначити прочитаним"
+ "Позначити непрочитаним"
+ "Цю кімнату оновлено"
+ "Схоже, ви використовуєте новий пристрій. Щоб отримати доступ до зашифрованих повідомлень, підтвердьте особу за допомогою іншого пристрою."
+ "Підтвердьте, що це ви"
+
diff --git a/features/home/impl/src/main/res/values-ur/translations.xml b/features/home/impl/src/main/res/values-ur/translations.xml
new file mode 100644
index 00000000000..7a0fd3acb6b
--- /dev/null
+++ b/features/home/impl/src/main/res/values-ur/translations.xml
@@ -0,0 +1,40 @@
+
+
+ "بازیابی مرتب کریں"
+ "اپنے کلید کے ذخیرہ اور پیغام کی سرگزشت تک رسائی کو برقرار رکھنے کیلئے اپنی بازیابی کلید کی تصدیق کریں۔"
+ "آپ کا کلید کا ذخیرہ غیر ہم وقت ساز ہے۔"
+ "اس بات کو یقینی بنانے کے لیے کہ آپ کبھی بھی اہم مکالمہ سے محروم نہ ہوں، براہ کرم اپنی ترتیبات تبدیل کریں تاکہ آپ کا ہاتف مقفل ہونے پر مکمل پردۂ نمائش اطلاعات کی اجازت دی جا سکے۔"
+ "اپنے مکالمتی تجربے کو احسن کریں"
+ "کیا آپکو یقین ہے کہ آپ %1$s میں شامل ہونے کی درخواست مسترد کرنا چاہتے ہیں؟"
+ "دعوت مسترد کریں"
+ "کیا آپکو یقین ہے کہ آپ %1$s کیساتھ نجی گفتگو مسترد کرنا چاہتے ہیں؟"
+ "گفتگو مسترد کریں"
+ "کوئی دعوت نامے نہیں"
+ "%1$s (%2$s) نے آپ کو مدعو کیا"
+ "یہ ایک بار کا عمل ہے، انتظار کرنے کا شکریہ۔"
+ "آپکا کھاتہ مرتب کر رہا ہے"
+ "ایک نئی گفتگو یا کمرہ تخلیق کریں"
+ "کسی کو پیغام بھیج کر شروع کریں۔"
+ "ابھی تک کوئی گفتگوئیں نہیں ہیں۔"
+ "پسندیدگان"
+ "آپ گفتگو کی ترتیبات میں اپنے پسندیدہ میں گفتگو شامل کر سکتے ہیں۔
+ ابھی کے لیے، آپ اپنی دوسری گفتگوئیں دیکھنے کے لیے مرشحات کو غیر منتخب کر سکتے ہیں۔"
+ "آپ کے پاس ابھی تک پسندیدہ گفتگوئیں نہیں ہیں"
+ "دعوت نامے"
+ "آپ کے پاس کوئی زیر التوا دعوتیں نہیں ہیں۔"
+ "کم ترجیحی"
+ "آپ اپنی دیگر گفتگئہں دیکھنے کیلئے مرشحات کو غیر منتخب کرسکتے ہیں"
+ "آپ کے پاس اس انتخاب کے لیے گفتگو ئیں نہیں ہیں۔"
+ "لوگ"
+ "آپ کے پاس ابھی تک کوئی براہ راست پیغامات نہیں ہے۔"
+ "کمرے"
+ "آپ ابھی تک کسی کمرے میں نہیں ہیں"
+ "غیر مقروءہ"
+ "مبارک ہو!
+آپ کے پاس کوئی غیر مقروءہ پیغامات نہیں!"
+ "گفتگوئیں"
+ "بطور مقروءہ نشانزد کریں"
+ "بطور غیر مقروءہ نشانزد کریں"
+ "ایسا لگتا ہے کہ آپ ایک نیا آلہ استعمال کر رہے ہیں۔ اپنے مرموزکردہ پیغامات تک رسائی کیلئے کسی دوسرے آلے سے توثیق کریں۔"
+ "تصدیق کریں کہ آپ ہی ہیں"
+
diff --git a/features/home/impl/src/main/res/values-uz/translations.xml b/features/home/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 00000000000..077f9ecc4ae
--- /dev/null
+++ b/features/home/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,20 @@
+
+
+ "Qayta tiklashni sozlang"
+ "Haqiqatan ham qo\'shilish taklifini rad qilmoqchimisiz%1$s ?"
+ "Taklifni rad etish"
+ "Haqiqatan ham bu shaxsiy chatni rad qilmoqchimisiz%1$s ?"
+ "Chatni rad etish"
+ "Takliflar yo\'q"
+ "%1$s(%2$s ) sizni taklif qildi"
+ "Bu bir martalik jarayon, kutganingiz uchun rahmat."
+ "Hisobingiz sozlanmoqda."
+ "Yangi suhbat yoki xona yarating"
+ "Kimgadir xabar yuborishdan boshlang."
+ "Hozircha chatlar yo‘q."
+ "Takliflar"
+ "Odamlar"
+ "Suhbatlar"
+ "Siz yangi qurilmadan foydalanayotganga o‘xshaysiz. Shifrlangan xabarlaringizga kirish uchun boshqa qurilma bilan tasdiqlang."
+ "Siz ekanligingizni tasdiqlang"
+
diff --git a/features/home/impl/src/main/res/values-zh-rTW/translations.xml b/features/home/impl/src/main/res/values-zh-rTW/translations.xml
new file mode 100644
index 00000000000..1bc2ae181c5
--- /dev/null
+++ b/features/home/impl/src/main/res/values-zh-rTW/translations.xml
@@ -0,0 +1,45 @@
+
+
+ "若您遺失了所有現有裝置,則請使用復原金鑰以救援您的密碼學身份與訊息歷史紀錄。"
+ "設定復原"
+ "設定備援以保護您的帳號"
+ "確認您的復原金鑰以維持對金鑰儲存空間與訊息歷史紀錄的存取權。"
+ "輸入您的復原金鑰"
+ "忘記了您的復原金鑰?"
+ "您的金鑰儲存空間並未同步"
+ "為確保您永遠不會錯過重要通話,請變更設定以允許在手機鎖定時允許全螢幕通知。"
+ "提升您的通話體驗"
+ "您確定您想要拒絕加入 %1$s 的邀請嗎?"
+ "拒絕邀請"
+ "您確定您要拒絕此與 %1$s 的私人聊天嗎?"
+ "拒絕聊天"
+ "沒有邀請"
+ "%1$s(%2$s)邀請您"
+ "這是一次性的程序,感謝您耐心等候。"
+ "正在設定您的帳號。"
+ "建立新的對話或聊天室"
+ "從向某人傳送訊息開始。"
+ "尚無聊天室。"
+ "我的最愛"
+ "您可以在聊天設定中將聊天新增至收藏。
+目前,您可以取消選取篩選條件以檢視其他聊天"
+ "您尚無收藏聊天"
+ "邀請"
+ "您沒有任何擱置中的邀請。"
+ "低優先度"
+ "您可以取消選取篩選條件以檢視其他聊天"
+ "您並無此選擇的聊天"
+ "夥伴"
+ "您尚無任何私人訊息"
+ "聊天室"
+ "您尚未進入任何聊天室"
+ "未讀"
+ "恭喜!
+您沒有任何未讀的訊息!"
+ "已傳送加入請求"
+ "所有聊天室"
+ "標為已讀"
+ "標為未讀"
+ "您似乎正在使用新的裝置。請使用另一個裝置進行驗證,以存取您的加密訊息。"
+ "驗證這是您本人"
+
diff --git a/features/home/impl/src/main/res/values-zh/translations.xml b/features/home/impl/src/main/res/values-zh/translations.xml
new file mode 100644
index 00000000000..4ca91cb7968
--- /dev/null
+++ b/features/home/impl/src/main/res/values-zh/translations.xml
@@ -0,0 +1,47 @@
+
+
+ "禁用优化"
+ "通知未送达?"
+ "生成新的恢复密钥,该密钥可用于在您无法访问设备时恢复加密的消息历史记录。"
+ "设置恢复"
+ "设置恢复"
+ "确认恢复密钥,以保持对密钥存储和消息历史的访问。"
+ "输入恢复密钥"
+ "忘记了恢复密钥?"
+ "你的密钥存储已不同步"
+ "为确保您不会错过重要来电,请更改设置以允许锁屏时的全屏通知。"
+ "提升通话体验"
+ "您确定要拒绝加入 %1$s 的邀请吗?"
+ "拒绝邀请"
+ "您确定要拒绝与 %1$s 开始私聊吗?"
+ "拒绝聊天"
+ "没有邀请"
+ "%1$s (%2$s)邀请了你"
+ "这是一个一次性的过程,感谢您的等待。"
+ "设置您的账户。"
+ "创建新的对话或聊天室"
+ "通过向某人发送消息来开始。"
+ "还没有聊天。"
+ "收藏夹"
+ "可以在聊天设置里将聊天添加到收藏夹中。
+现在,可以取消选择过滤器以查看其他对话。"
+ "您未收藏任何聊天"
+ "邀请"
+ "没有待处理的邀请。"
+ "低优先级"
+ "您可以取消选择过滤器以查看其他对话"
+ "您没有关于此选项的聊天"
+ "用户"
+ "目前您还没有私信"
+ "聊天室"
+ "您尚未进入任何聊天室"
+ "未读"
+ "恭喜!
+没有任何未读消息!"
+ "加入请求已发送"
+ "全部聊天"
+ "标记为已读"
+ "标记为未读"
+ "您似乎正在使用新设备。使用另一台设备进行验证以访问您的加密消息。"
+ "验证是你本人"
+
diff --git a/features/home/impl/src/main/res/values/localazy.xml b/features/home/impl/src/main/res/values/localazy.xml
new file mode 100644
index 00000000000..58afb065915
--- /dev/null
+++ b/features/home/impl/src/main/res/values/localazy.xml
@@ -0,0 +1,50 @@
+
+
+ "Disable battery optimization for this app, to make sure all notifications are received."
+ "Disable optimization"
+ "Notifications not arriving?"
+ "Recover your cryptographic identity and message history with a recovery key if you have lost all your existing devices."
+ "Set up recovery"
+ "Set up recovery to protect your account"
+ "Confirm your recovery key to maintain access to your key storage and message history."
+ "Enter your recovery key"
+ "Forgot your recovery key?"
+ "Your key storage is out of sync"
+ "To ensure you never miss an important call, please change your settings to allow full-screen notifications when your phone is locked."
+ "Enhance your call experience"
+ "Are you sure you want to decline the invitation to join %1$s?"
+ "Decline invite"
+ "Are you sure you want to decline this private chat with %1$s?"
+ "Decline chat"
+ "No Invites"
+ "%1$s (%2$s) invited you"
+ "This is a one time process, thanks for waiting."
+ "Setting up your account."
+ "Create a new conversation or room"
+ "Clear filters"
+ "Get started by messaging someone."
+ "No chats yet."
+ "Favourites"
+ "You can add a chat to your favourites in the chat settings.
+For now, you can deselect filters in order to see your other chats"
+ "You don’t have favourite chats yet"
+ "Invites"
+ "You don\'t have any pending invites."
+ "Low Priority"
+ "You can deselect filters in order to see your other chats"
+ "You don’t have chats for this selection"
+ "People"
+ "You don’t have any DMs yet"
+ "Rooms"
+ "You’re not in any room yet"
+ "Unreads"
+ "Congrats!
+You don’t have any unread messages!"
+ "Request to join sent"
+ "Chats"
+ "Mark as read"
+ "Mark as unread"
+ "This room has been upgraded"
+ "Looks like you’re using a new device. Verify with another device to access your encrypted messages."
+ "Verify it’s you"
+
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/FakeDateTimeObserver.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/FakeDateTimeObserver.kt
new file mode 100644
index 00000000000..6cbebb3135e
--- /dev/null
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/FakeDateTimeObserver.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2025 New Vector 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.home.impl
+
+import io.element.android.libraries.androidutils.system.DateTimeObserver
+import kotlinx.coroutines.flow.MutableSharedFlow
+
+class FakeDateTimeObserver : DateTimeObserver {
+ override val changes = MutableSharedFlow(extraBufferCapacity = 1)
+
+ fun given(event: DateTimeObserver.Event) {
+ changes.tryEmit(event)
+ }
+}
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt
new file mode 100644
index 00000000000..6913ea19bf6
--- /dev/null
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt
@@ -0,0 +1,148 @@
+/*
+ * Copyright 2025 New Vector 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.home.impl
+
+import app.cash.molecule.RecompositionMode
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.home.impl.roomlist.aRoomListState
+import io.element.android.features.logout.api.direct.aDirectLogoutState
+import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
+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.indicator.api.IndicatorService
+import io.element.android.libraries.indicator.test.FakeIndicatorService
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.sync.SyncService
+import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.matrix.test.AN_AVATAR_URL
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.libraries.matrix.test.A_USER_ID
+import io.element.android.libraries.matrix.test.A_USER_NAME
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.sync.FakeSyncService
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class HomePresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - should start with no user and then load user with success`() = runTest {
+ val matrixClient = FakeMatrixClient(
+ userDisplayName = null,
+ userAvatarUrl = null,
+ )
+ matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.success(MatrixUser(matrixClient.sessionId, A_USER_NAME, AN_AVATAR_URL)))
+ val presenter = createHomePresenter(
+ client = matrixClient,
+ rageshakeFeatureAvailability = { false },
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.matrixUser).isEqualTo(MatrixUser(A_USER_ID))
+ assertThat(initialState.canReportBug).isFalse()
+ val withUserState = awaitItem()
+ assertThat(withUserState.matrixUser.userId).isEqualTo(A_USER_ID)
+ assertThat(withUserState.matrixUser.displayName).isEqualTo(A_USER_NAME)
+ assertThat(withUserState.matrixUser.avatarUrl).isEqualTo(AN_AVATAR_URL)
+ assertThat(withUserState.showAvatarIndicator).isFalse()
+ assertThat(withUserState.isSpaceFeatureEnabled).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - space feature enabled`() = runTest {
+ val presenter = createHomePresenter(
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.Space.key to true),
+ ),
+ )
+ presenter.test {
+ skipItems(1)
+ val initialState = awaitItem()
+ assertThat(initialState.isSpaceFeatureEnabled).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - show avatar indicator`() = runTest {
+ val indicatorService = FakeIndicatorService()
+ val presenter = createHomePresenter(
+ indicatorService = indicatorService,
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.showAvatarIndicator).isFalse()
+ indicatorService.setShowRoomListTopBarIndicator(true)
+ val finalState = awaitItem()
+ assertThat(finalState.showAvatarIndicator).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - should start with no user and then load user with error`() = runTest {
+ val matrixClient = FakeMatrixClient(
+ userDisplayName = null,
+ userAvatarUrl = null,
+ )
+ matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.failure(AN_EXCEPTION))
+ val presenter = createHomePresenter(client = matrixClient)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.matrixUser).isEqualTo(MatrixUser(matrixClient.sessionId))
+ // No new state is coming
+ }
+ }
+
+ @Test
+ fun `present - NavigationBar change`() = runTest {
+ val presenter = createHomePresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats)
+ initialState.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces))
+ val finalState = awaitItem()
+ assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces)
+ }
+ }
+
+ private fun TestScope.createHomePresenter(
+ client: MatrixClient = FakeMatrixClient(),
+ syncService: SyncService = FakeSyncService(),
+ snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
+ rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { true },
+ indicatorService: IndicatorService = FakeIndicatorService(),
+ featureFlagService: FeatureFlagService = FakeFeatureFlagService()
+ ) = HomePresenter(
+ client = client,
+ syncService = syncService,
+ snackbarDispatcher = snackbarDispatcher,
+ indicatorService = indicatorService,
+ logoutPresenter = { aDirectLogoutState() },
+ roomListPresenter = { aRoomListState() },
+ rageshakeFeatureAvailability = rageshakeFeatureAvailability,
+ featureFlagService = featureFlagService,
+ )
+}
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSourceTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt
similarity index 96%
rename from features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSourceTest.kt
rename to features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt
index dcf6cdc0325..03fe2efd942 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSourceTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt
@@ -5,11 +5,11 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.datasource
+package io.element.android.features.home.impl.datasource
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.roomlist.impl.FakeDateTimeObserver
+import io.element.android.features.home.impl.FakeDateTimeObserver
import io.element.android.libraries.androidutils.system.DateTimeObserver
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
@@ -100,7 +100,7 @@ class RoomListDataSourceTest {
roomListRoomSummaryFactory = roomListRoomSummaryFactory,
coroutineDispatchers = testCoroutineDispatchers(),
notificationSettingsService = notificationSettingsService,
- appScope = backgroundScope,
+ sessionCoroutineScope = backgroundScope,
dateTimeObserver = dateTimeObserver,
)
}
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactoryTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactoryTest.kt
similarity index 92%
rename from features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactoryTest.kt
rename to features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactoryTest.kt
index 86c0981d4f9..95444b6c4f3 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactoryTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactoryTest.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.datasource
+package io.element.android.features.home.impl.datasource
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersEmptyStateResourcesTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResourcesTest.kt
similarity index 97%
rename from features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersEmptyStateResourcesTest.kt
rename to features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResourcesTest.kt
index 67ef79ac817..3f4f45afaeb 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersEmptyStateResourcesTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResourcesTest.kt
@@ -5,10 +5,10 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.filters
+package io.element.android.features.home.impl.filters
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.roomlist.impl.R
+import io.element.android.features.home.impl.R
import org.junit.Test
class RoomListFiltersEmptyStateResourcesTest {
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt
similarity index 95%
rename from features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenterTest.kt
rename to features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt
index 560e5f938ee..a71afca607f 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt
@@ -5,14 +5,14 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.filters
+package io.element.android.features.home.impl.filters
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.roomlist.impl.filters.selection.DefaultFilterSelectionStrategy
-import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
+import io.element.android.features.home.impl.filters.selection.DefaultFilterSelectionStrategy
+import io.element.android.features.home.impl.filters.selection.FilterSelectionState
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.awaitLastSequentialItem
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersViewTest.kt
similarity index 90%
rename from features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersViewTest.kt
rename to features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersViewTest.kt
index 0041ef988d9..1fd37d92528 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersViewTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersViewTest.kt
@@ -5,13 +5,13 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.filters
+package io.element.android.features.home.impl.filters
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
-import io.element.android.features.roomlist.impl.R
-import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
+import io.element.android.features.home.impl.R
+import io.element.android.features.home.impl.filters.selection.FilterSelectionState
import io.element.android.libraries.testtags.TestTags
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/model/RoomListBaseRoomSummaryTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/model/RoomListBaseRoomSummaryTest.kt
new file mode 100644
index 00000000000..55b3f1ffee3
--- /dev/null
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/model/RoomListBaseRoomSummaryTest.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2024 New Vector 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.home.impl.model
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_ROOM_NAME
+import kotlinx.collections.immutable.toPersistentList
+import org.junit.Test
+
+class RoomListBaseRoomSummaryTest {
+ @Test
+ fun `test default value`() {
+ val sut = createRoomListRoomSummary(
+ isMarkedUnread = false,
+ )
+ assertThat(sut.isHighlighted).isFalse()
+ assertThat(sut.hasNewContent).isFalse()
+ }
+
+ @Test
+ fun `test muted room`() {
+ val sut = createRoomListRoomSummary(
+ userDefinedNotificationMode = RoomNotificationMode.MUTE,
+ )
+ assertThat(sut.isHighlighted).isFalse()
+ assertThat(sut.hasNewContent).isFalse()
+ }
+
+ @Test
+ fun `test muted room isMarkedUnread set to true`() {
+ val sut = createRoomListRoomSummary(
+ isMarkedUnread = true,
+ userDefinedNotificationMode = RoomNotificationMode.MUTE,
+ )
+ assertThat(sut.isHighlighted).isTrue()
+ assertThat(sut.hasNewContent).isTrue()
+ }
+
+ @Test
+ fun `test muted room with unread message`() {
+ val sut = createRoomListRoomSummary(
+ numberOfUnreadNotifications = 1,
+ userDefinedNotificationMode = RoomNotificationMode.MUTE,
+ )
+ assertThat(sut.isHighlighted).isFalse()
+ assertThat(sut.hasNewContent).isTrue()
+ }
+
+ @Test
+ fun `test isMarkedUnread set to true`() {
+ val sut = createRoomListRoomSummary(
+ isMarkedUnread = true,
+ )
+ assertThat(sut.isHighlighted).isTrue()
+ assertThat(sut.hasNewContent).isTrue()
+ }
+
+ @Test
+ fun `when display type is invite then isHighlighted and hasNewContent are false`() {
+ val sut = createRoomListRoomSummary(
+ displayType = RoomSummaryDisplayType.INVITE,
+ )
+ assertThat(sut.isHighlighted).isFalse()
+ assertThat(sut.hasNewContent).isFalse()
+ }
+}
+
+internal fun createRoomListRoomSummary(
+ numberOfUnreadMentions: Long = 0,
+ numberOfUnreadMessages: Long = 0,
+ numberOfUnreadNotifications: Long = 0,
+ isMarkedUnread: Boolean = false,
+ userDefinedNotificationMode: RoomNotificationMode? = null,
+ isFavorite: Boolean = false,
+ displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
+ heroes: List = emptyList(),
+ timestamp: String? = null,
+ isTombstoned: Boolean = false,
+) = RoomListRoomSummary(
+ id = A_ROOM_ID.value,
+ roomId = A_ROOM_ID,
+ name = A_ROOM_NAME,
+ numberOfUnreadMentions = numberOfUnreadMentions,
+ numberOfUnreadMessages = numberOfUnreadMessages,
+ numberOfUnreadNotifications = numberOfUnreadNotifications,
+ isMarkedUnread = isMarkedUnread,
+ timestamp = timestamp,
+ lastMessage = "",
+ avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem),
+ displayType = displayType,
+ userDefinedNotificationMode = userDefinedNotificationMode,
+ hasRoomCall = false,
+ isDirect = false,
+ isFavorite = isFavorite,
+ canonicalAlias = null,
+ inviteSender = null,
+ isDm = false,
+ heroes = heroes.toPersistentList(),
+ isTombstoned = isTombstoned,
+)
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenuTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenuTest.kt
new file mode 100644
index 00000000000..b336178d22d
--- /dev/null
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenuTest.kt
@@ -0,0 +1,148 @@
+/*
+ * Copyright 2024 New Vector 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.home.impl.roomlist
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.home.impl.R
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureCalledOnceWithParam
+import io.element.android.tests.testutils.EnsureNeverCalledWithParam
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.setSafeContent
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class RoomListContextMenuTest {
+ @get:Rule val rule = createAndroidComposeRule()
+
+ @Test
+ fun `clicking on Mark as read generates expected Events`() {
+ val eventsRecorder = EventsRecorder()
+ val contextMenu = aContextMenuShown(hasNewContent = true)
+ rule.setRoomListContextMenu(
+ contextMenu = contextMenu,
+ eventSink = eventsRecorder,
+ )
+ rule.clickOn(R.string.screen_roomlist_mark_as_read)
+ eventsRecorder.assertList(
+ listOf(
+ RoomListEvents.HideContextMenu,
+ RoomListEvents.MarkAsRead(contextMenu.roomId),
+ )
+ )
+ }
+
+ @Test
+ fun `clicking on Mark as unread generates expected Events`() {
+ val eventsRecorder = EventsRecorder()
+ val contextMenu = aContextMenuShown(hasNewContent = false)
+ rule.setRoomListContextMenu(
+ contextMenu = contextMenu,
+ eventSink = eventsRecorder,
+ )
+ rule.clickOn(R.string.screen_roomlist_mark_as_unread)
+ eventsRecorder.assertList(
+ listOf(
+ RoomListEvents.HideContextMenu,
+ RoomListEvents.MarkAsUnread(contextMenu.roomId),
+ )
+ )
+ }
+
+ @Test
+ fun `clicking on Leave room generates expected Events`() {
+ val eventsRecorder = EventsRecorder()
+ val contextMenu = aContextMenuShown(isDm = false)
+ rule.setRoomListContextMenu(
+ contextMenu = contextMenu,
+ eventSink = eventsRecorder,
+ )
+ rule.clickOn(CommonStrings.action_leave_room)
+ eventsRecorder.assertList(
+ listOf(
+ RoomListEvents.HideContextMenu,
+ RoomListEvents.LeaveRoom(contextMenu.roomId),
+ )
+ )
+ }
+
+ @Test
+ fun `clicking on Report room invokes the expected callback and generates expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ val contextMenu = aContextMenuShown()
+ val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit)
+ rule.setRoomListContextMenu(
+ contextMenu = contextMenu,
+ canReportRoom = true,
+ eventSink = eventsRecorder,
+ onRoomSettingsClick = EnsureNeverCalledWithParam(),
+ onReportRoomClick = callback,
+ )
+ rule.clickOn(CommonStrings.action_report_room)
+ eventsRecorder.assertSingle(RoomListEvents.HideContextMenu)
+ callback.assertSuccess()
+ }
+
+ @Test
+ fun `clicking on Settings invokes the expected callback and generates expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ val contextMenu = aContextMenuShown()
+ val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit)
+ rule.setRoomListContextMenu(
+ contextMenu = contextMenu,
+ eventSink = eventsRecorder,
+ onRoomSettingsClick = callback,
+ )
+ rule.clickOn(CommonStrings.common_settings)
+ eventsRecorder.assertSingle(RoomListEvents.HideContextMenu)
+ callback.assertSuccess()
+ }
+
+ @Test
+ fun `clicking on Favourites generates expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ val contextMenu = aContextMenuShown(isDm = false, isFavorite = false)
+ val callback = EnsureNeverCalledWithParam()
+ rule.setRoomListContextMenu(
+ contextMenu = contextMenu,
+ eventSink = eventsRecorder,
+ onRoomSettingsClick = callback,
+ )
+ rule.clickOn(CommonStrings.common_favourite)
+ eventsRecorder.assertList(
+ listOf(
+ RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, true),
+ )
+ )
+ }
+
+ private fun AndroidComposeTestRule<*, *>.setRoomListContextMenu(
+ contextMenu: RoomListState.ContextMenu.Shown,
+ canReportRoom: Boolean = false,
+ eventSink: (RoomListEvents) -> Unit,
+ onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
+ onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
+ ) {
+ setSafeContent {
+ RoomListContextMenu(
+ contextMenu = contextMenu,
+ canReportRoom = canReportRoom,
+ onRoomSettingsClick = onRoomSettingsClick,
+ onReportRoomClick = onReportRoomClick,
+ eventSink = eventSink,
+ )
+ }
+ }
+}
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenuTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenuTest.kt
new file mode 100644
index 00000000000..48a51b87bb0
--- /dev/null
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenuTest.kt
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2024 New Vector 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.home.impl.roomlist
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.home.impl.model.aRoomListRoomSummary
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureCalledOnceWithParam
+import io.element.android.tests.testutils.EnsureNeverCalledWithParam
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.setSafeContent
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class RoomListDeclineInviteMenuTest {
+ @get:Rule val rule = createAndroidComposeRule()
+
+ @Test
+ fun `clicking on decline emits the expected Events`() {
+ val eventsRecorder = EventsRecorder()
+ val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
+ rule.setSafeContent {
+ RoomListDeclineInviteMenu(
+ menu = menu,
+ canReportRoom = false,
+ onDeclineAndBlockClick = EnsureNeverCalledWithParam(),
+ eventSink = eventsRecorder,
+ )
+ }
+ rule.clickOn(CommonStrings.action_decline)
+ eventsRecorder.assertList(
+ listOf(
+ RoomListEvents.HideDeclineInviteMenu,
+ RoomListEvents.DeclineInvite(menu.roomSummary, blockUser = false),
+ )
+ )
+ }
+
+ @Test
+ fun `clicking on decline and block when canReportRoom=true, it emits the expected Events and callback`() {
+ val eventsRecorder = EventsRecorder()
+ val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
+ rule.setSafeContent {
+ RoomListDeclineInviteMenu(
+ menu = menu,
+ canReportRoom = true,
+ onDeclineAndBlockClick = EnsureCalledOnceWithParam(menu.roomSummary, Unit),
+ eventSink = eventsRecorder,
+ )
+ }
+ rule.clickOn(CommonStrings.action_decline_and_block)
+ val expectedEvents = listOf(RoomListEvents.HideDeclineInviteMenu)
+ eventsRecorder.assertList(expectedEvents)
+ }
+
+ @Test
+ fun `clicking on decline and block when canReportRoom=false, it emits the expected Events`() {
+ val eventsRecorder = EventsRecorder()
+ val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
+ rule.setSafeContent {
+ RoomListDeclineInviteMenu(
+ menu = menu,
+ canReportRoom = false,
+ onDeclineAndBlockClick = EnsureNeverCalledWithParam(),
+ eventSink = eventsRecorder,
+ )
+ }
+ rule.clickOn(CommonStrings.action_decline_and_block)
+ val expectedEvents = listOf(
+ RoomListEvents.HideDeclineInviteMenu,
+ RoomListEvents.DeclineInvite(menu.roomSummary, blockUser = true),
+ )
+ eventsRecorder.assertList(expectedEvents)
+ }
+
+ @Test
+ fun `clicking on cancel emits the expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
+ rule.setSafeContent {
+ RoomListDeclineInviteMenu(
+ menu = menu,
+ canReportRoom = false,
+ onDeclineAndBlockClick = EnsureNeverCalledWithParam(),
+ eventSink = eventsRecorder,
+ )
+ }
+ rule.clickOn(CommonStrings.action_cancel)
+ eventsRecorder.assertList(listOf(RoomListEvents.HideDeclineInviteMenu))
+ }
+}
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
similarity index 82%
rename from features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
rename to features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
index a49e020d8bf..bc0163585f1 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
@@ -5,70 +5,65 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl
+package io.element.android.features.home.impl.roomlist
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction
-import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
-import io.element.android.features.invite.api.response.AcceptDeclineInviteState
-import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
+import io.element.android.features.home.impl.FakeDateTimeObserver
+import io.element.android.features.home.impl.datasource.RoomListDataSource
+import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory
+import io.element.android.features.home.impl.filters.RoomListFiltersState
+import io.element.android.features.home.impl.filters.aRoomListFiltersState
+import io.element.android.features.home.impl.model.createRoomListRoomSummary
+import io.element.android.features.home.impl.search.RoomListSearchEvents
+import io.element.android.features.home.impl.search.RoomListSearchState
+import io.element.android.features.home.impl.search.aRoomListSearchState
+import io.element.android.features.invite.api.SeenInvitesStore
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
+import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
-import io.element.android.features.logout.api.direct.aDirectLogoutState
-import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
-import io.element.android.features.roomlist.impl.datasource.aRoomListRoomSummaryFactory
-import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
-import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
-import io.element.android.features.roomlist.impl.model.createRoomListRoomSummary
-import io.element.android.features.roomlist.impl.search.RoomListSearchEvents
-import io.element.android.features.roomlist.impl.search.RoomListSearchState
-import io.element.android.features.roomlist.impl.search.aRoomListSearchState
-import io.element.android.libraries.androidutils.system.DateTimeObserver
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
-import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
-import io.element.android.libraries.indicator.impl.DefaultIndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
-import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomList
-import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.timeline.ReceiptType
-import io.element.android.libraries.matrix.api.user.MatrixUser
-import io.element.android.libraries.matrix.test.AN_AVATAR_URL
-import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
import io.element.android.libraries.matrix.test.A_SESSION_ID
-import io.element.android.libraries.matrix.test.A_USER_ID
-import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
-import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
+import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
+import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.services.analytics.api.AnalyticsService
@@ -82,7 +77,6 @@ import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
@@ -94,79 +88,17 @@ class RoomListPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
- @Test
- fun `present - should start with no user and then load user with success`() = runTest {
- val matrixClient = FakeMatrixClient(
- userDisplayName = null,
- userAvatarUrl = null,
- )
- matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.success(MatrixUser(matrixClient.sessionId, A_USER_NAME, AN_AVATAR_URL)))
- val presenter = createRoomListPresenter(
- client = matrixClient,
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- assertThat(initialState.matrixUser).isEqualTo(MatrixUser(A_USER_ID))
- val withUserState = awaitItem()
- assertThat(withUserState.matrixUser.userId).isEqualTo(A_USER_ID)
- assertThat(withUserState.matrixUser.displayName).isEqualTo(A_USER_NAME)
- assertThat(withUserState.matrixUser.avatarUrl).isEqualTo(AN_AVATAR_URL)
- assertThat(withUserState.showAvatarIndicator).isTrue()
- }
- }
-
- @Test
- fun `present - show avatar indicator`() = runTest {
- val encryptionService = FakeEncryptionService()
- val sessionVerificationService = FakeSessionVerificationService()
- val matrixClient = FakeMatrixClient(
- encryptionService = encryptionService,
- sessionVerificationService = sessionVerificationService,
- )
- val presenter = createRoomListPresenter(
- client = matrixClient,
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- assertThat(initialState.showAvatarIndicator).isTrue()
- sessionVerificationService.emitNeedsSessionVerification(false)
- encryptionService.emitBackupState(BackupState.ENABLED)
- val finalState = awaitItem()
- assertThat(finalState.showAvatarIndicator).isFalse()
- }
- }
-
- @Test
- fun `present - should start with no user and then load user with error`() = runTest {
- val matrixClient = FakeMatrixClient(
- userDisplayName = null,
- userAvatarUrl = null,
- )
- matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.failure(AN_EXCEPTION))
- val presenter = createRoomListPresenter(client = matrixClient)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- assertThat(initialState.matrixUser).isEqualTo(MatrixUser(matrixClient.sessionId))
- // No new state is coming
- }
- }
-
@Test
fun `present - load 1 room with success`() = runTest {
val roomListService = FakeRoomListService()
val matrixClient = FakeMatrixClient(
roomListService = roomListService
)
- val presenter = createRoomListPresenter(client = matrixClient)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val presenter = createRoomListPresenter(
+ client = matrixClient,
+ seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)),
+ )
+ presenter.test {
val initialState = consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Skeleton }.last()
assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Skeleton::class.java)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
@@ -188,6 +120,7 @@ class RoomListPresenterTest {
timestamp = "0 TimeOrDate true",
)
)
+ assertThat(withRoomsState.contentAsRooms().seenRoomInvites).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
cancelAndIgnoreRemainingEvents()
}
}
@@ -265,7 +198,7 @@ class RoomListPresenterTest {
@Test
fun `present - show context menu`() = runTest {
- val room = FakeMatrixRoom()
+ val room = FakeBaseRoom()
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
@@ -286,8 +219,8 @@ class RoomListPresenterTest {
isDm = false,
isFavorite = false,
markAsUnreadFeatureFlagEnabled = true,
- eventCacheFeatureFlagEnabled = false,
hasNewContent = false,
+ displayClearRoomCacheAction = false,
)
)
}
@@ -304,8 +237,37 @@ class RoomListPresenterTest {
isDm = false,
isFavorite = true,
markAsUnreadFeatureFlagEnabled = true,
- eventCacheFeatureFlagEnabled = false,
hasNewContent = false,
+ displayClearRoomCacheAction = false,
+ )
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `present - show context menu with view source on`() = runTest {
+ val presenter = createRoomListPresenter(
+ appPreferencesStore = InMemoryAppPreferencesStore(
+ isDeveloperModeEnabled = true,
+ )
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ val summary = createRoomListRoomSummary()
+ initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
+ awaitItem().also { state ->
+ assertThat(state.contextMenu)
+ .isEqualTo(
+ RoomListState.ContextMenu.Shown(
+ roomId = summary.roomId,
+ roomName = summary.name,
+ isDm = false,
+ isFavorite = false,
+ markAsUnreadFeatureFlagEnabled = true,
+ // true here.
+ hasNewContent = false,
+ displayClearRoomCacheAction = true,
)
)
}
@@ -314,7 +276,7 @@ class RoomListPresenterTest {
@Test
fun `present - hide context menu`() = runTest {
- val room = FakeMatrixRoom()
+ val room = FakeBaseRoom()
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
@@ -335,8 +297,8 @@ class RoomListPresenterTest {
isDm = false,
isFavorite = false,
markAsUnreadFeatureFlagEnabled = true,
- eventCacheFeatureFlagEnabled = false,
hasNewContent = false,
+ displayClearRoomCacheAction = false,
)
)
@@ -424,7 +386,7 @@ class RoomListPresenterTest {
@Test
fun `present - when set is favorite event is emitted, then the action is called`() = runTest {
val setIsFavoriteResult = lambdaRecorder { _: Boolean -> Result.success(Unit) }
- val room = FakeMatrixRoom(
+ val room = FakeBaseRoom(
setIsFavoriteResult = setIsFavoriteResult
)
val analyticsService = FakeAnalyticsService()
@@ -471,9 +433,18 @@ class RoomListPresenterTest {
@Test
fun `present - check that the room is marked as read with correct RR and as unread`() = runTest {
- val room = FakeMatrixRoom()
- val room2 = FakeMatrixRoom(roomId = A_ROOM_ID_2)
- val room3 = FakeMatrixRoom(roomId = A_ROOM_ID_3)
+ val markAsReadResult = lambdaRecorder> { Result.success(Unit) }
+ val markAsReadResult3 = lambdaRecorder> { Result.success(Unit) }
+ val room = FakeBaseRoom(
+ markAsReadResult = markAsReadResult,
+ )
+ val room2 = FakeBaseRoom(
+ roomId = A_ROOM_ID_2,
+ )
+ val room3 = FakeBaseRoom(
+ roomId = A_ROOM_ID_3,
+ markAsReadResult = markAsReadResult3,
+ )
val allRooms = setOf(room, room2, room3)
val sessionPreferencesStore = InMemorySessionPreferencesStore()
val matrixClient = FakeMatrixClient().apply {
@@ -497,21 +468,19 @@ class RoomListPresenterTest {
}.test {
val initialState = awaitItem()
allRooms.forEach {
- assertThat(it.markAsReadCalls).isEmpty()
assertThat(it.setUnreadFlagCalls).isEmpty()
}
initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID))
- assertThat(room.markAsReadCalls).isEqualTo(listOf(ReceiptType.READ))
+ markAsReadResult.assertions().isCalledOnce().with(value(ReceiptType.READ))
assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false))
clearMessagesForRoomLambda.assertions().isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
initialState.eventSink.invoke(RoomListEvents.MarkAsUnread(A_ROOM_ID_2))
- assertThat(room2.markAsReadCalls).isEmpty()
assertThat(room2.setUnreadFlagCalls).isEqualTo(listOf(true))
// Test again with private read receipts
sessionPreferencesStore.setSendPublicReadReceipts(false)
initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID_3))
- assertThat(room3.markAsReadCalls).isEqualTo(listOf(ReceiptType.READ_PRIVATE))
+ markAsReadResult3.assertions().isCalledOnce().with(value(ReceiptType.READ_PRIVATE))
assertThat(room3.setUnreadFlagCalls).isEqualTo(listOf(false))
clearMessagesForRoomLambda.assertions().isCalledExactly(2)
.withSequence(
@@ -538,7 +507,8 @@ class RoomListPresenterTest {
roomListService = roomListService,
)
val roomSummary = aRoomSummary(
- currentUserMembership = CurrentUserMembership.INVITED
+ currentUserMembership = CurrentUserMembership.INVITED,
+ inviter = aRoomMember(),
)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
roomListService.postAllRooms(listOf(roomSummary))
@@ -554,16 +524,16 @@ class RoomListPresenterTest {
val roomListRoomSummary = state.contentAsRooms().summaries.first {
it.id == roomSummary.roomId.value
}
+
state.eventSink(RoomListEvents.AcceptInvite(roomListRoomSummary))
- state.eventSink(RoomListEvents.DeclineInvite(roomListRoomSummary))
+ state.eventSink(RoomListEvents.DeclineInvite(roomListRoomSummary, blockUser = false))
val inviteData = roomListRoomSummary.toInviteData()
-
assert(eventSinkRecorder)
.isCalledExactly(2)
.withSequence(
listOf(value(AcceptDeclineInviteEvents.AcceptInvite(inviteData))),
- listOf(value(AcceptDeclineInviteEvents.DeclineInvite(inviteData))),
+ listOf(value(AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = false))),
)
}
}
@@ -631,8 +601,6 @@ class RoomListPresenterTest {
private fun TestScope.createRoomListPresenter(
client: MatrixClient = FakeMatrixClient(),
- syncService: SyncService = FakeSyncService(),
- snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
dateFormatter: DateFormatter = FakeDateFormatter(),
roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(),
@@ -643,10 +611,10 @@ class RoomListPresenterTest {
searchPresenter: Presenter = Presenter { aRoomListSearchState() },
acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() },
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
+ appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
+ seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
) = RoomListPresenter(
client = client,
- syncService = syncService,
- snackbarDispatcher = snackbarDispatcher,
leaveRoomPresenter = { leaveRoomState },
roomListDataSource = RoomListDataSource(
roomListService = client.roomListService,
@@ -656,29 +624,19 @@ class RoomListPresenterTest {
),
coroutineDispatchers = testCoroutineDispatchers(),
notificationSettingsService = client.notificationSettingsService(),
- appScope = backgroundScope,
+ sessionCoroutineScope = backgroundScope,
dateTimeObserver = FakeDateTimeObserver(),
),
featureFlagService = featureFlagService,
- indicatorService = DefaultIndicatorService(
- sessionVerificationService = client.sessionVerificationService(),
- encryptionService = client.encryptionService(),
- ),
searchPresenter = searchPresenter,
sessionPreferencesStore = sessionPreferencesStore,
filtersPresenter = filtersPresenter,
analyticsService = analyticsService,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() },
+ batteryOptimizationPresenter = { aBatteryOptimizationState() },
notificationCleaner = notificationCleaner,
- logoutPresenter = { aDirectLogoutState() },
+ appPreferencesStore = appPreferencesStore,
+ seenInvitesStore = seenInvitesStore,
)
}
-
-class FakeDateTimeObserver : DateTimeObserver {
- override val changes = MutableSharedFlow(extraBufferCapacity = 1)
-
- fun given(event: DateTimeObserver.Event) {
- changes.tryEmit(event)
- }
-}
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt
new file mode 100644
index 00000000000..3837fe6f55e
--- /dev/null
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt
@@ -0,0 +1,10 @@
+/*
+ * Copyright 2024 New Vector 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.home.impl.roomlist
+
+internal fun RoomListState.contentAsRooms() = contentState as RoomListContentState.Rooms
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt
similarity index 82%
rename from features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
rename to features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt
index ee7989cafca..0465926abc5 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl
+package io.element.android.features.home.impl.roomlist
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
@@ -16,8 +16,12 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4
-import io.element.android.features.roomlist.impl.components.RoomListMenuAction
-import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
+import io.element.android.features.home.impl.HomeView
+import io.element.android.features.home.impl.R
+import io.element.android.features.home.impl.aHomeState
+import io.element.android.features.home.impl.components.RoomListMenuAction
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
@@ -26,15 +30,19 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
+import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class RoomListViewTest {
- @get:Rule val rule = createAndroidComposeRule()
+ @get:Rule
+ val rule = createAndroidComposeRule()
+ @Config(qualifiers = "h1024dp")
@Test
fun `displaying the view automatically sends a couple of UpdateVisibleRangeEvents`() {
val eventsRecorder = EventsRecorder()
@@ -48,7 +56,7 @@ class RoomListViewTest {
eventsRecorder.assertList(
listOf(
RoomListEvents.UpdateVisibleRange(IntRange.EMPTY),
- RoomListEvents.UpdateVisibleRange(0 until 2),
+ RoomListEvents.UpdateVisibleRange(0..5),
)
)
}
@@ -167,6 +175,29 @@ class RoomListViewTest {
eventsRecorder.assertEmpty()
}
+ @Test
+ fun `clicking on a room twice invokes the expected callback only once`() {
+ val eventsRecorder = EventsRecorder()
+ val state = aRoomListState(
+ eventSink = eventsRecorder,
+ )
+ val room0 = state.contentAsRooms().summaries.first {
+ it.displayType == RoomSummaryDisplayType.ROOM
+ }
+ ensureCalledOnceWithParam(room0.roomId) { callback ->
+ rule.setRoomListView(
+ state = state,
+ onRoomClick = callback,
+ )
+ // Remove automatic initial events
+ eventsRecorder.clear()
+ rule.onNodeWithText(room0.lastMessage!!.toString())
+ .performClick()
+ .performClick()
+ }
+ eventsRecorder.assertEmpty()
+ }
+
@Test
fun `long clicking on a room emits the expected Event`() {
val eventsRecorder = EventsRecorder()
@@ -226,23 +257,11 @@ class RoomListViewTest {
rule.clickOn(CommonStrings.action_accept)
rule.clickOn(CommonStrings.action_decline)
eventsRecorder.assertList(
- listOf(RoomListEvents.AcceptInvite(invitedRoom), RoomListEvents.DeclineInvite(invitedRoom)),
- )
- }
-
- @Test
- fun `clicking on logout and migrate calls the migration clicked callback`() {
- val state = aRoomListState(
- contentState = aRoomsContentState(securityBannerState = SecurityBannerState.NeedsNativeSlidingSyncMigration),
- eventSink = {},
- )
- ensureCalledOnce { callback ->
- rule.setRoomListView(
- state = state,
- onMigrateToNativeSlidingSyncClick = callback,
+ listOf(
+ RoomListEvents.AcceptInvite(invitedRoom),
+ RoomListEvents.ShowDeclineInviteMenu(invitedRoom),
)
- rule.clickOn(R.string.banner_migrate_to_native_sliding_sync_action)
- }
+ )
}
}
@@ -255,12 +274,12 @@ private fun AndroidComposeTestRule.setRoomL
onCreateRoomClick: () -> Unit = EnsureNeverCalled(),
onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
- onRoomDirectorySearchClick: () -> Unit = EnsureNeverCalled(),
- onMigrateToNativeSlidingSyncClick: () -> Unit = EnsureNeverCalled()
+ onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
+ onDeclineInviteAndBlockUser: (RoomListRoomSummary) -> Unit = EnsureNeverCalledWithParam(),
) {
- setContent {
- RoomListView(
- state = state,
+ setSafeContent {
+ HomeView(
+ homeState = aHomeState(roomListState = state),
onRoomClick = onRoomClick,
onSettingsClick = onSettingsClick,
onSetUpRecoveryClick = onSetUpRecoveryClick,
@@ -268,8 +287,8 @@ private fun AndroidComposeTestRule.setRoomL
onCreateRoomClick = onCreateRoomClick,
onRoomSettingsClick = onRoomSettingsClick,
onMenuActionClick = onMenuActionClick,
- onRoomDirectorySearchClick = onRoomDirectorySearchClick,
- onMigrateToNativeSlidingSyncClick = onMigrateToNativeSlidingSyncClick,
+ onDeclineInviteAndBlockUser = onDeclineInviteAndBlockUser,
+ onReportRoomClick = onReportRoomClick,
acceptDeclineInviteView = { },
)
}
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt
similarity index 82%
rename from features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTest.kt
rename to features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt
index 82fef235081..7649b6141ee 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt
@@ -5,18 +5,15 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.roomlist.impl.search
+package io.element.android.features.home.impl.search
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.roomlist.impl.datasource.aRoomListRoomSummaryFactory
+import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
-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.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.room.aRoomSummary
@@ -118,26 +115,10 @@ class RoomListSearchPresenterTest {
}
}
}
-
- @Test
- fun `present - room directory search`() = runTest {
- val featureFlagService = FakeFeatureFlagService()
- featureFlagService.setFeatureEnabled(FeatureFlags.RoomDirectorySearch, true)
- val presenter = createRoomListSearchPresenter(featureFlagService = featureFlagService)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- skipItems(1)
- awaitItem().let { state ->
- assertThat(state.isRoomDirectorySearchEnabled).isTrue()
- }
- }
- }
}
fun TestScope.createRoomListSearchPresenter(
roomListService: RoomListService = FakeRoomListService(),
- featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): RoomListSearchPresenter {
return RoomListSearchPresenter(
dataSource = RoomListSearchDataSource(
@@ -148,6 +129,5 @@ fun TestScope.createRoomListSearchPresenter(
),
coroutineDispatchers = testCoroutineDispatchers(),
),
- featureFlagService = featureFlagService,
)
}
diff --git a/features/invite/api/build.gradle.kts b/features/invite/api/build.gradle.kts
index c4039e2382b..d17b392e594 100644
--- a/features/invite/api/build.gradle.kts
+++ b/features/invite/api/build.gradle.kts
@@ -7,6 +7,7 @@
plugins {
id("io.element.android-compose-library")
+ id("kotlin-parcelize")
}
android {
@@ -16,5 +17,7 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.matrixui)
+ implementation(projects.libraries.designsystem)
implementation(projects.services.analytics.api)
}
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/InviteData.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/InviteData.kt
new file mode 100644
index 00000000000..eb1dfd3df67
--- /dev/null
+++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/InviteData.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2025 New Vector 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.invite.api
+
+import android.os.Parcelable
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.RoomInfo
+import io.element.android.libraries.matrix.api.room.isDm
+import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class InviteData(
+ val roomId: RoomId,
+ val roomName: String,
+ val isDm: Boolean,
+) : Parcelable
+
+fun RoomPreviewInfo.toInviteData(): InviteData {
+ return InviteData(
+ roomId = roomId,
+ roomName = name ?: roomId.value,
+ isDm = false,
+ )
+}
+
+fun RoomInfo.toInviteData(): InviteData {
+ return InviteData(
+ roomId = id,
+ roomName = name ?: id.value,
+ isDm = isDm,
+ )
+}
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt
new file mode 100644
index 00000000000..682970ffe7d
--- /dev/null
+++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2025 New Vector 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.invite.api
+
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.coroutines.flow.Flow
+
+interface SeenInvitesStore {
+ /**
+ * Returns a flow of seen room IDs of invitation.
+ */
+ fun seenRoomIds(): Flow>
+
+ /**
+ * Mark the invitation as seen.
+ * Call this when the invitation details are shown to the user.
+ * @param roomId the room ID of the invitation to mark as seen.
+ */
+ suspend fun markAsSeen(roomId: RoomId)
+
+ /**
+ * Mark the invitation as unseen.
+ * Call this when the invitation has been accepted or declined.
+ * @param roomId the room ID of the invitation to mark as unseen.
+ */
+ suspend fun markAsUnSeen(roomId: RoomId)
+
+ /**
+ * Delete the store.
+ */
+ suspend fun clear()
+}
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteEvents.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteEvents.kt
new file mode 100644
index 00000000000..015fc161822
--- /dev/null
+++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteEvents.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright 2024 New Vector 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.invite.api.acceptdecline
+
+import io.element.android.features.invite.api.InviteData
+
+interface AcceptDeclineInviteEvents {
+ data class AcceptInvite(val invite: InviteData) : AcceptDeclineInviteEvents
+ data class DeclineInvite(val invite: InviteData, val blockUser: Boolean, val shouldConfirm: Boolean) : AcceptDeclineInviteEvents
+}
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteState.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteState.kt
similarity index 88%
rename from features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteState.kt
rename to features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteState.kt
index ad883119cb7..f4645383df6 100644
--- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteState.kt
+++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteState.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.invite.api.response
+package io.element.android.features.invite.api.acceptdecline
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteView.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteView.kt
new file mode 100644
index 00000000000..78be5ebd165
--- /dev/null
+++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteView.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2024 New Vector 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.invite.api.acceptdecline
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import io.element.android.libraries.matrix.api.core.RoomId
+
+interface AcceptDeclineInviteView {
+ @Composable
+ fun Render(
+ state: AcceptDeclineInviteState,
+ onAcceptInviteSuccess: (RoomId) -> Unit,
+ onDeclineInviteSuccess: (RoomId) -> Unit,
+ modifier: Modifier,
+ )
+}
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/ConfirmingDeclineInvite.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/ConfirmingDeclineInvite.kt
new file mode 100644
index 00000000000..f0c43663512
--- /dev/null
+++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/ConfirmingDeclineInvite.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright 2024 New Vector 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.invite.api.acceptdecline
+
+import io.element.android.features.invite.api.InviteData
+import io.element.android.libraries.architecture.AsyncAction
+
+data class ConfirmingDeclineInvite(val inviteData: InviteData, val blockUser: Boolean) : AsyncAction.Confirming
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt
new file mode 100644
index 00000000000..27080ee354c
--- /dev/null
+++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2025 New Vector 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.invite.api.declineandblock
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import io.element.android.features.invite.api.InviteData
+import io.element.android.libraries.architecture.FeatureEntryPoint
+
+interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint {
+ fun createNode(parentNode: Node, buildContext: BuildContext, inviteData: InviteData): Node
+}
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteEvents.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteEvents.kt
deleted file mode 100644
index 6aad688e77a..00000000000
--- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteEvents.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-/*
- * Copyright 2024 New Vector 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.invite.api.response
-
-interface AcceptDeclineInviteEvents {
- data class AcceptInvite(val invite: InviteData) : AcceptDeclineInviteEvents
- data class DeclineInvite(val invite: InviteData) : AcceptDeclineInviteEvents
-}
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteStateProvider.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteStateProvider.kt
deleted file mode 100644
index 810a97bac58..00000000000
--- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteStateProvider.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * Copyright 2024 New Vector 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.invite.api.response
-
-import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.libraries.architecture.AsyncAction
-import io.element.android.libraries.matrix.api.core.RoomId
-
-open class AcceptDeclineInviteStateProvider : PreviewParameterProvider {
- override val values: Sequence
- get() = sequenceOf(
- anAcceptDeclineInviteState(),
- anAcceptDeclineInviteState(
- declineAction = ConfirmingDeclineInvite(
- InviteData(RoomId("!room:matrix.org"), isDm = true, roomName = "Alice")
- ),
- ),
- anAcceptDeclineInviteState(
- declineAction = ConfirmingDeclineInvite(
- InviteData(RoomId("!room:matrix.org"), isDm = false, roomName = "Some room")
- ),
- ),
- anAcceptDeclineInviteState(
- acceptAction = AsyncAction.Failure(Throwable("Whoops")),
- ),
- anAcceptDeclineInviteState(
- declineAction = AsyncAction.Failure(Throwable("Whoops")),
- ),
- )
-}
-
-fun anAcceptDeclineInviteState(
- acceptAction: AsyncAction = AsyncAction.Uninitialized,
- declineAction: AsyncAction = AsyncAction.Uninitialized,
- eventSink: (AcceptDeclineInviteEvents) -> Unit = {}
-) = AcceptDeclineInviteState(
- acceptAction = acceptAction,
- declineAction = declineAction,
- eventSink = eventSink,
-)
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteView.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteView.kt
deleted file mode 100644
index cdcd5b15ecd..00000000000
--- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteView.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * Copyright 2024 New Vector 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.invite.api.response
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import io.element.android.libraries.matrix.api.core.RoomId
-
-interface AcceptDeclineInviteView {
- @Composable
- fun Render(
- state: AcceptDeclineInviteState,
- onAcceptInvite: (RoomId) -> Unit,
- onDeclineInvite: (RoomId) -> Unit,
- modifier: Modifier,
- )
-}
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/ConfirmingDeclineInvite.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/ConfirmingDeclineInvite.kt
deleted file mode 100644
index bf8fa4f0689..00000000000
--- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/ConfirmingDeclineInvite.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-/*
- * Copyright 2024 New Vector 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.invite.api.response
-
-import io.element.android.libraries.architecture.AsyncAction
-
-data class ConfirmingDeclineInvite(
- val inviteData: InviteData,
-) : AsyncAction.Confirming
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/InviteData.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/InviteData.kt
deleted file mode 100644
index 20d3ba298f6..00000000000
--- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/InviteData.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * Copyright 2024 New Vector 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.invite.api.response
-
-import io.element.android.libraries.matrix.api.core.RoomId
-
-data class InviteData(
- val roomId: RoomId,
- val roomName: String,
- val isDm: Boolean,
-)
diff --git a/features/invite/impl/build.gradle.kts b/features/invite/impl/build.gradle.kts
index 8c00ac3d23c..d6337f65c9f 100644
--- a/features/invite/impl/build.gradle.kts
+++ b/features/invite/impl/build.gradle.kts
@@ -14,6 +14,11 @@ plugins {
android {
namespace = "io.element.android.features.invite.impl"
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
}
setupAnvil()
@@ -21,6 +26,7 @@ setupAnvil()
dependencies {
api(projects.features.invite.api)
implementation(libs.androidx.datastore.preferences)
+ implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
@@ -35,8 +41,12 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
+ testImplementation(libs.test.robolectric)
+ testImplementation(projects.features.invite.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils)
+ testImplementation(libs.androidx.compose.ui.test.junit)
+ testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/AcceptInvite.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/AcceptInvite.kt
new file mode 100644
index 00000000000..716cd7f9070
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/AcceptInvite.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2025 New Vector 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.invite.impl
+
+import com.squareup.anvil.annotations.ContributesBinding
+import im.vector.app.features.analytics.plan.JoinedRoom
+import io.element.android.features.invite.api.SeenInvitesStore
+import io.element.android.libraries.core.extensions.mapFailure
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
+import io.element.android.libraries.matrix.api.exception.ClientException
+import io.element.android.libraries.matrix.api.exception.ErrorKind
+import io.element.android.libraries.matrix.api.room.join.JoinRoom
+import io.element.android.libraries.push.api.notifications.NotificationCleaner
+import javax.inject.Inject
+
+interface AcceptInvite {
+ suspend operator fun invoke(roomId: RoomId): Result
+
+ sealed class Failures : Exception() {
+ data object InvalidInvite : Failures()
+ }
+}
+
+@ContributesBinding(SessionScope::class)
+class DefaultAcceptInvite @Inject constructor(
+ private val client: MatrixClient,
+ private val joinRoom: JoinRoom,
+ private val notificationCleaner: NotificationCleaner,
+ private val seenInvitesStore: SeenInvitesStore,
+) : AcceptInvite {
+ override suspend fun invoke(roomId: RoomId): Result {
+ return joinRoom(
+ roomIdOrAlias = roomId.toRoomIdOrAlias(),
+ serverNames = emptyList(),
+ trigger = JoinedRoom.Trigger.Invite,
+ ).onSuccess {
+ notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
+ seenInvitesStore.markAsUnSeen(roomId)
+ }.mapFailure {
+ if (it is ClientException.MatrixApi) {
+ when (it.kind) {
+ ErrorKind.Unknown -> AcceptInvite.Failures.InvalidInvite
+ else -> it
+ }
+ } else {
+ it
+ }
+ }.map { roomId }
+ }
+}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DeclineInvite.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DeclineInvite.kt
new file mode 100644
index 00000000000..9276f373652
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DeclineInvite.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2025 New Vector 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.invite.impl
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.invite.api.SeenInvitesStore
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.push.api.notifications.NotificationCleaner
+import javax.inject.Inject
+
+interface DeclineInvite {
+ suspend operator fun invoke(
+ roomId: RoomId,
+ blockUser: Boolean,
+ reportRoom: Boolean,
+ reportReason: String?
+ ): Result
+
+ sealed class Exception : kotlin.Exception() {
+ data object RoomNotFound : Exception()
+ data object DeclineInviteFailed : Exception()
+ data object ReportRoomFailed : Exception()
+ data object BlockUserFailed : Exception()
+ }
+}
+
+@ContributesBinding(SessionScope::class)
+class DefaultDeclineInvite @Inject constructor(
+ private val client: MatrixClient,
+ private val notificationCleaner: NotificationCleaner,
+ private val seenInvitesStore: SeenInvitesStore,
+) : DeclineInvite {
+ override suspend fun invoke(
+ roomId: RoomId,
+ blockUser: Boolean,
+ reportRoom: Boolean,
+ reportReason: String?
+ ): Result {
+ val room = client.getRoom(roomId) ?: return Result.failure(DeclineInvite.Exception.RoomNotFound)
+ room.use {
+ room.leave()
+ .onFailure { return Result.failure(DeclineInvite.Exception.DeclineInviteFailed) }
+ .onSuccess {
+ notificationCleaner.clearMembershipNotificationForRoom(
+ sessionId = client.sessionId,
+ roomId = roomId
+ )
+ seenInvitesStore.markAsUnSeen(roomId)
+ }
+
+ if (blockUser) {
+ val userIdToBlock = room.info().inviter?.userId
+ if (userIdToBlock != null) {
+ client
+ .ignoreUser(userIdToBlock)
+ .onFailure { return Result.failure(DeclineInvite.Exception.BlockUserFailed) }
+ }
+ }
+ if (reportRoom) {
+ room
+ .reportRoom(reportReason)
+ .onFailure { return Result.failure(DeclineInvite.Exception.ReportRoomFailed) }
+ }
+ }
+ return Result.success(roomId)
+ }
+}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt
new file mode 100644
index 00000000000..38295daa27b
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2025 New Vector 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.invite.impl
+
+import android.content.Context
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import androidx.datastore.preferences.preferencesDataStoreFile
+import io.element.android.features.invite.api.SeenInvitesStore
+import io.element.android.libraries.androidutils.file.safeDelete
+import io.element.android.libraries.androidutils.hash.hash
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.sessionstorage.api.observer.SessionListener
+import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+private val seenInvitesKey = stringSetPreferencesKey("seenInvites")
+
+class DefaultSeenInvitesStore(
+ context: Context,
+ sessionId: SessionId,
+ sessionCoroutineScope: CoroutineScope,
+ sessionObserver: SessionObserver,
+) : SeenInvitesStore {
+ init {
+ sessionObserver.addListener(object : SessionListener {
+ override suspend fun onSessionCreated(userId: String) = Unit
+ override suspend fun onSessionDeleted(userId: String) {
+ if (sessionId.value == userId) {
+ clear()
+ }
+ }
+ })
+ }
+
+ private val dataStoreFile = sessionId.value.hash().take(16).let { hashedUserId ->
+ context.preferencesDataStoreFile("session_${hashedUserId}_seen-invites")
+ }
+
+ private val store = PreferenceDataStoreFactory.create(
+ scope = sessionCoroutineScope,
+ migrations = emptyList(),
+ ) {
+ dataStoreFile
+ }
+
+ override fun seenRoomIds(): Flow> =
+ store.data.map { prefs ->
+ prefs[seenInvitesKey]
+ .orEmpty()
+ .map { RoomId(it) }
+ .toSet()
+ }
+
+ override suspend fun markAsSeen(roomId: RoomId) {
+ store.edit { prefs ->
+ prefs[seenInvitesKey] = prefs[seenInvitesKey].orEmpty() + roomId.value
+ }
+ }
+
+ override suspend fun markAsUnSeen(roomId: RoomId) {
+ store.edit { prefs ->
+ prefs[seenInvitesKey] = prefs[seenInvitesKey].orEmpty() - roomId.value
+ }
+ }
+
+ override suspend fun clear() {
+ dataStoreFile.safeDelete()
+ }
+}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStoreFactory.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStoreFactory.kt
new file mode 100644
index 00000000000..256214e5d2a
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStoreFactory.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2025 New Vector 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.invite.impl
+
+import android.content.Context
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.invite.api.SeenInvitesStore
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
+import kotlinx.coroutines.CoroutineScope
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+
+@SingleIn(AppScope::class)
+@ContributesBinding(AppScope::class)
+class DefaultSeenInvitesStoreFactory @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val sessionObserver: SessionObserver,
+) : SeenInvitesStoreFactory {
+ // We can have only one class accessing a single data store, so keep a cache of them.
+ private val cache = ConcurrentHashMap()
+
+ override fun getOrCreate(
+ sessionId: SessionId,
+ sessionCoroutineScope: CoroutineScope,
+ ): SeenInvitesStore {
+ return cache.getOrPut(sessionId) {
+ DefaultSeenInvitesStore(
+ context = context,
+ sessionId = sessionId,
+ sessionCoroutineScope = sessionCoroutineScope,
+ sessionObserver = sessionObserver,
+ )
+ }
+ }
+}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/SeenInvitesStoreFactory.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/SeenInvitesStoreFactory.kt
new file mode 100644
index 00000000000..4d681e84624
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/SeenInvitesStoreFactory.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2025 New Vector 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.invite.impl
+
+import io.element.android.features.invite.api.SeenInvitesStore
+import io.element.android.libraries.matrix.api.core.SessionId
+import kotlinx.coroutines.CoroutineScope
+
+interface SeenInvitesStoreFactory {
+ fun getOrCreate(
+ sessionId: SessionId,
+ sessionCoroutineScope: CoroutineScope,
+ ): SeenInvitesStore
+}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenter.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenter.kt
new file mode 100644
index 00000000000..6dd75761d31
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenter.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2024 New Vector 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.invite.impl.acceptdecline
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import io.element.android.features.invite.api.InviteData
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
+import io.element.android.features.invite.api.acceptdecline.ConfirmingDeclineInvite
+import io.element.android.features.invite.impl.AcceptInvite
+import io.element.android.features.invite.impl.DeclineInvite
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.runUpdatingState
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class AcceptDeclineInvitePresenter @Inject constructor(
+ private val acceptInvite: AcceptInvite,
+ private val declineInvite: DeclineInvite,
+) : Presenter {
+ @Composable
+ override fun present(): AcceptDeclineInviteState {
+ val localCoroutineScope = rememberCoroutineScope()
+ val acceptedAction: MutableState> =
+ remember { mutableStateOf(AsyncAction.Uninitialized) }
+ val declinedAction: MutableState> =
+ remember { mutableStateOf(AsyncAction.Uninitialized) }
+
+ fun handleEvents(event: AcceptDeclineInviteEvents) {
+ when (event) {
+ is AcceptDeclineInviteEvents.AcceptInvite -> {
+ localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction)
+ }
+
+ is AcceptDeclineInviteEvents.DeclineInvite -> {
+ val inviteData = event.invite
+ if (event.shouldConfirm) {
+ declinedAction.value = ConfirmingDeclineInvite(inviteData, event.blockUser)
+ } else {
+ localCoroutineScope.declineInvite(
+ inviteData = inviteData,
+ blockUser = event.blockUser,
+ declinedAction = declinedAction,
+ )
+ }
+ }
+ is InternalAcceptDeclineInviteEvents.CancelDeclineInvite -> {
+ declinedAction.value = AsyncAction.Uninitialized
+ }
+
+ is InternalAcceptDeclineInviteEvents.DismissAcceptError -> {
+ acceptedAction.value = AsyncAction.Uninitialized
+ }
+
+ is InternalAcceptDeclineInviteEvents.DismissDeclineError -> {
+ declinedAction.value = AsyncAction.Uninitialized
+ }
+ }
+ }
+
+ return AcceptDeclineInviteState(
+ acceptAction = acceptedAction.value,
+ declineAction = declinedAction.value,
+ eventSink = ::handleEvents
+ )
+ }
+
+ private fun CoroutineScope.acceptInvite(
+ roomId: RoomId,
+ acceptedAction: MutableState>,
+ ) = launch {
+ acceptedAction.runUpdatingState {
+ acceptInvite(roomId)
+ }
+ }
+
+ private fun CoroutineScope.declineInvite(
+ inviteData: InviteData,
+ blockUser: Boolean,
+ declinedAction: MutableState>,
+ ) = launch {
+ declinedAction.runUpdatingState {
+ declineInvite(
+ roomId = inviteData.roomId,
+ blockUser = blockUser,
+ reportRoom = false,
+ reportReason = null
+ )
+ }
+ }
+}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteStateProvider.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteStateProvider.kt
new file mode 100644
index 00000000000..9896de1d3e9
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteStateProvider.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2025 New Vector 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.invite.impl.acceptdecline
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.invite.api.InviteData
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
+import io.element.android.features.invite.api.acceptdecline.ConfirmingDeclineInvite
+import io.element.android.features.invite.impl.AcceptInvite
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.core.RoomId
+
+open class AcceptDeclineInviteStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ anAcceptDeclineInviteState(),
+ anAcceptDeclineInviteState(
+ declineAction = ConfirmingDeclineInvite(
+ InviteData(
+ roomId = RoomId("!room:matrix.org"),
+ isDm = true,
+ roomName = "Alice"
+ ),
+ blockUser = false,
+ ),
+ ),
+ anAcceptDeclineInviteState(
+ declineAction = ConfirmingDeclineInvite(
+ InviteData(
+ roomId = RoomId("!room:matrix.org"),
+ isDm = true,
+ roomName = "Alice"
+ ),
+ blockUser = true,
+ ),
+ ),
+ anAcceptDeclineInviteState(
+ acceptAction = AsyncAction.Failure(RuntimeException("Error while accepting invite")),
+ ),
+ anAcceptDeclineInviteState(
+ acceptAction = AsyncAction.Failure(AcceptInvite.Failures.InvalidInvite),
+ ),
+ anAcceptDeclineInviteState(
+ declineAction = AsyncAction.Failure(RuntimeException("Error while declining invite")),
+ ),
+ )
+}
+
+private fun anAcceptDeclineInviteState(
+ acceptAction: AsyncAction = AsyncAction.Uninitialized,
+ declineAction: AsyncAction = AsyncAction.Uninitialized,
+ eventSink: (AcceptDeclineInviteEvents) -> Unit = {}
+) = AcceptDeclineInviteState(
+ acceptAction = acceptAction,
+ declineAction = declineAction,
+ eventSink = eventSink,
+)
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteView.kt
new file mode 100644
index 00000000000..9c672594e06
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteView.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2024 New Vector 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.invite.impl.acceptdecline
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import io.element.android.features.invite.api.InviteData
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
+import io.element.android.features.invite.api.acceptdecline.ConfirmingDeclineInvite
+import io.element.android.features.invite.impl.AcceptInvite
+import io.element.android.features.invite.impl.R
+import io.element.android.libraries.designsystem.components.async.AsyncActionView
+import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun AcceptDeclineInviteView(
+ state: AcceptDeclineInviteState,
+ onAcceptInviteSuccess: (RoomId) -> Unit,
+ onDeclineInviteSuccess: (RoomId) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(modifier = modifier) {
+ AsyncActionView(
+ async = state.acceptAction,
+ onSuccess = onAcceptInviteSuccess,
+ onErrorDismiss = {
+ state.eventSink(InternalAcceptDeclineInviteEvents.DismissAcceptError)
+ },
+ errorTitle = {
+ stringResource(CommonStrings.common_something_went_wrong)
+ },
+ errorMessage = { error ->
+ if (error is AcceptInvite.Failures.InvalidInvite) {
+ stringResource(CommonStrings.error_invalid_invite)
+ } else {
+ stringResource(CommonStrings.error_network_or_server_issue)
+ }
+ }
+ )
+ AsyncActionView(
+ async = state.declineAction,
+ onSuccess = onDeclineInviteSuccess,
+ onErrorDismiss = {
+ state.eventSink(InternalAcceptDeclineInviteEvents.DismissDeclineError)
+ },
+ errorTitle = {
+ stringResource(CommonStrings.common_something_went_wrong)
+ },
+ errorMessage = {
+ stringResource(CommonStrings.error_network_or_server_issue)
+ },
+ confirmationDialog = { confirming ->
+ // Note: confirming will always be of type ConfirmingDeclineInvite.
+ if (confirming is ConfirmingDeclineInvite) {
+ DeclineConfirmationDialog(
+ invite = confirming.inviteData,
+ blockUser = confirming.blockUser,
+ onConfirmClick = {
+ state.eventSink(
+ AcceptDeclineInviteEvents.DeclineInvite(
+ confirming.inviteData,
+ blockUser = confirming.blockUser,
+ shouldConfirm = false
+ )
+ )
+ },
+ onDismissClick = {
+ state.eventSink(InternalAcceptDeclineInviteEvents.CancelDeclineInvite)
+ }
+ )
+ }
+ }
+ )
+ }
+}
+
+@Composable
+private fun DeclineConfirmationDialog(
+ invite: InviteData,
+ blockUser: Boolean,
+ onConfirmClick: () -> Unit,
+ onDismissClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ ConfirmationDialog(
+ modifier = modifier,
+ content = stringResource(R.string.screen_invites_decline_chat_message, invite.roomName),
+ title = if (blockUser) {
+ stringResource(R.string.screen_join_room_decline_and_block_alert_title)
+ } else {
+ stringResource(R.string.screen_invites_decline_chat_title)
+ },
+ submitText = if (blockUser) {
+ stringResource(R.string.screen_join_room_decline_and_block_alert_confirmation)
+ } else {
+ stringResource(CommonStrings.action_decline)
+ },
+ cancelText = stringResource(CommonStrings.action_cancel),
+ onSubmitClick = onConfirmClick,
+ onDismiss = onDismissClick,
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun AcceptDeclineInviteViewPreview(@PreviewParameter(AcceptDeclineInviteStateProvider::class) state: AcceptDeclineInviteState) =
+ ElementPreview {
+ AcceptDeclineInviteView(
+ state = state,
+ onAcceptInviteSuccess = {},
+ onDeclineInviteSuccess = {},
+ )
+ }
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/DefaultAcceptDeclineInviteView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/DefaultAcceptDeclineInviteView.kt
new file mode 100644
index 00000000000..e37582f63f5
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/DefaultAcceptDeclineInviteView.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2024 New Vector 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.invite.impl.acceptdecline
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.core.RoomId
+import javax.inject.Inject
+
+@ContributesBinding(SessionScope::class)
+class DefaultAcceptDeclineInviteView @Inject constructor() : AcceptDeclineInviteView {
+ @Composable
+ override fun Render(
+ state: AcceptDeclineInviteState,
+ onAcceptInviteSuccess: (RoomId) -> Unit,
+ onDeclineInviteSuccess: (RoomId) -> Unit,
+ modifier: Modifier,
+ ) {
+ AcceptDeclineInviteView(
+ state = state,
+ onAcceptInviteSuccess = onAcceptInviteSuccess,
+ onDeclineInviteSuccess = onDeclineInviteSuccess,
+ modifier = modifier
+ )
+ }
+}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/InternalAcceptDeclineInviteEvents.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/InternalAcceptDeclineInviteEvents.kt
new file mode 100644
index 00000000000..44234304322
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/InternalAcceptDeclineInviteEvents.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2024 New Vector 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.invite.impl.acceptdecline
+
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
+
+sealed interface InternalAcceptDeclineInviteEvents : AcceptDeclineInviteEvents {
+ data object CancelDeclineInvite : InternalAcceptDeclineInviteEvents
+ data object DismissAcceptError : InternalAcceptDeclineInviteEvents
+ data object DismissDeclineError : InternalAcceptDeclineInviteEvents
+}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockEvents.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockEvents.kt
new file mode 100644
index 00000000000..033d2e58def
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockEvents.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2025 New Vector 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.invite.impl.declineandblock
+
+sealed interface DeclineAndBlockEvents {
+ data class UpdateReportReason(val reason: String) : DeclineAndBlockEvents
+ data object ToggleReportRoom : DeclineAndBlockEvents
+ data object ToggleBlockUser : DeclineAndBlockEvents
+ data object Decline : DeclineAndBlockEvents
+ data object ClearDeclineAction : DeclineAndBlockEvents
+}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt
new file mode 100644
index 00000000000..100a5de4b86
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2025 New Vector 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.invite.impl.declineandblock
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.invite.api.InviteData
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+class DeclineAndBlockNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ presenterFactory: DeclineAndBlockPresenter.Factory,
+) : Node(buildContext, plugins = plugins) {
+ data class Inputs(val inviteData: InviteData) : NodeInputs
+
+ private val inviteData = inputs().inviteData
+ private val presenter = presenterFactory.create(inviteData)
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ DeclineAndBlockView(
+ state = state,
+ onBackClick = ::navigateUp,
+ modifier = modifier
+ )
+ }
+}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockPresenter.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockPresenter.kt
new file mode 100644
index 00000000000..9a18aa746bd
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockPresenter.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2025 New Vector 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.invite.impl.declineandblock
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import io.element.android.features.invite.api.InviteData
+import io.element.android.features.invite.impl.DeclineInvite
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
+import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+class DeclineAndBlockPresenter @AssistedInject constructor(
+ @Assisted private val inviteData: InviteData,
+ private val declineInvite: DeclineInvite,
+ private val snackbarDispatcher: SnackbarDispatcher,
+) : Presenter {
+ @AssistedFactory
+ interface Factory {
+ fun create(inviteData: InviteData): DeclineAndBlockPresenter
+ }
+
+ @Composable
+ override fun present(): DeclineAndBlockState {
+ var reportReason by rememberSaveable { mutableStateOf("") }
+ var blockUser by rememberSaveable { mutableStateOf(true) }
+ var reportRoom by rememberSaveable { mutableStateOf(false) }
+ val declineAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
+
+ val coroutineScope = rememberCoroutineScope()
+
+ fun handleEvents(event: DeclineAndBlockEvents) {
+ when (event) {
+ DeclineAndBlockEvents.ClearDeclineAction -> declineAction.value = AsyncAction.Uninitialized
+ DeclineAndBlockEvents.Decline -> coroutineScope.decline(reportReason, blockUser, reportRoom, declineAction)
+ DeclineAndBlockEvents.ToggleBlockUser -> blockUser = !blockUser
+ DeclineAndBlockEvents.ToggleReportRoom -> reportRoom = !reportRoom
+ is DeclineAndBlockEvents.UpdateReportReason -> reportReason = event.reason
+ }
+ }
+
+ return DeclineAndBlockState(
+ reportRoom = reportRoom,
+ reportReason = reportReason,
+ blockUser = blockUser,
+ declineAction = declineAction.value,
+ eventSink = ::handleEvents
+ )
+ }
+
+ private fun CoroutineScope.decline(
+ reason: String,
+ blockUser: Boolean,
+ reportRoom: Boolean,
+ action: MutableState>
+ ) = launch {
+ action.value = AsyncAction.Loading
+ declineInvite(
+ roomId = inviteData.roomId,
+ blockUser = blockUser,
+ reportRoom = reportRoom,
+ reportReason = reason
+ ).onSuccess {
+ action.value = AsyncAction.Success(Unit)
+ }.onFailure { error ->
+ if (error is DeclineInvite.Exception.DeclineInviteFailed) {
+ action.value = AsyncAction.Failure(error)
+ } else {
+ action.value = AsyncAction.Uninitialized
+ snackbarDispatcher.post(SnackbarMessage(CommonStrings.error_unknown))
+ }
+ }
+ }
+}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockState.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockState.kt
new file mode 100644
index 00000000000..0ee7444b2ce
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockState.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2025 New Vector 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.invite.impl.declineandblock
+
+import io.element.android.libraries.architecture.AsyncAction
+
+data class DeclineAndBlockState(
+ val reportRoom: Boolean,
+ val reportReason: String,
+ val blockUser: Boolean,
+ val declineAction: AsyncAction,
+ val eventSink: (DeclineAndBlockEvents) -> Unit
+) {
+ val canDecline = blockUser || reportRoom && reportReason.isNotEmpty()
+}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockStateProvider.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockStateProvider.kt
new file mode 100644
index 00000000000..87016d13cca
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockStateProvider.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2025 New Vector 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.invite.impl.declineandblock
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.AsyncAction
+
+open class DeclineAndBlockStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aDeclineAndBlockState(),
+ aDeclineAndBlockState(
+ reportRoom = true,
+ reportReason = "Inappropriate content",
+ ),
+ aDeclineAndBlockState(
+ blockUser = true,
+ ),
+ aDeclineAndBlockState(
+ declineAction = AsyncAction.Loading,
+ ),
+ aDeclineAndBlockState(
+ declineAction = AsyncAction.Failure(Exception("Failed to decline")),
+ ),
+ )
+}
+
+fun aDeclineAndBlockState(
+ reportRoom: Boolean = false,
+ reportReason: String = "",
+ blockUser: Boolean = false,
+ declineAction: AsyncAction = AsyncAction.Uninitialized,
+ eventSink: (DeclineAndBlockEvents) -> Unit = {},
+) = DeclineAndBlockState(
+ reportRoom = reportRoom,
+ reportReason = reportReason,
+ blockUser = blockUser,
+ declineAction = declineAction,
+ eventSink = eventSink,
+)
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt
new file mode 100644
index 00000000000..05110f982c1
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2025 New Vector 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.invite.impl.declineandblock
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.invite.impl.R
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.designsystem.components.async.AsyncActionView
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.components.list.ListItemContent
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.ListItem
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TextField
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DeclineAndBlockView(
+ state: DeclineAndBlockState,
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val focusManager = LocalFocusManager.current
+
+ val isDeclining = state.declineAction is AsyncAction.Loading
+ AsyncActionView(
+ async = state.declineAction,
+ onSuccess = { onBackClick() },
+ errorMessage = { stringResource(CommonStrings.error_unknown) },
+ onRetry = { state.eventSink(DeclineAndBlockEvents.Decline) },
+ onErrorDismiss = { state.eventSink(DeclineAndBlockEvents.ClearDeclineAction) }
+ )
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ titleStr = stringResource(R.string.screen_decline_and_block_title),
+ navigationIcon = {
+ BackButton(onClick = onBackClick)
+ }
+ )
+ },
+ modifier = modifier
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .consumeWindowInsets(padding)
+ .imePadding()
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(vertical = 16.dp)
+ ) {
+ ListItem(
+ modifier = Modifier.padding(end = 8.dp),
+ headlineContent = {
+ Text(text = stringResource(R.string.screen_decline_and_block_block_user_option_title))
+ },
+ supportingContent = {
+ Text(text = stringResource(R.string.screen_decline_and_block_block_user_option_description))
+ },
+ onClick = {
+ state.eventSink(DeclineAndBlockEvents.ToggleBlockUser)
+ },
+ trailingContent = ListItemContent.Switch(checked = state.blockUser)
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+ ListItem(
+ modifier = Modifier.padding(end = 8.dp),
+ headlineContent = {
+ Text(text = stringResource(CommonStrings.action_report_room))
+ },
+ supportingContent = {
+ Text(text = stringResource(R.string.screen_decline_and_block_report_user_option_description))
+ },
+ onClick = {
+ state.eventSink(DeclineAndBlockEvents.ToggleReportRoom)
+ },
+ trailingContent = ListItemContent.Switch(checked = state.reportRoom)
+ )
+
+ if (state.reportRoom) {
+ Spacer(modifier = Modifier.height(24.dp))
+ TextField(
+ value = state.reportReason,
+ onValueChange = { state.eventSink(DeclineAndBlockEvents.UpdateReportReason(it)) },
+ placeholder = stringResource(R.string.screen_decline_and_block_report_user_reason_placeholder),
+ minLines = 3,
+ enabled = !isDeclining,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .heightIn(min = 90.dp),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ Button(
+ text = stringResource(CommonStrings.action_decline),
+ destructive = true,
+ showProgress = isDeclining,
+ enabled = !isDeclining && state.canDecline,
+ onClick = {
+ focusManager.clearFocus(force = true)
+ state.eventSink(DeclineAndBlockEvents.Decline)
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ )
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun DeclineAndBlockViewPreview(
+ @PreviewParameter(DeclineAndBlockStateProvider::class) state: DeclineAndBlockState
+) = ElementPreview {
+ DeclineAndBlockView(
+ state = state,
+ onBackClick = {},
+ )
+}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt
new file mode 100644
index 00000000000..5eab22dd912
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2025 New Vector 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.invite.impl.declineandblock
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.invite.api.InviteData
+import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultDeclineAndBlockEntryPoint @Inject constructor() : DeclineInviteAndBlockEntryPoint {
+ override fun createNode(parentNode: Node, buildContext: BuildContext, inviteData: InviteData): Node {
+ val inputs = DeclineAndBlockNode.Inputs(inviteData)
+ return parentNode.createNode(buildContext, plugins = listOf(inputs))
+ }
+}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/di/InviteModule.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/di/InviteModule.kt
index 6a117b621b9..4f5da603a6a 100644
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/di/InviteModule.kt
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/di/InviteModule.kt
@@ -10,14 +10,31 @@ package io.element.android.features.invite.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
-import io.element.android.features.invite.api.response.AcceptDeclineInviteState
-import io.element.android.features.invite.impl.response.AcceptDeclineInvitePresenter
+import dagger.Provides
+import io.element.android.features.invite.api.SeenInvitesStore
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
+import io.element.android.features.invite.impl.SeenInvitesStoreFactory
+import io.element.android.features.invite.impl.acceptdecline.AcceptDeclineInvitePresenter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.MatrixClient
@ContributesTo(SessionScope::class)
@Module
interface InviteModule {
@Binds
fun bindAcceptDeclinePresenter(presenter: AcceptDeclineInvitePresenter): Presenter
+
+ companion object {
+ @Provides
+ fun providesSeenInvitesStore(
+ factory: SeenInvitesStoreFactory,
+ matrixClient: MatrixClient,
+ ): SeenInvitesStore {
+ return factory.getOrCreate(
+ matrixClient.sessionId,
+ matrixClient.sessionCoroutineScope,
+ )
+ }
+ }
}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt
deleted file mode 100644
index bfc751f03a9..00000000000
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- * Copyright 2024 New Vector 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.invite.impl.response
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import im.vector.app.features.analytics.plan.JoinedRoom
-import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
-import io.element.android.features.invite.api.response.AcceptDeclineInviteState
-import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
-import io.element.android.libraries.architecture.AsyncAction
-import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.architecture.runCatchingUpdatingState
-import io.element.android.libraries.architecture.runUpdatingState
-import io.element.android.libraries.matrix.api.MatrixClient
-import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
-import io.element.android.libraries.matrix.api.room.join.JoinRoom
-import io.element.android.libraries.push.api.notifications.NotificationCleaner
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-import javax.inject.Inject
-
-class AcceptDeclineInvitePresenter @Inject constructor(
- private val client: MatrixClient,
- private val joinRoom: JoinRoom,
- private val notificationCleaner: NotificationCleaner,
-) : Presenter {
- @Composable
- override fun present(): AcceptDeclineInviteState {
- val localCoroutineScope = rememberCoroutineScope()
- val acceptedAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
- val declinedAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
-
- fun handleEvents(event: AcceptDeclineInviteEvents) {
- when (event) {
- is AcceptDeclineInviteEvents.AcceptInvite -> {
- localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction)
- }
-
- is AcceptDeclineInviteEvents.DeclineInvite -> {
- declinedAction.value = ConfirmingDeclineInvite(event.invite)
- }
-
- is InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite -> {
- localCoroutineScope.declineInvite(event.roomId, declinedAction)
- }
-
- is InternalAcceptDeclineInviteEvents.CancelDeclineInvite -> {
- declinedAction.value = AsyncAction.Uninitialized
- }
-
- is InternalAcceptDeclineInviteEvents.DismissAcceptError -> {
- acceptedAction.value = AsyncAction.Uninitialized
- }
-
- is InternalAcceptDeclineInviteEvents.DismissDeclineError -> {
- declinedAction.value = AsyncAction.Uninitialized
- }
- }
- }
-
- return AcceptDeclineInviteState(
- acceptAction = acceptedAction.value,
- declineAction = declinedAction.value,
- eventSink = ::handleEvents
- )
- }
-
- private fun CoroutineScope.acceptInvite(
- roomId: RoomId,
- acceptedAction: MutableState>,
- ) = launch {
- acceptedAction.runUpdatingState {
- joinRoom(
- roomIdOrAlias = roomId.toRoomIdOrAlias(),
- serverNames = emptyList(),
- trigger = JoinedRoom.Trigger.Invite,
- )
- .onSuccess {
- notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
- }
- .map { roomId }
- }
- }
-
- private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState>) = launch {
- suspend {
- client.getPendingRoom(roomId)?.use {
- it.leave().getOrThrow()
- notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
- }
- roomId
- }.runCatchingUpdatingState(declinedAction)
- }
-}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt
deleted file mode 100644
index b40a08fdbc1..00000000000
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt
+++ /dev/null
@@ -1,105 +0,0 @@
-/*
- * Copyright 2024 New Vector 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.invite.impl.response
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.PreviewParameter
-import io.element.android.features.invite.api.response.AcceptDeclineInviteState
-import io.element.android.features.invite.api.response.AcceptDeclineInviteStateProvider
-import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
-import io.element.android.features.invite.api.response.InviteData
-import io.element.android.features.invite.impl.R
-import io.element.android.libraries.designsystem.components.async.AsyncActionView
-import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
-import io.element.android.libraries.designsystem.preview.ElementPreview
-import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.ui.strings.CommonStrings
-
-@Composable
-fun AcceptDeclineInviteView(
- state: AcceptDeclineInviteState,
- onAcceptInvite: (RoomId) -> Unit,
- onDeclineInvite: (RoomId) -> Unit,
- modifier: Modifier = Modifier,
-) {
- Box(modifier = modifier) {
- AsyncActionView(
- async = state.acceptAction,
- onSuccess = onAcceptInvite,
- onErrorDismiss = {
- state.eventSink(InternalAcceptDeclineInviteEvents.DismissAcceptError)
- },
- )
- AsyncActionView(
- async = state.declineAction,
- onSuccess = onDeclineInvite,
- onErrorDismiss = {
- state.eventSink(InternalAcceptDeclineInviteEvents.DismissDeclineError)
- },
- confirmationDialog = { confirming ->
- // Note: confirming will always be of type ConfirmingDeclineInvite.
- if (confirming is ConfirmingDeclineInvite) {
- DeclineConfirmationDialog(
- invite = confirming.inviteData,
- onConfirmClick = {
- state.eventSink(InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite(confirming.inviteData.roomId))
- },
- onDismissClick = {
- state.eventSink(InternalAcceptDeclineInviteEvents.CancelDeclineInvite)
- }
- )
- }
- }
- )
- }
-}
-
-@Composable
-private fun DeclineConfirmationDialog(
- invite: InviteData,
- onConfirmClick: () -> Unit,
- onDismissClick: () -> Unit,
- modifier: Modifier = Modifier
-) {
- val contentResource = if (invite.isDm) {
- R.string.screen_invites_decline_direct_chat_message
- } else {
- R.string.screen_invites_decline_chat_message
- }
-
- val titleResource = if (invite.isDm) {
- R.string.screen_invites_decline_direct_chat_title
- } else {
- R.string.screen_invites_decline_chat_title
- }
-
- ConfirmationDialog(
- modifier = modifier,
- content = stringResource(contentResource, invite.roomName),
- title = stringResource(titleResource),
- submitText = stringResource(CommonStrings.action_decline),
- cancelText = stringResource(CommonStrings.action_cancel),
- onSubmitClick = onConfirmClick,
- onDismiss = onDismissClick,
- )
-}
-
-@PreviewsDayNight
-@Composable
-internal fun AcceptDeclineInviteViewPreview(@PreviewParameter(AcceptDeclineInviteStateProvider::class) state: AcceptDeclineInviteState) =
- ElementPreview {
- AcceptDeclineInviteView(
- state = state,
- onAcceptInvite = {},
- onDeclineInvite = {},
- )
- }
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/DefaultAcceptDeclineInviteView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/DefaultAcceptDeclineInviteView.kt
deleted file mode 100644
index 8d52c26df7d..00000000000
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/DefaultAcceptDeclineInviteView.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright 2024 New Vector 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.invite.impl.response
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.features.invite.api.response.AcceptDeclineInviteState
-import io.element.android.features.invite.api.response.AcceptDeclineInviteView
-import io.element.android.libraries.di.SessionScope
-import io.element.android.libraries.matrix.api.core.RoomId
-import javax.inject.Inject
-
-@ContributesBinding(SessionScope::class)
-class DefaultAcceptDeclineInviteView @Inject constructor() : AcceptDeclineInviteView {
- @Composable
- override fun Render(
- state: AcceptDeclineInviteState,
- onAcceptInvite: (RoomId) -> Unit,
- onDeclineInvite: (RoomId) -> Unit,
- modifier: Modifier,
- ) {
- AcceptDeclineInviteView(
- state = state,
- onAcceptInvite = onAcceptInvite,
- onDeclineInvite = onDeclineInvite,
- modifier = modifier
- )
- }
-}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/InternalAcceptDeclineInviteEvents.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/InternalAcceptDeclineInviteEvents.kt
deleted file mode 100644
index 55072189e98..00000000000
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/InternalAcceptDeclineInviteEvents.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * Copyright 2024 New Vector 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.invite.impl.response
-
-import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
-import io.element.android.libraries.matrix.api.core.RoomId
-
-sealed interface InternalAcceptDeclineInviteEvents : AcceptDeclineInviteEvents {
- data class ConfirmDeclineInvite(val roomId: RoomId) : InternalAcceptDeclineInviteEvents
- data object CancelDeclineInvite : InternalAcceptDeclineInviteEvents
- data object DismissAcceptError : InternalAcceptDeclineInviteEvents
- data object DismissDeclineError : InternalAcceptDeclineInviteEvents
-}
diff --git a/features/invite/impl/src/main/res/values-bg/translations.xml b/features/invite/impl/src/main/res/values-bg/translations.xml
index 3418954ad6e..5e3c9a6fbdf 100644
--- a/features/invite/impl/src/main/res/values-bg/translations.xml
+++ b/features/invite/impl/src/main/res/values-bg/translations.xml
@@ -1,5 +1,6 @@
+ "Блокиране на потребителя"
"Сигурни ли сте, че искате да отхвърлите поканата за присъединяване в %1$s?"
"Отказване на покана"
"Няма покани"
diff --git a/features/invite/impl/src/main/res/values-cs/translations.xml b/features/invite/impl/src/main/res/values-cs/translations.xml
index d4c60464b3b..f5762693435 100644
--- a/features/invite/impl/src/main/res/values-cs/translations.xml
+++ b/features/invite/impl/src/main/res/values-cs/translations.xml
@@ -1,9 +1,18 @@
+ "Od tohoto uživatele neuvidíte žádné zprávy ani pozvánky do místnosti"
+ "Zablokovat uživatele"
+ "Nahlaste tuto místnost svému poskytovateli účtu."
+ "Popište důvod nahlášení…"
+ "Odmítnout a zablokovat"
"Opravdu chcete odmítnout pozvánku do %1$s?"
"Odmítnout pozvání"
"Opravdu chcete odmítnout tuto soukromou konverzaci s %1$s?"
"Odmítnout chat"
"Žádné pozvánky"
"%1$s (%2$s) vás pozval(a)"
+ "Ano, odmítnout a zablokovat"
+ "Opravdu chcete odmítnout pozvánku do této místnosti? Tím také zabráníte tomu, aby vás %1$s kontaktoval(a) nebo pozval(a) do místností."
+ "Odmítnout pozvání a zablokovat"
+ "Odmítnout a zablokovat"
diff --git a/features/invite/impl/src/main/res/values-cy/translations.xml b/features/invite/impl/src/main/res/values-cy/translations.xml
new file mode 100644
index 00000000000..a84fa2397a9
--- /dev/null
+++ b/features/invite/impl/src/main/res/values-cy/translations.xml
@@ -0,0 +1,18 @@
+
+
+ "Fyddwch chi ddim yn gweld unrhyw negeseuon neu wahoddiadau ystafell gan y defnyddiwr hwn"
+ "Rhwystro defnyddiwr"
+ "Adrodd am yr ystafell hon i ddarparwr eich cyfrif."
+ "Disgrifiwch y rheswm dros adrodd…"
+ "Gwrthod a rhwystro"
+ "Ydych chi\'n siŵr eich bod am wrthod y gwahoddiad i ymuno â %1$s?"
+ "Gwrthod y gwahoddiad"
+ "Ydych chi\'n siŵr eich bod am wrthod y sgwrs breifat hon gyda %1$s?"
+ "Gwrthod sgwrs"
+ "Dim Gwahoddiadau"
+ "Mae %1$s (%2$s) wedi eich gwahodd"
+ "Iawn, gwrthod a rhwystro"
+ "Ydych chi\'n siŵr eich bod am wrthod y gwahoddiad i ymuno â\'r ystafell hon? Bydd hyn hefyd yn atal %1$s rhag cysylltu â chi neu eich gwahodd i ystafelloedd."
+ "Gwrthod gwahoddiad a rhwystro"
+ "Gwrthod a rhwystro"
+
diff --git a/features/invite/impl/src/main/res/values-da/translations.xml b/features/invite/impl/src/main/res/values-da/translations.xml
new file mode 100644
index 00000000000..6093a431bc5
--- /dev/null
+++ b/features/invite/impl/src/main/res/values-da/translations.xml
@@ -0,0 +1,18 @@
+
+
+ "Du vil ikke se nogen beskeder eller rum-invitationer fra denne bruger"
+ "Bloker bruger"
+ "Anmeld dette rum til din kontoudbyder."
+ "Beskriv årsagen til anmeldelsen…"
+ "Afvis og blokér"
+ "Er du sikker på, at du vil afvise invitationen til at deltage i %1$s?"
+ "Afvis invitation"
+ "Er du sikker på, at du vil afvise denne private samtale med %1$s?"
+ "Afvis samtale"
+ "Ingen invitationer"
+ "%1$s(%2$s ) inviterede dig"
+ "Ja, afvis og blokér"
+ "Er du sikker på, at du vil afvise invitationen til at deltage i dette rum? Dette forhindrer også %1$s i at kontakte dig eller invitere dig til andre rum."
+ "Afvis invitation og blokér"
+ "Afvis og blokér"
+
diff --git a/features/invite/impl/src/main/res/values-de/translations.xml b/features/invite/impl/src/main/res/values-de/translations.xml
index bb38811a7a3..e3937fbda13 100644
--- a/features/invite/impl/src/main/res/values-de/translations.xml
+++ b/features/invite/impl/src/main/res/values-de/translations.xml
@@ -1,9 +1,18 @@
- "Möchtest du die Einladung zum Betreten von %1$s wirklich ablehnen?"
+ "Sie werden keine Nachrichten oder Chateinladungen von diesem Nutzer sehen."
+ "Benutzer blockieren"
+ "Melden Sie diesen Raum Ihrem Kontoanbieter."
+ "Nennen Sie den Grund für die Meldung…"
+ "Ablehnen und blockieren"
+ "Möchten Sie die Einladung zum Betreten von %1$s wirklich ablehnen?"
"Einladung ablehnen"
- "Bist du sicher, dass du diese Direktnachricht von %1$s ablehnen möchtest?"
+ "Möchten Sie diesen privaten Chat mit %1$s wirklich ablehnen?"
"Einladung ablehnen"
"Keine Einladungen"
"%1$s (%2$s) hat dich eingeladen"
+ "Ja, ablehnen & blockieren"
+ "Sind Sie sicher, dass Sie die Einladung zu diesem Raum ablehnen möchten? Dadurch wird auch verhindert, dass %1$s Sie kontaktiert oder in Räume einlädt."
+ "Einladung ablehnen & Nutzer blockieren"
+ "Ablehnen und blockieren"
diff --git a/features/invite/impl/src/main/res/values-el/translations.xml b/features/invite/impl/src/main/res/values-el/translations.xml
index 78f2871617a..96f10b29506 100644
--- a/features/invite/impl/src/main/res/values-el/translations.xml
+++ b/features/invite/impl/src/main/res/values-el/translations.xml
@@ -1,9 +1,18 @@
+ "Δεν θα βλέπετε μηνύματα ή προσκλήσεις αίθουσας από αυτόν τον χρήστη"
+ "Αποκλεισμός χρήστη"
+ "Αναφέρετε αυτή την αίθουσα στον πάροχο του λογαριασμού σας."
+ "Περιγράψτε τον λόγο αναφοράς…"
+ "Απόρριψη και αποκλεισμός"
"Σίγουρα θες να απορρίψεις την πρόσκληση συμμετοχής στο %1$s;"
"Απόρριψη πρόσκλησης"
"Σίγουρα θες να απορρίψεις την ιδιωτική συνομιλία με τον χρήστη %1$s;"
"Απόρριψη συνομιλίας"
"Χωρίς προσκλήσεις"
"%1$s (%2$s) σέ προσκάλεσε"
+ "Ναι, απόρριψη και αποκλεισμός"
+ "Είστε βέβαιοι ότι θέλετε να απορρίψετε την πρόσκληση συμμετοχής σε αυτήν την αίθουσα; Αυτό θα εμποδίσει επίσης τον χρήστη %1$s να επικοινωνήσει μαζί σας ή να σας προσκαλέσει σε αίθουσες."
+ "Απόρριψη πρόσκλησης και αποκλεισμός"
+ "Απόρριψη και αποκλεισμός"
diff --git a/features/invite/impl/src/main/res/values-es/translations.xml b/features/invite/impl/src/main/res/values-es/translations.xml
index 890df29447a..a88f9bc28db 100644
--- a/features/invite/impl/src/main/res/values-es/translations.xml
+++ b/features/invite/impl/src/main/res/values-es/translations.xml
@@ -1,9 +1,18 @@
+ "No verás ningún mensaje ni invitación a sala que provenga de este usuario"
+ "Bloquear usuario"
+ "Denunciar esta sala a tu proveedor de cuentas."
+ "Describe el motivo de la denuncia…"
+ "Rechazar y bloquear"
"¿Estás seguro de que quieres rechazar la invitación a unirte a %1$s?"
"Rechazar la invitación"
- "¿Estás seguro de que quieres rechazar este chat privado con%1$s?"
+ "¿Estás seguro de que quieres rechazar este chat privado con %1$s?"
"Rechazar el chat"
"Sin invitaciones"
"%1$s (%2$s) te invitó"
+ "Sí, rechazar y bloquear"
+ "¿Estás seguro de que deseas rechazar la invitación para unirte a esta sala? Esto también impedirá que %1$s pueda contactar contigo o invitarte a salas."
+ "Rechazar invitación y bloquear"
+ "Rechazar y bloquear"
diff --git a/features/invite/impl/src/main/res/values-et/translations.xml b/features/invite/impl/src/main/res/values-et/translations.xml
index 316ba75ef9a..91906fa8778 100644
--- a/features/invite/impl/src/main/res/values-et/translations.xml
+++ b/features/invite/impl/src/main/res/values-et/translations.xml
@@ -1,9 +1,18 @@
+ "Sa ei näe enam selle kasutaja saadetud sõnumeid ja jututubade kutseid"
+ "Blokeeri kasutaja"
+ "Teata sellest jututoast oma teenusepakkujale."
+ "Kirjelda teatamise põhjust…"
+ "Keeldu ja blokeeri"
"Kas sa oled kindel, et soovid keelduda liitumiskutsest: %1$s?"
"Lükka kutse tagasi"
"Kas sa oled kindel, et soovid keelduda privaatsest vestlusest kasutajaga %1$s?"
"Keeldu vestlusest"
"Kutseid pole"
"%1$s (%2$s) saatis sulle kutse"
+ "Jah, keeldu ja blokeeri"
+ "Kas sa oled kindel, et soovid keelduda kutsest sellesse jututuppa? Samaga kaob kasutajal %1$s võimalus sinuga suhelda ja saata sulle jututubade kutseid."
+ "Keeldu kutsest ja blokeeri"
+ "Keeldu ja blokeeri"
diff --git a/features/invite/impl/src/main/res/values-eu/translations.xml b/features/invite/impl/src/main/res/values-eu/translations.xml
new file mode 100644
index 00000000000..d60aeced771
--- /dev/null
+++ b/features/invite/impl/src/main/res/values-eu/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Ziur %1$s(e)ra batzeko gonbidapena baztertu nahi duzula?"
+ "Baztertu gonbidapena"
+ "Ziur %1$s(r)en txat pribatua baztertu nahi duzula?"
+ "Baztertu txata"
+ "Ez dago gonbidapenik"
+ "%1$s(e)k (%2$s) gonbidatu zaitu"
+
diff --git a/features/invite/impl/src/main/res/values-fa/translations.xml b/features/invite/impl/src/main/res/values-fa/translations.xml
index 8102a3be578..9cbad94f91a 100644
--- a/features/invite/impl/src/main/res/values-fa/translations.xml
+++ b/features/invite/impl/src/main/res/values-fa/translations.xml
@@ -1,9 +1,15 @@
+ "انسداد کاربر"
+ "شرح دلیل گزارش…"
+ "رد و انسداد"
"مطمئنید که میخواهید دعوت پیوستن به %1$s را رد کنید؟"
"رد دعوت"
"مطمئنید که میخواهید این گپ خصوصی با %1$s را رد کنید؟"
"رد گپ"
"بدون دعوت"
"%1$s (%2$s) دعوتتان کرد"
+ "بله. رد و انسداد"
+ "رد دعوت و انسداد"
+ "رد و انسداد"
diff --git a/features/invite/impl/src/main/res/values-fi/translations.xml b/features/invite/impl/src/main/res/values-fi/translations.xml
index bf5b83a8c6f..47ad368d738 100644
--- a/features/invite/impl/src/main/res/values-fi/translations.xml
+++ b/features/invite/impl/src/main/res/values-fi/translations.xml
@@ -1,9 +1,18 @@
+ "Et tule näkemään viestejä tai kutsuja tältä käyttäjältä"
+ "Estä käyttäjä"
+ "Ilmoita tästä huoneesta palveluntarjoajallesi."
+ "Kerro syy ilmoittamiseen…"
+ "Hylkää ja estä"
"Haluatko varmasti hylätä kutsun liittyä %1$s -huoneeseen?"
"Hylkää kutsu"
"Haluatko varmasti hylätä kutsun yksityiseen keskusteluun käyttäjän %1$s kanssa?"
"Hylkää keskustelu"
"Ei kutsuja"
"%1$s (%2$s) kutsui sinut"
+ "Kyllä, hylkää ja estä"
+ "Oletko varma, että haluat kieltäytyä kutsusta liittyä tähän huoneeseen? Tämä estää myös käyttäjää %1$s ottamasta sinuun yhteyttä tai kutsumasta sinua huoneisiin."
+ "Hylkää kutsu ja estä"
+ "Hylkää ja estä"
diff --git a/features/invite/impl/src/main/res/values-fr/translations.xml b/features/invite/impl/src/main/res/values-fr/translations.xml
index 45c29b35723..1775da05389 100644
--- a/features/invite/impl/src/main/res/values-fr/translations.xml
+++ b/features/invite/impl/src/main/res/values-fr/translations.xml
@@ -1,9 +1,18 @@
+ "Vous ne verrez aucun messages ou invitation à un salon de la part de cet utilisateur"
+ "Bloquer l’utilisateur"
+ "Signalez ce salon à votre fournisseur de compte."
+ "Décrivez la raison du signalement…"
+ "Refuser et bloquer"
"Êtes-vous sûr de vouloir décliner l’invitation à rejoindre %1$s ?"
"Refuser l’invitation"
"Êtes-vous sûr de vouloir refuser cette discussion privée avec %1$s ?"
"Refuser l’invitation"
"Aucune invitation"
"%1$s (%2$s) vous a invité(e)"
+ "Oui, refuser et bloquer"
+ "Êtes-vous sûr de vouloir refuser l’invitation à rejoindre ce salon ? Cela empêchera également %1$s de vous contacter ou de vous inviter dans les salons."
+ "Refuser l’invitation et bloquer"
+ "Refuser et bloquer"
diff --git a/features/invite/impl/src/main/res/values-hu/translations.xml b/features/invite/impl/src/main/res/values-hu/translations.xml
index e7faddd8f95..97595ed421d 100644
--- a/features/invite/impl/src/main/res/values-hu/translations.xml
+++ b/features/invite/impl/src/main/res/values-hu/translations.xml
@@ -1,9 +1,18 @@
+ "Ettől a felhasználótól nem fog többé üzeneteket vagy meghívásokat látni."
+ "Felhasználó letiltása"
+ "A szoba jelentése a fiókszolgáltatójának."
+ "Írja le a jelentés okát…"
+ "Elutasítás és blokkolás"
"Biztos, hogy elutasítja a meghívást, hogy csatlakozzon ehhez: %1$s?"
"Meghívás elutasítása"
"Biztos, hogy elutasítja ezt a privát csevegést vele: %1$s?"
"Csevegés elutasítása"
"Nincsenek meghívások"
"%1$s (%2$s) meghívta"
+ "Igen, elutasítás és blokkolás"
+ "Biztos, hogy elutasítja a meghívást, hogy csatlakozzon ehhez a szobához? Ez azt is megakadályozza, hogy %1$s kapcsolatba lépjen Önnel, vagy szobákba hívja."
+ "Meghívó elutasítása és blokkolás"
+ "Elutasítás és blokkolás"
diff --git a/features/invite/impl/src/main/res/values-in/translations.xml b/features/invite/impl/src/main/res/values-in/translations.xml
index 97866a90433..6f8ab156103 100644
--- a/features/invite/impl/src/main/res/values-in/translations.xml
+++ b/features/invite/impl/src/main/res/values-in/translations.xml
@@ -1,9 +1,18 @@
+ "Anda tidak akan melihat pesan atau undangan ruangan dari pengguna ini"
+ "Blokir pengguna"
+ "Laporkan ruangan ini ke penyedia akun Anda."
+ "Jelaskan alasan untuk melaporkan…"
+ "Tolak dan blokir"
"Apakah Anda yakin ingin menolak undangan untuk bergabung ke %1$s?"
"Tolak undangan"
"Apakah Anda yakin ingin menolak obrolan pribadi dengan %1$s?"
"Tolak obrolan"
"Tidak ada undangan"
"%1$s (%2$s) mengundang Anda"
+ "Ya, tolak & blokir"
+ "Apakah Anda yakin ingin menolak undangan untuk bergabung dengan ruangan ini? Ini juga akan mencegah %1$s menghubungi Anda atau mengundang Anda ke ruangan."
+ "Tolak undangan & blokir"
+ "Tolak dan blokir"
diff --git a/features/invite/impl/src/main/res/values-it/translations.xml b/features/invite/impl/src/main/res/values-it/translations.xml
index 7da765090e3..d88183f90ac 100644
--- a/features/invite/impl/src/main/res/values-it/translations.xml
+++ b/features/invite/impl/src/main/res/values-it/translations.xml
@@ -1,9 +1,18 @@
+ "Non vedrai alcun messaggio o invito ad una stanza da parte di questo utente"
+ "Blocca utente"
+ "Segnala questa stanza al fornitore del tuo account."
+ "Descrivi il motivo della segnalazione…"
+ "Rifiuta e blocca"
"Vuoi davvero rifiutare l\'invito ad entrare in %1$s?"
"Rifiuta l\'invito"
"Vuoi davvero rifiutare questa conversazione privata con %1$s?"
"Rifiuta l\'invito alla conversazione"
"Nessun invito"
"%1$s (%2$s) ti ha invitato"
+ "Sì, rifiuta e blocca"
+ "Sei sicuro di voler rifiutare l\'invito a entrare in questa stanza? Ciò impedirà a %1$s di contattarti o invitarti nuovamente in una stanza."
+ "Rifiuta invito e blocca"
+ "Rifiuta e blocca"
diff --git a/features/invite/impl/src/main/res/values-lt/translations.xml b/features/invite/impl/src/main/res/values-lt/translations.xml
new file mode 100644
index 00000000000..c94b61fd5d5
--- /dev/null
+++ b/features/invite/impl/src/main/res/values-lt/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Ar tikrai norite atmesti kvietimą prisijungti prie %1$s?"
+ "Atmesti kvietimą"
+ "Ar tikrai norite atmesti šį privatų pokalbį su %1$s ?"
+ "Atmesti pokalbį"
+ "Jokių kvietimų"
+ "%1$s(%2$s) pakvietė Jus"
+
diff --git a/features/invite/impl/src/main/res/values-nb/translations.xml b/features/invite/impl/src/main/res/values-nb/translations.xml
index 59b1fffb086..25234663487 100644
--- a/features/invite/impl/src/main/res/values-nb/translations.xml
+++ b/features/invite/impl/src/main/res/values-nb/translations.xml
@@ -1,9 +1,18 @@
+ "Du vil ikke se noen meldinger eller rominvitasjoner fra denne brukeren"
+ "Blokker bruker"
+ "Rapporter dette rommet til din kontoleverandør."
+ "Beskriv årsaken for å rapportere…"
+ "Avslå og blokker"
"Er du sikker på at du vil takke nei til invitasjonen til å bli med i %1$s?"
"Avvis invitasjon"
"Er du sikker på at du vil avslå denne private chatten med %1$s?"
"Avslå chat"
"Ingen invitasjoner"
"%1$s(%2$s) inviterte deg"
+ "Ja, avslå og blokker"
+ "Er du sikker på at du vil avslå invitasjonen til å bli med i dette rommet? Dette vil også forhindre %1$s fra å kontakte deg eller invitere deg til rom."
+ "Avslå invitasjon og blokker"
+ "Avslå og blokker"
diff --git a/features/invite/impl/src/main/res/values-pl/translations.xml b/features/invite/impl/src/main/res/values-pl/translations.xml
index 95283e4cbab..e8636fc3dc1 100644
--- a/features/invite/impl/src/main/res/values-pl/translations.xml
+++ b/features/invite/impl/src/main/res/values-pl/translations.xml
@@ -1,9 +1,18 @@
+ "Nie zobaczysz żadnych wiadomości ani zaproszeń od tego użytkownika"
+ "Zablokuj użytkownika"
+ "Zgłoś pokój dostawcy swojego konta."
+ "Opisz powód zgłoszenia…"
+ "Odrzuć i zablokuj"
"Czy na pewno chcesz odrzucić zaproszenie dołączenia do %1$s?"
"Odrzuć zaproszenie"
"Czy na pewno chcesz odrzucić rozmowę prywatną z %1$s?"
"Odrzuć czat"
"Brak zaproszeń"
"%1$s (%2$s) zaprosił Cię"
+ "Tak, odrzuć i zablokuj"
+ "Czy na pewno chcesz odrzucić zaproszenie dołączenia do tego pokoju? %1$s nie będzie mógł się również z Tobą skontaktować, ani zaprosić Cię do pokoju."
+ "Odrzuć zaproszenie i zablokuj"
+ "Odrzuć i zablokuj"
diff --git a/features/invite/impl/src/main/res/values-pt-rBR/translations.xml b/features/invite/impl/src/main/res/values-pt-rBR/translations.xml
index 9cd284690cb..43569cdd312 100644
--- a/features/invite/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/invite/impl/src/main/res/values-pt-rBR/translations.xml
@@ -1,9 +1,18 @@
+ "Você não verá nenhuma mensagem ou convite de sala deste usuário"
+ "Bloquear usuário"
+ "Denuncie esta sala ao fornecedor da sua conta."
+ "Descreva o motivo da denúncia…"
+ "Recusar e bloquear"
"Tem certeza de que deseja recusar o convite para ingressar em %1$s?"
"Recusar convite"
"Tem certeza de que deseja recusar esse chat privado com %1$s?"
"Recusar chat"
"Sem convites"
"%1$s(%2$s) convidou você"
+ "Sim, recusar e bloquear"
+ "Você tem certeza de que deseja recusar o convite para participar desta sala? Isso também impedirá que %1$s entre em contato com você ou o convide para as salas."
+ "Recusar convite e bloquear"
+ "Recusar e bloquear"
diff --git a/features/invite/impl/src/main/res/values-pt/translations.xml b/features/invite/impl/src/main/res/values-pt/translations.xml
index a7f257261ae..5813c9e54f5 100644
--- a/features/invite/impl/src/main/res/values-pt/translations.xml
+++ b/features/invite/impl/src/main/res/values-pt/translations.xml
@@ -1,9 +1,18 @@
+ "Não verás quaisquer mensagens ou convites deste utilizador"
+ "Bloquear utilizador"
+ "Denunciar esta sala ao teu operador de conta."
+ "Descreve a razão de denúncia…"
+ "Rejeitar e bloquear"
"Tens a certeza que queres rejeitar o convite para entra em %1$s?"
"Rejeitar convite"
"Tem a certeza que queres rejeitar esta conversa privada com %1$s?"
"Rejeitar conversa"
"Sem convites"
"%1$s (%2$s) convidou-te"
+ "Sim, recusar & bloquear"
+ "Tens a certeza de que queres recusar o convite para entrar nesta sala? Isto também evitará que %1$s te contacte ou te convide para salas."
+ "Recusar convite & bloquear"
+ "Recusar e bloquear"
diff --git a/features/invite/impl/src/main/res/values-ru/translations.xml b/features/invite/impl/src/main/res/values-ru/translations.xml
index b13ca06a9df..90d467e62cc 100644
--- a/features/invite/impl/src/main/res/values-ru/translations.xml
+++ b/features/invite/impl/src/main/res/values-ru/translations.xml
@@ -1,9 +1,18 @@
+ "Вы не увидите сообщений или приглашений в комнату от этого пользователя"
+ "Заблокировать пользователя"
+ "Сообщите об этой комнате своему поставщику учетной записи."
+ "Опишите причину жалобы…"
+ "Отклонить и заблокировать"
"Вы уверены, что хотите отклонить приглашение в %1$s?"
"Отклонить приглашение"
"Вы уверены, что хотите отказаться от личного общения с %1$s?"
"Отклонить чат"
"Нет приглашений"
"%1$s (%2$s) пригласил вас"
+ "Да, отклонить и заблокировать"
+ "Вы действительно хотите отклонить приглашение в эту комнате? Это также предотвратит %1$s возможность связываться с вами или приглашать вас в комнаты."
+ "Отклонить приглашение и заблокировать"
+ "Отклонить и заблокировать"
diff --git a/features/invite/impl/src/main/res/values-sk/translations.xml b/features/invite/impl/src/main/res/values-sk/translations.xml
index 2875466fc7c..a14b5b0dae5 100644
--- a/features/invite/impl/src/main/res/values-sk/translations.xml
+++ b/features/invite/impl/src/main/res/values-sk/translations.xml
@@ -1,9 +1,18 @@
+ "Od tohto používateľa sa vám nezobrazia žiadne správy ani pozvánky do miestnosti"
+ "Zablokovať používateľa"
+ "Nahlásiť túto miestnosť poskytovateľovi účtu."
+ "Opíšte dôvod nahlásenia…"
+ "Odmietnuť a zablokovať"
"Naozaj chcete odmietnuť pozvánku na pripojenie do %1$s?"
"Odmietnuť pozvanie"
"Naozaj chcete odmietnuť túto súkromnú konverzáciu s %1$s?"
"Odmietnuť konverzáciu"
"Žiadne pozvánky"
"%1$s (%2$s) vás pozval/a"
+ "Áno, odmietnuť a zablokovať"
+ "Ste si istí, že chcete odmietnuť pozvanie na vstup do tejto miestnosti? To tiež zabráni tomu, aby vás %1$s kontaktoval/a alebo vás pozval/a do miestností."
+ "Odmietnuť pozvánku a zablokovať"
+ "Odmietnuť a zablokovať"
diff --git a/features/invite/impl/src/main/res/values-sv/translations.xml b/features/invite/impl/src/main/res/values-sv/translations.xml
index 6a091b096b2..a57336b50e4 100644
--- a/features/invite/impl/src/main/res/values-sv/translations.xml
+++ b/features/invite/impl/src/main/res/values-sv/translations.xml
@@ -1,9 +1,18 @@
+ "Du kommer inte att se några meddelanden eller rumsinbjudningar från den här användaren"
+ "Blockera användare"
+ "Rapportera det här rummet till din kontoleverantör."
+ "Beskriv skälet för anmälan …"
+ "Avvisa och blockera"
"Är du säker på att du vill tacka nej till inbjudan att gå med%1$s?"
"Avböj inbjudan"
"Är du säker på att du vill avböja denna privata chatt med %1$s?"
"Avböj chatt"
"Inga inbjudningar"
"%1$s (%2$s) bjöd in dig"
+ "Ja, avvisa och blockera"
+ "Är du säker på att du vill avvisa inbjudan att gå med i det här rummet? Detta kommer också att hindra %1$s från att kontakta dig eller bjuda in dig till rum."
+ "Avvisa inbjudan och blockera"
+ "Avvisa och blockera"
diff --git a/features/invite/impl/src/main/res/values-tr/translations.xml b/features/invite/impl/src/main/res/values-tr/translations.xml
new file mode 100644
index 00000000000..7c60a22b8a5
--- /dev/null
+++ b/features/invite/impl/src/main/res/values-tr/translations.xml
@@ -0,0 +1,13 @@
+
+
+ "%1$s katılma davetini reddetmek istediğinizden emin misiniz?"
+ "Daveti reddet"
+ "%1$s ile bu özel sohbeti reddetmek istediğinizden emin misiniz?"
+ "Sohbeti reddet"
+ "Davet Yok"
+ "%1$s (%2$s) sizi davet etti"
+ "Evet, reddet ve engelle"
+ "Bu odaya katılma davetini reddetmek istediğinizden emin misiniz? Bu aynı zamanda %1$s sizinle iletişim kurmasını veya sizi odalara davet etmesini de engeller."
+ "Daveti reddet ve engelle"
+ "Reddet ve engelle"
+
diff --git a/features/invite/impl/src/main/res/values-uk/translations.xml b/features/invite/impl/src/main/res/values-uk/translations.xml
index fa535213bae..9a5aef72987 100644
--- a/features/invite/impl/src/main/res/values-uk/translations.xml
+++ b/features/invite/impl/src/main/res/values-uk/translations.xml
@@ -1,9 +1,18 @@
+ "Ви не бачитимете повідомлень або запрошень у кімнату від цього користувача"
+ "Заблокувати користувача"
+ "Поскаржитися на цю кімнату постачальнику облікового запису."
+ "Опишіть причину скарги…"
+ "Відхилити та заблокувати"
"Ви впевнені, що хочете відхилити запрошення приєднатися до %1$s?"
"Відхилити запрошення"
"Ви дійсно хочете відмовитися від приватної бесіди з %1$s?"
"Відхилити бесіду"
"Немає запрошень"
"%1$s (%2$s) запрошує вас"
+ "Так, відхилити та заблокувати"
+ "Ви впевнені, що хочете відхилити запрошення приєднатися до цієї кімнати? Це також завадить %1$s зв\'язатися з вами або запрошувати вас в кімнати."
+ "Відхилити запрошення та заблокувати"
+ "Відхилити та заблокувати"
diff --git a/features/invite/impl/src/main/res/values-ur/translations.xml b/features/invite/impl/src/main/res/values-ur/translations.xml
new file mode 100644
index 00000000000..641f5e7b7e7
--- /dev/null
+++ b/features/invite/impl/src/main/res/values-ur/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "کیا آپکو یقین ہے کہ آپ %1$s میں شامل ہونے کی درخواست مسترد کرنا چاہتے ہیں؟"
+ "دعوت مسترد کریں"
+ "کیا آپکو یقین ہے کہ آپ %1$s کیساتھ نجی گفتگو مسترد کرنا چاہتے ہیں؟"
+ "گفتگو مسترد کریں"
+ "کوئی دعوت نامے نہیں"
+ "%1$s (%2$s) نے آپ کو مدعو کیا"
+
diff --git a/features/invite/impl/src/main/res/values-zh-rTW/translations.xml b/features/invite/impl/src/main/res/values-zh-rTW/translations.xml
index 4c7d99280fa..5b75fcd726a 100644
--- a/features/invite/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/invite/impl/src/main/res/values-zh-rTW/translations.xml
@@ -1,9 +1,18 @@
+ "您將不會看到來自此使用者的任何訊息或聊天室邀請"
+ "封鎖使用者"
+ "向您的帳號提供者回報此聊天室。"
+ "說明回報的原因……"
+ "拒絕並封鎖"
"您確定您想要拒絕加入 %1$s 的邀請嗎?"
"拒絕邀請"
"您確定您要拒絕此與 %1$s 的私人聊天嗎?"
"拒絕聊天"
"沒有邀請"
"%1$s(%2$s)邀請您"
+ "是的,拒絕並封鎖"
+ "您確定要拒絕加入此聊天室的邀請嗎?這也會防止 %1$s 聯絡您或邀請您加入聊天室。"
+ "拒絕邀請並封鎖"
+ "拒絕並封鎖"
diff --git a/features/invite/impl/src/main/res/values/localazy.xml b/features/invite/impl/src/main/res/values/localazy.xml
index 7c2c0194667..425602595f1 100644
--- a/features/invite/impl/src/main/res/values/localazy.xml
+++ b/features/invite/impl/src/main/res/values/localazy.xml
@@ -1,9 +1,18 @@
+ "You will not see any messages or room invites from this user"
+ "Block user"
+ "Report this room to your account provider."
+ "Describe the reason to report…"
+ "Decline and block"
"Are you sure you want to decline the invitation to join %1$s?"
"Decline invite"
"Are you sure you want to decline this private chat with %1$s?"
"Decline chat"
"No Invites"
"%1$s (%2$s) invited you"
+ "Yes, decline & block"
+ "Are you sure you want to decline the invite to join this room? This will also prevent %1$s from contacting you or inviting you to rooms."
+ "Decline invite & block"
+ "Decline and block"
diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/DefaultAcceptInviteTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/DefaultAcceptInviteTest.kt
new file mode 100644
index 00000000000..eeefe688a17
--- /dev/null
+++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/DefaultAcceptInviteTest.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2025 New Vector 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.invite.impl
+
+import com.google.common.truth.Truth.assertThat
+import im.vector.app.features.analytics.plan.JoinedRoom
+import io.element.android.features.invite.test.InMemorySeenInvitesStore
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
+import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
+import io.element.android.tests.testutils.lambda.any
+import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class DefaultAcceptInviteTest {
+ private val roomId = A_ROOM_ID
+ private val client = FakeMatrixClient()
+ private val seenInvitesStore = InMemorySeenInvitesStore(initialRoomIds = setOf(roomId))
+
+ private val clearMembershipNotificationForRoomLambda =
+ lambdaRecorder { _, _ -> }
+ private val notificationCleaner =
+ FakeNotificationCleaner(clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda)
+
+ @Test
+ fun `accept invite success scenario`() = runTest {
+ val joinRoomLambda =
+ lambdaRecorder, JoinedRoom.Trigger, Result> { _, _, _ ->
+ Result.success(Unit)
+ }
+
+ val acceptInvite = DefaultAcceptInvite(
+ client = client,
+ notificationCleaner = notificationCleaner,
+ joinRoom = FakeJoinRoom(lambda = joinRoomLambda),
+ seenInvitesStore = seenInvitesStore
+ )
+
+ val result = acceptInvite(roomId)
+
+ assertThat(result.isSuccess).isTrue()
+
+ assert(joinRoomLambda)
+ .isCalledOnce()
+ .with(value(roomId.toRoomIdOrAlias()), any(), any())
+
+ assert(clearMembershipNotificationForRoomLambda)
+ .isCalledOnce()
+ .with(value(client.sessionId), value(roomId))
+
+ assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
+ }
+
+ @Test
+ fun `accept invite failure scenario`() = runTest {
+ val joinRoomLambda =
+ lambdaRecorder, JoinedRoom.Trigger, Result> { _, _, _ ->
+ Result.failure(RuntimeException("Join room failed"))
+ }
+
+ val acceptInvite = DefaultAcceptInvite(
+ client = client,
+ notificationCleaner = notificationCleaner,
+ joinRoom = FakeJoinRoom(lambda = joinRoomLambda),
+ seenInvitesStore = seenInvitesStore
+ )
+
+ val result = acceptInvite(roomId)
+
+ assertThat(result.isFailure).isTrue()
+
+ assert(joinRoomLambda)
+ .isCalledOnce()
+ .with(value(roomId.toRoomIdOrAlias()), any(), any())
+
+ assert(clearMembershipNotificationForRoomLambda).isNeverCalled()
+
+ assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(roomId)
+ }
+}
diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/DefaultDeclineInviteTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/DefaultDeclineInviteTest.kt
new file mode 100644
index 00000000000..cb07bd5191f
--- /dev/null
+++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/DefaultDeclineInviteTest.kt
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2025 New Vector 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.invite.impl
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.invite.test.InMemorySeenInvitesStore
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.room.FakeBaseRoom
+import io.element.android.libraries.matrix.test.room.aRoomInfo
+import io.element.android.libraries.matrix.test.room.aRoomMember
+import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
+import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class DefaultDeclineInviteTest {
+ private val roomId = A_ROOM_ID
+ private val inviter = aRoomMember()
+ private val seenInvitesStore = InMemorySeenInvitesStore(initialRoomIds = setOf(roomId))
+ private val clearMembershipNotificationForRoomLambda =
+ lambdaRecorder { _, _ -> }
+ private val notificationCleaner =
+ FakeNotificationCleaner(clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda)
+
+ private val successLeaveRoomLambda = lambdaRecorder> { -> Result.success(Unit) }
+ private val successIgnoreUserLambda =
+ lambdaRecorder> { _ -> Result.success(Unit) }
+ private val successReportRoomLambda =
+ lambdaRecorder> { _ -> Result.success(Unit) }
+
+ private val failureLeaveRoomLambda =
+ lambdaRecorder> { -> Result.failure(Exception("Leave room error")) }
+ private val failureIgnoreUserLambda =
+ lambdaRecorder> { _ -> Result.failure(Exception("Ignore user error")) }
+ private val failureReportRoomLambda =
+ lambdaRecorder> { _ -> Result.failure(Exception("Report room error")) }
+
+ @Test
+ fun `decline invite, block=false, report=false, all success`() = runTest {
+ val room = FakeBaseRoom(
+ roomId = roomId,
+ leaveRoomLambda = successLeaveRoomLambda,
+ reportRoomResult = successReportRoomLambda
+ )
+ val client = FakeMatrixClient(ignoreUserResult = successIgnoreUserLambda).apply {
+ givenGetRoomResult(roomId, room)
+ }
+
+ val declineInvite = DefaultDeclineInvite(
+ client = client,
+ notificationCleaner = notificationCleaner,
+ seenInvitesStore = seenInvitesStore
+ )
+
+ val result =
+ declineInvite(roomId, blockUser = false, reportRoom = false, reportReason = null)
+
+ assertThat(result.isSuccess).isTrue()
+
+ assert(clearMembershipNotificationForRoomLambda)
+ .isCalledOnce()
+ .with(value(client.sessionId), value(roomId))
+
+ assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
+ }
+
+ @Test
+ fun `decline invite, block=true, report=true, all success`() = runTest {
+ val room = FakeBaseRoom(
+ roomId = roomId,
+ leaveRoomLambda = successLeaveRoomLambda,
+ reportRoomResult = successReportRoomLambda,
+ initialRoomInfo = aRoomInfo(inviter = inviter)
+ )
+ val client = FakeMatrixClient(ignoreUserResult = successIgnoreUserLambda).apply {
+ givenGetRoomResult(roomId, room)
+ }
+ val declineInvite = DefaultDeclineInvite(
+ client = client,
+ notificationCleaner = notificationCleaner,
+ seenInvitesStore = seenInvitesStore
+ )
+ val result = declineInvite(roomId, blockUser = true, reportRoom = true, reportReason = null)
+
+ assertThat(result.isSuccess).isTrue()
+
+ assert(clearMembershipNotificationForRoomLambda)
+ .isCalledOnce()
+ .with(value(client.sessionId), value(roomId))
+
+ assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
+ }
+
+ @Test
+ fun `decline invite, block=true, report=true, decline invite failed`() = runTest {
+ val room = FakeBaseRoom(
+ roomId = roomId,
+ leaveRoomLambda = failureLeaveRoomLambda,
+ reportRoomResult = successReportRoomLambda
+ )
+ val client = FakeMatrixClient(ignoreUserResult = successIgnoreUserLambda).apply {
+ givenGetRoomResult(roomId, room)
+ }
+ val declineInvite = DefaultDeclineInvite(
+ client = client,
+ notificationCleaner = notificationCleaner,
+ seenInvitesStore = seenInvitesStore
+ )
+ val result = declineInvite(roomId, blockUser = true, reportRoom = true, reportReason = null)
+
+ assertThat(result.exceptionOrNull()).isEqualTo(DeclineInvite.Exception.DeclineInviteFailed)
+
+ assert(clearMembershipNotificationForRoomLambda)
+ .isNeverCalled()
+
+ assertThat(seenInvitesStore.seenRoomIds().first()).isNotEmpty()
+ }
+
+ @Test
+ fun `decline invite, block=true, report=true, ignore user failed`() = runTest {
+ val room = FakeBaseRoom(
+ roomId = roomId,
+ leaveRoomLambda = successLeaveRoomLambda,
+ reportRoomResult = successReportRoomLambda,
+ initialRoomInfo = aRoomInfo(inviter = inviter)
+ )
+ val client = FakeMatrixClient(ignoreUserResult = failureIgnoreUserLambda).apply {
+ givenGetRoomResult(roomId, room)
+ }
+ val declineInvite = DefaultDeclineInvite(
+ client = client,
+ notificationCleaner = notificationCleaner,
+ seenInvitesStore = seenInvitesStore
+ )
+ val result = declineInvite(roomId, blockUser = true, reportRoom = true, reportReason = null)
+
+ assertThat(result.exceptionOrNull()).isEqualTo(DeclineInvite.Exception.BlockUserFailed)
+
+ assert(clearMembershipNotificationForRoomLambda).isCalledOnce()
+ assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
+ }
+
+ @Test
+ fun `decline invite, block=true, report=true, report room failed`() = runTest {
+ val room = FakeBaseRoom(
+ roomId = roomId,
+ leaveRoomLambda = successLeaveRoomLambda,
+ reportRoomResult = failureReportRoomLambda,
+ initialRoomInfo = aRoomInfo(inviter = inviter)
+ )
+ val client = FakeMatrixClient(ignoreUserResult = successIgnoreUserLambda).apply {
+ givenGetRoomResult(roomId, room)
+ }
+ val declineInvite = DefaultDeclineInvite(
+ client = client,
+ notificationCleaner = notificationCleaner,
+ seenInvitesStore = seenInvitesStore
+ )
+ val result = declineInvite(roomId, blockUser = true, reportRoom = true, reportReason = null)
+
+ assertThat(result.exceptionOrNull()).isEqualTo(DeclineInvite.Exception.ReportRoomFailed)
+
+ assert(clearMembershipNotificationForRoomLambda).isCalledOnce()
+ assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
+ }
+}
diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenterTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenterTest.kt
new file mode 100644
index 00000000000..c8a427bea6a
--- /dev/null
+++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenterTest.kt
@@ -0,0 +1,217 @@
+/*
+ * Copyright 2024 New Vector 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.invite.impl.acceptdecline
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.invite.api.InviteData
+import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
+import io.element.android.features.invite.api.acceptdecline.ConfirmingDeclineInvite
+import io.element.android.features.invite.impl.AcceptInvite
+import io.element.android.features.invite.impl.DeclineInvite
+import io.element.android.features.invite.impl.fake.FakeAcceptInvite
+import io.element.android.features.invite.impl.fake.FakeDeclineInvite
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_ROOM_NAME
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class AcceptDeclineInvitePresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = createAcceptDeclineInvitePresenter()
+ presenter.test {
+ awaitItem().also { state ->
+ assertThat(state.acceptAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ }
+ }
+ }
+
+ @Test
+ fun `present - declining invite cancel flow`() = runTest {
+ val presenter = createAcceptDeclineInvitePresenter()
+ presenter.test {
+ val inviteData = anInviteData()
+ awaitItem().also { state ->
+ state.eventSink(
+ AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = true)
+ )
+ }
+ awaitItem().also { state ->
+ assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, false))
+ state.eventSink(
+ InternalAcceptDeclineInviteEvents.CancelDeclineInvite
+ )
+ }
+ awaitItem().also { state ->
+ assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ }
+ }
+ }
+
+ @Test
+ fun `present - declining invite error flow`() = runTest {
+ val declineInviteFailure = lambdaRecorder> { _, _, _, _ ->
+ Result.failure(DeclineInvite.Exception.DeclineInviteFailed)
+ }
+ val presenter = createAcceptDeclineInvitePresenter(
+ declineInvite = FakeDeclineInvite(lambda = declineInviteFailure)
+ )
+ presenter.test {
+ val inviteData = anInviteData()
+ awaitItem().also { state ->
+ state.eventSink(
+ AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = true)
+ )
+ }
+ awaitItem().also { state ->
+ assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, false))
+ state.eventSink(
+ AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = false)
+ )
+ }
+ assertThat(awaitItem().declineAction.isLoading()).isTrue()
+ awaitItem().also { state ->
+ assertThat(state.declineAction).isInstanceOf(AsyncAction.Failure::class.java)
+ state.eventSink(
+ InternalAcceptDeclineInviteEvents.DismissDeclineError
+ )
+ }
+ awaitItem().also { state ->
+ assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ }
+ cancelAndConsumeRemainingEvents()
+ }
+ assert(declineInviteFailure)
+ .isCalledOnce()
+ .with(value(A_ROOM_ID), value(false), value(false), value(null))
+ }
+
+ @Test
+ fun `present - declining invite success flow`() = runTest {
+ val declineInviteSuccess = lambdaRecorder> { roomId, _, _, _ -> Result.success(roomId) }
+ val presenter = createAcceptDeclineInvitePresenter(
+ declineInvite = FakeDeclineInvite(lambda = declineInviteSuccess)
+ )
+ presenter.test {
+ val inviteData = anInviteData()
+ awaitItem().also { state ->
+ state.eventSink(
+ AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = true)
+ )
+ }
+ awaitItem().also { state ->
+ assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, blockUser = false))
+ state.eventSink(
+ AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = false)
+ )
+ }
+ assertThat(awaitItem().declineAction.isLoading()).isTrue()
+ awaitItem().also { state ->
+ assertThat(state.declineAction).isInstanceOf(AsyncAction.Success::class.java)
+ }
+ cancelAndConsumeRemainingEvents()
+ }
+ assert(declineInviteSuccess)
+ .isCalledOnce()
+ .with(value(A_ROOM_ID), value(false), value(false), value(null))
+ }
+
+ @Test
+ fun `present - accepting invite error flow`() = runTest {
+ val acceptInviteFailure = lambdaRecorder> { roomId: RoomId ->
+ Result.failure(RuntimeException("Failed to accept invite"))
+ }
+ val presenter = createAcceptDeclineInvitePresenter(
+ acceptInvite = FakeAcceptInvite(lambda = acceptInviteFailure),
+ )
+ presenter.test {
+ val inviteData = anInviteData()
+ awaitItem().also { state ->
+ state.eventSink(
+ AcceptDeclineInviteEvents.AcceptInvite(inviteData)
+ )
+ }
+ awaitItem().also { state ->
+ assertThat(state.acceptAction).isEqualTo(AsyncAction.Loading)
+ }
+ awaitItem().also { state ->
+ assertThat(state.acceptAction).isInstanceOf(AsyncAction.Failure::class.java)
+ state.eventSink(
+ InternalAcceptDeclineInviteEvents.DismissAcceptError
+ )
+ }
+ awaitItem().also { state ->
+ assertThat(state.acceptAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ }
+ cancelAndConsumeRemainingEvents()
+ }
+ assert(acceptInviteFailure)
+ .isCalledOnce()
+ .with(value(A_ROOM_ID))
+ }
+
+ @Test
+ fun `present - accepting invite success flow`() = runTest {
+ val acceptInviteSuccess = lambdaRecorder> { roomId: RoomId -> Result.success(roomId) }
+ val presenter = createAcceptDeclineInvitePresenter(
+ acceptInvite = FakeAcceptInvite(lambda = acceptInviteSuccess)
+ )
+ presenter.test {
+ val inviteData = anInviteData()
+ awaitItem().also { state ->
+ state.eventSink(
+ AcceptDeclineInviteEvents.AcceptInvite(inviteData)
+ )
+ }
+ awaitItem().also { state ->
+ assertThat(state.acceptAction).isEqualTo(AsyncAction.Loading)
+ }
+ awaitItem().also { state ->
+ assertThat(state.acceptAction).isInstanceOf(AsyncAction.Success::class.java)
+ }
+ cancelAndConsumeRemainingEvents()
+ }
+ acceptInviteSuccess.assertions()
+ .isCalledOnce()
+ .with(value(A_ROOM_ID))
+ }
+
+ private fun anInviteData(
+ roomId: RoomId = A_ROOM_ID,
+ name: String = A_ROOM_NAME,
+ isDm: Boolean = false,
+ ): InviteData {
+ return InviteData(
+ roomId = roomId,
+ roomName = name,
+ isDm = isDm,
+ )
+ }
+
+ private fun createAcceptDeclineInvitePresenter(
+ acceptInvite: AcceptInvite = FakeAcceptInvite(),
+ declineInvite: DeclineInvite = FakeDeclineInvite(),
+ ): AcceptDeclineInvitePresenter {
+ return AcceptDeclineInvitePresenter(
+ acceptInvite = acceptInvite,
+ declineInvite = declineInvite,
+ )
+ }
+}
diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockPresenterTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockPresenterTest.kt
new file mode 100644
index 00000000000..c5d2f53c92c
--- /dev/null
+++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockPresenterTest.kt
@@ -0,0 +1,175 @@
+/*
+ * Copyright 2025 New Vector 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.invite.impl.declineandblock
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.invite.api.InviteData
+import io.element.android.features.invite.impl.DeclineInvite
+import io.element.android.features.invite.impl.fake.FakeDeclineInvite
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_ROOM_NAME
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class DeclineAndBlockPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = createDeclineAndBlockPresenter()
+ presenter.test {
+ awaitItem().also { state ->
+ assertThat(state.blockUser).isTrue()
+ assertThat(state.reportRoom).isFalse()
+ assertThat(state.reportReason).isEmpty()
+ assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ assertThat(state.canDecline).isTrue()
+ }
+ }
+ }
+
+ @Test
+ fun `present - update form values`() = runTest {
+ val presenter = createDeclineAndBlockPresenter()
+ presenter.test {
+ awaitItem().also { state ->
+ assertThat(state.reportRoom).isFalse()
+ assertThat(state.blockUser).isTrue()
+ assertThat(state.reportReason).isEmpty()
+ assertThat(state.canDecline).isTrue()
+ state.eventSink(DeclineAndBlockEvents.ToggleBlockUser)
+ }
+ awaitItem().also { state ->
+ assertThat(state.reportRoom).isFalse()
+ assertThat(state.blockUser).isFalse()
+ assertThat(state.reportReason).isEmpty()
+ assertThat(state.canDecline).isFalse()
+ state.eventSink(DeclineAndBlockEvents.ToggleReportRoom)
+ }
+ awaitItem().also { state ->
+ assertThat(state.reportRoom).isTrue()
+ assertThat(state.blockUser).isFalse()
+ assertThat(state.reportReason).isEmpty()
+ assertThat(state.canDecline).isFalse()
+ state.eventSink(DeclineAndBlockEvents.UpdateReportReason("Spam"))
+ }
+ awaitItem().also { state ->
+ assertThat(state.reportRoom).isTrue()
+ assertThat(state.blockUser).isFalse()
+ assertThat(state.reportReason).isEqualTo("Spam")
+ assertThat(state.canDecline).isTrue()
+ }
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - declining invite success flow`() = runTest {
+ val declineInviteSuccess = lambdaRecorder> { roomId, _, _, _ -> Result.success(roomId) }
+ val presenter = createDeclineAndBlockPresenter(
+ declineInvite = FakeDeclineInvite(lambda = declineInviteSuccess)
+ )
+ presenter.test {
+ awaitItem().also { state ->
+ state.eventSink(DeclineAndBlockEvents.Decline)
+ }
+ assertThat(awaitItem().declineAction.isLoading()).isTrue()
+ awaitItem().also { state ->
+ assertThat(state.declineAction).isInstanceOf(AsyncAction.Success::class.java)
+ }
+ cancelAndConsumeRemainingEvents()
+ }
+ assert(declineInviteSuccess)
+ .isCalledOnce()
+ .with(value(A_ROOM_ID), value(true), value(false), value(""))
+ }
+
+ @Test
+ fun `present - declining invite error flow`() = runTest {
+ val declineInviteFailure = lambdaRecorder> { _, _, _, _ ->
+ Result.failure(DeclineInvite.Exception.DeclineInviteFailed)
+ }
+ val presenter = createDeclineAndBlockPresenter(
+ declineInvite = FakeDeclineInvite(lambda = declineInviteFailure)
+ )
+ presenter.test {
+ awaitItem().also { state ->
+ state.eventSink(DeclineAndBlockEvents.Decline)
+ }
+ assertThat(awaitItem().declineAction.isLoading()).isTrue()
+ awaitItem().also { state ->
+ assertThat(state.declineAction).isInstanceOf(AsyncAction.Failure::class.java)
+ state.eventSink(DeclineAndBlockEvents.ClearDeclineAction)
+ }
+ awaitItem().also { state ->
+ assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ }
+ cancelAndConsumeRemainingEvents()
+ }
+ assert(declineInviteFailure)
+ .isCalledOnce()
+ .with(value(A_ROOM_ID), value(true), value(false), value(""))
+ }
+
+ @Test
+ fun `present - block user error flow`() = runTest {
+ val declineInviteFailure = lambdaRecorder> { _, _, _, _ ->
+ Result.failure(DeclineInvite.Exception.BlockUserFailed)
+ }
+ val presenter = createDeclineAndBlockPresenter(
+ declineInvite = FakeDeclineInvite(lambda = declineInviteFailure)
+ )
+ presenter.test {
+ awaitItem().also { state ->
+ state.eventSink(DeclineAndBlockEvents.Decline)
+ }
+ assertThat(awaitItem().declineAction.isLoading()).isTrue()
+ awaitItem().also { state ->
+ assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ }
+ cancelAndConsumeRemainingEvents()
+ }
+ assert(declineInviteFailure)
+ .isCalledOnce()
+ .with(value(A_ROOM_ID), value(true), value(false), value(""))
+ }
+
+ private fun anInviteData(
+ roomId: RoomId = A_ROOM_ID,
+ name: String = A_ROOM_NAME,
+ isDm: Boolean = false,
+ ): InviteData {
+ return InviteData(
+ roomId = roomId,
+ roomName = name,
+ isDm = isDm,
+ )
+ }
+
+ private fun createDeclineAndBlockPresenter(
+ inviteData: InviteData = anInviteData(),
+ declineInvite: DeclineInvite = FakeDeclineInvite(),
+ snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
+ ): DeclineAndBlockPresenter {
+ return DeclineAndBlockPresenter(
+ inviteData = inviteData,
+ declineInvite = declineInvite,
+ snackbarDispatcher = snackbarDispatcher,
+ )
+ }
+}
diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt
new file mode 100644
index 00000000000..60e00f8e35b
--- /dev/null
+++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt
@@ -0,0 +1,124 @@
+/*
+ * Copyright 2025 New Vector 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.invite.impl.declineandblock
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performTextInput
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.invite.impl.R
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.pressBack
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class DeclineAndBlockViewTest {
+ @get:Rule val rule = createAndroidComposeRule()
+
+ @Test
+ fun `clicking on back invoke the expected callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce {
+ rule.setDeclineAndBlockView(
+ aDeclineAndBlockState(
+ eventSink = eventsRecorder,
+ ),
+ onBackClick = it
+ )
+ rule.pressBack()
+ }
+ }
+
+ @Test
+ fun `clicking on decline when enabled emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setDeclineAndBlockView(
+ aDeclineAndBlockState(
+ blockUser = true,
+ eventSink = eventsRecorder,
+ ),
+ )
+ rule.clickOn(CommonStrings.action_decline)
+ eventsRecorder.assertSingle(DeclineAndBlockEvents.Decline)
+ }
+
+ @Test
+ fun `clicking on decline when disabled does not emit event`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ rule.setDeclineAndBlockView(
+ aDeclineAndBlockState(
+ blockUser = false,
+ reportRoom = false,
+ eventSink = eventsRecorder,
+ ),
+ )
+ rule.clickOn(CommonStrings.action_decline)
+ eventsRecorder.assertEmpty()
+ }
+
+ @Test
+ fun `clicking on block option emits the expected event`() {
+ val eventsRecorder = EventsRecorder