diff --git a/.github/renovate.json5 b/.github/renovate.json5
index c16310cf43..86ab2f1b1e 100644
--- a/.github/renovate.json5
+++ b/.github/renovate.json5
@@ -7,7 +7,6 @@
"PR-Dependencies",
],
"ignoreDeps": [
- "string:app_name",
"gradle",
],
"minimumReleaseAge": "7 days",
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 69152b7e2d..48f67099ea 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -51,12 +51,12 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false
- name: Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APKs
diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml
index f2658bb342..7560c36a06 100644
--- a/.github/workflows/build_enterprise.yml
+++ b/.github/workflows/build_enterprise.yml
@@ -56,12 +56,12 @@ jobs:
- name: Clone submodules
run: git submodule update --init --recursive
- name: Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug Gplay Enterprise APK
diff --git a/.github/workflows/generate_github_pages.yml b/.github/workflows/generate_github_pages.yml
index 45e886f904..300058ec87 100644
--- a/.github/workflows/generate_github_pages.yml
+++ b/.github/workflows/generate_github_pages.yml
@@ -19,12 +19,12 @@ jobs:
- name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
diff --git a/.github/workflows/gradle-wrapper-update.yml b/.github/workflows/gradle-wrapper-update.yml
index 6b64a731d7..c64ab731a3 100644
--- a/.github/workflows/gradle-wrapper-update.yml
+++ b/.github/workflows/gradle-wrapper-update.yml
@@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
name: Use JDK 21
with:
distribution: 'temurin' # See 'Supported distributions' for available options
diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml
index 198b462349..e2d37aadb9 100644
--- a/.github/workflows/maestro-local.yml
+++ b/.github/workflows/maestro-local.yml
@@ -44,13 +44,13 @@ jobs:
# https://github.com/actions/checkout/issues/881
ref: ${{ github.ref }}
persist-credentials: false
- - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
name: Use JDK 21
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 9beab4e01c..67c94da9f6 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -36,7 +36,7 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml
index 23cfffc265..929dd6680a 100644
--- a/.github/workflows/nightlyReports.yml
+++ b/.github/workflows/nightlyReports.yml
@@ -37,13 +37,13 @@ jobs:
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: false
@@ -80,12 +80,12 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index e72ae0b4d1..14e5ee7a74 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -69,12 +69,12 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
@@ -108,12 +108,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Konsist tests
@@ -149,12 +149,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run compose tests
@@ -183,12 +183,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build Gplay Debug
@@ -228,12 +228,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Detekt
@@ -269,12 +269,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Ktlint check
@@ -336,7 +336,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- - uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
+ - uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
upload_reports:
name: Project Check Suite
diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml
index 00502d9612..44082bd347 100644
--- a/.github/workflows/recordScreenshots.yml
+++ b/.github/workflows/recordScreenshots.yml
@@ -54,13 +54,13 @@ jobs:
with:
persist-credentials: false
- name: ☕️ Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
# Add gradle cache, this should speed up the process
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4bc71a02ff..b9d8be16c9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -38,12 +38,12 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: Create app bundle
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -82,12 +82,12 @@ jobs:
- name: Clone submodules
run: git submodule update --init --recursive
- name: Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: Create Enterprise app bundle
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -126,12 +126,12 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: Create APKs
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
diff --git a/.github/workflows/scripts/recordScreenshots.sh b/.github/workflows/scripts/recordScreenshots.sh
index d29353a2c2..d04ee46f72 100755
--- a/.github/workflows/scripts/recordScreenshots.sh
+++ b/.github/workflows/scripts/recordScreenshots.sh
@@ -78,6 +78,10 @@ else
git config --local user.name "${INPUT_AUTHOR_EMAIL}"
fi
git add -A
+if git diff --cached --quiet; then
+ echo "Screenshots are already up to date, nothing to commit."
+ exit 0
+fi
git commit -m "Update screenshots"
GITHUB_REPO="https://$GITHUB_ACTOR:$TOKEN@github.com/$REPO.git"
diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml
index 6d9a62a2d1..26e24f3bd4 100644
--- a/.github/workflows/sonar.yml
+++ b/.github/workflows/sonar.yml
@@ -45,12 +45,12 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false
- name: Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build debug code and test fixtures
diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml
index 61b4a8d521..939354f12f 100644
--- a/.github/workflows/sync-localazy.yml
+++ b/.github/workflows/sync-localazy.yml
@@ -17,12 +17,12 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 5ee7f5065d..d558baf73a 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -63,12 +63,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: ☕️ Use JDK 21
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
diff --git a/.gitignore b/.gitignore
index 77acaadb20..a50ae12c6e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,9 +31,6 @@ __pycache__/
# Local configuration file (sdk path, etc)
local.properties
-# Proguard folder generated by Eclipse
-proguard/
-
# Log Files
*.log
diff --git a/.idea/copyright/Element_Enterprise.xml b/.idea/copyright/Element_Enterprise.xml
index b556049368..f7637c76a5 100644
--- a/.idea/copyright/Element_Enterprise.xml
+++ b/.idea/copyright/Element_Enterprise.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/.maestro/tests/account/changeServer.yaml b/.maestro/tests/account/changeServer.yaml
deleted file mode 100644
index b07fa5cc09..0000000000
--- a/.maestro/tests/account/changeServer.yaml
+++ /dev/null
@@ -1,21 +0,0 @@
-appId: ${MAESTRO_APP_ID}
----
-- tapOn:
- id: "login-change_server"
-- takeScreenshot: build/maestro/200-ChangeServer
-- tapOn: "matrix.org"
-- tapOn:
- id: "login-change_server"
-- tapOn: "Other"
-- tapOn:
- id: "change_server-server"
-- inputText: "element"
-- hideKeyboard
-- extendedWaitUntil:
- visible: "element.io"
- timeout: 10000
-- tapOn: "element.io"
-# Revert to matrix.org
-- tapOn:
- id: "login-change_server"
-- tapOn: "matrix.org"
diff --git a/.maestro/tests/account/login.yaml b/.maestro/tests/account/login.yaml
index 873843b22d..81922c7f38 100644
--- a/.maestro/tests/account/login.yaml
+++ b/.maestro/tests/account/login.yaml
@@ -3,8 +3,7 @@ appId: ${MAESTRO_APP_ID}
- tapOn: "Sign in manually"
- runFlow: ../assertions/assertLoginDisplayed.yaml
- takeScreenshot: build/maestro/100-SignIn
-- runFlow: changeServer.yaml
-- runFlow: ../assertions/assertLoginDisplayed.yaml
+# The account provider field defaults to matrix.org, so continue straight to the MAS login.
- tapOn:
id: "login-continue"
## MAS page
diff --git a/.maestro/tests/assertions/assertLoginDisplayed.yaml b/.maestro/tests/assertions/assertLoginDisplayed.yaml
index 6d8558c38e..c32f23c323 100644
--- a/.maestro/tests/assertions/assertLoginDisplayed.yaml
+++ b/.maestro/tests/assertions/assertLoginDisplayed.yaml
@@ -1,5 +1,5 @@
appId: ${MAESTRO_APP_ID}
---
- extendedWaitUntil:
- visible: "Change account provider"
+ visible: "Enter your account provider"
timeout: 10000
diff --git a/AGENTS.md b/AGENTS.md
index d206be98c6..917285ab30 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -37,8 +37,9 @@ PRs must meet these rules.
- Create previews for **all main states** of a Composable.
- Use `@PreviewsDayNight` for consistency.
-- Use `PreviewParameterProvider` (e.g., `FooStateProvider`) to provide states.
+- Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states.
- Wrap previews in `ElementPreview { ... }`.
+- When writing tests, never try to record the screenshots, the CI will do it.
---
@@ -88,7 +89,7 @@ We use [Appyx](https://bumble-tech.github.io/appyx/) for navigation and [Molecul
| `FooView.kt` | Stateless Composable rendering the UI from `FooState`. |
| `FooState.kt` | Data class representing the immutable UI state. |
| `FooEvent.kt` | Sealed interface for UI actions sent to the Presenter. |
-| `FooStateProvider.kt` | Provides sample states for Previews and Screenshot tests. |
+| `FooStatePreviewParam.kt` | Provides sample states for Previews and Screenshot tests. |
| `FooPresenterTest.kt` | Unit tests for the Presenter logic using Turbine. |
---
diff --git a/CHANGES.md b/CHANGES.md
index 3f7360a338..db18e59251 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,178 @@
+Changes in Element X v26.08.2
+=============================
+
+
+
+**Note: this release in an internal release, it will not be pushed to the stores.**
+
+## What's Changed
+### ✨ Features
+* Remove the legacy web-based account creation for matrix.org by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7412
+### 🙌 Improvements
+* Move the custom tile server URL config to the Element wellknown content by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7416
+* Explain a rate limiting push gateway in the notification troubleshooter by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7448
+* Move the `:libraries:wellknown` logic into `enterprise` modules by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7429
+* Refresh the room members when a mention is started by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7438
+* Close the invite screen once the invite has been declined by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7440
+* Use Profiles sliding sync extension check for own profile subscription by @ganfra in https://github.com/element-hq/element-x-android/pull/7403
+* Simplify choosing the account provider during sign in, with autocomplete of previously used ones by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7394
+### 🐛 Bugfixes
+* Fix wrong TextComposer placeholder displayed when in a thread. by @bmarty in https://github.com/element-hq/element-x-android/pull/7413
+* Fix bug reports failing to send after the app has crashed by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7395
+* Fix outgoing session verification stuck with incomplete recovery setup by @hardcore-sushi in https://github.com/element-hq/element-x-android/pull/7433
+* Keep transparency when an image is shared into the app with an imprecise mime type by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7452
+* Don't restart the sync service if the client is shutting down by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7472
+* Normalize the applicationName used in user agent by @bmarty in https://github.com/element-hq/element-x-android/pull/7477
+* Keep playing a voice message when another app plays a short sound by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7454
+* Keep the send button when the composer is emptied while editing a message by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7456
+* Do not open the room again when the app is resumed from the recents list by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7436
+* Stop waiting for a room alias that the remote server never resolves by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7446
+### 🧱 Build
+* build-rust-sdk: print the command we run by @richvdh in https://github.com/element-hq/element-x-android/pull/7407
+* Remove license check on Pro modules. by @bmarty in https://github.com/element-hq/element-x-android/pull/7418
+* Improve build time config and update the Firebase configuration by @bmarty in https://github.com/element-hq/element-x-android/pull/7473
+* Make the PUSHER_APP_ID configurable at build time. by @bmarty in https://github.com/element-hq/element-x-android/pull/7478
+### 📄 Documentation
+* Add KDoc to all the interfaces of the api modules by @bmarty in https://github.com/element-hq/element-x-android/pull/7426
+### 🚧 In development 🚧
+* [LinkNewDevice] Ensure the notice screen is also protected by the timeout. by @bmarty in https://github.com/element-hq/element-x-android/pull/7428
+### Dependency upgrades
+* Update kotlin to v2.3.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7396
+* Update plugin sonarqube to v7.4.0.8496 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7415
+* Update metro to v1.4.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7421
+* Update roborazzi to v1.71.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7423
+* Update dependency io.sentry:sentry-android to v8.52.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7414
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7420
+* Update dependency io.github.sergio-sastre.ComposablePreviewScanner:android to v0.9.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7431
+* Update dependency org.maplibre.compose:maplibre-compose to v0.14.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7432
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.17 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7460
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.19 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7479
+* Update media3 to v1.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7427
+* Update dependency androidx.compose:compose-bom to v2026.08.00 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7483
+### 💼 Element Pro
+* Add hooks to allow providing additional feature flags in EX pro by @richvdh in https://github.com/element-hq/element-x-android/pull/7419
+* Use the right base URL for the ESS config/well known endpoints by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7469
+### Others
+* Do not display pinned events that the client cannot render by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7379
+* Hide the forward action on live location shares by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7343
+* Do not fail the screenshot recording job when there is nothing to commit by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7355
+* Show the account a UnifiedPush registration belongs to in the distributor by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7345
+* Stop repeating messages that are already shown in a room notification by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7370
+* Turn a matrix: URI in a message into a pill by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7354
+* Send a message with Enter on a physical keyboard, Shift+Enter for a new line by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7353
+* Do not group timeline messages sent more than five minutes apart by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7349
+* Linkify URLs whose host is a bracketed IPv6 address by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7347
+* Add missing tests for `RustTemporaryMatrixClient` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7417
+* Revert "Keep manually revealed media visible when re-entering a room" by @bmarty in https://github.com/element-hq/element-x-android/pull/7424
+* Fix outdated documentation. by @bmarty in https://github.com/element-hq/element-x-android/pull/7430
+* Sync strings - New language supported: Arabic by @bmarty in https://github.com/element-hq/element-x-android/pull/7461
+* Keep the focused login field visible above the keyboard by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7455
+* Relax image compression so photos and screenshots keep more detail by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7451
+* Open the user profile of a user id that contains a slash by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7445
+* Remove the F-Droid dex patching step from the release script by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7442
+* Convert markdown to HTML when a caption is edited by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7441
+* Exclude carriage returns from the login and password fields by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7439
+* Show an error when the room to leave cannot be found by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7437
+* Give the room name in the timeline top bar a 48dp tap target by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7450
+* Model the fake voice recorder on a cancellable recording job by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7466
+
+## New Contributors
+* @hardcore-sushi made their first contribution in https://github.com/element-hq/element-x-android/pull/7433
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.08.1...v26.08.2
+
+Changes in Element X v26.08.1
+=============================
+
+
+
+## What's Changed
+### 🙌 Improvements
+* Render push notifications for live location share starts (MSC4505) by @ara4n in https://github.com/element-hq/element-x-android/pull/7203
+* [Task] Show active call participants in the people list #7199 by @Eklavya47 in https://github.com/element-hq/element-x-android/pull/7300
+### 🐛 Bugfixes
+* Fix overlap issue. by @bmarty in https://github.com/element-hq/element-x-android/pull/7331
+* Announce checked/unchecked state for user rows in invite screen by @bmarty in https://github.com/element-hq/element-x-android/pull/7136
+* Display 'add reactions' menu in the threads screen by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7388
+* Expand the emoji picker to full height when the keyboard is shown by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7382
+* UI fixes by @bmarty in https://github.com/element-hq/element-x-android/pull/7409
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7389
+### 🧱 Build
+* Don't check enterprise previews in `KonsistPreviewTest` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7297
+* CI: split unit tests, screenshots and coverage in `tests.yml` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7296
+* Update Gradle Wrapper from 9.6.1 to 9.7.0 by @ElementBot in https://github.com/element-hq/element-x-android/pull/7335
+### 🚧 In development 🚧
+* QR code login iteration by @bmarty in https://github.com/element-hq/element-x-android/pull/6719
+* User status last pieces by @ganfra in https://github.com/element-hq/element-x-android/pull/7328
+* [Link new device] Add timeout by @bmarty in https://github.com/element-hq/element-x-android/pull/7338
+### Dependency upgrades
+* Update dependency org.maplibre.compose:maplibre-compose to v0.13.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7291
+* Update android.gradle.plugin to v9.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7248
+* Update dependency io.sentry:sentry-android to v8.50.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7298
+* Update zizmorcore/zizmor-action action to v0.6.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7306
+* Update android.gradle.plugin to v9.3.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7304
+* Update dependency com.posthog:posthog-android to v3.56.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7311
+* Update dependencyAnalysis to v3.18.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7318
+* Update actions/stale action to v11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7317
+* Update dependency io.github.zxing-cpp:android to v3.1.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7322
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.05 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7324
+* Update dependency androidx.constraintlayout:constraintlayout to v2.2.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7329
+* Update reactivecircus/android-emulator-runner action to v2.38.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7189
+* Update dependency io.sentry:sentry-android to v8.51.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7301
+* Update dependency org.maplibre.gl:android-sdk to v13.4.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7305
+* Update dependency io.element.android:element-call-embedded to v0.23.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7323
+* Update metro to v1.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7334
+* Update dependency com.google.firebase:firebase-bom to v34.17.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7333
+* Update dependency androidx.compose.material3:material3 to v1.5.0-alpha23 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7175
+* Update dependency org.jsoup:jsoup to v1.23.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7332
+* Update dependency androidx.constraintlayout:constraintlayout-compose to v1.1.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7330
+* Update zizmorcore/zizmor-action action to v0.6.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7383
+* Update actions/setup-java action to v5.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7339
+* Update gradle/actions action to v6.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7384
+* Update plugin dependencycheck to v13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7386
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7402
+### 💼 Element Pro
+* Add support for checking the ESS config in Element Pro by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7302
+### Others
+* Add extra vertical padding to `ReplyToInvalidContent` and `ReplyToNotFoundContent` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7288
+* Index messages locally in an encrypted on-device store so encrypted rooms can be searched at all by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7249
+* Bump enterprise module to the latest commit by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7307
+* Bump enterprise module to the latest commit by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7309
+* Move deviceId and git details to developer settings by @bmarty in https://github.com/element-hq/element-x-android/pull/7241
+* Show a Message not found placeholder when a replied to message cannot be loaded by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7376
+* Update in reply to message when message is not found. by @bmarty in https://github.com/element-hq/element-x-android/pull/7387
+* Add default `OAUTH_CLIENT_URL_PATH` alternative by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7385
+* Fix the feature module template writing the impl build script into the api module by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7374
+* Let users enter their full Matrix user ID when searching for a homeserver by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7375
+* Keep manually revealed media visible when re-entering a room by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7378
+* Copy a blocked user's Matrix ID by long pressing them by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7372
+* Do not draw the media loading progress bar under the status bar by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7366
+* Do not notify about an event sent by another account on the same device by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7360
+* Keep the opened image on screen when the media gallery fails to load by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7362
+* Do not linkify the server part of a fediverse handle as an email address by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7356
+* Expose global account data get and set on MatrixClient by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7373
+* Show the alias or the direct message user's Matrix ID in the room picker by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7371
+* Use a dedicated string for the Mute room notification mode by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7367
+* Keep playing a voice message when the screen turns off by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7365
+* Let a voice message without a waveform be seeked by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7363
+* Ignore a second voice recording start while one is already running by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7346
+* Do not offer to start a call in a DM with no other member by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7352
+* Do not notify about a redaction whose target event is unknown by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7350
+* Ignore a room list filter that is incompatible with an already selected one by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7342
+* Stop a right to left sender name from reordering a room list preview by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7348
+* Do not append the application MapTiler key to a custom map style url by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7340
+* Hide space children you have left that can only be joined by invitation by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7369
+* Never re-encode a video at a higher bitrate than the source by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7351
+* Display push notifications for knocks by @ara4n in https://github.com/element-hq/element-x-android/pull/7207
+
+## New Contributors
+* @hayaksi1 made their first contribution in https://github.com/element-hq/element-x-android/pull/7249
+* @ara4n made their first contribution in https://github.com/element-hq/element-x-android/pull/7203
+* @Eklavya47 made their first contribution in https://github.com/element-hq/element-x-android/pull/7300
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.08.0...v26.08.1
+
Changes in Element X v26.08.0
=============================
diff --git a/annotations/src/main/kotlin/io/element/android/annotations/ContributesNode.kt b/annotations/src/main/kotlin/io/element/android/annotations/ContributesNode.kt
index 632bdc3e81..8091206cbd 100644
--- a/annotations/src/main/kotlin/io/element/android/annotations/ContributesNode.kt
+++ b/annotations/src/main/kotlin/io/element/android/annotations/ContributesNode.kt
@@ -14,16 +14,16 @@ import kotlin.reflect.KClass
* Adds Node to the specified component graph.
* Equivalent to the following declaration:
*
+ * ```
* @BindingContainer
* @ContributesTo(Scope::class)
- * abstract class YourNodeModule {
-
- * @Binds
- * @IntoMap
- * @NodeKey(YourNode::class)
- * abstract fun bindYourNodeFactory(factory: YourNode.Factory): AssistedNodeFactory<*>
- *}
-
+ * interface YourNodeBindingContainer {
+ * @Binds
+ * @IntoMap
+ * @NodeKey(YourNode::class)
+ * fun bindYourNodeAssistedFactory(factory: YourNodeAssistedFactory): AssistedNodeFactory<*>
+ * }
+ * ```
*/
@Target(AnnotationTarget.CLASS)
annotation class ContributesNode(
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ecd5ed8e7b..f85d8dc33c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -127,24 +127,11 @@ android {
optimization {
enable = true
keepRules {
- files.add(File(projectDir, "common-proguard-rules.pro"))
- files.add(getDefaultProguardFile("proguard-android-optimize.txt"))
-
- // Depending on whether the app flavor is enterprise or not we want to use different proguard rules.
- val flavorProguardFile = if (isEnterpriseBuild) {
- // Custom rules for enterprise builds
- File(projectDir, "enterprise-proguard-rules.pro")
- } else {
- // These default rules prevent the OSS app from being obfuscated
- File(projectDir, "default-proguard-rules.pro")
- }
-
- if (flavorProguardFile.exists()) {
- files.add(flavorProguardFile)
- } else {
- logger.warn("Proguard file ${flavorProguardFile.absolutePath} does not exist")
- }
+ // Equivalent of adding `getDefaultProguardFile("proguard-android-optimize.txt")` (this is the default value).
+ includeDefault = true
}
+ // Our custom keep rules are registered as `keepRules` source folders in the `androidComponents` block below,
+ // as the former `keepRules.files` DSL is deprecated since AGP 9.
}
}
@@ -229,6 +216,30 @@ androidComponents {
)
onVariants { variant ->
+ // Register the R8 keep rules source folders for optimized build types (release, nightly).
+ // Replaces the deprecated `optimization.keepRules.files` DSL (AGP 9+).
+ if (variant.buildType != "debug") {
+ variant.sources.keepRules?.let { keepRules ->
+ // Common rules, always applied.
+ keepRules.addStaticSourceDirectory("proguard/common")
+
+ // Depending on whether the app flavor is enterprise or not we want to use different proguard rules.
+ val flavorProguardDir = if (isEnterpriseBuild) {
+ // Custom rules for enterprise builds
+ "../enterprise/proguard"
+ } else {
+ // Custom fules for FOSS builds
+ "proguard/foss"
+ }
+
+ if (File(projectDir, flavorProguardDir).exists()) {
+ keepRules.addStaticSourceDirectory(flavorProguardDir)
+ } else {
+ logger.warn("Proguard folder ${File(projectDir, flavorProguardDir).absolutePath} does not exist")
+ }
+ }
+ }
+
// Assigns a different version code for each output APK
// other than the universal APK.
variant.outputs.forEach { output ->
diff --git a/app/common-proguard-rules.pro b/app/proguard/common/common-proguard-rules.keep
similarity index 100%
rename from app/common-proguard-rules.pro
rename to app/proguard/common/common-proguard-rules.keep
diff --git a/app/default-proguard-rules.pro b/app/proguard/foss/default-proguard-rules.keep
similarity index 100%
rename from app/default-proguard-rules.pro
rename to app/proguard/foss/default-proguard-rules.keep
diff --git a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt b/app/src/main/kotlin/io/element/android/x/di/AppBindingContainer.kt
similarity index 87%
rename from app/src/main/kotlin/io/element/android/x/di/AppModule.kt
rename to app/src/main/kotlin/io/element/android/x/di/AppBindingContainer.kt
index 7564db5d95..3f8e94d8ef 100644
--- a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt
+++ b/app/src/main/kotlin/io/element/android/x/di/AppBindingContainer.kt
@@ -18,7 +18,7 @@ import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.element.android.appconfig.ApplicationConfig
-import io.element.android.features.enterprise.api.EnterpriseService
+import io.element.android.features.enterprise.api.IsEnterpriseBuild
import io.element.android.libraries.androidutils.system.getVersionCodeFromManifest
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.meta.BuildMeta
@@ -38,7 +38,7 @@ import java.io.File
@BindingContainer
@ContributesTo(AppScope::class)
-object AppModule {
+object AppBindingContainer {
@Provides
@BaseDirectory
fun providesBaseDirectory(@ApplicationContext context: Context): File {
@@ -74,17 +74,18 @@ object AppModule {
fun providesBuildMeta(
@ApplicationContext context: Context,
buildType: BuildType,
- enterpriseService: EnterpriseService,
+ isEnterpriseBuild: IsEnterpriseBuild,
): BuildMeta {
val applicationName = ApplicationConfig.APPLICATION_NAME.takeIf { it.isNotEmpty() } ?: context.getString(R.string.app_name)
+ val isEnterprise = isEnterpriseBuild()
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,
+ productionApplicationName = if (isEnterprise) applicationName else ApplicationConfig.PRODUCTION_APPLICATION_NAME,
+ desktopApplicationName = if (isEnterprise) applicationName else ApplicationConfig.DESKTOP_APPLICATION_NAME,
applicationId = BuildConfig.APPLICATION_ID,
- isEnterpriseBuild = enterpriseService.isEnterpriseBuild,
+ isEnterpriseBuild = isEnterprise,
// TODO EAx Config.LOW_PRIVACY_LOG_ENABLE,
lowPrivacyLoggingEnabled = false,
versionName = BuildConfig.VERSION_NAME,
@@ -110,7 +111,7 @@ object AppModule {
@Provides
@SingleIn(AppScope::class)
- fun provideSnackbarDispatcher(): SnackbarDispatcher {
+ fun providesSnackbarDispatcher(): SnackbarDispatcher {
return SnackbarDispatcher()
}
}
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt
deleted file mode 100644
index d36fb742b0..0000000000
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.appconfig
-
-object PushConfig {
- /**
- * Note: pusher_app_id cannot exceed 64 chars.
- */
- const val PUSHER_APP_ID: String = "im.vector.app.android"
-}
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 6dd2143d95..82d2145ecc 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt
@@ -35,4 +35,11 @@ object RageshakeConfig {
* The maximum number of log lines a rageshake can contain.
*/
const val MAX_LOG_LINES_SIZE = 1_000_000
+
+ /**
+ * The maximum size of the description. The bug report server copies it verbatim into a GitHub issue body,
+ * which GitHub rejects above 65536 characters. The remaining budget is left to the other reported fields,
+ * mainly the list of the omitted log file names.
+ */
+ const val MAX_DESCRIPTION_SIZE = 40 * 1024
}
diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts
index ce9764360b..12b95d9a09 100644
--- a/appnav/build.gradle.kts
+++ b/appnav/build.gradle.kts
@@ -44,6 +44,7 @@ dependencies {
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.wellknown.api)
implementation(projects.features.login.api)
implementation(libs.coil)
@@ -60,16 +61,23 @@ dependencies {
testCommonDependencies(libs)
testImplementation(projects.features.login.test)
testImplementation(projects.features.share.test)
+ testImplementation(projects.libraries.accountselect.test)
+ testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.libraries.matrixmedia.test)
testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
+ testImplementation(projects.libraries.sessionStorage.test)
+ testImplementation(projects.features.announcement.test)
testImplementation(projects.features.forward.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.features.networkmonitor.test)
+ testImplementation(projects.features.preferences.test)
testImplementation(projects.features.rageshake.test)
+ testImplementation(projects.features.signedout.test)
testImplementation(projects.services.apperror.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.analytics.test)
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 14230d7c5a..dab9b7828c 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
@@ -11,8 +11,10 @@ package io.element.android.appnav
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
@@ -49,11 +51,13 @@ import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.SessionEnterpriseService
+import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
+import io.element.android.features.location.api.LocalMapTilerConfig
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
@@ -153,6 +157,7 @@ class LoggedInFlowNode(
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val activeLiveLocationShareManager: ActiveLiveLocationShareManager,
+ private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
) : BaseFlowNode(
backstack = BackStack(
initialElement = NavTarget.Placeholder,
@@ -330,11 +335,11 @@ class LoggedInFlowNode(
}
NavTarget.Home -> {
val callback = object : HomeEntryPoint.Callback {
- override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) {
+ override fun navigateToRoom(roomId: RoomId, eventId: EventId?, joinedRoom: JoinedRoom?) {
lifecycleScope.launch {
attachRoom(
roomIdOrAlias = roomId.toRoomIdOrAlias(),
- initialElement = RoomNavigationTarget.Root(joinedRoom = joinedRoom),
+ initialElement = RoomNavigationTarget.Root(joinedRoom = joinedRoom, eventId = eventId),
clearBackstack = false,
)
}
@@ -677,6 +682,12 @@ class LoggedInFlowNode(
val colors by remember {
enterpriseService.semanticColorsFlow(sessionId = matrixClient.sessionId)
}.collectAsState(SemanticColorsLightDark.default)
+
+ val currentMapTilerConfig = LocalMapTilerConfig.current
+ val updatedMapTilerConfig by produceState(currentMapTilerConfig) {
+ value = customMapTilerConfigProvider.get().getOrNull() ?: currentMapTilerConfig
+ }
+
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
featureFlagService = featureFlagService,
@@ -684,16 +695,18 @@ class LoggedInFlowNode(
compoundDark = colors.dark,
buildMeta = buildMeta,
) {
- val isOnline by syncService.isOnline.collectAsState()
- ConnectivityIndicatorContainer(
- isOnline = isOnline,
- modifier = modifier,
- ) { contentModifier ->
- Box(modifier = contentModifier) {
- val ftueState by ftueService.state.collectAsState()
- BackstackView(transitionHandler = rememberLoggedInFlowTransitionHandler(backstack))
- if (ftueState is FtueState.Complete) {
- PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
+ CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) {
+ val isOnline by syncService.isOnline.collectAsState()
+ ConnectivityIndicatorContainer(
+ isOnline = isOnline,
+ modifier = modifier,
+ ) { contentModifier ->
+ Box(modifier = contentModifier) {
+ val ftueState by ftueService.state.collectAsState()
+ BackstackView(transitionHandler = rememberLoggedInFlowTransitionHandler(backstack))
+ if (ftueState is FtueState.Complete) {
+ PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
+ }
}
}
}
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 acf7b66db9..c9deaeb3b3 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
@@ -32,7 +32,6 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.annotations.ContributesNode
-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.room.RoomFlowNode
@@ -40,6 +39,7 @@ import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView
+import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
@@ -109,6 +109,13 @@ class RootFlowNode(
buildContext = buildContext,
plugins = plugins
) {
+ /**
+ * Login params coming from a launch or new [Intent], waiting to be consumed by the not logged in flow.
+ * Kept here so that the root nav target can be computed from both the logged in state and the pending
+ * login params, whatever the order in which the intent and the first nav state emission are processed.
+ */
+ private var pendingLoginParams: LoginParams? = null
+
override fun onBuilt() {
analyticsColdStartWatcher.start()
appCoroutineScope.launch {
@@ -152,7 +159,7 @@ class RootFlowNode(
}
}
LoggedInState.NotLoggedIn -> {
- switchToNotLoggedInFlow(null)
+ switchToNotLoggedInFlow(pendingLoginParams)
}
}
}
@@ -194,15 +201,18 @@ class RootFlowNode(
}
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
+ pendingLoginParams = null
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
}
private fun switchToNotLoggedInFlow(params: LoginParams?) {
+ Timber.d("switchToNotLoggedInFlow, hasLoginParams=${params != null}")
matrixSessionCache.removeAll()
backstack.safeRoot(NavTarget.NotLoggedInFlow(params))
}
private fun switchToSignedOutFlow(sessionId: SessionId) {
+ pendingLoginParams = null
backstack.safeRoot(NavTarget.SignedOutFlow(sessionId))
}
@@ -319,6 +329,7 @@ class RootFlowNode(
}
override fun onDone() {
+ pendingLoginParams = null
backstack.pop()
}
}
@@ -418,6 +429,7 @@ class RootFlowNode(
Timber.w("Login link ignored, multi account is disabled")
}
} else {
+ pendingLoginParams = params
switchToNotLoggedInFlow(params)
}
} else {
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 ee316f00aa..5d3a85fa9a 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
@@ -76,6 +76,7 @@ class IntentResolver(
}
private fun Intent.canBeIgnored(): Boolean {
+ if (flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0) return true
return action == Intent.ACTION_MAIN &&
categories?.contains(Intent.CATEGORY_LAUNCHER) == true
}
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 0723ad4606..5cd3efa801 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
@@ -120,6 +120,12 @@ class LoggedInPresenter(
}.launchIn(this)
}
+ LaunchedEffect(Unit) {
+ // Keep automatic call status (m.call) in sync with homeserver support.
+ val enabled = matrixClient.isUserStatusSupported().getOrDefault(false)
+ matrixClient.enableAutomaticCallStatus(enabled)
+ }
+
val networkConnectivity by networkMonitor.connectivity.collectAsState()
LaunchedEffect(networkConnectivity) {
if (networkConnectivity == NetworkStatus.Connected) {
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStatePreviewParam.kt
similarity index 95%
rename from appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt
rename to appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStatePreviewParam.kt
index 67fcb95c2c..b479e00279 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStatePreviewParam.kt
@@ -13,7 +13,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
import io.element.android.libraries.push.api.PusherRegistrationFailure
-open class LoggedInStateProvider : PreviewParameterProvider {
+open class LoggedInStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aLoggedInState(),
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 e57d378da4..7e607e7d92 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
@@ -124,7 +124,7 @@ private fun ForceNativeSlidingSyncMigrationDialog(
@PreviewsDayNight
@Composable
-internal fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview {
+internal fun LoggedInViewPreview(@PreviewParameter(LoggedInStatePreviewParam::class) state: LoggedInState) = ElementPreview {
LoggedInView(
state = state,
navigateToNotificationTroubleshoot = {},
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 a596052fca..175b662e08 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
@@ -29,7 +29,7 @@ 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.DelayedVisibility
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
-import io.element.android.libraries.matrix.ui.room.LoadingRoomStateProvider
+import io.element.android.libraries.matrix.ui.room.LoadingRoomStatePreviewParam
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -84,7 +84,7 @@ private fun LoadingRoomTopBar(
@PreviewsDayNight
@Composable
-internal fun LoadingRoomNodeViewPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) = ElementPreview {
+internal fun LoadingRoomNodeViewPreview(@PreviewParameter(LoadingRoomStatePreviewParam::class) state: LoadingRoomState) = ElementPreview {
LoadingRoomNodeView(
state = state,
onBackClick = {}
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 9a91e9765b..8b02752a64 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
@@ -11,7 +11,7 @@ package io.element.android.appnav.root
import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.core.state.SavedStateMap
import dev.zacsweers.metro.Inject
-import io.element.android.appnav.di.MatrixSessionCache
+import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootStateProvider.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootStatePreviewParam.kt
similarity index 95%
rename from appnav/src/main/kotlin/io/element/android/appnav/root/RootStateProvider.kt
rename to appnav/src/main/kotlin/io/element/android/appnav/root/RootStatePreviewParam.kt
index 26db205498..8ae3d261e8 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootStateProvider.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootStatePreviewParam.kt
@@ -14,7 +14,7 @@ import io.element.android.features.rageshake.api.detection.aRageshakeDetectionSt
import io.element.android.services.apperror.api.AppErrorState
import io.element.android.services.apperror.api.aAppErrorState
-open class RootStateProvider : PreviewParameterProvider {
+open class RootStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aRootState().copy(
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt
index 19aa820433..aa6bcf1a88 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt
@@ -60,7 +60,7 @@ fun RootView(
@PreviewsDayNight
@Composable
-internal fun RootViewPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreview {
+internal fun RootViewPreview(@PreviewParameter(RootStatePreviewParam::class) rootState: RootState) = ElementPreview {
RootView(
state = rootState,
onOpenBugReport = {},
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt b/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt
similarity index 98%
rename from appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt
rename to appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt
index 11337f2e0a..ffae5f2b17 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.appnav.di
+package io.element.android.appnav.session
import androidx.annotation.VisibleForTesting
import com.bumble.appyx.core.state.MutableSavedStateMap
@@ -115,7 +115,7 @@ class MatrixSessionCache(
private fun onNewMatrixClient(matrixClient: MatrixClient) {
val syncOrchestrator = syncOrchestratorFactory.create(
- syncService = matrixClient.syncService,
+ matrixClient = matrixClient,
sessionCoroutineScope = matrixClient.sessionCoroutineScope,
)
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt b/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt
similarity index 91%
rename from appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt
rename to appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt
index 1ce423a569..3a72c10ab7 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.appnav.di
+package io.element.android.appnav.session
import androidx.annotation.VisibleForTesting
import dev.zacsweers.metro.Assisted
@@ -16,7 +16,7 @@ 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.sync.SyncService
+import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.recordTransaction
@@ -37,7 +37,7 @@ import kotlin.time.Duration.Companion.seconds
@AssistedInject
class SyncOrchestrator(
- @Assisted private val syncService: SyncService,
+ @Assisted private val matrixClient: MatrixClient,
@Assisted sessionCoroutineScope: CoroutineScope,
private val appForegroundStateService: AppForegroundStateService,
private val networkMonitor: NetworkMonitor,
@@ -47,13 +47,15 @@ class SyncOrchestrator(
@AssistedFactory
interface Factory {
fun create(
- syncService: SyncService,
+ matrixClient: MatrixClient,
sessionCoroutineScope: CoroutineScope,
): SyncOrchestrator
}
private val tag = "SyncOrchestrator"
+ private val syncService = matrixClient.syncService
+
private val coroutineScope = sessionCoroutineScope.childScope(dispatchers.io, tag)
private val started = AtomicBoolean(false)
@@ -94,7 +96,7 @@ class SyncOrchestrator(
appForegroundStateService.isInCall,
appForegroundStateService.isSyncingNotificationEvent,
appForegroundStateService.hasRingingCall,
- appForegroundStateService.isSharingLiveLocation
+ appForegroundStateService.isSharingLiveLocation,
)
val isAppActiveFlow = combine(isAppActiveFlows) { actives -> actives.any { it } }
combine(
@@ -103,6 +105,11 @@ class SyncOrchestrator(
networkMonitor.connectivity,
isAppActiveFlow,
) { syncState, networkState, isAppActive ->
+ if (matrixClient.isShuttingDown) {
+ Timber.tag(tag).d("Matrix client is shutting down, no need to start the sync service again")
+ return@combine SyncStateAction.NoOp
+ }
+
val isNetworkAvailable = networkState == NetworkStatus.Connected
Timber.tag(tag).d("isAppActive=$isAppActive, isNetworkAvailable=$isNetworkAvailable")
diff --git a/appnav/src/main/res/values-ar/translations.xml b/appnav/src/main/res/values-ar/translations.xml
new file mode 100644
index 0000000000..ca3a804097
--- /dev/null
+++ b/appnav/src/main/res/values-ar/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "تسجيل الخروج والترقية"
+ "لم يعد %1$s يدعم البروتوكول القديم. يرجى تسجيل الخروج ثم تسجيل الدخول مرة أخرى لمتابعة استخدام التطبيق."
+ "لم يعد خادمك المنزلي يدعم البروتوكول القديم. يرجى تسجيل الخروج ثم تسجيل الدخول مرة أخرى لمتابعة استخدام التطبيق."
+
diff --git a/appnav/src/main/res/values-fa/translations.xml b/appnav/src/main/res/values-fa/translations.xml
index 16bb7fd3e2..dfff863365 100644
--- a/appnav/src/main/res/values-fa/translations.xml
+++ b/appnav/src/main/res/values-fa/translations.xml
@@ -2,4 +2,5 @@
"خروج و ارتقا"
"%1$s دیگر از شیوهنامهٔ قدیمی پشتیبانی نمیکند. لطفاً برای ادامهٔ استفاده از کاره، خارج شده و دوباره وارد شوید."
+ "سرور خانگی شما دیگر از پروتکل قدیمی پشتیبانی نمیکند. لطفاً برای ادامه استفاده از برنامه، از سیستم خارج شده و دوباره وارد شوید."
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt
new file mode 100644
index 0000000000..fea30c8019
--- /dev/null
+++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt
@@ -0,0 +1,189 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package io.element.android.appnav
+
+import android.content.Intent
+import android.net.Uri
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import com.bumble.appyx.core.modality.AncestryInfo
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.node
+import com.bumble.appyx.navmodel.backstack.activeElement
+import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
+import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
+import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl
+import com.google.common.truth.Truth.assertThat
+import io.element.android.appnav.intent.IntentResolver
+import io.element.android.appnav.root.RootNavStateFlowFactory
+import io.element.android.appnav.root.RootPresenter
+import io.element.android.appnav.session.FakeSyncOrchestratorFactory
+import io.element.android.appnav.session.MatrixSessionCache
+import io.element.android.features.login.api.LoginEntryPoint
+import io.element.android.features.login.api.LoginParams
+import io.element.android.features.login.test.FakeLoginEntryPoint
+import io.element.android.features.login.test.FakeLoginIntentResolver
+import io.element.android.features.login.test.accesscontrol.FakeAccountProviderAccessControl
+import io.element.android.features.preferences.test.FakeCacheService
+import io.element.android.features.rageshake.test.FakeBugReportEntryPoint
+import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
+import io.element.android.features.share.test.FakeShareIntentHandler
+import io.element.android.features.signedout.test.FakeSignedOutEntryPoint
+import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint
+import io.element.android.libraries.architecture.AssistedNodeFactory
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.libraries.matrix.test.FakeSdkMetadata
+import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
+import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder
+import io.element.android.libraries.oauth.test.FakeOAuthActionFlow
+import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver
+import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory
+import io.element.android.libraries.sessionstorage.api.SessionStore
+import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
+import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher
+import io.element.android.services.apperror.test.FakeAppErrorStateService
+import io.element.android.tests.testutils.node.FakeNodeFactoriesBindings
+import io.element.android.tests.testutils.node.FakeParentNode
+import io.element.android.tests.testutils.presenter.NotUsedPresenter
+import io.element.android.tests.testutils.robolectric.RobolectricTest
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+private const val A_LOGIN_LINK = "https://mobile.element.io/element/?account_provider=example.com&login_hint=mxid:@alice:example.com"
+
+private val A_LOGIN_PARAMS = LoginParams(
+ accountProvider = "example.com",
+ loginHint = "mxid:@alice:example.com",
+)
+
+private val A_LOGIN_ENTRY_POINT_PARAMS = LoginEntryPoint.Params(
+ accountProvider = A_LOGIN_PARAMS.accountProvider,
+ loginHint = A_LOGIN_PARAMS.loginHint,
+)
+
+class RootFlowNodeTest : RobolectricTest() {
+ @get:Rule
+ val instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ @Test
+ fun `given no session, when a login link is handled before the nav state is observed, then the login params are kept`() = runTest {
+ var loginEntryPointParams: LoginEntryPoint.Params? = null
+ val rootFlowNode = createRootFlowNode(
+ loginEntryPoint = FakeLoginEntryPoint { buildContext, params ->
+ loginEntryPointParams = params
+ node(buildContext) {}
+ },
+ )
+ rootFlowNode.parentNodeTestHelper()
+ // The intent is handled first, this is what happens when the deep link cold starts the app.
+ rootFlowNode.handleIntent(aLoginIntent())
+ assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS))
+ // Then the first nav state emission lands, it must not override the login params.
+ runCurrent()
+ assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS))
+ assertThat(loginEntryPointParams).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS)
+ }
+
+ @Test
+ fun `given no session, when a login link is handled after the nav state is observed, then the login params are applied`() = runTest {
+ var loginEntryPointParams: LoginEntryPoint.Params? = null
+ val rootFlowNode = createRootFlowNode(
+ loginEntryPoint = FakeLoginEntryPoint { buildContext, params ->
+ loginEntryPointParams = params
+ node(buildContext) {}
+ },
+ )
+ rootFlowNode.parentNodeTestHelper()
+ // The nav state is observed first, this is what happens when the app is already running.
+ runCurrent()
+ assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(null))
+ rootFlowNode.handleIntent(aLoginIntent())
+ runCurrent()
+ assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS))
+ assertThat(loginEntryPointParams).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS)
+ }
+
+ private fun aLoginIntent() = Intent(Intent.ACTION_VIEW, Uri.parse(A_LOGIN_LINK))
+
+ private fun TestScope.createRootFlowNode(
+ loginEntryPoint: LoginEntryPoint,
+ sessionStore: SessionStore = InMemorySessionStore(),
+ ): RootFlowNode {
+ val matrixSessionCache = MatrixSessionCache(
+ authenticationService = FakeMatrixAuthenticationService(),
+ syncOrchestratorFactory = FakeSyncOrchestratorFactory(),
+ analyticsService = FakeAnalyticsService(),
+ )
+ val parentNode = FakeParentNode(
+ graph = FakeNodeFactoriesBindings(
+ mapOf(
+ NotLoggedInFlowNode::class to AssistedNodeFactory { buildContext, plugins ->
+ NotLoggedInFlowNode(
+ buildContext = buildContext,
+ plugins = plugins,
+ loginEntryPoint = loginEntryPoint,
+ imageLoaderHolder = FakeImageLoaderHolder(),
+ analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(),
+ )
+ }
+ )
+ )
+ )
+ return RootFlowNode(
+ buildContext = BuildContext(
+ ancestryInfo = AncestryInfo.Child(anchor = parentNode),
+ savedStateMap = null,
+ customisations = NodeCustomisationDirectoryImpl(),
+ ),
+ plugins = emptyList(),
+ sessionStore = sessionStore,
+ accountProviderAccessControl = FakeAccountProviderAccessControl { true },
+ navStateFlowFactory = RootNavStateFlowFactory(
+ sessionStore = sessionStore,
+ cacheService = FakeCacheService(),
+ matrixSessionCache = matrixSessionCache,
+ imageLoaderHolder = FakeImageLoaderHolder(),
+ sessionPreferencesStoreFactory = FakeSessionPreferencesStoreFactory(),
+ ),
+ matrixSessionCache = matrixSessionCache,
+ presenter = RootPresenter(
+ crashDetectionPresenter = NotUsedPresenter(),
+ rageshakeDetectionPresenter = NotUsedPresenter(),
+ appErrorStateService = FakeAppErrorStateService(),
+ analyticsService = FakeAnalyticsService(),
+ sdkMetadata = FakeSdkMetadata("sha"),
+ ),
+ bugReportEntryPoint = FakeBugReportEntryPoint(),
+ signedOutEntryPoint = FakeSignedOutEntryPoint(),
+ accountSelectEntryPoint = FakeAccountSelectEntryPoint(),
+ intentResolver = IntentResolver(
+ deeplinkParser = { null },
+ loginIntentResolver = FakeLoginIntentResolver { A_LOGIN_PARAMS },
+ oAuthIntentResolver = FakeOAuthIntentResolver { null },
+ permalinkParser = FakePermalinkParser(),
+ shareIntentHandler = FakeShareIntentHandler(),
+ ),
+ oAuthActionFlow = FakeOAuthActionFlow(),
+ featureFlagService = FakeFeatureFlagService(),
+ announcementService = FakeAnnouncementService(),
+ analyticsService = FakeAnalyticsService(),
+ analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(),
+ appCoroutineScope = backgroundScope,
+ )
+ }
+}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt
index 7309ca6ac2..32d0ec8f56 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt
@@ -8,10 +8,11 @@
package io.element.android.appnav
-import io.element.android.appnav.di.SyncOrchestrator
+import io.element.android.appnav.session.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.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
@@ -40,7 +41,7 @@ class SyncOrchestratorTest {
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
)
@@ -60,7 +61,7 @@ class SyncOrchestratorTest {
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
)
@@ -85,7 +86,7 @@ class SyncOrchestratorTest {
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -117,7 +118,7 @@ class SyncOrchestratorTest {
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -165,7 +166,7 @@ class SyncOrchestratorTest {
initialIsSyncingNotificationEventValue = false,
)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -208,7 +209,7 @@ class SyncOrchestratorTest {
initialIsSyncingNotificationEventValue = false,
)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -252,7 +253,7 @@ class SyncOrchestratorTest {
initialHasRingingCall = false,
)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -296,7 +297,7 @@ class SyncOrchestratorTest {
initialIsInCallValue = true,
)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -339,7 +340,7 @@ class SyncOrchestratorTest {
initialIsInCallValue = false,
)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -369,7 +370,7 @@ class SyncOrchestratorTest {
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
val syncOrchestrator = createSyncOrchestrator(
- syncService = syncService,
+ matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
)
@@ -381,12 +382,44 @@ class SyncOrchestratorTest {
startSyncRecorder.assertions().isNeverCalled()
}
+ @Test
+ fun `when the client is shutting down, the orchestrator does nothing`() = runTest {
+ val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
+ val appForegroundStateService = FakeAppForegroundStateService(
+ initialForegroundValue = false,
+ initialIsSyncingNotificationEventValue = false,
+ initialIsInCallValue = false,
+ )
+
+ val startSyncRecorder = lambdaRecorder> { Result.success(Unit) }
+ val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
+ startSyncLambda = startSyncRecorder
+ }
+ val syncOrchestrator = createSyncOrchestrator(
+ matrixClient = FakeMatrixClient(syncService = syncService, isShuttingDownResult = { true }),
+ networkMonitor = networkMonitor,
+ appForegroundStateService = appForegroundStateService,
+ )
+
+ // We start observing
+ syncOrchestrator.observeStates()
+
+ // These should still not trigger a sync, sync the client is shutting down
+ networkMonitor.givenNetworkBlocked(false)
+ appForegroundStateService.updateIsInCallState(true)
+ appForegroundStateService.isInForeground.value = true
+
+ advanceTimeBy(10.seconds)
+
+ startSyncRecorder.assertions().isNeverCalled()
+ }
+
private fun TestScope.createSyncOrchestrator(
- syncService: FakeSyncService = FakeSyncService(),
+ matrixClient: FakeMatrixClient = FakeMatrixClient(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
) = SyncOrchestrator(
- syncService = syncService,
+ matrixClient = matrixClient,
sessionCoroutineScope = backgroundScope,
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
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 841f79678b..ddeae41a78 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
@@ -45,6 +45,24 @@ class IntentResolverTest : RobolectricTest() {
assertThat(result).isNull()
}
+ @Test
+ fun `resolve intent replayed from the history should return null`() {
+ val sut = createIntentResolver(
+ deeplinkParserResult = DeeplinkData.Room(
+ sessionId = A_SESSION_ID,
+ roomId = A_ROOM_ID,
+ threadId = null,
+ eventId = null,
+ )
+ )
+ val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
+ action = Intent.ACTION_VIEW
+ addFlags(Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY)
+ }
+ val result = sut.resolve(intent)
+ assertThat(result).isNull()
+ }
+
@Test
fun `test resolve navigation intent root`() {
val sut = createIntentResolver(
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 faa19d9e61..0e2d459d1e 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
@@ -48,6 +48,7 @@ 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.consumeItemsUntilPredicate
+import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@@ -346,6 +347,44 @@ class LoggedInPresenterTest {
}
}
+ @Test
+ fun `present - does not enable automatic call status when server does not support it`() = runTest {
+ val enableAutomaticCallStatusLambda = lambdaRecorder { }
+ val matrixClient = FakeMatrixClient(
+ accountManagementUrlResult = { Result.success(null) },
+ enableAutomaticCallStatusLambda = enableAutomaticCallStatusLambda,
+ ).apply {
+ isUserStatusSupportedResult = Result.success(false)
+ }
+ createLoggedInPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ cancelAndConsumeRemainingEvents()
+ assert(enableAutomaticCallStatusLambda)
+ .isCalledOnce()
+ .with(value(false))
+ }
+ }
+
+ @Test
+ fun `present - enables automatic call status when server supports it`() = runTest {
+ val enableAutomaticCallStatusLambda = lambdaRecorder { }
+ val matrixClient = FakeMatrixClient(
+ accountManagementUrlResult = { Result.success(null) },
+ enableAutomaticCallStatusLambda = enableAutomaticCallStatusLambda,
+ ).apply {
+ isUserStatusSupportedResult = Result.success(true)
+ }
+ createLoggedInPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ cancelAndConsumeRemainingEvents()
+ assert(enableAutomaticCallStatusLambda)
+ .isCalledOnce()
+ .with(value(true))
+ }
+ }
+
private suspend fun ReceiveTurbine.awaitFirstItem(): T {
skipItems(1)
return awaitItem()
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/session/FakeSyncOrchestratorFactory.kt b/appnav/src/test/kotlin/io/element/android/appnav/session/FakeSyncOrchestratorFactory.kt
new file mode 100644
index 0000000000..586c8f0804
--- /dev/null
+++ b/appnav/src/test/kotlin/io/element/android/appnav/session/FakeSyncOrchestratorFactory.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.appnav.session
+
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.tests.testutils.lambda.lambdaError
+import kotlinx.coroutines.CoroutineScope
+
+class FakeSyncOrchestratorFactory : SyncOrchestrator.Factory {
+ override fun create(
+ matrixClient: MatrixClient,
+ sessionCoroutineScope: CoroutineScope,
+ ): SyncOrchestrator = lambdaError()
+}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt
similarity index 97%
rename from appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt
rename to appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt
index 36fa471dd0..bf315ddb01 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt
@@ -6,12 +6,12 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.appnav.di
+package io.element.android.appnav.session
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.sync.SyncService
+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
@@ -138,11 +138,11 @@ class MatrixSessionCacheTest {
return object : SyncOrchestrator.Factory {
override fun create(
- syncService: SyncService,
+ matrixClient: MatrixClient,
sessionCoroutineScope: CoroutineScope,
): SyncOrchestrator {
return SyncOrchestrator(
- syncService = syncService,
+ matrixClient = matrixClient,
sessionCoroutineScope = sessionCoroutineScope,
appForegroundStateService = FakeAppForegroundStateService(),
networkMonitor = FakeNetworkMonitor(),
diff --git a/build.gradle.kts b/build.gradle.kts
index 0e0df9dbc6..6bbc568c66 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -98,7 +98,7 @@ allprojects {
// This is disabled by default, but the CI will enforce this.
// You can override by passing `-PallWarningsAsErrors=true` in the command line
// Or add a line with "allWarningsAsErrors=true" in your ~/.gradle/gradle.properties file
- allWarningsAsErrors = project.properties["allWarningsAsErrors"] == "true"
+ allWarningsAsErrors = findProperty("allWarningsAsErrors") == "true"
// Uncomment to suppress Compose Kotlin compiler compatibility warning
// freeCompilerArgs.addAll(listOf("-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true"))
diff --git a/codegen/build.gradle.kts b/codegen/build.gradle.kts
index 5c71c170bf..396ef665eb 100644
--- a/codegen/build.gradle.kts
+++ b/codegen/build.gradle.kts
@@ -1,3 +1,5 @@
+import org.sonarqube.gradle.SonarResolverTask
+
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
@@ -18,3 +20,9 @@ dependencies {
implementation(libs.ksp.plugin)
implementation(libs.kotlinpoet.ksp)
}
+
+// Ensure that the processResources task is executed before the SonarResolverTask to avoid issues with missing resources during SonarQube analysis.
+// This *should not be necesssary* but is a workaround for a known issue with the SonarQube Gradle plugin.
+tasks.withType().configureEach {
+ dependsOn("processResources")
+}
diff --git a/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt b/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt
index 8b537a53fe..3e6c05ad01 100644
--- a/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt
+++ b/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt
@@ -61,32 +61,32 @@ class ContributesNodeProcessor(
if (config.enableLogging) {
logger.warn("Processing ${ksClass.qualifiedName?.asString()}")
}
- generateModule(ksClass)
+ generateBindingContainer(ksClass)
generateFactory(ksClass)
}
return invalidSymbols
}
- private fun generateModule(ksClass: KSClassDeclaration) {
+ private fun generateBindingContainer(ksClass: KSClassDeclaration) {
val annotation = ksClass.annotations.find { it.shortName.asString() == "ContributesNode" }!!
val scope = annotation.arguments.find { it.name?.asString() == "scope" }!!.value as KSType
- val modulePackage = ksClass.packageName.asString()
- val moduleClassName = "${ksClass.simpleName.asString()}_Module"
+ val bindingContainerPackage = ksClass.packageName.asString()
+ val bindingContainerClassName = "${ksClass.simpleName.asString()}BindingContainer"
val nodeClassName = ClassName.bestGuess(ksClass.qualifiedName!!.asString())
val content = FileSpec.builder(
- packageName = modulePackage,
- fileName = moduleClassName,
+ packageName = bindingContainerPackage,
+ fileName = bindingContainerClassName,
)
.addType(
- TypeSpec.interfaceBuilder(moduleClassName)
+ TypeSpec.interfaceBuilder(bindingContainerClassName)
.addAnnotation(AnnotationSpec.builder(Origin::class).addMember(CLASS_PLACEHOLDER, nodeClassName).build())
.addAnnotation(BindingContainer::class)
.addAnnotation(AnnotationSpec.builder(ContributesTo::class).addMember(CLASS_PLACEHOLDER, scope.toTypeName()).build())
.addFunction(
- FunSpec.builder("bind${ksClass.simpleName.asString()}Factory")
+ FunSpec.builder("bind${ksClass.simpleName.asString()}AssistedFactory")
.addModifiers(KModifier.ABSTRACT)
- .addParameter("factory", ClassName(modulePackage, "${ksClass.simpleName.asString()}_AssistedFactory"))
+ .addParameter("factory", ClassName(bindingContainerPackage, "${ksClass.simpleName.asString()}AssistedFactory"))
.returns(ClassName.bestGuess(assistedNodeFactoryFqName.asString()).parameterizedBy(STAR))
.addAnnotation(Binds::class)
.addAnnotation(IntoMap::class)
@@ -114,7 +114,7 @@ class ContributesNodeProcessor(
@OptIn(KspExperimental::class)
private fun generateFactory(ksClass: KSClassDeclaration) {
val generatedPackage = ksClass.packageName.asString()
- val assistedFactoryClassName = "${ksClass.simpleName.asString()}_AssistedFactory"
+ val assistedFactoryClassName = "${ksClass.simpleName.asString()}AssistedFactory"
val constructor = ksClass.getConstructors().first { it.parameters.isNotEmpty() }
val assistedParameters = constructor.parameters.filter { it.isAnnotationPresent(Assisted::class) }
if (assistedParameters.size != 2) {
diff --git a/docs/_developer_onboarding.md b/docs/_developer_onboarding.md
index 74020ead98..e7515eb466 100644
--- a/docs/_developer_onboarding.md
+++ b/docs/_developer_onboarding.md
@@ -303,6 +303,8 @@ Follow these steps to install and configure the plugin and templates:
- Click on OK
4. Configure generate-module-from-template plugin :
- Navigate to AS/Settings/Tools/Module Template Settings
+ - If a `FeatureModule` template is already listed, select it and remove it, since the plugin keeps its own copy of the
+ imported template and will not pick up changes made to `FeatureModule.json` on its own
- Click on + / Import From File
- Pick the `tools/templates/FeatureModule.json`
@@ -320,9 +322,11 @@ Example for a new feature called RoomDetails:
4. Verify that the structure looks ok and click on Finish
5. The modules api/impl should be created under `features/roomdetails` directory.
6. Sync project with Gradle so the modules are recognized (no need to add them to settings.gradle).
-7. You can now add more Presentation classes (Events, State, StateProvider, View, Presenter) in the impl module with the `Template Presentation Classes`.
- To use it, just right click on the package where you want to generate classes, and click on `Template Presentation Classes`.
- Fill the text field with the base name of the classes, ie `RootRoomDetails` in the `root` package.
+7. You can now add more Presentation classes (Events, State, StatePreviewParam, View, Presenter) in the impl module with the `Template Presentation Classes`.
+ To use it, just right click on the package where you want to generate classes, and click on `New` / `Template Presentation Classes`.
+ Fill the text field with the base name of the classes: the generated files are `Event.kt`, `State.kt`,
+ `StatePreviewParam.kt`, `Presenter.kt`, `Node.kt` and `View.kt`. For instance
+ `PreferencesRoot` in the `features/preferences/impl/root` package.
Note that naming of files and classes is important, since those names are used to set up code coverage rules. For instance, presenters MUST have a
@@ -356,9 +360,9 @@ We have 3 tests frameworks in place, and this should be sufficient to guarantee
- Maestro to test the global usage of the application. See the related [documentation](../.maestro/README.md).
- Combination of [Showkase](https://github.com/airbnb/Showkase) and [Paparazzi](https://github.com/cashapp/paparazzi), to test UI pixel perfect. To add test,
just add `@Preview` for the composable you are adding. See the related [documentation](screenshot_testing.md) and see in the template the
- file [TemplateView.kt](../features/template/src/main/kotlin/io/element/android/features/template/TemplateView.kt). We create PreviewProvider to provide
+ file [TemplateView.kt](../features/template/src/main/kotlin/io/element/android/features/template/TemplateView.kt). We create PreviewParam classes to provide
different states. See for instance the
- file [TemplateStateProvider.kt](../features/template/src/main/kotlin/io/element/android/features/template/TemplateStateProvider.kt)
+ file [TemplateStatePreviewParam.kt](../features/template/src/main/kotlin/io/element/android/features/template/TemplateStatePreviewParam.kt)
- Tests on presenter with [Molecule](https://github.com/cashapp/molecule) and [Turbine](https://github.com/cashapp/turbine). See in the template the class [TemplatePresenterTests](../features/template/src/test/kotlin/io/element/android/features/template/TemplatePresenterTests.kt).
**Note** For now we want to avoid using class mocking (with library such as *mockk*), because this should be not necessary. We prefer to create Fake
diff --git a/docs/oauth.md b/docs/oauth.md
index 1080c64b0e..8cce5abc91 100644
--- a/docs/oauth.md
+++ b/docs/oauth.md
@@ -6,8 +6,6 @@ Rust sdk branch: https://github.com/matrix-org/matrix-rust-sdk/tree/oidc-ffi
Figma https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?node-id=133-5426&t=yQXKeANatk6keoZF-0
-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,
diff --git a/enterprise b/enterprise
index 029db99513..e633a343b1 160000
--- a/enterprise
+++ b/enterprise
@@ -1 +1 @@
-Subproject commit 029db99513d569b715ad3bb24145b3c2ac9c14cc
+Subproject commit e633a343b12088b1fe3fd6b27ec319c1f7f2931c
diff --git a/fastlane/metadata/android/en-US/changelogs/202608010.txt b/fastlane/metadata/android/en-US/changelogs/202608010.txt
new file mode 100644
index 0000000000..a4b397f1bb
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202608010.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/202608020.txt b/fastlane/metadata/android/en-US/changelogs/202608020.txt
new file mode 100644
index 0000000000..a4b397f1bb
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202608020.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/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/AnalyticsPreferencesStatePreviewParam.kt
similarity index 89%
rename from features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt
rename to features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStatePreviewParam.kt
index 02e07a86a2..89d19ebac8 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/AnalyticsPreferencesStatePreviewParam.kt
@@ -10,7 +10,7 @@ package io.element.android.features.analytics.api.preferences
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-open class AnalyticsPreferencesStateProvider : PreviewParameterProvider {
+open class AnalyticsPreferencesStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aAnalyticsPreferencesState().copy(isEnabled = true),
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 1158b57673..0d141e5ed2 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
@@ -66,7 +66,7 @@ fun AnalyticsPreferencesView(
@PreviewsDayNight
@Composable
-internal fun AnalyticsPreferencesViewPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) =
+internal fun AnalyticsPreferencesViewPreview(@PreviewParameter(AnalyticsPreferencesStatePreviewParam::class) state: AnalyticsPreferencesState) =
ElementPreview {
AnalyticsPreferencesView(
state = state,
diff --git a/features/analytics/api/src/main/res/values-ar/translations.xml b/features/analytics/api/src/main/res/values-ar/translations.xml
new file mode 100644
index 0000000000..449a0a01cb
--- /dev/null
+++ b/features/analytics/api/src/main/res/values-ar/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "مشاركة بيانات استخدام مجهولة لمساعدتنا على تتبع المشاكل."
+ "يمكنك قراءة كافة شروطنا %1$s."
+ "هنا"
+ "مشاركة البيانات الإحصائية"
+
diff --git a/features/analytics/api/src/main/res/values-pl/translations.xml b/features/analytics/api/src/main/res/values-pl/translations.xml
index a3347582cc..37848b4c3f 100644
--- a/features/analytics/api/src/main/res/values-pl/translations.xml
+++ b/features/analytics/api/src/main/res/values-pl/translations.xml
@@ -1,6 +1,6 @@
- "Udostępniaj anonimowe dane użytkowania, aby pomóc nam identyfikować problemy."
+ "Udostępniaj anonimowe dane użytkowania, aby pomóc nam ulepszać aplikację."
"Przeczytaj nasze warunki użytkowania %1$s."
"tutaj"
"Udostępniaj dane analityczne"
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/AnalyticsOptInStatePreviewParam.kt
similarity index 88%
rename from features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInStateProvider.kt
rename to features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInStatePreviewParam.kt
index 30a396c178..960abb7216 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/AnalyticsOptInStatePreviewParam.kt
@@ -10,7 +10,7 @@ package io.element.android.features.analytics.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-open class AnalyticsOptInStateProvider : PreviewParameterProvider {
+open class AnalyticsOptInStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aAnalyticsOptInState(),
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 c05edd825c..678d28c5c9 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
@@ -171,7 +171,7 @@ private fun AnalyticsOptInFooter(
@PreviewsDayNight
@Composable
-internal fun AnalyticsOptInViewPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreview {
+internal fun AnalyticsOptInViewPreview(@PreviewParameter(AnalyticsOptInStatePreviewParam::class) state: AnalyticsOptInState) = ElementPreview {
AnalyticsOptInView(
state = state,
onClickTerms = {},
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/di/AnalyticsModule.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/di/AnalyticsBindingContainer.kt
similarity index 95%
rename from features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/di/AnalyticsModule.kt
rename to features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/di/AnalyticsBindingContainer.kt
index 32a3d5826a..a4c2c68670 100644
--- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/di/AnalyticsModule.kt
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/di/AnalyticsBindingContainer.kt
@@ -18,7 +18,7 @@ import io.element.android.libraries.architecture.Presenter
@ContributesTo(AppScope::class)
@BindingContainer
-interface AnalyticsModule {
+interface AnalyticsBindingContainer {
@Binds
fun bindAnalyticsPreferencesPresenter(presenter: AnalyticsPreferencesPresenter): Presenter
}
diff --git a/features/analytics/impl/src/main/res/values-ar/translations.xml b/features/analytics/impl/src/main/res/values-ar/translations.xml
new file mode 100644
index 0000000000..8d1bf460d6
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-ar/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "لن نسجّل أو ننشئ ملفات شخصية لأي معلومات شخصية"
+ "مشاركة بيانات استخدام مجهولة لمساعدتنا على تتبع المشاكل."
+ "يمكنك قراءة كافة شروطنا %1$s."
+ "هنا"
+ "يمكنك إطفاء هذا الخيار في أي وقت"
+ "لن نشارك بياناتك مع أطراف ثالثة"
+ "ساعدنا على التحسن %1$s"
+
diff --git a/features/analytics/impl/src/main/res/values-pl/translations.xml b/features/analytics/impl/src/main/res/values-pl/translations.xml
index 61da8408ad..c58d630ce9 100644
--- a/features/analytics/impl/src/main/res/values-pl/translations.xml
+++ b/features/analytics/impl/src/main/res/values-pl/translations.xml
@@ -1,7 +1,7 @@
"Nie będziemy rejestrować ani profilować żadnych danych osobistych"
- "Udostępniaj anonimowe dane użytkowania, aby pomóc nam identyfikować problemy."
+ "Udostępniaj anonimowe dane użytkowania, aby pomóc nam ulepszać aplikację."
"Przeczytaj nasze warunki użytkowania %1$s."
"tutaj"
"Możesz to wyłączyć w dowolnym momencie"
diff --git a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementService.kt b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementService.kt
index 42c66aa29d..23c748d6da 100644
--- a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementService.kt
+++ b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementService.kt
@@ -12,15 +12,34 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import kotlinx.coroutines.flow.Flow
+/**
+ * Queues the full-screen announcements shown to the user once, such as the notice about a new feature.
+ *
+ * A dismissed announcement is remembered so it is not shown again.
+ */
interface AnnouncementService {
+ /**
+ * Queues an announcement, unless the user has already dismissed it.
+ *
+ * @param announcement the announcement to show.
+ */
suspend fun showAnnouncement(announcement: Announcement)
+ /**
+ * Records that the user dismissed an announcement, so it is not queued again.
+ *
+ * @param announcement the announcement that was dismissed.
+ */
suspend fun onAnnouncementDismissed(announcement: Announcement)
+ /** The announcements still waiting to be shown, in the order they should appear. */
fun announcementsToShowFlow(): Flow>
/**
* Use this composable to render the announcement UI in Fullscreen.
+ * Draws nothing while there is no announcement to show.
+ *
+ * @param modifier layout modifier for the container.
*/
@Composable
fun Render(
diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementStateProvider.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementStatePreviewParam.kt
similarity index 90%
rename from features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementStateProvider.kt
rename to features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementStatePreviewParam.kt
index 2412fee167..df7afad32f 100644
--- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementStateProvider.kt
+++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementStatePreviewParam.kt
@@ -11,7 +11,7 @@ package io.element.android.features.announcement.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.announcement.api.Announcement
-open class AnnouncementStateProvider : PreviewParameterProvider {
+open class AnnouncementStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
anAnnouncementState(),
diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementView.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementView.kt
index 5f7778900b..cb962f8968 100644
--- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementView.kt
+++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementView.kt
@@ -33,7 +33,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.impl.AnnouncementEvent
import io.element.android.features.announcement.impl.AnnouncementState
-import io.element.android.features.announcement.impl.AnnouncementStateProvider
+import io.element.android.features.announcement.impl.AnnouncementStatePreviewParam
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
@@ -216,7 +216,7 @@ private fun Announcement.Fullscreen.notice(): String? = when (this) {
@PreviewsDayNight
@Composable
-internal fun FullscreenAnnouncementViewPreview(@PreviewParameter(AnnouncementStateProvider::class) state: AnnouncementState) = ElementPreview {
+internal fun FullscreenAnnouncementViewPreview(@PreviewParameter(AnnouncementStatePreviewParam::class) state: AnnouncementState) = ElementPreview {
FullscreenAnnouncementView(
state = state,
)
diff --git a/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleaner.kt b/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleaner.kt
index b5a5396b16..48b464cb21 100644
--- a/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleaner.kt
+++ b/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleaner.kt
@@ -8,6 +8,9 @@
package io.element.android.features.cachecleaner.api
+/**
+ * Removes the temporary files holding decrypted content, so plaintext media does not linger on disk.
+ */
interface CacheCleaner {
/**
* Clear the cache subdirs holding temporarily decrypted content (such as media and voice messages).
diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallService.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallService.kt
index 25720596ba..20ff7ef4d8 100644
--- a/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallService.kt
+++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallService.kt
@@ -10,6 +10,9 @@ package io.element.android.features.call.api
import kotlinx.coroutines.flow.StateFlow
+/**
+ * Tracks the call the user is currently in on this device, so unrelated parts of the app can react to it.
+ */
interface CurrentCallService {
/**
* The current call state flow, which will be updated when the active call changes.
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt
index 1af13e148e..2053ff5102 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt
@@ -11,7 +11,9 @@ package io.element.android.features.call.impl.pip
import io.element.android.features.call.impl.utils.PipController
sealed interface PictureInPictureEvent {
+ data object OnCallStarted : PictureInPictureEvent
data class SetPipController(val pipController: PipController) : PictureInPictureEvent
data object EnterPictureInPicture : PictureInPictureEvent
data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvent
+ data class SetPipOrientation(val orientation: Int?) : PictureInPictureEvent
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt
index 2dc16d7f26..274a80cc26 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt
@@ -38,6 +38,9 @@ class PictureInPicturePresenter(
fun handleEvent(event: PictureInPictureEvent) {
when (event) {
+ is PictureInPictureEvent.OnCallStarted -> {
+ pipController?.setupPipCallbacks()
+ }
is PictureInPictureEvent.SetPipController -> {
pipController = event.pipController
}
@@ -55,6 +58,12 @@ class PictureInPicturePresenter(
pipController?.exitPip()
}
}
+ is PictureInPictureEvent.SetPipOrientation -> {
+ Timber.tag(loggerTag.value).d("onOrientationChange: ${event.orientation}")
+ if (isPipSupported) {
+ pipView?.setPipOrientation(event.orientation)
+ }
+ }
}
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt
index 90f74a37fd..54b8d77601 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt
@@ -8,8 +8,15 @@
package io.element.android.features.call.impl.pip
+import android.os.Build
+import androidx.annotation.RequiresApi
+
interface PipView {
+ @RequiresApi(Build.VERSION_CODES.O)
+ fun setPipOrientation(orientation: Int?)
+ @RequiresApi(Build.VERSION_CODES.O)
fun setPipParams()
+ @RequiresApi(Build.VERSION_CODES.O)
fun enterPipMode(): Boolean
fun hangUp()
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallNotificationDataProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallNotificationDataPreviewParam.kt
similarity index 94%
rename from features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallNotificationDataProvider.kt
rename to features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallNotificationDataPreviewParam.kt
index 9e551b3e1b..8ca3893f2b 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallNotificationDataProvider.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallNotificationDataPreviewParam.kt
@@ -16,7 +16,7 @@ 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
-open class CallNotificationDataProvider : PreviewParameterProvider {
+open class CallNotificationDataPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aCallNotificationData(
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStatePreviewParam.kt
similarity index 93%
rename from features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt
rename to features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStatePreviewParam.kt
index 155c5d3380..cc21197a51 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStatePreviewParam.kt
@@ -11,7 +11,7 @@ package io.element.android.features.call.impl.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
-open class CallScreenStateProvider : PreviewParameterProvider {
+open class CallScreenStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aCallScreenState(),
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 2537eb739c..33c3024f83 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
@@ -118,6 +118,7 @@ internal fun CallScreenView(
if (webViewAudioManager?.isInCallMode?.get() == false) {
Timber.d("URL $url is loaded, starting in-call audio mode")
webViewAudioManager?.onCallStarted()
+ pipState.eventSink(PictureInPictureEvent.OnCallStarted)
} else {
Timber.d("Can't start in-call audio mode since the app is already in it.")
}
@@ -130,7 +131,12 @@ internal fun CallScreenView(
onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
)
state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor))
- val pipController = WebViewPipController(webView)
+ val pipController = WebViewPipController(
+ webView = webView,
+ updatePipOrientation = { orientation ->
+ pipState.eventSink(PictureInPictureEvent.SetPipOrientation(orientation))
+ }
+ )
pipState.eventSink(PictureInPictureEvent.SetPipController(pipController))
},
onDestroyWebView = {
@@ -260,7 +266,7 @@ private fun WebView.dispatchEscKeyEvent() {
@PreviewsDayNight
@Composable
internal fun CallScreenViewPreview(
- @PreviewParameter(CallScreenStateProvider::class) state: CallScreenState,
+ @PreviewParameter(CallScreenStatePreviewParam::class) state: CallScreenState,
) = ElementPreview {
CallScreenView(
state = state,
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 9a093ff5a6..4a6b8f1c05 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
@@ -11,9 +11,9 @@ 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.os.Build
import android.os.Bundle
-import android.util.Rational
import android.view.WindowManager
import android.webkit.PermissionRequest
import androidx.activity.compose.setContent
@@ -49,6 +49,7 @@ import io.element.android.features.call.impl.pip.PipView
import io.element.android.features.call.impl.services.CallForegroundService
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.androidutils.browser.ConsoleMessageLogger
+import io.element.android.libraries.androidutils.media.setAspectRatioFromOrientation
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.audio.api.AudioFocus
@@ -86,6 +87,8 @@ class ElementCallActivity :
private var eventSink: ((CallScreenEvent) -> Unit)? = null
+ private var currentPipOrientation: Int? = null
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -199,6 +202,9 @@ class ElementCallActivity :
}
DisposableEffect(Unit) {
val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo ->
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ setPipParams()
+ }
pipEventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(isInPictureInPictureMode))
if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call")
@@ -296,11 +302,18 @@ class ElementCallActivity :
}
}
+ @RequiresApi(Build.VERSION_CODES.O)
+ override fun setPipOrientation(orientation: Int?) {
+ currentPipOrientation = orientation
+ setPictureInPictureParams(getPictureInPictureParams())
+ }
+
@RequiresApi(Build.VERSION_CODES.O)
private fun getPictureInPictureParams(): PictureInPictureParams {
+ // Portrait for calls seems more appropriate as a fallback value
+ val orientation = currentPipOrientation ?: Configuration.ORIENTATION_PORTRAIT
return PictureInPictureParams.Builder()
- // Portrait for calls seems more appropriate
- .setAspectRatio(Rational(3, 5))
+ .setAspectRatioFromOrientation(orientation)
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setAutoEnterEnabled(true)
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 72edd59ed5..1489d9d0e7 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
@@ -161,7 +161,7 @@ private fun ActionButton(
@PreviewsDayNight
@Composable
internal fun IncomingCallScreenPreview(
- @PreviewParameter(CallNotificationDataProvider::class) state: CallNotificationData,
+ @PreviewParameter(CallNotificationDataPreviewParam::class) state: CallNotificationData,
) = ElementPreview {
IncomingCallScreen(
notificationData = state,
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt
index b259816f9d..f7788ad520 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt
@@ -9,6 +9,7 @@
package io.element.android.features.call.impl.utils
interface PipController {
+ fun setupPipCallbacks()
suspend fun canEnterPip(): Boolean
fun enterPip()
fun exitPip()
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
index e68e57118b..3290269971 100644
--- 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
@@ -250,7 +250,7 @@ class WebViewAudioManager(
hasRegisteredCallbacks = true
}
- }
+ },
)
Timber.d("Setting androidNativeBridge javascript interface in webview")
webView.addJavascriptInterface(webViewAudioDeviceSelectedCallback, "androidNativeBridge")
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 12f8dfd00f..88648e84fa 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
@@ -8,7 +8,10 @@
package io.element.android.features.call.impl.utils
+import android.content.res.Configuration
+import android.webkit.JavascriptInterface
import android.webkit.WebView
+import timber.log.Timber
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@@ -18,7 +21,18 @@ import kotlin.coroutines.suspendCoroutine
*/
class WebViewPipController(
private val webView: WebView,
+ updatePipOrientation: (orientation: Int?) -> Unit,
) : PipController {
+ init {
+ // Register the JavaScript interface ahead of time to ensure it's available when the WebView content is loaded and tries to call it
+ webView.addJavascriptInterface(PipBridge(updatePipOrientation), "androidPipBridge")
+ }
+
+ override fun setupPipCallbacks() {
+ Timber.d("Adding callback in controls.onPipMediaOrientationUpdate")
+ webView.evaluateJavascript("controls.onPipMediaOrientationUpdate = (orientation) => { androidPipBridge.setPipOrientation(orientation); };", null)
+ }
+
override suspend fun canEnterPip(): Boolean {
return suspendCoroutine { continuation ->
webView.evaluateJavascript("controls.canEnterPip()") { result ->
@@ -36,3 +50,19 @@ class WebViewPipController(
webView.evaluateJavascript("controls.disablePip()", null)
}
}
+
+private class PipBridge(
+ private val onOrientationChange: (orientation: Int?) -> Unit,
+) {
+ @JavascriptInterface
+ fun setPipOrientation(orientation: String?) {
+ Timber.d("Picture-in-picture orientation changed in webview, orientation: $orientation")
+ // This callback is used to update the picture-in-picture orientation in the WebView when it changes
+ val orientation = when (orientation) {
+ "portrait" -> Configuration.ORIENTATION_PORTRAIT
+ "landscape" -> Configuration.ORIENTATION_LANDSCAPE
+ else -> return
+ }
+ onOrientationChange(orientation)
+ }
+}
diff --git a/features/call/impl/src/main/res/values-ar/translations.xml b/features/call/impl/src/main/res/values-ar/translations.xml
new file mode 100644
index 0000000000..612dd16a28
--- /dev/null
+++ b/features/call/impl/src/main/res/values-ar/translations.xml
@@ -0,0 +1,8 @@
+
+
+ "مكالمة جارية"
+ "اضغط للعودة إلى المكالمة"
+ "☎️ الاتصال يتمّ"
+ "لا يدعم Element Call استخدام أجهزة الصوت عبر Bluetooth في إصدار Android هذا. يرجى اختيار جهاز صوت آخر."
+ "مكالمة Element Call واردة"
+
diff --git a/features/call/impl/src/main/res/values-fa/translations.xml b/features/call/impl/src/main/res/values-fa/translations.xml
index c58ebfd5c6..622be3d428 100644
--- a/features/call/impl/src/main/res/values-fa/translations.xml
+++ b/features/call/impl/src/main/res/values-fa/translations.xml
@@ -3,5 +3,6 @@
"تماس خروجی"
"زدن برای بازگشت به تماس"
"تماس در جریان ☎️"
+ "برنامه Element Call در این نسخه اندروید از دستگاههای صوتی بلوتوث پشتیبانی نمیکند. لطفاً دستگاه صوتی دیگری را انتخاب کنید."
"تماس المنتی ورودی"
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt
index 5153f79c31..f2eca681e9 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt
@@ -12,10 +12,13 @@ import io.element.android.features.call.impl.utils.PipController
import io.element.android.tests.testutils.lambda.lambdaError
class FakePipController(
+ private val setupPipCallbacksResult: () -> Unit = { lambdaError() },
private val canEnterPipResult: () -> Boolean = { lambdaError() },
private val enterPipResult: () -> Unit = { lambdaError() },
private val exitPipResult: () -> Unit = { lambdaError() },
) : PipController {
+ override fun setupPipCallbacks() = setupPipCallbacksResult()
+
override suspend fun canEnterPip(): Boolean = canEnterPipResult()
override fun enterPip() = enterPipResult()
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt
index 55e94312bf..adfcde2c38 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt
@@ -13,9 +13,11 @@ import io.element.android.tests.testutils.lambda.lambdaError
class FakePipView(
private val setPipParamsResult: () -> Unit = { lambdaError() },
private val enterPipModeResult: () -> Boolean = { lambdaError() },
- private val handUpResult: () -> Unit = { lambdaError() }
+ private val handUpResult: () -> Unit = { lambdaError() },
+ private val onOrientationChangeResult: (orientation: Int?) -> Unit = { lambdaError() },
) : PipView {
override fun setPipParams() = setPipParamsResult()
override fun enterPipMode(): Boolean = enterPipModeResult()
override fun hangUp() = handUpResult()
+ override fun setPipOrientation(orientation: Int?) = onOrientationChangeResult(orientation)
}
diff --git a/features/contentscanner/api/src/main/kotlin/io/element/android/features/contentscanner/api/ContentScannerService.kt b/features/contentscanner/api/src/main/kotlin/io/element/android/features/contentscanner/api/ContentScannerService.kt
index 68f7af28f1..3649768eb7 100644
--- a/features/contentscanner/api/src/main/kotlin/io/element/android/features/contentscanner/api/ContentScannerService.kt
+++ b/features/contentscanner/api/src/main/kotlin/io/element/android/features/contentscanner/api/ContentScannerService.kt
@@ -18,5 +18,11 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentVal
* This will only process media when a [ContentScanner] is provided when building the client in Pro.
*/
fun interface ContentScannerService {
+ /**
+ * Starts scanning the given media, reporting the verdict of each one through [contentValidationState] rather than by returning.
+ *
+ * @param mediaSources the media to scan.
+ * @param contentValidationState where the outcome of each scan is published for the UI to observe.
+ */
fun scan(mediaSources: List, contentValidationState: ContentValidationState)
}
diff --git a/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerModule.kt b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerBindingContainer.kt
similarity index 97%
rename from features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerModule.kt
rename to features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerBindingContainer.kt
index 9446a603b2..8c25d8bf6a 100644
--- a/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerModule.kt
+++ b/features/contentscanner/impl/src/main/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerBindingContainer.kt
@@ -21,7 +21,7 @@ import kotlinx.coroutines.CoroutineScope
@BindingContainer
@ContributesTo(RoomScope::class)
-object ContentScannerModule {
+object ContentScannerBindingContainer {
@Provides
@SingleIn(RoomScope::class)
fun providesContentScannerService(
diff --git a/features/contentscanner/impl/src/test/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerModuleTest.kt b/features/contentscanner/impl/src/test/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerBindingContainerTest.kt
similarity index 77%
rename from features/contentscanner/impl/src/test/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerModuleTest.kt
rename to features/contentscanner/impl/src/test/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerBindingContainerTest.kt
index 8c3834d696..858a21df6c 100644
--- a/features/contentscanner/impl/src/test/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerModuleTest.kt
+++ b/features/contentscanner/impl/src/test/kotlin/io/element/android/features/contentscanner/impl/di/ContentScannerBindingContainerTest.kt
@@ -15,16 +15,16 @@ import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.runTest
import org.junit.Test
-class ContentScannerModuleTest {
+class ContentScannerBindingContainerTest {
@Test
- fun `ContentScannerModule provides NoopContentScannerService when ContentScanner is null`() = runTest {
+ fun `ContentScannerBindingContainer provides NoopContentScannerService when ContentScanner is null`() = runTest {
// Given
val contentScanner: ContentScanner? = null
val sessionCoroutineScope = backgroundScope
val coroutineDispatchers = testCoroutineDispatchers()
// When
- val contentScannerService = ContentScannerModule.providesContentScannerService(
+ val contentScannerService = ContentScannerBindingContainer.providesContentScannerService(
contentScanner = contentScanner,
coroutineScope = sessionCoroutineScope,
coroutineDispatchers = coroutineDispatchers,
@@ -35,14 +35,14 @@ class ContentScannerModuleTest {
}
@Test
- fun `ContentScannerModule provides DefaultContentScannerService when ContentScanner is not null`() = runTest {
+ fun `ContentScannerBindingContainer provides DefaultContentScannerService when ContentScanner is not null`() = runTest {
// Given
val contentScanner: ContentScanner? = FakeContentScanner()
val sessionCoroutineScope = backgroundScope
val coroutineDispatchers = testCoroutineDispatchers()
// When
- val contentScannerService = ContentScannerModule.providesContentScannerService(
+ val contentScannerService = ContentScannerBindingContainer.providesContentScannerService(
contentScanner = contentScanner,
coroutineScope = sessionCoroutineScope,
coroutineDispatchers = coroutineDispatchers,
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 271fdb2332..3128b8073a 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
@@ -15,9 +15,21 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
interface CreateRoomEntryPoint : FeatureEntryPoint {
+ /**
+ * Configures the create-room flow before it is started, since it serves rooms and spaces from the same screens.
+ */
interface Builder {
+ /**
+ * @param isSpace true to create a space rather than a room.
+ */
fun setIsSpace(isSpace: Boolean): Builder
+
+ /**
+ * @param parentSpaceId the space the new room should be added to once created.
+ */
fun setParentSpace(parentSpaceId: RoomId): Builder
+
+ /** Builds the node with the options configured so far. */
fun build(): Node
}
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 e03d36363d..f9075691aa 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
@@ -23,7 +23,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.features.createroom.impl.R
import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeopleState
-import io.element.android.features.invitepeople.api.InvitePeopleStateProvider
+import io.element.android.features.invitepeople.api.InvitePeopleStatePreviewParam
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -86,7 +86,7 @@ private fun AddPeopleTopBar(
@PreviewsDayNight
@Composable
-internal fun AddPeopleViewPreview(@PreviewParameter(InvitePeopleStateProvider::class) state: InvitePeopleState) = ElementPreview {
+internal fun AddPeopleViewPreview(@PreviewParameter(InvitePeopleStatePreviewParam::class) state: InvitePeopleState) = ElementPreview {
AddPeopleView(
state = state,
invitePeopleView = {},
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStatePreviewParam.kt
similarity index 98%
rename from features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt
rename to features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStatePreviewParam.kt
index 9c2ad76a9b..151c7d13d0 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStatePreviewParam.kt
@@ -21,7 +21,7 @@ import io.element.android.libraries.permissions.api.aPermissionsState
import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.toImmutableList
-open class ConfigureRoomStateProvider : PreviewParameterProvider {
+open class ConfigureRoomStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aConfigureRoomState(),
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 c929060e13..e39df69f97 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
@@ -349,12 +349,12 @@ private fun RoomJoinRuleOptions(
@PreviewWithLargeHeight
@Composable
-internal fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
+internal fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStatePreviewParam::class) state: ConfigureRoomState) =
ElementPreviewLight { ContentToPreview(state) }
@PreviewWithLargeHeight
@Composable
-internal fun ConfigureRoomViewDarkPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
+internal fun ConfigureRoomViewDarkPreview(@PreviewParameter(ConfigureRoomStatePreviewParam::class) state: ConfigureRoomState) =
ElementPreviewDark { ContentToPreview(state) }
@ExcludeFromCoverage
diff --git a/features/createroom/impl/src/main/res/values-ar/translations.xml b/features/createroom/impl/src/main/res/values-ar/translations.xml
new file mode 100644
index 0000000000..7cb356739a
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-ar/translations.xml
@@ -0,0 +1,36 @@
+
+
+ "إنشاء غرفة جديدة"
+ "دعوة أشخاص"
+ "حصل خطأ أثناء إنشاء الغرفة"
+ "تعذّر إنشاء المساحة بسبب خطأ غير معروف. حاول مرة أخرى لاحقًا."
+ "إضافة اسم…"
+ "غرفة جديدة"
+ "مساحة جديدة"
+ "لا يدخل إلا المدعوّون."
+ "خاصة"
+ "بإمكان الجميع العثور على هذه الغرفة.
+يمكنك تغيير هذا من إعدادات الغرفة متى شئت."
+ "يمكن لأي شخص الانضمام."
+ "عامة"
+ "يمكن لأي شخص طلب الانضمام، لكن يجب أن يقبل الطلب مسؤول أو مشرف."
+ "السماح بطلبات الانضمام"
+ "يمكن لأي شخص في %1$s الانضمام، لكن يجب على الآخرين طلب الوصول."
+ "طلب الانضمام"
+ "يمكن للمدعوين فقط الانضمام."
+ "خاصة"
+ "يمكن لأي شخص الانضمام."
+ "عامة"
+ "يمكن لأي شخص في %1$s الانضمام."
+ "قياسي"
+ "من يمكنه الوصول"
+ "تحتاج إلى عنوان حتى تظهر الغرفة في الدليل العام."
+ "العنوان"
+ "ظهور الغرفة"
+ "(لا توجد مساحة)"
+ "عدم الإضافة إلى مساحة"
+ "لم يتم اختيار أي مساحة"
+ "إضافة إلى مساحة"
+ "الموضوع (اختياري)"
+ "إضافة وصف…"
+
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 740e0b70cc..e3ceccbb83 100644
--- a/features/createroom/impl/src/main/res/values-es/translations.xml
+++ b/features/createroom/impl/src/main/res/values-es/translations.xml
@@ -3,14 +3,34 @@
"Nueva sala"
"Invitar personas"
"Se ha producido un error al crear la sala"
+ "No se pudo crear el espacio debido a un error desconocido. Inténtalo de nuevo más tarde."
+ "Añadir nombre…"
+ "Nueva sala"
+ "Nuevo espacio"
"Solo las personas invitadas pueden acceder."
+ "Privado"
"Cualquiera puede encontrar esta sala.
Puedes cambiar esto en cualquier momento en los ajustes de la sala."
+ "Cualquiera puede unirse."
+ "Público"
"Cualquiera puede solicitar unirse, pero un administrador o un moderador tendrá que aceptar la solicitud"
"Permitir solicitar unirse"
+ "Cualquiera en %1$s puede unirse, pero el resto debe solicitar acceso."
+ "Solicitar unirse"
+ "Solo las personas invitadas pueden unirse."
+ "Privado"
"Cualquiera puede unirse."
+ "Público"
+ "Cualquiera de %1$s puede unirse."
+ "Estándar"
+ "Quién tiene acceso"
"Necesitarás una dirección de sala para que sea visible en el directorio de salas públicas."
"Dirección"
"Visibilidad de la sala"
+ "(ningún espacio)"
+ "No añadir a un espacio"
+ "No se ha seleccionado ningún espacio"
+ "Añadir a un espacio"
"Tema (opcional)"
+ "Añadir descripción…"
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 821d55af67..c3ec327d5b 100644
--- a/features/createroom/impl/src/main/res/values-fa/translations.xml
+++ b/features/createroom/impl/src/main/res/values-fa/translations.xml
@@ -3,12 +3,34 @@
"اتاق جدید"
"دعوت افراد"
"هنگام ایجاد اتاق خطایی رخ داد"
+ "به دلیل خطای ناشناختهای، فضا ایجاد نشد. بعداً دوباره امتحان کنید."
+ "افزودن نام…"
+ "اتاق جدید"
+ "فضای جدید"
"تنها افراد دعوت شده میتوانند بپیوندند."
+ "خصوصی"
"هرکسی میتواند اتاق را بیابد.
میتوانید بعداً در تظیمات اتاق عوضش کنید."
- "درخواست دعوت"
- "هرکسی میتواند به این اتاق بپیوندد"
- "نشانی اتاق"
+ "هر کسی میتواند عضو شود."
+ "عمومی"
+ "هر کسی میتواند درخواست عضویت بدهد، اما یک مدیر یا ناظر باید درخواست را بپذیرد."
+ "اجازه درخواست عضویت"
+ "هر کسی در %1$s میتواند عضو شود، اما بقیه باید درخواست دسترسی بدهند."
+ "درخواست عضویت"
+ "تنها افراد دعوت شده میتوانند بپیوندند."
+ "خصوصی"
+ "هر کسی میتواند عضو شود."
+ "عمومی"
+ "هر کسی که در %1$s باشد میتواند عضو شود."
+ "استاندارد"
+ "چه کسی دسترسی دارد"
+ "برای اینکه بتوانید آن را در فهرست عمومی مشاهده کنید، به یک آدرس نیاز دارید."
+ "آدرس"
"نمایانی اتاق"
+ "(بدون فضا)"
+ "به یک فضا اضافه نکنید"
+ "فضایی انتخاب نشده است"
+ "به فضا اضافه کنید"
"موضوع (اختیاری)"
+ "اضافه کردن توضیحات…"
diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStateProvider.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStatePreviewParam.kt
similarity index 94%
rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStateProvider.kt
rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStatePreviewParam.kt
index 5c832a4d75..bd86af9572 100644
--- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStateProvider.kt
+++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStatePreviewParam.kt
@@ -11,7 +11,7 @@ package io.element.android.features.logout.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
-open class AccountDeactivationStateProvider : PreviewParameterProvider {
+open class AccountDeactivationStatePreviewParam : PreviewParameterProvider {
private val filledForm = aDeactivateFormState(eraseData = true, password = "password")
override val values: Sequence
get() = sequenceOf(
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 738046b3a3..eb6b401667 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
@@ -299,16 +299,16 @@ private fun Content(
}
/**
- * Ensure that the string does not contain any new line characters, which can happen when pasting values.
+ * Ensure that the string does not contain any line separator, which can happen when pasting values.
*/
private fun String.sanitize(): String {
- return replace("\n", "")
+ return filterNot { it == '\n' || it == '\r' }
}
@PreviewsDayNight
@Composable
internal fun AccountDeactivationViewPreview(
- @PreviewParameter(AccountDeactivationStateProvider::class) state: AccountDeactivationState,
+ @PreviewParameter(AccountDeactivationStatePreviewParam::class) state: AccountDeactivationState,
) = ElementPreview {
AccountDeactivationView(
state,
diff --git a/features/deactivation/impl/src/main/res/values-ar/translations.xml b/features/deactivation/impl/src/main/res/values-ar/translations.xml
new file mode 100644
index 0000000000..d0fa5afe94
--- /dev/null
+++ b/features/deactivation/impl/src/main/res/values-ar/translations.xml
@@ -0,0 +1,14 @@
+
+
+ "يرجى تأكيد رغبتك في حذف حسابك. لا يمكن التراجع عن هذا الإجراء."
+ "حذف جميع رسائلي"
+ "تحذير: قد يرى المستخدمون في المستقبل محادثات غير مكتملة."
+ "حذف حسابك %1$s، وسيؤدي إلى:"
+ "إجراء لا رجعة فيه"
+ "%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
index 17ae73d6c8..dc3e090e06 100644
--- a/features/deactivation/impl/src/main/res/values-es/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-es/translations.xml
@@ -1,13 +1,14 @@
- "Confirma que quieres desactivar tu cuenta. Esta acción no se puede deshacer."
+ "Confirma que quieres eliminar 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:"
+ "Eliminar 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."
+ "Eliminar cuenta"
diff --git a/features/deactivation/impl/src/main/res/values-fa/translations.xml b/features/deactivation/impl/src/main/res/values-fa/translations.xml
index 5c4eb00d62..38828d5e60 100644
--- a/features/deactivation/impl/src/main/res/values-fa/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-fa/translations.xml
@@ -1,9 +1,14 @@
+ "لطفاً تأیید کنید که میخواهید حساب کاربری خود را حذف کنید. این اقدام قابل بازگشت نیست."
"حذف همهٔ پیامهایم"
+ "هشدار: کاربران آینده ممکن است مکالمات ناقص را ببینند."
+ "حذف حساب کاربری شما %1$s است، این کار باعث میشود:"
"بازگشتناپذیر"
+ "حساب کاربری خود را %1$s کنید (شما نمیتوانید دوباره وارد سیستم شوید و شناسه شما قابل استفاده مجدد نیست)."
"از کار انداختن دایمی"
"برداشتنتان از همهٔ اتاقهای گپ."
"حذف اطّلاعات حسابتان از کارساز هویت."
- "غیرفعّالسازی حساب"
+ "پیامهای شما همچنان برای کاربران ثبتنامشده قابل مشاهده خواهند بود، اما اگر تصمیم به حذف آنها بگیرید، برای کاربران جدید یا ثبتنامنشده در دسترس نخواهند بود."
+ "حذف حساب کاربری"
diff --git a/features/deactivation/impl/src/main/res/values-zh-rTW/translations.xml b/features/deactivation/impl/src/main/res/values-zh-rTW/translations.xml
index a099a67f7f..159d8f4a41 100644
--- a/features/deactivation/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-zh-rTW/translations.xml
@@ -1,14 +1,14 @@
- "請確認您想要停用您的帳號。此動作無法還原。"
+ "請確認您想要刪除您的帳號。此動作無法還原。"
"刪除我所有的訊息"
"警告:未來的使用者可能會看到不完整的對話。"
- "停用您的帳號為 %1$s,它將:"
+ "刪除您的帳號為 %1$s,它將:"
"不可逆"
"%1$s 您的帳號(您將無法重新登入,也無法重用您的 ID)。"
"永久停用"
"將您從所有聊天室移除。"
"從我們的身份伺服器將您的帳號資訊刪除。"
"註冊使用者仍可看到您的訊息,但如果您選擇刪除,新使用者與未註冊的使用者將看不到它們。"
- "停用帳號"
+ "刪除帳號"
diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt
new file mode 100644
index 0000000000..bfb9fb538a
--- /dev/null
+++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.enterprise.api
+
+import io.element.android.libraries.matrix.api.MatrixClientBuilder
+import io.element.android.libraries.matrix.api.core.SessionId
+
+/**
+ * A hook that can be used to customize the [MatrixClientBuilder] for enterprise features.
+ */
+fun interface ClientBuilderEnterpriseHook {
+ suspend operator fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder
+}
diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientEnterpriseHook.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientEnterpriseHook.kt
new file mode 100644
index 0000000000..e6d5c9aac1
--- /dev/null
+++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientEnterpriseHook.kt
@@ -0,0 +1,17 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.enterprise.api
+
+import io.element.android.libraries.matrix.api.MatrixClient
+
+/**
+ * A hook that can be used to customize the [MatrixClient] for enterprise features.
+ */
+fun interface ClientEnterpriseHook {
+ suspend operator fun invoke(client: MatrixClient)
+}
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 7b595966d4..2cb4a356be 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
@@ -10,17 +10,52 @@ package io.element.android.features.enterprise.api
import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark
+import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
-import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.flow.Flow
+/**
+ * Exposes the restrictions and customisations an enterprise deployment can impose: allowed homeservers, branding, push gateways and more.
+ *
+ * On a standard build this reports permissive defaults, so callers do not need to branch on the build type themselves.
+ */
interface EnterpriseService {
- val isEnterpriseBuild: Boolean
+ /**
+ * Whether the given session belongs to an enterprise deployment, which can be true even on a standard build.
+ *
+ * @param sessionId the session to check.
+ */
suspend fun isEnterpriseUser(sessionId: SessionId): Boolean
- suspend fun tweakMasUrl(url: String, homeserver: String): String
- fun defaultHomeserverList(): List
+
+ /**
+ * Rewrites the authentication server URL when the deployment requires a different one from the one advertised.
+ *
+ * @param url the URL to rewrite.
+ * @param urlContentFetcher used to read the deployment configuration; a client that is not authenticated yet also works.
+ */
+ suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String
+
+ /**
+ * Returns the list of homeservers the user is allowed to sign in to.
+ *
+ * If the list is empty or contains the special value [ANY_ACCOUNT_PROVIDER], the user is allowed to sign in to any homeserver.
+ */
+ fun homeserverAllowList(): List
+
+ /**
+ * Whether the user is allowed to sign in to a given homeserver, according to [homeserverAllowList].
+ *
+ * @param homeserverUrl the server the user is trying to use.
+ */
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean
+ /**
+ * Whether the given homeserver enforces the use of Element Pro or a derived app.
+ *
+ * @param serverName the homeserver to check.
+ */
+ suspend fun isElementProEnforced(serverName: String): Boolean
+
/**
* Override the brand color.
* @param sessionId the session to override the brand color for, or null to set the brand color to use when there is no session.
@@ -28,32 +63,48 @@ interface EnterpriseService {
*/
suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?)
+ /**
+ * The brand color to apply, or `null` when the default theme color should be used.
+ *
+ * @param sessionId the session to read the color of, or `null` for the color used when no session is active.
+ */
fun brandColorsFlow(sessionId: SessionId?): Flow
+ /**
+ * The full light and dark color palettes derived from the brand color.
+ *
+ * @param sessionId the session to read the colors of, or `null` for the colors used when no session is active.
+ */
fun semanticColorsFlow(sessionId: SessionId?): Flow
+ /** The push gateway to register with when using Firebase, or `null` to use the app default. */
fun firebasePushGateway(): String?
+
+ /** The push gateway to register with when using UnifiedPush, or `null` to use the app default. */
fun unifiedPushDefaultPushGateway(): String?
+ /**
+ * Where bug reports should be submitted, which an enterprise deployment can redirect or disable entirely.
+ *
+ * @param sessionId the session to read the setting of, or `null` for the setting used when no session is active.
+ */
fun bugReportUrlFlow(sessionId: SessionId?): Flow
/**
* Gets Notification Channel to use for the noisy notifications of the provided session.
+ * Returns `null` when the session has no dedicated channel and the app-wide one should be used.
+ *
+ * @param sessionId the session whose channel is requested.
*/
fun getNoisyNotificationChannelId(sessionId: SessionId): String?
- /**
- * Gets the overridden Element Well-Known data if it has been set, or null if not set.
- */
- fun overriddenElementWellKnown(): ElementWellKnown?
-
companion object {
const val ANY_ACCOUNT_PROVIDER = "*"
}
}
fun EnterpriseService.canConnectToAnyHomeserver(): Boolean {
- return defaultHomeserverList().let {
+ return homeserverAllowList().let {
it.isEmpty() || it.contains(EnterpriseService.ANY_ACCOUNT_PROVIDER)
}
}
diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/IsEnterpriseBuild.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/IsEnterpriseBuild.kt
new file mode 100644
index 0000000000..83591ea7bd
--- /dev/null
+++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/IsEnterpriseBuild.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.enterprise.api
+
+/**
+ * A use case to check if this build of the app is an enterprise build.
+ */
+fun interface IsEnterpriseBuild {
+ /**
+ * Returns `true` if this build of the app is an enterprise build, `false` otherwise.
+ */
+ operator fun invoke(): Boolean
+}
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
index ee005a0b6b..94f455f2ff 100644
--- 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
@@ -8,11 +8,25 @@
package io.element.android.features.enterprise.api
+/**
+ * The session-scoped counterpart of [EnterpriseService], answering the same kind of questions for the session it belongs to.
+ *
+ * [init] must have run before the other members return meaningful answers.
+ */
interface SessionEnterpriseService {
+ /** Loads the deployment configuration of this session; to be called once when the session starts. */
+ suspend fun init()
+
+ /** Whether Element Call may be used on this session, which a deployment can turn off. */
suspend fun isElementCallAvailable(): Boolean
+
+ /**
+ * Rewrites the authentication server URL for this session; see [EnterpriseService.tweakMasUrl].
+ *
+ * @param url the URL to rewrite.
+ */
suspend fun tweakMasUrl(url: String): String
+ /** Whether the homeserver forbids creating encrypted rooms, in which case the app must not offer the option. */
suspend fun isEncryptionDisabledByHomeserver(): Boolean
-
- suspend fun init()
}
diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/CustomMapTilerConfigProvider.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/CustomMapTilerConfigProvider.kt
new file mode 100644
index 0000000000..9bfd3c940a
--- /dev/null
+++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/CustomMapTilerConfigProvider.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.enterprise.api.remoteconfig
+
+/**
+ * A provider of a runtime-fetched MapTiler configuration, which can be used to override the default MapTiler configuration.
+ */
+fun interface CustomMapTilerConfigProvider {
+ suspend fun get(): Result
+}
diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/MapTilerConfig.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/MapTilerConfig.kt
new file mode 100644
index 0000000000..3e8b8b14e1
--- /dev/null
+++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/MapTilerConfig.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.enterprise.api.remoteconfig
+
+import androidx.compose.runtime.Immutable
+
+/**
+ * A `MapTiler` configuration received from the server. This configuration is used to display maps in the app.
+ */
+@Immutable
+data class MapTilerConfig(
+ /** The API key to use with MapTiler. This is always not null, but can be empty/blank. */
+ val apiKey: String,
+ /** The style ID to use for light mode. This is optional and can be null. */
+ val lightStyleId: String?,
+ /** The style ID to use for light mode. This is optional and can be null. */
+ val darkStyleId: String?,
+ /** The base URL to use for MapTiler. This is optional and can be null. */
+ val baseUrl: String?,
+)
diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt
new file mode 100644
index 0000000000..0cdcdebe0b
--- /dev/null
+++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.enterprise.api.remoteconfig
+
+sealed interface RemoteEnterpriseConfigResult {
+ /**
+ * Well-known data has been successfully retrieved.
+ */
+ data class Success(val data: T) : RemoteEnterpriseConfigResult
+
+ /**
+ * Well-known data has been retrieved from the local cache but is outdated and a manual refresh has been launched.
+ */
+ data class Outdated(val data: T) : RemoteEnterpriseConfigResult
+
+ /**
+ * Well-known data is not found (file does not exist server side, we got a 404).
+ */
+ data object NotFound : RemoteEnterpriseConfigResult
+
+ /**
+ * Any other error.
+ */
+ data class Error(val exception: Exception) : RemoteEnterpriseConfigResult
+
+ fun dataOrNull(): T? = when (this) {
+ is Success -> data
+ is Outdated -> data
+ is Error -> null
+ is NotFound -> null
+ }
+
+ fun upToDateDataOrNull(): T? = when (this) {
+ is Success -> data
+ is Outdated -> null
+ is Error -> null
+ is NotFound -> null
+ }
+}
diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt
new file mode 100644
index 0000000000..ee22e4399d
--- /dev/null
+++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.enterprise.impl
+
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.features.enterprise.api.ClientBuilderEnterpriseHook
+import io.element.android.libraries.matrix.api.MatrixClientBuilder
+import io.element.android.libraries.matrix.api.core.SessionId
+
+/**
+ * Default implementation of [ClientBuilderEnterpriseHook] that does nothing for FOSS.
+ */
+@ContributesBinding(AppScope::class)
+class DefaultClientBuilderEnterpriseHook : ClientBuilderEnterpriseHook {
+ override suspend fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder {
+ // No modification
+ return clientBuilder
+ }
+}
diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientEnterpriseHook.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientEnterpriseHook.kt
new file mode 100644
index 0000000000..b0c0152d94
--- /dev/null
+++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientEnterpriseHook.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.enterprise.impl
+
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.features.enterprise.api.ClientEnterpriseHook
+import io.element.android.libraries.matrix.api.MatrixClient
+
+/**
+ * Default implementation of [ClientEnterpriseHook] that does nothing for FOSS.
+ */
+@ContributesBinding(AppScope::class)
+class DefaultClientEnterpriseHook : ClientEnterpriseHook {
+ override suspend fun invoke(client: MatrixClient) {
+ // No-op
+ }
+}
diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt
index bc2a3f1bf1..bb10550369 100644
--- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt
+++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt
@@ -14,19 +14,18 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
+import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
-import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
@ContributesBinding(AppScope::class)
class DefaultEnterpriseService : EnterpriseService {
- override val isEnterpriseBuild = false
-
override suspend fun isEnterpriseUser(sessionId: SessionId) = false
- override suspend fun tweakMasUrl(url: String, homeserver: String) = url
- override fun defaultHomeserverList(): List = emptyList()
+ override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url
+ override fun homeserverAllowList(): List = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
+ override suspend fun isElementProEnforced(serverName: String): Boolean = false
override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = Unit
@@ -46,6 +45,4 @@ class DefaultEnterpriseService : EnterpriseService {
}
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null
-
- override fun overriddenElementWellKnown(): ElementWellKnown? = null
}
diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultIsEnterpriseBuild.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultIsEnterpriseBuild.kt
new file mode 100644
index 0000000000..73da4d5d03
--- /dev/null
+++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultIsEnterpriseBuild.kt
@@ -0,0 +1,17 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.enterprise.impl
+
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.features.enterprise.api.IsEnterpriseBuild
+
+@ContributesBinding(AppScope::class)
+class DefaultIsEnterpriseBuild : IsEnterpriseBuild {
+ override fun invoke(): Boolean = false
+}
diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/remoteconfig/DefaultCustomMapTilerConfigProvider.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/remoteconfig/DefaultCustomMapTilerConfigProvider.kt
new file mode 100644
index 0000000000..2b136d3739
--- /dev/null
+++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/remoteconfig/DefaultCustomMapTilerConfigProvider.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.enterprise.impl.remoteconfig
+
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
+import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
+import io.element.android.libraries.di.SessionScope
+
+@ContributesBinding(SessionScope::class)
+class DefaultCustomMapTilerConfigProvider : CustomMapTilerConfigProvider {
+ override suspend fun get(): Result = Result.success(null)
+}
diff --git a/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt
index 2bee3f8b4d..474990c6d9 100644
--- a/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt
+++ b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt
@@ -19,15 +19,9 @@ import org.junit.Test
class DefaultEnterpriseServiceTest {
@Test
- fun `isEnterpriseBuild is false`() {
+ fun homeserverWhitelist() {
val defaultEnterpriseService = DefaultEnterpriseService()
- assertThat(defaultEnterpriseService.isEnterpriseBuild).isFalse()
- }
-
- @Test
- fun `defaultHomeserverList should return empty list`() {
- val defaultEnterpriseService = DefaultEnterpriseService()
- assertThat(defaultEnterpriseService.defaultHomeserverList()).isEmpty()
+ assertThat(defaultEnterpriseService.homeserverAllowList()).isEmpty()
}
@Test
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
index f6379bef30..a8efcb5873 100644
--- 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
@@ -12,8 +12,8 @@ import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
+import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
-import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow
@@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class FakeEnterpriseService(
- override val isEnterpriseBuild: Boolean = false,
private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() },
private val defaultHomeserverListResult: () -> List = { emptyList() },
private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() },
@@ -31,8 +30,8 @@ class FakeEnterpriseService(
private val firebasePushGatewayResult: () -> String? = { lambdaError() },
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() },
- private val tweakMasUrlResult: (String, String) -> String = { _, _ -> lambdaError() },
- private val overrideWellKnownResult: () -> ElementWellKnown? = { lambdaError() },
+ private val tweakMasUrlResult: (String, ClientUrlContentFetcher) -> String = { _, _ -> lambdaError() },
+ private val isElementProEnforcedResult: (String) -> Boolean = { lambdaError() },
) : EnterpriseService {
private val brandColorState = MutableStateFlow(initialBrandColor)
private val semanticColorsState = MutableStateFlow(initialSemanticColors)
@@ -41,11 +40,11 @@ class FakeEnterpriseService(
isEnterpriseUserResult(sessionId)
}
- override suspend fun tweakMasUrl(url: String, homeserver: String): String = simulateLongTask {
- tweakMasUrlResult(url, homeserver)
+ override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String = simulateLongTask {
+ tweakMasUrlResult(url, urlContentFetcher)
}
- override fun defaultHomeserverList(): List {
+ override fun homeserverAllowList(): List {
return defaultHomeserverListResult()
}
@@ -53,6 +52,10 @@ class FakeEnterpriseService(
isAllowedToConnectToHomeserverResult(homeserverUrl)
}
+ override suspend fun isElementProEnforced(serverName: String): Boolean = simulateLongTask {
+ isElementProEnforcedResult(serverName)
+ }
+
override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = simulateLongTask {
overrideBrandColorResult(sessionId, brandColor)
}
@@ -81,8 +84,4 @@ class FakeEnterpriseService(
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? {
return getNoisyNotificationChannelIdResult(sessionId)
}
-
- override fun overriddenElementWellKnown(): ElementWellKnown? {
- return overrideWellKnownResult()
- }
}
diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/Fixtures.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/Fixtures.kt
new file mode 100644
index 0000000000..fd429eada5
--- /dev/null
+++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/Fixtures.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.enterprise.test.remoteconfig
+
+import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
+
+fun aMapTilerConfig(
+ apiKey: String = "test_api_key",
+ baseUrl: String? = null,
+ lightStyleId: String? = null,
+ darkStyleId: String? = null,
+) = MapTilerConfig(
+ apiKey = apiKey,
+ baseUrl = baseUrl,
+ lightStyleId = lightStyleId,
+ darkStyleId = darkStyleId,
+)
diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesStateProvider.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesStatePreviewParam.kt
similarity index 92%
rename from features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesStateProvider.kt
rename to features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesStatePreviewParam.kt
index bbb5b380ae..8ff621c2a6 100644
--- a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesStateProvider.kt
+++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesStatePreviewParam.kt
@@ -12,7 +12,7 @@ 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 ForwardMessagesStateProvider : PreviewParameterProvider {
+open class ForwardMessagesStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aForwardMessagesState(),
diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesView.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesView.kt
index 8065054b2d..f350c5b516 100644
--- a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesView.kt
+++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesView.kt
@@ -38,7 +38,7 @@ fun ForwardMessagesView(
@PreviewsDayNight
@Composable
-internal fun ForwardMessagesViewPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) = ElementPreview {
+internal fun ForwardMessagesViewPreview(@PreviewParameter(ForwardMessagesStatePreviewParam::class) state: ForwardMessagesState) = ElementPreview {
ForwardMessagesView(
state = state,
onForwardSuccess = {}
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/FtueBindingContainer.kt
similarity index 82%
rename from features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/di/FtueModule.kt
rename to features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/di/FtueBindingContainer.kt
index 52f7a43bde..3ba42f687f 100644
--- 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/FtueBindingContainer.kt
@@ -18,7 +18,7 @@ import io.element.android.libraries.di.SessionScope
@ContributesTo(SessionScope::class)
@BindingContainer
-interface FtueModule {
+interface FtueBindingContainer {
@Binds
- fun bindChooseSelfVerificationMethodPresenter(presenter: ChooseSelfVerificationModePresenter): Presenter
+ fun bindChooseSelfVerificationModePresenter(presenter: ChooseSelfVerificationModePresenter): Presenter
}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInStateProvider.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInStatePreviewParam.kt
similarity index 88%
rename from features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInStateProvider.kt
rename to features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInStatePreviewParam.kt
index 1b2e5d2d58..07569a238e 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInStateProvider.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInStatePreviewParam.kt
@@ -11,7 +11,7 @@ package io.element.android.features.ftue.impl.notifications
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.permissions.api.aPermissionsState
-open class NotificationsOptInStateProvider : PreviewParameterProvider {
+open class NotificationsOptInStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aNotificationsOptInState(),
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 6955d8e501..e2eabe8f2b 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
@@ -178,7 +178,7 @@ private fun NotificationRow(
@PreviewsDayNight
@Composable
internal fun NotificationsOptInViewPreview(
- @PreviewParameter(NotificationsOptInStateProvider::class) state: NotificationsOptInState
+ @PreviewParameter(NotificationsOptInStatePreviewParam::class) state: NotificationsOptInState
) {
ElementPreview {
NotificationsOptInView(
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/ChooseSelfVerificationModeStatePreviewParam.kt
similarity index 97%
rename from features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStateProvider.kt
rename to features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt
index b35a9624b9..1af6786289 100644
--- 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/ChooseSelfVerificationModeStatePreviewParam.kt
@@ -12,7 +12,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.architecture.AsyncData
-class ChooseSelfVerificationModeStateProvider :
+class ChooseSelfVerificationModeStatePreviewParam :
PreviewParameterProvider {
override val values = sequenceOf(
aChooseSelfVerificationModeState(
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
index 1bfa10daf2..96d6b82d47 100644
--- 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
@@ -149,7 +149,7 @@ private fun ChooseSelfVerificationModeButtons(
@PreviewsDayNight
@Composable
internal fun ChooseSelfVerificationModeViewPreview(
- @PreviewParameter(ChooseSelfVerificationModeStateProvider::class) state: ChooseSelfVerificationModeState
+ @PreviewParameter(ChooseSelfVerificationModeStatePreviewParam::class) state: ChooseSelfVerificationModeState
) = ElementPreview {
ChooseSelfVerificationModeView(
state = state,
diff --git a/features/ftue/impl/src/main/res/values-ar/translations.xml b/features/ftue/impl/src/main/res/values-ar/translations.xml
new file mode 100644
index 0000000000..afd974d4bb
--- /dev/null
+++ b/features/ftue/impl/src/main/res/values-ar/translations.xml
@@ -0,0 +1,15 @@
+
+
+ "لا يمكنك التأكيد؟"
+ "إنشاء مفتاح استرداد جديد"
+ "اختر طريقة التحقق لإعداد المراسلة الآمنة."
+ "تأكيد هويتك الرقمية"
+ "استخدام جهاز آخر"
+ "استخدام مفتاح الاسترداد"
+ "يمكنك الآن قراءة الرسائل أو إرسالها بأمان، ويمكن لأي شخص تدردش معه أن يثق بهذا الجهاز أيضًا."
+ "تم التحقق من الجهاز"
+ "استخدام جهاز آخر"
+ "في انتظار الجهاز الآخر…"
+ "يمكنك تغيير إعداداتك لاحقاً."
+ "السماح بالإشعارات وعدم تفويت أي رسالة"
+
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 8f49a77e0d..9b232f8dab 100644
--- a/features/ftue/impl/src/main/res/values-es/translations.xml
+++ b/features/ftue/impl/src/main/res/values-es/translations.xml
@@ -2,8 +2,8 @@
"¿No puedes confirmar?"
"Crear una nueva clave de recuperación"
- "Verifica este dispositivo para configurar la mensajería segura."
- "Confirma que eres tú"
+ "Elige cómo verificar para configurar la mensajería segura."
+ "Confirma tu identidad digital"
"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."
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
index 1a90245611..1c6a0a906e 100644
--- 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
@@ -12,6 +12,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.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
@@ -23,7 +24,7 @@ interface HomeEntryPoint : FeatureEntryPoint {
): Node
interface Callback : Plugin {
- fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?)
+ fun navigateToRoom(roomId: RoomId, eventId: EventId?, joinedRoom: JoinedRoom?)
fun navigateToCreateRoom()
fun navigateToCreateSpace()
fun navigateToSettings()
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
index 00f285e3f4..20f27f67bd 100644
--- 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
@@ -56,6 +56,7 @@ import io.element.android.libraries.designsystem.utils.DelayedVisibility
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.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
@@ -183,6 +184,7 @@ class HomeFlowNode(
fun navigateToRoom(
roomId: RoomId,
+ eventId: EventId?,
) {
if (!loadingJoinedRoomJob.value.isUninitialized()) {
Timber.w("Already loading a room, ignoring navigateToRoom for $roomId")
@@ -195,7 +197,7 @@ class HomeFlowNode(
}.fold(
onSuccess = { joinedRoom ->
if (isActive) {
- callback.navigateToRoom(roomId, joinedRoom)
+ callback.navigateToRoom(roomId = roomId, eventId = eventId, joinedRoom = joinedRoom)
loadingJoinedRoomJob.value = AsyncData.Success(coroutineContext.job)
// Wait a bit before resetting the state to avoid allowing to open several rooms
delay(200.milliseconds)
@@ -205,7 +207,7 @@ class HomeFlowNode(
onFailure = {
// If the operation wasn't cancelled, navigate without the room, using the room id
if (it !is CancellationException) {
- callback.navigateToRoom(roomId, null)
+ callback.navigateToRoom(roomId = roomId, eventId = null, joinedRoom = null)
}
loadingJoinedRoomJob.value = AsyncData.Failure(error = it, prevData = coroutineContext.job)
// Wait a bit before resetting the state to avoid allowing to open several rooms
@@ -233,7 +235,7 @@ class HomeFlowNode(
acceptDeclineInviteView = {
acceptDeclineInviteView.Render(
state = state.roomListState.acceptDeclineInviteState,
- onAcceptInviteSuccess = ::navigateToRoom,
+ onAcceptInviteSuccess = { navigateToRoom(roomId = it, eventId = null) },
onDeclineInviteSuccess = { },
modifier = Modifier
)
@@ -269,6 +271,11 @@ class HomeFlowNode(
parentNode = this,
buildContext = buildContext,
inviteData = navTarget.inviteData,
+ callback = object : DeclineInviteAndBlockEntryPoint.Callback {
+ override fun onDeclineSuccess() {
+ backstack.pop()
+ }
+ },
)
}
is NavTarget.SelectNewOwnersWhenLeavingRoom -> {
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/HomeStatePreviewParam.kt
similarity index 96%
rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt
index ea9ff7a234..14ab6dc7f6 100644
--- 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/HomeStatePreviewParam.kt
@@ -10,7 +10,7 @@ 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.RoomListStatePreviewParam
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
@@ -24,7 +24,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
-open class HomeStateProvider : PreviewParameterProvider {
+open class HomeStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aHomeState(),
@@ -43,7 +43,7 @@ open class HomeStateProvider : PreviewParameterProvider {
aHomeState(
currentHomeNavigationBarItem = HomeNavigationBarItem.Spaces,
),
- ) + RoomListStateProvider().values.map {
+ ) + RoomListStatePreviewParam().values.map {
aHomeState(roomListState = it)
} + aHomeState(
currentHomeNavigationBarItem = HomeNavigationBarItem.Spaces,
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
index f5a2400f2f..024d013871 100644
--- 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
@@ -55,6 +55,8 @@ 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.RoomListEvent
import io.element.android.features.home.impl.roomlist.RoomListState
+import io.element.android.features.home.impl.search.GlobalSearchEvent
+import io.element.android.features.home.impl.search.GlobalSearchView
import io.element.android.features.home.impl.search.RoomListSearchView
import io.element.android.features.home.impl.spacefilters.SpaceFiltersEvent
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
@@ -73,6 +75,7 @@ import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
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.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@@ -80,7 +83,7 @@ import kotlinx.coroutines.launch
@Composable
fun HomeView(
homeState: HomeState,
- onRoomClick: (RoomId) -> Unit,
+ onRoomClick: (RoomId, EventId?) -> Unit,
onSettingsClick: () -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
@@ -122,22 +125,34 @@ fun HomeView(
state = homeState,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
- onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) },
+ onRoomClick = { roomId -> if (firstThrottler.canHandle()) onRoomClick(roomId, null) },
onOpenSettings = { if (firstThrottler.canHandle()) onSettingsClick() },
onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() },
onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() },
onMenuActionClick = onMenuActionClick,
)
- // 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
- .fillMaxSize()
- .background(ElementTheme.colors.bgCanvasDefault)
- )
+
+ if (state.globalSearchState.isEnabled) {
+ GlobalSearchView(
+ state = state.globalSearchState,
+ onSelectSearchResult = { roomId, eventId -> if (firstThrottler.canHandle()) onRoomClick(roomId, eventId) },
+ modifier = Modifier
+ .fillMaxSize()
+ .background(ElementTheme.colors.bgCanvasDefault),
+ )
+ } else {
+ // This overlaid view will only be visible when state.displaySearchResults is true
+ RoomListSearchView(
+ state = state.searchState,
+ eventSink = state.eventSink,
+ hideInvitesAvatars = state.hideInvitesAvatars,
+ onRoomClick = { roomId -> if (firstThrottler.canHandle()) onRoomClick(roomId, null) },
+ modifier = Modifier
+ .fillMaxSize()
+ .background(ElementTheme.colors.bgCanvasDefault)
+ )
+ }
+
acceptDeclineInviteView()
}
}
@@ -186,8 +201,18 @@ private fun HomeScaffold(
selectedNavigationItem = state.currentHomeNavigationBarItem,
currentUserAndNeighbors = state.currentUserAndNeighbors,
showAvatarIndicator = state.showAvatarIndicator,
- areSearchResultsDisplayed = roomListState.searchState.isSearchActive,
- onToggleSearch = { roomListState.eventSink(RoomListEvent.ToggleSearchResults) },
+ areSearchResultsDisplayed = if (roomListState.globalSearchState.isEnabled) {
+ roomListState.globalSearchState.isSearchActive
+ } else {
+ roomListState.searchState.isSearchActive
+ },
+ onToggleSearch = {
+ if (roomListState.globalSearchState.isEnabled) {
+ roomListState.globalSearchState.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
+ } else {
+ roomListState.eventSink(RoomListEvent.ToggleSearchResults)
+ }
+ },
onMenuActionClick = onMenuActionClick,
onOpenSettings = onOpenSettings,
onAccountSwitch = {
@@ -346,10 +371,10 @@ internal fun RoomListRoomSummary.contentType() = displayType.ordinal
@PreviewsDayNight
@Composable
-internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state: HomeState) = ElementPreview {
+internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) state: HomeState) = ElementPreview {
HomeView(
homeState = state,
- onRoomClick = {},
+ onRoomClick = { _, _ -> },
onSettingsClick = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
@@ -369,7 +394,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state:
internal fun HomeViewA11yPreview() = ElementPreview {
HomeView(
homeState = aHomeState(),
- onRoomClick = {},
+ onRoomClick = { _, _ -> },
onSettingsClick = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt
index 8163d29d92..63d28dca21 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt
@@ -8,6 +8,8 @@
package io.element.android.features.home.impl.components
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
@@ -15,9 +17,11 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
@@ -25,8 +29,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.rememberTopAppBarState
+import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -37,10 +44,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.CompositingStrategy
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme
@@ -71,10 +88,13 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.model.getAvatarData
+import io.element.android.libraries.matrix.ui.model.toEmojiText
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@@ -151,7 +171,7 @@ fun HomeTopBar(
},
// We want a 16dp left padding for the navigationIcon :
// 4dp from default TopAppBarHorizontalPadding
- // 8dp from AccountIcon default padding (because of IconButton)
+ // 8dp from AccountIcon default padding (48dp touch target around 32dp avatar)
// 4dp extra padding using left insets
windowInsets = WindowInsets(left = 4.dp),
)
@@ -316,19 +336,36 @@ private fun AccountIcon(
modifier: Modifier = Modifier,
) {
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
- IconButton(
- modifier = modifier.then(testTag),
- onClick = onClick,
+ val interactionSource = remember { MutableInteractionSource() }
+ Box(
+ modifier = modifier
+ .then(testTag)
+ .minimumInteractiveComponentSize()
+ .clickable(
+ interactionSource = interactionSource,
+ onClick = onClick,
+ indication = ripple(bounded = false),
+ ),
+ contentAlignment = Alignment.Center,
) {
- Box {
val avatarData by remember(matrixUser) {
derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
}
+ val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
+ val avatarModifier = if (statusEmoji != null) {
+ Modifier.eraseStatusEmojiBackground(
+ parentSize = AvatarSize.CurrentUserTopBar.dp,
+ layoutDirection = LocalLayoutDirection.current,
+ )
+ } else {
+ Modifier
+ }
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
+ modifier = avatarModifier,
contentDescription = if (isCurrentAccount) {
if (showAvatarIndicator) {
stringResource(CommonStrings.a11y_settings_with_required_action)
@@ -339,11 +376,69 @@ private fun AccountIcon(
null
},
)
+ if (statusEmoji != null) {
+ StatusEmojiBadge(
+ emoji = statusEmoji,
+ modifier = Modifier.align(Alignment.BottomEnd),
+ )
+ }
if (showAvatarIndicator) {
RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd)
)
}
+ }
+}
+
+private val statusEmojiBadgeSize = 20.dp
+private val statusEmojiBadgeRadius = statusEmojiBadgeSize / 2
+private val statusEmojiBadgeOffset = 8.dp
+
+private fun Modifier.eraseStatusEmojiBackground(
+ parentSize: Dp,
+ layoutDirection: LayoutDirection,
+): Modifier = this
+ .graphicsLayer {
+ compositingStrategy = CompositingStrategy.Offscreen
+ }
+ .drawWithContent {
+ drawContent()
+ drawCircle(
+ color = Color.Black,
+ center = Offset(
+ x = if (layoutDirection == LayoutDirection.Ltr) {
+ (parentSize - statusEmojiBadgeRadius + statusEmojiBadgeOffset).toPx()
+ } else {
+ (statusEmojiBadgeRadius - statusEmojiBadgeOffset).toPx()
+ },
+ y = size.height - statusEmojiBadgeRadius.toPx(),
+ ),
+ radius = statusEmojiBadgeRadius.toPx(),
+ blendMode = BlendMode.Clear,
+ )
+ }
+
+@Composable
+private fun StatusEmojiBadge(
+ emoji: String,
+ modifier: Modifier = Modifier,
+) {
+ val density = LocalDensity.current
+ // Ensure the emoji size is not affected by the user's font scale settings
+ val fixedFontScaleDensity = remember(density.density) {
+ Density(density = density.density, fontScale = 1f)
+ }
+ Box(
+ modifier = modifier
+ .offset(x = statusEmojiBadgeOffset)
+ .size(statusEmojiBadgeSize),
+ contentAlignment = Alignment.Center,
+ ) {
+ CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) {
+ Text(
+ text = emoji,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ )
}
}
}
@@ -432,6 +527,33 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
)
}
+@OptIn(ExperimentalMaterial3Api::class)
+@PreviewsDayNight
+@Composable
+internal fun HomeTopBarWithStatusPreview() = ElementPreview {
+ HomeTopBar(
+ selectedNavigationItem = HomeNavigationBarItem.Chats,
+ currentUserAndNeighbors = persistentListOf(
+ aMatrixUser(
+ id = "@id:domain",
+ displayName = USER_NAME_ALICE,
+ displayedStatus = DisplayedStatus.UserSet(UserStatus(emoji = "🌴", text = "Away")),
+ )
+ ),
+ showAvatarIndicator = false,
+ areSearchResultsDisplayed = false,
+ scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
+ onOpenSettings = {},
+ onAccountSwitch = {},
+ onToggleSearch = {},
+ canReportBug = true,
+ displayFilters = true,
+ filtersState = aRoomListFiltersState(),
+ spaceFiltersState = anUnselectedSpaceFiltersState(),
+ onMenuActionClick = {},
+ )
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt
index 266b6f5a59..e06fe7c46d 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt
@@ -42,7 +42,7 @@ import io.element.android.features.home.impl.filters.selection.FilterSelectionSt
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.RoomListContentStatePreviewParam
import io.element.android.features.home.impl.roomlist.RoomListEvent
import io.element.android.features.home.impl.roomlist.SecurityBannerState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
@@ -336,7 +336,7 @@ private fun EmptyScaffold(
@PreviewsDayNight
@Composable
-internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStateProvider::class) state: RoomListContentState) = ElementPreview {
+internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePreviewParam::class) state: RoomListContentState) = ElementPreview {
RoomListContentView(
contentState = state,
filtersState = aRoomListFiltersState(
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt
index 2a21518e83..883d8aff76 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt
@@ -43,7 +43,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.model.LatestEvent
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.RoomListRoomSummaryPreviewParam
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.features.home.impl.roomlist.RoomListEvent
import io.element.android.libraries.core.extensions.orEmpty
@@ -449,7 +449,7 @@ private fun MentionIndicatorAtom() {
@PreviewsDayNight
@Composable
-internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryProvider::class) data: RoomListRoomSummary) = ElementPreview {
+internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryPreviewParam::class) data: RoomListRoomSummary) = ElementPreview {
RoomSummaryRow(
room = data,
hideInviteAvatars = false,
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/HomeSpacesModule.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/HomeSpacesBindingContainer.kt
similarity index 95%
rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/HomeSpacesModule.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/HomeSpacesBindingContainer.kt
index 5e2212375d..989a558718 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/HomeSpacesModule.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/HomeSpacesBindingContainer.kt
@@ -18,7 +18,7 @@ import io.element.android.libraries.di.SessionScope
@BindingContainer
@ContributesTo(SessionScope::class)
-interface HomeSpacesModule {
+interface HomeSpacesBindingContainer {
@Binds
fun bindHomeSpacesPresenter(presenter: HomeSpacesPresenter): Presenter
}
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/RoomListBindingContainer.kt
similarity index 73%
rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListBindingContainer.kt
index 1eeba4fff7..b275559206 100644
--- 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/RoomListBindingContainer.kt
@@ -15,6 +15,8 @@ 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.GlobalSearchPresenter
+import io.element.android.features.home.impl.search.GlobalSearchState
import io.element.android.features.home.impl.search.RoomListSearchPresenter
import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersPresenter
@@ -24,16 +26,19 @@ import io.element.android.libraries.di.SessionScope
@ContributesTo(SessionScope::class)
@BindingContainer
-interface RoomListModule {
+interface RoomListBindingContainer {
@Binds
fun bindRoomListPresenter(presenter: RoomListPresenter): Presenter
@Binds
- fun bindSearchPresenter(presenter: RoomListSearchPresenter): Presenter
+ fun bindRoomListSearchPresenter(presenter: RoomListSearchPresenter): Presenter
@Binds
- fun bindFiltersPresenter(presenter: RoomListFiltersPresenter): Presenter
+ fun bindRoomListFiltersPresenter(presenter: RoomListFiltersPresenter): Presenter
@Binds
fun bindSpaceFiltersPresenter(presenter: SpaceFiltersPresenter): Presenter
+
+ @Binds
+ fun bindGlobalSearchPresenter(presenter: GlobalSearchPresenter): Presenter
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersStatePreviewParam.kt
similarity index 92%
rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersStateProvider.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersStatePreviewParam.kt
index 7e095512fe..8118a65e1d 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersStateProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersStatePreviewParam.kt
@@ -12,7 +12,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.home.impl.filters.selection.FilterSelectionState
import kotlinx.collections.immutable.toImmutableList
-class RoomListFiltersStateProvider : PreviewParameterProvider {
+class RoomListFiltersStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aRoomListFiltersState(),
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt
index 9931a98c4e..c671eecf45 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt
@@ -212,7 +212,7 @@ private fun RoomListFilterView(
@PreviewsDayNight
@Composable
-internal fun RoomListFiltersViewPreview(@PreviewParameter(RoomListFiltersStateProvider::class) state: RoomListFiltersState) = ElementPreview {
+internal fun RoomListFiltersViewPreview(@PreviewParameter(RoomListFiltersStatePreviewParam::class) state: RoomListFiltersState) = ElementPreview {
RoomListFiltersView(
modifier = Modifier.padding(vertical = 4.dp),
state = state,
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt
index 847dbc2c39..9b5951ae3d 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt
@@ -22,6 +22,7 @@ class DefaultFilterSelectionStrategy : FilterSelectionStrategy {
override val filterSelectionStates = MutableStateFlow(buildFilters())
override fun select(filter: RoomListFilter) {
+ if (selectedFilters.any { it in filter.incompatibleFilters }) return
selectedFilters.add(filter)
filterSelectionStates.value = buildFilters()
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryPreviewParam.kt
similarity index 99%
rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryProvider.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryPreviewParam.kt
index 2e75858e3e..c04c3e446b 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryPreviewParam.kt
@@ -24,7 +24,7 @@ import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.ui.model.InviteSender
import kotlinx.collections.immutable.toImmutableList
-open class RoomListRoomSummaryProvider : PreviewParameterProvider {
+open class RoomListRoomSummaryPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
listOf(
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/RoomListContentStatePreviewParam.kt
similarity index 96%
rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStateProvider.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStatePreviewParam.kt
index 0ac2b7bf62..4ca87992f9 100644
--- 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/RoomListContentStatePreviewParam.kt
@@ -19,7 +19,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableSet
-open class RoomListContentStateProvider : PreviewParameterProvider {
+open class RoomListContentStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aRoomsContentState(),
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt
index bfa6962e8b..6a334a94c2 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt
@@ -199,7 +199,7 @@ private fun RoomListModalBottomSheetContent(
@PreviewsDayNight
@Composable
internal fun RoomListContextMenuPreview(
- @PreviewParameter(RoomListStateContextMenuShownProvider::class) contextMenu: RoomListState.ContextMenu.Shown
+ @PreviewParameter(RoomListStateContextMenuShownPreviewParam::class) contextMenu: RoomListState.ContextMenu.Shown
) = ElementPreview(fillMaxSize = true) {
RoomListContextMenu(
contextMenu = contextMenu,
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
index 0a7a29ebc2..022696cbb1 100644
--- 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
@@ -126,7 +126,7 @@ private fun RoomListDeclineInviteMenuContent(
@PreviewsDayNight
@Composable
internal fun RoomListDeclineInviteMenuPreview(
- @PreviewParameter(RoomListStateDeclineInviteMenuShownProvider::class) menu: RoomListState.DeclineInviteMenu.Shown,
+ @PreviewParameter(RoomListStateDeclineInviteMenuShownPreviewParam::class) menu: RoomListState.DeclineInviteMenu.Shown,
) = ElementPreview {
RoomListDeclineInviteMenu(
menu = menu,
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
index fe09bd7cd1..1c21177988 100644
--- 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
@@ -30,6 +30,7 @@ import io.element.android.features.announcement.api.AnnouncementService
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.filters.into
+import io.element.android.features.home.impl.search.GlobalSearchState
import io.element.android.features.home.impl.search.RoomListSearchEvent
import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
@@ -85,6 +86,7 @@ class RoomListPresenter(
private val announcementService: AnnouncementService,
private val coldStartWatcher: AnalyticsColdStartWatcher,
private val spaceFiltersPresenter: Presenter,
+ private val globalSearchPresenter: Presenter,
private val featureFlagService: FeatureFlagService,
) : Presenter {
private val encryptionService = client.encryptionService
@@ -97,6 +99,7 @@ class RoomListPresenter(
val searchState = searchPresenter.present()
val spaceFiltersState = spaceFiltersPresenter.present()
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
+ val globalSearchState = globalSearchPresenter.present()
LaunchedEffect(Unit) {
roomListDataSource.launchIn(this)
@@ -177,6 +180,7 @@ class RoomListPresenter(
leaveRoomState = leaveRoomState,
filtersState = filtersState,
searchState = searchState,
+ globalSearchState = globalSearchState,
spaceFiltersState = spaceFiltersState,
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
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
index e184a5a57f..4164c56e9d 100644
--- 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
@@ -11,6 +11,7 @@ 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.GlobalSearchState
import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
@@ -27,6 +28,7 @@ data class RoomListState(
val leaveRoomState: LeaveRoomState,
val filtersState: RoomListFiltersState,
val searchState: RoomListSearchState,
+ val globalSearchState: GlobalSearchState,
val spaceFiltersState: SpaceFiltersState,
val contentState: RoomListContentState,
val acceptDeclineInviteState: AcceptDeclineInviteState,
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownPreviewParam.kt
similarity index 90%
rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownProvider.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownPreviewParam.kt
index eca1b63d7d..f4bd0eac2e 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownPreviewParam.kt
@@ -11,7 +11,7 @@ package io.element.android.features.home.impl.roomlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomId
-open class RoomListStateContextMenuShownProvider : PreviewParameterProvider {
+open class RoomListStateContextMenuShownPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aContextMenuShown(hasNewContent = true),
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateDeclineInviteMenuShownProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateDeclineInviteMenuShownPreviewParam.kt
similarity index 90%
rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateDeclineInviteMenuShownProvider.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateDeclineInviteMenuShownPreviewParam.kt
index 73d4785e96..c19da0595a 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateDeclineInviteMenuShownProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateDeclineInviteMenuShownPreviewParam.kt
@@ -12,7 +12,7 @@ import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.model.aRoomListRoomSummary
-open class RoomListStateDeclineInviteMenuShownProvider : PreviewParameterProvider {
+open class RoomListStateDeclineInviteMenuShownPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aDeclineInviteMenuShown(),
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/RoomListStatePreviewParam.kt
similarity index 91%
rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStatePreviewParam.kt
index 97600e0247..04f612870f 100644
--- 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/RoomListStatePreviewParam.kt
@@ -16,7 +16,9 @@ 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.GlobalSearchState
import io.element.android.features.home.impl.search.RoomListSearchState
+import io.element.android.features.home.impl.search.aGlobalSearchState
import io.element.android.features.home.impl.search.aRoomListSearchState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState
@@ -31,7 +33,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
-open class RoomListStateProvider : PreviewParameterProvider {
+open class RoomListStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aRoomListState(),
@@ -41,7 +43,10 @@ open class RoomListStateProvider : PreviewParameterProvider {
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
aRoomListState(contentState = anEmptyContentState()),
aRoomListState(contentState = aSkeletonContentState()),
- aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
+ aRoomListState(
+ searchState = aRoomListSearchState(isSearchActive = true, query = "Test"),
+ globalSearchState = aGlobalSearchState(isEnabled = false),
+ ),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery)),
aRoomListState(contentState = aRoomsContentState(batteryOptimizationState = aBatteryOptimizationState(shouldDisplayBanner = true))),
aRoomListState(contentState = anEmptyContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
@@ -53,6 +58,7 @@ internal fun aRoomListState(
declineInviteMenu: RoomListState.DeclineInviteMenu = RoomListState.DeclineInviteMenu.Hidden,
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
searchState: RoomListSearchState = aRoomListSearchState(),
+ globalSearchState: GlobalSearchState = aGlobalSearchState(),
filtersState: RoomListFiltersState = aRoomListFiltersState(),
spaceFiltersState: SpaceFiltersState = anUnselectedSpaceFiltersState(),
contentState: RoomListContentState = aRoomsContentState(),
@@ -66,6 +72,7 @@ internal fun aRoomListState(
leaveRoomState = leaveRoomState,
filtersState = filtersState,
searchState = searchState,
+ globalSearchState = globalSearchState,
spaceFiltersState = spaceFiltersState,
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt
new file mode 100644
index 0000000000..76d9147e94
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.home.impl.search
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+sealed interface GlobalSearchEvent {
+ object ClearQuery : GlobalSearchEvent
+ object ToggleSearchVisibility : GlobalSearchEvent
+ data class UpdateVisibleRange(val range: IntRange) : GlobalSearchEvent
+ data class UpdateTarget(val target: GlobalSearchTarget) : GlobalSearchEvent
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt
new file mode 100644
index 0000000000..6e153344c9
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt
@@ -0,0 +1,301 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.home.impl.search
+
+import androidx.compose.foundation.text.input.clearText
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.setValue
+import dev.zacsweers.metro.Inject
+import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.dateformatter.api.DateFormatter
+import io.element.android.libraries.dateformatter.api.DateFormatterMode
+import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+import io.element.android.libraries.matrix.api.room.RoomInfo
+import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
+import io.element.android.libraries.matrix.api.search.MessageSearch
+import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState
+import io.element.android.libraries.matrix.api.search.MessageSearchResult
+import io.element.android.libraries.matrix.api.search.MessageSearchService
+import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageTypeWithAttachment
+import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.isMediaContent
+import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
+import io.element.android.libraries.matrix.ui.messages.toPlainText
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import kotlin.jvm.optionals.getOrElse
+import kotlin.time.Duration.Companion.milliseconds
+
+@Inject
+class GlobalSearchPresenter(
+ private val roomListSearchDataSourceFactory: RoomListSearchDataSource.Factory,
+ private val messageSearchService: MessageSearchService,
+ private val featureFlagService: FeatureFlagService,
+ private val latestEventFormatter: RoomLatestEventFormatter,
+ private val dateFormatter: DateFormatter,
+ private val fileSizeFormatter: FileSizeFormatter,
+ private val permalinkParser: PermalinkParser,
+ private val coroutineDispatchers: CoroutineDispatchers,
+ private val matrixClient: MatrixClient,
+) : Presenter {
+ @Composable
+ override fun present(): GlobalSearchState {
+ val coroutineScope = rememberCoroutineScope()
+ val isEnabled by produceState(false) {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch).collectLatest { value = it }
+ }
+ val roomListSearchDataSource = remember { roomListSearchDataSourceFactory.create(coroutineScope = coroutineScope) }
+ val queryState = rememberTextFieldState()
+ var isSearchActive by remember { mutableStateOf(false) }
+ var searchResults: AsyncData by remember { mutableStateOf(AsyncData.Uninitialized) }
+ var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) }
+ val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) }
+
+ LaunchedEffect(queryState.text) {
+ // Add a delay to avoid performing too many searches in a short period of time, which can be expensive
+ delay(200.milliseconds)
+
+ searchResults = if (queryState.text.isNotEmpty()) {
+ AsyncData.Loading(prevData = searchResults.dataOrNull())
+ } else {
+ AsyncData.Uninitialized
+ }
+
+ launch { roomListSearchDataSource.setSearchQuery(queryState.text.toString()) }
+ launch {
+ currentMessageSearch.setQuery(queryState.text.toString())
+ .onFailure { Timber.e(it, "Could not set query for message search") }
+ }
+ }
+
+ LaunchedEffect(currentTarget) {
+ if (queryState.text.isNotEmpty()) {
+ searchResults = AsyncData.Loading()
+ }
+
+ when (currentTarget) {
+ GlobalSearchTarget.ROOMS -> {
+ roomListSearchDataSource.roomSummaries.collectLatest { results ->
+ Timber.d("Room list search found ${results.size} items")
+ // The room list data source always returns some values, even if the query is empty,
+ // so we only want to update the search results if the query is not empty
+ searchResults = if (queryState.text.isNotEmpty()) {
+ AsyncData.Success(GlobalSearchResults.RoomListResults(results = results))
+ } else {
+ AsyncData.Uninitialized
+ }
+ }
+ }
+ GlobalSearchTarget.MESSAGES -> {
+ Timber.d("Message search found ${currentMessageSearch.results.value.size} items")
+ combine(
+ currentMessageSearch.results,
+ currentMessageSearch.paginationState,
+ ) { results, paginationState ->
+ Pair(results, paginationState)
+ }.collectLatest { (results, paginationState) ->
+ Timber.d("Message search found ${results.size} items, pagination state: $paginationState")
+
+ // Try processing the results in parallel to speed up the processing time, since we have to wait until we get the room info for each
+ // result, which can be slow if we have a lot of results. The original order is kept by using `awaitAll()`.
+ val mappedResults = results.map { result ->
+ async(coroutineDispatchers.computation) {
+ val roomInfo = matrixClient.getRoomInfoFlow(result.roomId).first().getOrElse { return@async null }
+ val formattedTimestamp = dateFormatter.format(
+ timestamp = result.timestamp,
+ mode = DateFormatterMode.TimeOrDate,
+ useRelative = true
+ )
+
+ if (!result.content.isMediaContent()) {
+ mapMessageContent(result, roomInfo, formattedTimestamp)
+ } else {
+ mapMediaContent(result, roomInfo, formattedTimestamp)
+ }
+ }
+ }
+ .awaitAll()
+ .mapNotNull { it }
+
+ searchResults = when {
+ queryState.text.isEmpty() -> AsyncData.Uninitialized
+ mappedResults.isNotEmpty() -> {
+ AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = mappedResults.toImmutableList()))
+ }
+ paginationState is MessageSearchPaginationState.Idle && paginationState.endReached -> {
+ AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = persistentListOf()))
+ }
+ paginationState is MessageSearchPaginationState.Loading -> {
+ AsyncData.Loading(prevData = searchResults.dataOrNull())
+ }
+ else -> {
+ AsyncData.Uninitialized
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fun handleEvent(event: GlobalSearchEvent) {
+ when (event) {
+ GlobalSearchEvent.ClearQuery -> {
+ queryState.clearText()
+ }
+ GlobalSearchEvent.ToggleSearchVisibility -> {
+ isSearchActive = !isSearchActive
+ queryState.clearText()
+ }
+ is GlobalSearchEvent.UpdateVisibleRange -> {
+ when (currentTarget) {
+ GlobalSearchTarget.ROOMS -> coroutineScope.launch {
+ // TODO Maybe this is not needed? We're not displaying the latest event or timestamp info so maybe we dont need to subscribe
+ // to the room info for each room in the search results.
+ roomListSearchDataSource.updateVisibleRange(event.range)
+ }
+ GlobalSearchTarget.MESSAGES -> coroutineScope.launch {
+ val currentCount = (searchResults.dataOrNull() as? GlobalSearchResults.MessageSearchResults)?.results?.size ?: 0
+ val currentPaginationState = currentMessageSearch.paginationState.value
+ val canPaginate = currentPaginationState is MessageSearchPaginationState.Idle && !currentPaginationState.endReached
+ if (event.range.last >= currentCount - 10 && canPaginate) {
+ currentMessageSearch.paginate()
+ }
+ }
+ }
+ }
+ is GlobalSearchEvent.UpdateTarget -> currentTarget = event.target
+ }
+ }
+
+ return GlobalSearchState(
+ isEnabled = isEnabled,
+ isSearchActive = isSearchActive,
+ queryState = queryState,
+ currentTarget = currentTarget,
+ results = searchResults,
+ eventSink = ::handleEvent,
+ )
+ }
+
+ private fun mapMessageContent(
+ result: MessageSearchResult,
+ roomInfo: RoomInfo,
+ formattedTimestamp: String,
+ ): MessageSearchResultItem.Message? {
+ val body = latestEventFormatter.format(
+ latestEvent = LatestEventValue.Remote(
+ timestamp = result.timestamp,
+ content = result.content,
+ senderId = result.senderId,
+ senderProfile = result.senderProfile,
+ isOwn = matrixClient.isMe(result.senderId),
+ ),
+ isDmRoom = false,
+ )
+ return MessageSearchResultItem.Message(
+ messageSearchResult = result,
+ body = body?.toString() ?: "",
+ formattedTimestamp = formattedTimestamp,
+ roomInfo = roomInfo,
+ )
+ }
+
+ private fun mapMediaContent(
+ result: MessageSearchResult,
+ roomInfo: RoomInfo,
+ formattedTimestamp: String,
+ ): MessageSearchResultItem.Media? {
+ val messageType = when (val content = result.content) {
+ is MessageContent if content.type is MessageTypeWithAttachment -> content.type as MessageTypeWithAttachment
+ else -> return null
+ }
+
+ val caption = messageType.toPlainText(permalinkParser, default = messageType.caption ?: messageType.filename)
+
+ val thumbnailType = when (messageType) {
+ is ImageMessageType -> AttachmentThumbnailType.Image
+ is VideoMessageType -> AttachmentThumbnailType.Video
+ is AudioMessageType -> AttachmentThumbnailType.Audio
+ is VoiceMessageType -> AttachmentThumbnailType.Voice
+ is FileMessageType -> AttachmentThumbnailType.File
+ is StickerMessageType -> AttachmentThumbnailType.Image
+ }
+ val thumbnailSource = when (messageType) {
+ is ImageMessageType -> messageType.info?.thumbnailSource ?: messageType.source
+ is VideoMessageType -> messageType.info?.thumbnailSource
+ is AudioMessageType -> null
+ is VoiceMessageType -> null
+ is FileMessageType -> null
+ is StickerMessageType -> messageType.info?.thumbnailSource ?: messageType.source
+ }
+ val blurhash = when (messageType) {
+ is ImageMessageType -> messageType.info?.blurhash
+ is VideoMessageType -> messageType.info?.blurhash
+ is AudioMessageType -> null
+ is VoiceMessageType -> null
+ is FileMessageType -> null
+ is StickerMessageType -> messageType.info?.blurhash
+ }
+
+ val extension = messageType.filename.split(".").takeIf { it.size > 1 }?.lastOrNull()?.uppercase()
+ val formattedSize = when (messageType) {
+ is ImageMessageType -> messageType.info?.size
+ is VideoMessageType -> messageType.info?.size
+ is AudioMessageType -> messageType.info?.size
+ is VoiceMessageType -> messageType.info?.size
+ is FileMessageType -> messageType.info?.size
+ is StickerMessageType -> messageType.info?.size
+ }
+ ?.let(fileSizeFormatter::format)
+
+ val mediaContent = MediaSearchResultContent(
+ filename = messageType.filename,
+ extension = extension,
+ caption = caption,
+ formattedSize = formattedSize,
+ thumbnailSource = thumbnailSource,
+ thumbnailType = thumbnailType,
+ blurhash = blurhash
+ )
+
+ return MessageSearchResultItem.Media(
+ messageSearchResult = result,
+ mediaContent = mediaContent,
+ formattedTimestamp = formattedTimestamp,
+ roomInfo = roomInfo,
+ )
+ }
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt
new file mode 100644
index 0000000000..d623fd5594
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.home.impl.search
+
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.runtime.Immutable
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.libraries.architecture.AsyncData
+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.media.MediaSource
+import io.element.android.libraries.matrix.api.room.RoomInfo
+import io.element.android.libraries.matrix.api.search.MessageSearchResult
+import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
+import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
+import kotlinx.collections.immutable.ImmutableList
+
+@Immutable
+data class GlobalSearchState(
+ val isEnabled: Boolean,
+ val isSearchActive: Boolean,
+ val queryState: TextFieldState,
+ val currentTarget: GlobalSearchTarget,
+ val results: AsyncData,
+ val eventSink: (GlobalSearchEvent) -> Unit
+)
+
+@Immutable
+enum class GlobalSearchTarget {
+ ROOMS,
+ MESSAGES,
+}
+
+@Immutable
+sealed interface GlobalSearchResults {
+ data class RoomListResults(val results: ImmutableList) : GlobalSearchResults
+ data class MessageSearchResults(val results: ImmutableList) : GlobalSearchResults
+
+ fun isEmpty(): Boolean = when (this) {
+ is RoomListResults -> results.isEmpty()
+ is MessageSearchResults -> results.isEmpty()
+ }
+}
+
+sealed interface MessageSearchResultItem {
+ val roomId: RoomId
+ val eventId: EventId
+ val senderId: UserId
+ val senderName: String
+ val roomInfo: RoomInfo
+ val formattedTimestamp: String
+
+ data class Message(
+ val messageSearchResult: MessageSearchResult,
+ val body: String,
+ override val roomInfo: RoomInfo,
+ override val formattedTimestamp: String,
+ ) : MessageSearchResultItem {
+ override val roomId: RoomId = messageSearchResult.roomId
+ override val eventId: EventId = messageSearchResult.eventId
+ override val senderId: UserId = messageSearchResult.senderId
+ override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId)
+ }
+
+ data class Media(
+ val messageSearchResult: MessageSearchResult,
+ val mediaContent: MediaSearchResultContent,
+ override val roomInfo: RoomInfo,
+ override val formattedTimestamp: String,
+ ) : MessageSearchResultItem {
+ override val roomId: RoomId = messageSearchResult.roomId
+ override val eventId: EventId = messageSearchResult.eventId
+ override val senderId: UserId = messageSearchResult.senderId
+ override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId)
+ }
+}
+
+data class MediaSearchResultContent(
+ val filename: String,
+ val extension: String?,
+ val caption: String?,
+ val formattedSize: String?,
+ val thumbnailSource: MediaSource?,
+ val thumbnailType: AttachmentThumbnailType,
+ val blurhash: String?,
+)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStatePreviewParam.kt
new file mode 100644
index 0000000000..73487c5069
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStatePreviewParam.kt
@@ -0,0 +1,249 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.home.impl.search
+
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.home.impl.roomlist.aRoomListRoomSummaryList
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.core.EventId
+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.UserId
+import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.matrix.api.room.CallIntentConsensus
+import io.element.android.libraries.matrix.api.room.CurrentUserMembership
+import io.element.android.libraries.matrix.api.room.RoomInfo
+import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
+import io.element.android.libraries.matrix.api.room.join.JoinRule
+import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
+import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
+import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
+import io.element.android.libraries.matrix.api.search.MessageSearchResult
+import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
+import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
+import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
+import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
+import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.persistentMapOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toPersistentList
+
+class GlobalSearchStatePreviewParam : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ // Not enabled, no preview
+ aGlobalSearchState(isEnabled = false),
+ aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")),
+ aGlobalSearchState(
+ isSearchActive = true,
+ currentTarget = GlobalSearchTarget.ROOMS,
+ queryState = TextFieldState("Query"),
+ results = AsyncData.Loading(),
+ ),
+ aGlobalSearchState(
+ isSearchActive = true,
+ currentTarget = GlobalSearchTarget.ROOMS,
+ queryState = TextFieldState("Query"),
+ results = AsyncData.Success(
+ GlobalSearchResults.RoomListResults(
+ results = aRoomListRoomSummaryList().mapIndexed { index, summary ->
+ summary.copy(name = "Room with Query #${index + 1}")
+ }.toPersistentList()
+ )
+ ),
+ ),
+ aGlobalSearchState(
+ isSearchActive = true,
+ currentTarget = GlobalSearchTarget.MESSAGES,
+ queryState = TextFieldState("Query"),
+ results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf(
+ MessageSearchResultItem.Message(
+ messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")),
+ body = "A message with Query",
+ roomInfo = aRoomInfo(),
+ formattedTimestamp = "12:00",
+ ),
+ MessageSearchResultItem.Media(
+ messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")),
+ mediaContent = MediaSearchResultContent(
+ filename = "file.png",
+ extension = "PNG",
+ caption = "A caption containing Query",
+ formattedSize = "1 MB",
+ thumbnailSource = MediaSource("https://example.com/thumbnail.png"),
+ thumbnailType = AttachmentThumbnailType.Image,
+ blurhash = null,
+ ),
+ roomInfo = aRoomInfo(),
+ formattedTimestamp = "12:00",
+ ),
+ ))),
+ ),
+ aGlobalSearchState(
+ isSearchActive = true,
+ currentTarget = GlobalSearchTarget.MESSAGES,
+ queryState = TextFieldState("Query"),
+ results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())),
+ ),
+ )
+}
+
+fun aRoomInfo(
+ id: RoomId = RoomId("!roomId:server.org"),
+ name: String? = "A room",
+ rawName: String? = "A room raw name",
+ topic: String? = "A room topic",
+ avatarUrl: String? = "https://example.com/avatar.png",
+ isPublic: Boolean = true,
+ isDirect: Boolean = false,
+ isEncrypted: Boolean = false,
+ joinRule: JoinRule? = JoinRule.Public,
+ isSpace: Boolean = false,
+ successorRoom: SuccessorRoom? = null,
+ isFavorite: Boolean = false,
+ canonicalAlias: RoomAlias? = null,
+ alternativeAliases: List = emptyList(),
+ currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
+ inviter: RoomMember? = null,
+ activeMembersCount: Long = 2,
+ invitedMembersCount: Long = 1,
+ joinedMembersCount: Long = 1,
+ highlightCount: Long = 0,
+ notificationCount: Long = 0,
+ userDefinedNotificationMode: RoomNotificationMode? = null,
+ hasRoomCall: Boolean = false,
+ roomPowerLevels: RoomPowerLevels? = RoomPowerLevels(
+ values = RoomPowerLevelsValues(
+ ban = 0,
+ invite = 0,
+ kick = 0,
+ eventsDefault = 0,
+ stateDefault = 0,
+ redactEvents = 0,
+ roomName = 0,
+ roomAvatar = 0,
+ roomTopic = 0,
+ spaceChild = 0,
+ beacon = 0,
+ beaconInfo = 0
+ ),
+ users = persistentMapOf(),
+ ),
+ activeRoomCallParticipants: List = emptyList(),
+ heroes: List = emptyList(),
+ pinnedEventIds: List = emptyList(),
+ roomCreators: List = emptyList(),
+ isMarkedUnread: Boolean = false,
+ numUnreadMessages: Long = 0,
+ numUnreadNotifications: Long = 0,
+ numUnreadMentions: Long = 0,
+ historyVisibility: RoomHistoryVisibility = RoomHistoryVisibility.Joined,
+ roomVersion: String? = "11",
+ privilegedCreatorRole: Boolean = false,
+ isLowPriority: Boolean = false,
+ activeCallIntentConsensus: CallIntentConsensus = CallIntentConsensus.None,
+ isDm: Boolean = false,
+ fullyReadEventId: EventId? = null,
+) = RoomInfo(
+ id = id,
+ name = name,
+ rawName = rawName,
+ topic = topic,
+ avatarUrl = avatarUrl,
+ isPublic = isPublic,
+ isDirect = isDirect,
+ isEncrypted = isEncrypted,
+ joinRule = joinRule,
+ isSpace = isSpace,
+ successorRoom = successorRoom,
+ isFavorite = isFavorite,
+ canonicalAlias = canonicalAlias,
+ alternativeAliases = alternativeAliases.toImmutableList(),
+ currentUserMembership = currentUserMembership,
+ inviter = inviter,
+ activeMembersCount = activeMembersCount,
+ invitedMembersCount = invitedMembersCount,
+ joinedMembersCount = joinedMembersCount,
+ highlightCount = highlightCount,
+ notificationCount = notificationCount,
+ userDefinedNotificationMode = userDefinedNotificationMode,
+ hasRoomCall = hasRoomCall,
+ roomPowerLevels = roomPowerLevels,
+ activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(),
+ heroes = heroes.toImmutableList(),
+ pinnedEventIds = pinnedEventIds.toImmutableList(),
+ creators = roomCreators.toImmutableList(),
+ isMarkedUnread = isMarkedUnread,
+ numUnreadMessages = numUnreadMessages,
+ numUnreadNotifications = numUnreadNotifications,
+ numUnreadMentions = numUnreadMentions,
+ historyVisibility = historyVisibility,
+ roomVersion = roomVersion,
+ privilegedCreatorRole = privilegedCreatorRole,
+ isLowPriority = isLowPriority,
+ activeCallIntentConsensus = activeCallIntentConsensus,
+ isDm = isDm,
+ fullyReadEventId = fullyReadEventId,
+)
+
+fun aMessageContent(
+ body: String = "body",
+ inReplyTo: InReplyTo? = null,
+ isEdited: Boolean = false,
+ threadInfo: EventThreadInfo? = null,
+ messageType: MessageType = TextMessageType(
+ body = body,
+ formatted = null
+ )
+) = MessageContent(
+ body = body,
+ inReplyTo = inReplyTo,
+ isEdited = isEdited,
+ threadInfo = threadInfo,
+ type = messageType
+)
+
+internal fun aMessageSearchResult(
+ roomId: RoomId = RoomId("!roomId:server.org"),
+ eventId: EventId = EventId("\$eventId:server.org"),
+ senderId: UserId = UserId("@user:server.org"),
+ senderProfile: ProfileDetails = aProfileDetailsReady(),
+ content: MessageContent = aMessageContent(),
+ timestamp: Long = 0L,
+) = MessageSearchResult(
+ roomId = roomId,
+ eventId = eventId,
+ senderId = senderId,
+ senderProfile = senderProfile,
+ content = content,
+ timestamp = timestamp,
+)
+
+internal fun aGlobalSearchState(
+ isEnabled: Boolean = true,
+ isSearchActive: Boolean = false,
+ queryState: TextFieldState = TextFieldState(),
+ currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS,
+ results: AsyncData = AsyncData.Uninitialized,
+ eventSink: (GlobalSearchEvent) -> Unit = {},
+) = GlobalSearchState(
+ isEnabled = isEnabled,
+ isSearchActive = isSearchActive,
+ queryState = queryState,
+ currentTarget = currentTarget,
+ results = results,
+ eventSink = eventSink
+)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt
new file mode 100644
index 0000000000..d5a0460f06
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt
@@ -0,0 +1,564 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.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.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.input.TextFieldLineLimits
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.TextFieldDefaults
+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.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextOverflow
+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.home.impl.R
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
+import io.element.android.libraries.designsystem.components.BigIcon
+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.button.BackButton
+import io.element.android.libraries.designsystem.modifiers.niceClickable
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.text.roundToPx
+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.LinearProgressIndicator
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.SegmentedButton
+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.OnVisibleRangeChangeEffect
+import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
+import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
+import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
+import io.element.android.libraries.matrix.ui.model.getAvatarData
+import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+@Composable
+fun GlobalSearchView(
+ state: GlobalSearchState,
+ onSelectSearchResult: (RoomId, EventId?) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ BackHandler(enabled = state.isSearchActive) {
+ state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
+ }
+
+ AnimatedVisibility(
+ visible = state.isSearchActive,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ Column(modifier = modifier) {
+ GlobalSearchContent(
+ state = state,
+ onBackButtonClick = {
+ state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
+ },
+ onSelectSearchResult = onSelectSearchResult,
+ )
+ }
+ }
+}
+
+@Composable
+private fun GlobalSearchContent(
+ state: GlobalSearchState,
+ onBackButtonClick: () -> Unit,
+ onSelectSearchResult: (RoomId, EventId?) -> Unit,
+) {
+ val borderColor = MaterialTheme.colorScheme.tertiary
+ val strokeWidth = 1.dp
+
+ val drawBottomLineModifier = Modifier.drawBehind {
+ drawLine(
+ color = borderColor,
+ start = Offset(0f, size.height),
+ end = Offset(size.width, size.height),
+ strokeWidth = strokeWidth.value
+ )
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ navigationIcon = { BackButton(onClick = onBackButtonClick) },
+ title = {
+ // The stateSaver will keep the selection state when returning to this UI
+ val focusRequester = remember { FocusRequester() }
+ val searchLabel = stringResource(CommonStrings.action_search)
+ FilledTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ .semantics { contentDescription = searchLabel },
+ state = state.queryState,
+ lineLimits = TextFieldLineLimits.SingleLine,
+ colors = TextFieldDefaults.colors(
+ focusedContainerColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ disabledContainerColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent,
+ errorIndicatorColor = Color.Transparent,
+ ),
+ trailingIcon = if (state.queryState.text.isNotEmpty()) {
+ @Composable {
+ IconButton(onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }) {
+ Icon(
+ imageVector = CompoundIcons.Close(),
+ contentDescription = stringResource(CommonStrings.a11y_clear_search_field)
+ )
+ }
+ }
+ } else {
+ null
+ },
+ )
+
+ LaunchedEffect(Unit) {
+ if (!focusRequester.restoreFocusedChild()) {
+ focusRequester.requestFocus()
+ }
+ focusRequester.saveFocusedChild()
+ }
+ }
+ )
+ },
+ contentWindowInsets = scaffoldScrollableContentInsets,
+ ) { padding ->
+ Column(
+ modifier = Modifier.padding(padding),
+ ) {
+ SingleChoiceSegmentedButtonRow(
+ modifier = drawBottomLineModifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
+ ) {
+ SegmentedButton(
+ index = 0,
+ count = 2,
+ selected = state.currentTarget == GlobalSearchTarget.ROOMS,
+ onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS)) },
+ text = stringResource(R.string.search_section_chats),
+ )
+
+ SegmentedButton(
+ index = 1,
+ count = 2,
+ selected = state.currentTarget == GlobalSearchTarget.MESSAGES,
+ onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) },
+ text = stringResource(R.string.search_section_messages),
+ )
+ }
+
+ val lazyListState = rememberLazyListState()
+ OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
+ state.eventSink(GlobalSearchEvent.UpdateVisibleRange(visibleRange))
+ }
+
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = lazyColumnContentPadding,
+ state = lazyListState,
+ ) {
+ val results = state.results.dataOrNull()
+ when {
+ state.results.isUninitialized() -> startSearching()
+ state.results.isLoading() -> loading()
+ results?.isEmpty() == true -> emptySearchResults(query = state.queryState.text.toString())
+ results is GlobalSearchResults.RoomListResults -> roomListResults(
+ results = results.results,
+ onRoomClick = { roomId -> onSelectSearchResult(roomId, null) },
+ )
+ results is GlobalSearchResults.MessageSearchResults -> messageSearchResults(
+ results = results.results,
+ onSearchResultSelected = onSelectSearchResult,
+ )
+ }
+ }
+ }
+ }
+}
+
+private fun LazyListScope.startSearching() {
+ item {
+ IconTitleSubtitleMolecule(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(40.dp),
+ title = stringResource(R.string.start_searching_title),
+ subTitle = stringResource(R.string.start_searching_subtitle),
+ iconStyle = BigIcon.Style.Default(CompoundIcons.Search()),
+ )
+ }
+}
+
+private fun LazyListScope.loading() {
+ item {
+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
+ }
+}
+
+private fun LazyListScope.emptySearchResults(query: String) {
+ item {
+ IconTitleSubtitleMolecule(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(40.dp),
+ title = stringResource(R.string.search_no_results_title),
+ subTitle = stringResource(R.string.search_no_results_subtitle, query),
+ iconStyle = BigIcon.Style.Default(CompoundIcons.Search()),
+ )
+ }
+}
+
+private fun LazyListScope.roomListResults(
+ results: ImmutableList,
+ onRoomClick: (RoomId) -> Unit,
+) {
+ itemsIndexed(
+ items = results,
+ key = { _, roomSummary -> roomSummary.roomId },
+ ) { index, result ->
+ Row(
+ modifier = Modifier
+ .niceClickable(onClick = { onRoomClick(result.roomId) })
+ .fillMaxWidth()
+ .addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Avatar(
+ avatarData = result.avatarData.copy(size = AvatarSize.SearchRoomListItem),
+ avatarType = AvatarType.Room(
+ heroes = result.heroes.map { it.copy(size = AvatarSize.SearchRoomListItem) }.toImmutableList(),
+ isTombstoned = result.isTombstoned,
+ ),
+ )
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Text(
+ text = result.name ?: result.roomId.value,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodyLgRegular,
+ )
+
+ if (result.isDm) {
+ val userId = result.heroes.firstOrNull()?.id
+ userId?.let {
+ Text(
+ text = it,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ } else {
+ result.canonicalAlias?.let {
+ Text(
+ text = it.value,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun LazyListScope.messageSearchResults(
+ results: ImmutableList,
+ onSearchResultSelected: (RoomId, EventId?) -> Unit,
+) {
+ if (results.isNotEmpty()) {
+ item {
+ Text(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+ text = stringResource(R.string.search_from_your_messages_header),
+ style = ElementTheme.typography.fontBodyMdMedium,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ }
+ itemsIndexed(
+ items = results,
+ contentType = { _, messageSearchResult -> messageSearchResult::class.java },
+ key = { _, messageSearchResult -> messageSearchResult.eventId },
+ ) { index, result ->
+ val clickableModifier = Modifier.niceClickable(onClick = { onSearchResultSelected(result.roomId, result.eventId) })
+ when (result) {
+ is MessageSearchResultItem.Message -> TextMessageSearchResultItemView(
+ modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex),
+ avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem),
+ avatarType = AvatarType.Room(
+ heroes = result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.RoomListItem) }.toImmutableList(),
+ isTombstoned = result.roomInfo.successorRoom != null
+ ),
+ roomName = result.roomInfo.name ?: result.roomInfo.id.value,
+ body = result.body,
+ formattedTimestamp = result.formattedTimestamp,
+ )
+ is MessageSearchResultItem.Media -> {
+ MediaMessageSearchResultItemView(
+ modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex),
+ avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem),
+ avatarType = AvatarType.Room(
+ heroes = result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.RoomListItem) }.toImmutableList(),
+ isTombstoned = result.roomInfo.successorRoom != null
+ ),
+ roomName = result.roomInfo.name ?: result.roomInfo.id.value,
+ content = result.mediaContent,
+ formattedTimestamp = result.formattedTimestamp,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TextMessageSearchResultItemView(
+ avatarData: AvatarData,
+ avatarType: AvatarType,
+ roomName: String,
+ body: String,
+ formattedTimestamp: String,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Avatar(
+ avatarData = avatarData,
+ avatarType = avatarType,
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 12.dp, bottom = 10.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = roomName,
+ maxLines = 1,
+ style = ElementTheme.typography.fontBodyLgMedium,
+ )
+ Text(
+ text = formattedTimestamp,
+ maxLines = 1,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ Text(
+ text = body,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ }
+}
+
+@Composable
+private fun MediaMessageSearchResultItemView(
+ avatarData: AvatarData,
+ avatarType: AvatarType,
+ roomName: String,
+ content: MediaSearchResultContent,
+ formattedTimestamp: String,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Avatar(
+ avatarData = avatarData,
+ avatarType = avatarType,
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = roomName,
+ maxLines = 1,
+ style = ElementTheme.typography.fontBodyLgMedium,
+ )
+ Text(
+ text = formattedTimestamp,
+ maxLines = 1,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+
+ content.caption?.let {
+ Text(
+ text = it,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth(1f)
+ .background(ElementTheme.colors.bgSubtleSecondary, shape = RoundedCornerShape(12.dp))
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AttachmentThumbnail(
+ modifier = Modifier
+ .size(36.dp)
+ .clip(RoundedCornerShape(4.dp)),
+ info = AttachmentThumbnailInfo(
+ type = content.thumbnailType,
+ thumbnailSource = content.thumbnailSource,
+ textContent = null,
+ blurHash = content.blurhash,
+ ),
+ thumbnailSize = 36.dp.roundToPx().toLong(),
+ backgroundColor = ElementTheme.colors.bgCanvasDefault,
+ )
+
+ Column {
+ Text(
+ text = content.filename,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodyLgRegular,
+ )
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ content.extension?.let {
+ Text(
+ text = it,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ content.formattedSize?.let {
+ Text(
+ text = "($it)",
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun Modifier.addSeparatorLine(color: Color, isLastItem: Boolean): Modifier {
+ if (isLastItem) return this
+
+ return this.drawBehind {
+ val strokeWidthPx = 1.dp.toPx()
+ drawLine(
+ color = color,
+ start = Offset(0f, size.height - strokeWidthPx),
+ end = Offset(size.width, size.height - strokeWidthPx),
+ strokeWidth = strokeWidthPx,
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun GlobalSearchViewPreview(
+ @PreviewParameter(GlobalSearchStatePreviewParam::class) state: GlobalSearchState,
+) {
+ ElementPreview {
+ GlobalSearchView(
+ state = state,
+ onSelectSearchResult = { _, _ -> },
+ )
+ }
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt
index 03fcb5520f..df7ed0dd37 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt
@@ -46,6 +46,8 @@ class RoomListSearchDataSource(
coroutineScope = coroutineScope
)
+ val loadingState = roomList.loadingState
+
val roomSummaries: Flow> = roomList.summaries
.map { roomSummaries ->
roomSummaries
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/RoomListSearchStatePreviewParam.kt
similarity index 93%
rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStatePreviewParam.kt
index e6ec92f68b..2b857354a3 100644
--- 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/RoomListSearchStatePreviewParam.kt
@@ -15,7 +15,7 @@ import io.element.android.features.home.impl.roomlist.aRoomListRoomSummaryList
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
-class RoomListSearchStateProvider : PreviewParameterProvider {
+class RoomListSearchStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aRoomListSearchState(),
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt
index 0535ded5c5..ff354cf717 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt
@@ -196,7 +196,7 @@ private fun RoomListSearchContent(
@PreviewsDayNight
@Composable
-internal fun RoomListSearchContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview {
+internal fun RoomListSearchContentPreview(@PreviewParameter(RoomListSearchStatePreviewParam::class) state: RoomListSearchState) = ElementPreview {
RoomListSearchContent(
state = state,
hideInvitesAvatars = false,
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersStatePreviewParam.kt
similarity index 97%
rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersStateProvider.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersStatePreviewParam.kt
index 264d122836..335b801908 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersStateProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersStatePreviewParam.kt
@@ -15,7 +15,7 @@ import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.toImmutableList
-class SpaceFiltersStateProvider : PreviewParameterProvider {
+class SpaceFiltersStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aSelectingSpaceFiltersState(),
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersView.kt
index 280f41b909..e4573f344e 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersView.kt
@@ -189,6 +189,6 @@ private fun SpaceFilterItem(
@PreviewsDayNight
@Composable
-internal fun SpaceFiltersViewPreview(@PreviewParameter(SpaceFiltersStateProvider::class) state: SpaceFiltersState) = ElementPreview(fillMaxSize = true) {
+internal fun SpaceFiltersViewPreview(@PreviewParameter(SpaceFiltersStatePreviewParam::class) state: SpaceFiltersState) = ElementPreview(fillMaxSize = true) {
SpaceFiltersView(state = state)
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStatePreviewParam.kt
similarity index 93%
rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStatePreviewParam.kt
index 17f2cbad31..57b219d302 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStatePreviewParam.kt
@@ -15,11 +15,11 @@ import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
-open class HomeSpacesStateProvider : PreviewParameterProvider {
+open class HomeSpacesStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aHomeSpacesState(
- spaceRooms = SpaceRoomProvider().values.toList(),
+ spaceRooms = SpaceRoomPreviewParam().values.toList(),
seenSpaceInvites = setOf(
RoomId("!spaceId3:example.com"),
),
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt
index 9125912bf0..914d08d7f6 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt
@@ -171,7 +171,7 @@ private fun EmptySpaceHomeView(
@PreviewsDayNight
@Composable
internal fun HomeSpacesViewPreview(
- @PreviewParameter(HomeSpacesStateProvider::class) state: HomeSpacesState,
+ @PreviewParameter(HomeSpacesStatePreviewParam::class) state: HomeSpacesState,
) = ElementPreview {
HomeSpacesView(
state = state,
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomPreviewParam.kt
similarity index 95%
rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomProvider.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomPreviewParam.kt
index 4a77e45372..25744157aa 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomPreviewParam.kt
@@ -14,7 +14,7 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.previewutils.room.aSpaceRoom
-class SpaceRoomProvider : PreviewParameterProvider {
+class SpaceRoomPreviewParam : PreviewParameterProvider {
override val values: Sequence = sequenceOf(
aSpaceRoom(),
aSpaceRoom(
diff --git a/features/home/impl/src/main/res/values-ar/translations.xml b/features/home/impl/src/main/res/values-ar/translations.xml
new file mode 100644
index 0000000000..59f4d6c821
--- /dev/null
+++ b/features/home/impl/src/main/res/values-ar/translations.xml
@@ -0,0 +1,56 @@
+
+
+ "عطّل تحسين البطارية لهذا التطبيق للتأكد من استلام جميع الإشعارات."
+ "تعطيل التحسين"
+ "هل لا تصلك الإشعارات؟"
+ "تم تحديث صوت إشعاراتك، ليصبح أوضح وأسرع وأقل إزعاجًا."
+ "حدّثنا الأصوات لديك"
+ "يتم نسخ دردشاتك احتياطيًا تلقائيًا بتشفير من طرف إلى طرف. لاستعادة هذه النسخة الاحتياطية والاحتفاظ بهويتك الرقمية إذا فقدت الوصول إلى جميع أجهزتك، ستحتاج إلى مفتاح الاسترداد."
+ "الحصول على مفتاح الاسترداد"
+ "احتفِظ بنسخة احتياطية من دردشاتك"
+ "أكّد مفتاح الاسترداد الخاص بك للحفاظ على الوصول إلى تخزين المفاتيح وسجل الرسائل."
+ "أدخل مفتاح الاسترداد"
+ "هل نسيت مفتاح الاسترداد؟"
+ "تخزين المفاتيح لديك غير متزامن"
+ "للتأكد من عدم تفويت أي مكالمة مهمة، يُرجى تغيير إعداداتك للسماح بإشعارات ملء الشاشة عندما يكون هاتفك مقفلاً."
+ "حسّن تجربة مكالماتك"
+ "المحادثات"
+ "المساحات"
+ "أمتأكد من رغبتك برفض دعوة للانضمام إلى %1$s؟"
+ "رفض الدعوة"
+ "أمتأكد من رغبتك برفض المحادثة الخاصة مع %1$s؟"
+ "رفض المحادثة"
+ "لا دعوات"
+ "دعاك %1$s (%2$s)"
+ "هذه العملية تُجرى لمرة واحدة، شكراً على انتظارك."
+ "إعداد حسابك"
+ "إنشاء محادثة جديد أو غرفة"
+ "مسح عوامل التصفية"
+ "ابدأ الآن بمراسلة من تشاء."
+ "لا محادثات بعد."
+ "المفضلة"
+ "يمكنك إضافة محادثة إلى المفضلة من إعدادات المحادثة.
+يمكنك الآن إلغاء تحديد عوامل التصفية لرؤية محادثاتك الأخرى"
+ "ليست لديك أي محادثات مفضلة بعد"
+ "الدعوات"
+ "ليس لديك أي دعوات قيد الانتظار."
+ "منخفضة الأولوية"
+ "ليست لديك أي دردشات منخفضة الأولوية بعد"
+ "يمكنك إلغاء تحديد عوامل التصفية لرؤية محادثاتك الأخرى"
+ "ليست لديك محادثات لهذا الاختيار"
+ "الأشخاص"
+ "ليست لديك أي رسائل مباشرة بعد"
+ "الغرف"
+ "لست في أي غرفة بعد"
+ "غير المقروءة"
+ "تهانينا!
+ليست لديك أي رسائل غير مقروءة!"
+ "تم إرسال طلب الانضمام"
+ "المحادثات"
+ "وضع علامة كمقروء"
+ "وضع علامة كغير مقروء"
+ "تمت ترقية هذه الغرفة"
+ "مساحاتك"
+ "يبدو أنك تستخدم جهازاً جديداً. تحقق باستخدام جهاز آخر ليصبح لك وصول إلى رسائلك المشفرة."
+ "أثبت هويتك"
+
diff --git a/features/home/impl/src/main/res/values-es/translations.xml b/features/home/impl/src/main/res/values-es/translations.xml
index ed6cfdc990..e841d6ebf8 100644
--- a/features/home/impl/src/main/res/values-es/translations.xml
+++ b/features/home/impl/src/main/res/values-es/translations.xml
@@ -3,9 +3,11 @@
"Deshabilita la optimización de la batería en esta aplicación para asegurarte de que recibes notificaciones."
"Deshabilitar la optimización"
"¿No llegan las notificaciones?"
- "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"
+ "Se ha actualizado tu sonido de notificación: más claro, rápido y menos disruptivo."
+ "Hemos actualizado tus sonidos"
+ "Tus chats se respaldan automáticamente con cifrado de extremo a extremo. Para restaurar esta copia de seguridad y conservar tu identidad digital si pierdes el acceso a todos tus dispositivos, necesitarás tu clave de recuperación."
+ "Obtener clave de recuperación"
+ "Copia de seguridad de tus chats"
"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?"
@@ -13,6 +15,7 @@
"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"
"Chats"
+ "Espacios"
"¿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?"
@@ -32,6 +35,7 @@ Por ahora, puedes deseleccionar los filtros para ver tus otros chats"
"Invitaciones"
"No tienes ninguna invitación pendiente."
"Prioridad baja"
+ "Aún no tienes ningún chat de prioridad baja"
"Puedes deseleccionar filtros para ver tus otros chats."
"No tienes chats para esta selección"
"Personas"
@@ -46,6 +50,7 @@ Por ahora, puedes deseleccionar los filtros para ver tus otros chats"
"Marcar como leído"
"Marcar como no leído"
"Esta sala ha sido renovada"
+ "Tus espacios"
"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-fa/translations.xml b/features/home/impl/src/main/res/values-fa/translations.xml
index b0c6a30bc4..64ce86ddc7 100644
--- a/features/home/impl/src/main/res/values-fa/translations.xml
+++ b/features/home/impl/src/main/res/values-fa/translations.xml
@@ -3,12 +3,16 @@
"از کار انداختن بهینهسازی باتری برای این کاره برای اطمینان از گرفتن همهٔ آگاهیها."
"از کار انداختن بهینه سازی"
"آگاهیها نمیرسند؟"
- "بازگردانی تاریخچهٔ پیامها و هویت رمزنگاشتهتان با کلید بازیابی در صورت از دست دادن همهٔ افزارههای موجودتان."
+ "پینگ اعلان شما بهروزرسانی شده است—واضحتر، سریعتر و کمدردسرتر."
+ "ما صداهای شما را تازه کردهایم"
+ "چتهای شما به طور خودکار با رمزگذاری سراسری پشتیبانگیری میشوند. برای بازیابی این پشتیبانگیری و حفظ هویت دیجیتال خود در زمانی که دسترسی به تمام دستگاههایتان را از دست میدهید، به کلید بازیابی خود نیاز خواهید داشت."
"دریافت کلید بازیابی"
- "برپایی بازیابی"
+ "از چتهایتان پشتیبان بگیرید"
"کلید بازیابی خود را تأیید کنید تا دسترسی به حافظه کلیدها و تاریخچه پیامهایتان حفظ شود ."
"ورود کلید بازیابیتان"
+ "کلید بازیابی خود را فراموش کردهاید؟"
"ذخیرهساز کلیدتان از همگام بودن در آمده"
+ "برای اطمینان از اینکه هرگز تماس مهمی را از دست نمیدهید، لطفاً تنظیمات خود را تغییر دهید تا وقتی تلفنتان قفل است، اعلانها به صورت تمام صفحه نمایش داده شوند."
"بهبود تجریهٔ تماستان"
"گپها"
"فضاها"
@@ -30,6 +34,7 @@
"دعوتها"
"هیچ دعوت منتظری ندارید."
"اولویت کم"
+ "شما هنوز هیچ چت با اولویت پایین ندارید"
"می توانید پالایهها را برای دیدن دیگر گپهایتان بردارید"
"هیچ گپی برای این گزینش ندارید"
"افراد"
@@ -44,6 +49,7 @@
"علامتگذاری به عنوان خوانده شده"
"نشان به ناخوانده"
"این اتاق ارتقا یافته"
+ "فضاهای شما"
"گویا از افزارهای جدید استفاده میکنید. تأیید با افزارهای دیگر برای دسترسی به پیامهای رمزنگاری شدهتان."
"تأیید کنید که خودتانید"
diff --git a/features/home/impl/src/main/res/values-pl/translations.xml b/features/home/impl/src/main/res/values-pl/translations.xml
index 0c1b07f27b..f6e2e70519 100644
--- a/features/home/impl/src/main/res/values-pl/translations.xml
+++ b/features/home/impl/src/main/res/values-pl/translations.xml
@@ -5,7 +5,7 @@
"Powiadomienia nie dochodzą?"
"Sygnał powiadomień został zaktualizowany — jest wyraźniejszy, szybszy i mniej uciążliwy."
"Odświeżyliśmy Twoje dźwięki"
- "Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić tę kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."
+ "Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."
"Uzyskaj klucz przywracania"
"Utwórz kopię zapasową swoich czatów"
"Potwierdź klucz przywracania, aby zachować dostęp do magazynu kluczy i historii wiadomości."
diff --git a/features/home/impl/src/main/res/values-ro/translations.xml b/features/home/impl/src/main/res/values-ro/translations.xml
index 14cf100c22..2446ebd495 100644
--- a/features/home/impl/src/main/res/values-ro/translations.xml
+++ b/features/home/impl/src/main/res/values-ro/translations.xml
@@ -5,7 +5,7 @@
"Nu primiți notificări?"
"Sunetul pentru notificări a fost actualizat — mai clar, mai rapid și mai puțin perturbatoar."
"Am reîmprospătat sunetele"
- "Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura această copie de rezervă și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."
+ "Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura aceast backup și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."
"Obțineți cheia de recuperare"
"Faceți un backup al mesajelor"
"Backup-ul pentru chat nu este sincronizat. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup."
diff --git a/features/home/impl/src/main/res/values/temporary.xml b/features/home/impl/src/main/res/values/temporary.xml
new file mode 100644
index 0000000000..c44001e5a6
--- /dev/null
+++ b/features/home/impl/src/main/res/values/temporary.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ Start searching…
+ Search for chats and messages
+ Chats
+ From your messages
+ Messages
+ No results
+ There are no results for “%1$s”. Try a new search term.
+
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt
index 8eee8e1ee2..c0cb5f7c01 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt
@@ -11,6 +11,8 @@ package io.element.android.features.home.impl
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.api.HomeEntryPoint
+import io.element.android.features.invite.test.declineandblock.FakeDeclineInviteAndBlockEntryPoint
+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.room.JoinedRoom
import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -36,14 +38,14 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
acceptDeclineInviteView = { _, _, _, _ -> lambdaError() },
directLogoutView = { _ -> lambdaError() },
reportRoomEntryPoint = { _, _, _ -> lambdaError() },
- declineInviteAndBlockUserEntryPoint = { _, _, _ -> lambdaError() },
+ declineInviteAndBlockUserEntryPoint = FakeDeclineInviteAndBlockEntryPoint(),
changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() },
leaveRoomRenderer = { _, _, _ -> lambdaError() },
sessionCoroutineScope = backgroundScope,
)
}
val callback = object : HomeEntryPoint.Callback {
- override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) = lambdaError()
+ override fun navigateToRoom(roomId: RoomId, eventId: EventId?, joinedRoom: JoinedRoom?) = lambdaError()
override fun navigateToCreateRoom() = lambdaError()
override fun navigateToCreateSpace() = lambdaError()
override fun navigateToSettings() = lambdaError()
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt
index 9fb87c0eec..02d947613f 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt
@@ -72,6 +72,49 @@ class RoomListFiltersPresenterTest {
}
}
+ @Test
+ @OptIn(ExperimentalCoroutinesApi::class)
+ fun `present - toggling two incompatible filters in the same frame keeps the first one`() = runTest {
+ val presenter = createRoomListFiltersPresenter()
+ presenter.test {
+ awaitItem().let { state ->
+ state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Favourites))
+ state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Invites))
+ }
+ advanceUntilIdle()
+ awaitLastSequentialItem().let { state ->
+ assertThat(state.selectedFilters()).containsExactly(RoomListFilter.Favourites)
+ assertThat(state.filterSelectionStates).containsExactly(
+ filterSelectionState(RoomListFilter.Favourites, true),
+ filterSelectionState(RoomListFilter.Unread, false),
+ filterSelectionState(RoomListFilter.People, false),
+ filterSelectionState(RoomListFilter.Rooms, false),
+ ).inOrder()
+ }
+ }
+ }
+
+ @Test
+ @OptIn(ExperimentalCoroutinesApi::class)
+ fun `present - toggling two incompatible categories in the same frame keeps the first one`() = runTest {
+ val presenter = createRoomListFiltersPresenter()
+ presenter.test {
+ awaitItem().let { state ->
+ state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.People))
+ state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
+ }
+ advanceUntilIdle()
+ awaitLastSequentialItem().let { state ->
+ assertThat(state.selectedFilters()).containsExactly(RoomListFilter.People)
+ assertThat(state.filterSelectionStates).containsExactly(
+ filterSelectionState(RoomListFilter.People, true),
+ filterSelectionState(RoomListFilter.Unread, false),
+ filterSelectionState(RoomListFilter.Favourites, false),
+ ).inOrder()
+ }
+ }
+ }
+
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - clear filters event`() = runTest {
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
index 64dfc46ee2..d2e33c77c1 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
@@ -20,6 +20,7 @@ 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.RoomListSearchEvent
import io.element.android.features.home.impl.search.RoomListSearchState
+import io.element.android.features.home.impl.search.aGlobalSearchState
import io.element.android.features.home.impl.search.aRoomListSearchState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spacefilters.aDisabledSpaceFiltersState
@@ -680,6 +681,7 @@ class RoomListPresenterTest {
analyticsService = FakeAnalyticsService(),
),
searchPresenter = searchPresenter,
+ globalSearchPresenter = { aGlobalSearchState() },
filtersPresenter = filtersPresenter,
spaceFiltersPresenter = spaceFiltersPresenter,
analyticsService = analyticsService,
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt
index 773f428917..bd18cd903e 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt
@@ -277,7 +277,7 @@ private fun AndroidComposeUiTest.setRoomListView(
setSafeContent {
HomeView(
homeState = aHomeState(roomListState = state),
- onRoomClick = onRoomClick,
+ onRoomClick = { roomId, _ -> onRoomClick(roomId) },
onSettingsClick = onSettingsClick,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt
new file mode 100644
index 0000000000..94c19bb81d
--- /dev/null
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt
@@ -0,0 +1,308 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package io.element.android.features.home.impl.search
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory
+import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
+import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.dateformatter.api.DateFormatter
+import io.element.android.libraries.dateformatter.test.FakeDateFormatter
+import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
+import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+import io.element.android.libraries.matrix.api.roomlist.RoomListService
+import io.element.android.libraries.matrix.api.search.MessageSearchService
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
+import io.element.android.libraries.matrix.test.room.aRoomSummary
+import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
+import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
+import io.element.android.libraries.matrix.test.search.FakeMessageSearch
+import io.element.android.libraries.matrix.test.search.FakeMessageSearchService
+import io.element.android.tests.testutils.consumeItemsUntilPredicate
+import io.element.android.tests.testutils.test
+import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import java.util.Optional
+
+class GlobalSearchPresenterTest {
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = createGlobalSearchPresenter()
+ presenter.test {
+ awaitItem().let { state ->
+ assertThat(state.isEnabled).isFalse()
+ assertThat(state.isSearchActive).isFalse()
+ assertThat(state.queryState.text.toString()).isEmpty()
+ assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS)
+ assertThat(state.results).isEqualTo(AsyncData.Uninitialized)
+ }
+ }
+ }
+
+ @Test
+ fun `present - isEnabled reflects the feature flag`() = runTest {
+ val presenter = createGlobalSearchPresenter(
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.MessageSearch.key to true),
+ ),
+ )
+ presenter.test {
+ // isEnabled is emitted asynchronously via produceState, so it starts as false and flips to true
+ var state = awaitItem()
+ while (!state.isEnabled) {
+ state = awaitItem()
+ }
+ assertThat(state.isEnabled).isTrue()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - toggle search visibility`() = runTest {
+ val presenter = createGlobalSearchPresenter()
+ presenter.test {
+ awaitItem().let { state ->
+ assertThat(state.isSearchActive).isFalse()
+ state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
+ }
+ awaitItem().let { state ->
+ assertThat(state.isSearchActive).isTrue()
+ state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
+ }
+ awaitItem().let { state ->
+ assertThat(state.isSearchActive).isFalse()
+ }
+ }
+ }
+
+ @Test
+ fun `present - update target`() = runTest {
+ val presenter = createGlobalSearchPresenter()
+ presenter.test {
+ awaitItem().let { state ->
+ assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS)
+ state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
+ }
+ awaitItem().let { state ->
+ assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.MESSAGES)
+ }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - clear query`() = runTest {
+ val presenter = createGlobalSearchPresenter()
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.queryState.edit { append("A query") }
+ awaitItem().let { state ->
+ assertThat(state.queryState.text.toString()).isEqualTo("A query")
+ state.eventSink(GlobalSearchEvent.ClearQuery)
+ }
+ awaitItem().let { state ->
+ assertThat(state.queryState.text.toString()).isEmpty()
+ }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - room search results are emitted when the room list changes`() = runTest {
+ val roomList = FakeDynamicRoomList()
+ val roomListService = FakeRoomListService(createRoomListLambda = { roomList })
+ val presenter = createGlobalSearchPresenter(roomListService = roomListService)
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.results).isEqualTo(AsyncData.Uninitialized)
+
+ initialState.queryState.edit { append("A query") }
+ // Let the query be propagated to the data source before emitting results
+ testScheduler.advanceUntilIdle()
+ roomList.summaries.emit(listOf(aRoomSummary()))
+
+ // Wait for the presenter to emit a loading state before the success state
+ consumeItemsUntilPredicate { it.results is AsyncData.Loading }
+
+ val successState = awaitItem()
+ val results = (successState.results.dataOrNull() as GlobalSearchResults.RoomListResults).results
+ assertThat(results).hasSize(1)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - room search displays the uninitialized state when removing the query`() = runTest {
+ val roomList = FakeDynamicRoomList()
+ val roomListService = FakeRoomListService(createRoomListLambda = { roomList })
+ val presenter = createGlobalSearchPresenter(roomListService = roomListService)
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.results).isEqualTo(AsyncData.Uninitialized)
+
+ initialState.queryState.edit { append("A query") }
+ // Let the query be propagated to the data source before emitting results
+ testScheduler.advanceUntilIdle()
+ roomList.summaries.emit(listOf(aRoomSummary()))
+
+ // Wait for the presenter to emit a loading state before the success state
+ consumeItemsUntilPredicate { it.results is AsyncData.Loading }
+
+ val successState = awaitItem()
+ val results = (successState.results.dataOrNull() as GlobalSearchResults.RoomListResults).results
+ assertThat(results).hasSize(1)
+
+ // Remove the query
+ successState.queryState.edit { replace(0, length, "") }
+ // Skip the intermediate state where the query is empty but results are still present
+ skipItems(1)
+ // It's back to the uninitialized state
+ assertThat(awaitItem().results).isInstanceOf(AsyncData.Uninitialized::class.java)
+ // This happens even if for some reason the room results still contained any items
+ assertThat(roomList.summaries.value).isNotEmpty()
+ }
+ }
+
+ @Test
+ fun `present - message search results are mapped when the message search emits`() = runTest {
+ val messageSearch = FakeMessageSearch()
+ val matrixClient = FakeMatrixClient().apply {
+ getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
+ }
+ val presenter = createGlobalSearchPresenter(
+ messageSearchService = FakeMessageSearchService(messageSearch),
+ matrixClient = matrixClient,
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
+ initialState.queryState.edit { append("A query") }
+ testScheduler.advanceUntilIdle()
+
+ messageSearch.emitResults(persistentListOf(aMessageSearchResult()))
+ testScheduler.advanceUntilIdle()
+
+ val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last()
+ val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results
+ assertThat(results).hasSize(1)
+ assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - message search displays the uninitialized state after removing the query`() = runTest {
+ val messageSearch = FakeMessageSearch()
+ val matrixClient = FakeMatrixClient().apply {
+ getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
+ }
+ val presenter = createGlobalSearchPresenter(
+ messageSearchService = FakeMessageSearchService(messageSearch),
+ matrixClient = matrixClient,
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
+ initialState.queryState.edit { append("A query") }
+ testScheduler.advanceUntilIdle()
+
+ messageSearch.emitResults(persistentListOf(aMessageSearchResult()))
+ testScheduler.advanceUntilIdle()
+
+ val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last()
+ val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results
+ assertThat(results).hasSize(1)
+ assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java)
+
+ // Remove the query
+ successState.queryState.edit { replace(0, length, "") }
+ // Skip the intermediate state where the query is empty but results are still present
+ skipItems(1)
+ // It's back to the uninitialized state
+ assertThat(awaitItem().results).isInstanceOf(AsyncData.Uninitialized::class.java)
+ // This happens even if for some reason the message results still contained any items
+ assertThat(messageSearch.results.value).isNotEmpty()
+ }
+ }
+
+ @Test
+ fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest {
+ val messageSearch = FakeMessageSearch()
+ val matrixClient = FakeMatrixClient().apply {
+ getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
+ }
+ val presenter = createGlobalSearchPresenter(
+ messageSearchService = FakeMessageSearchService(messageSearch),
+ matrixClient = matrixClient,
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
+ initialState.queryState.edit { append("A query") }
+ testScheduler.advanceUntilIdle()
+
+ messageSearch.emitResults(persistentListOf(aMessageSearchResult()))
+ val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last()
+
+ successState.eventSink(GlobalSearchEvent.UpdateVisibleRange(IntRange(0, 0)))
+ runCurrent()
+ testScheduler.advanceUntilIdle()
+
+ assertThat(messageSearch.paginateCallCount).isEqualTo(1)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+}
+
+private fun TestScope.createGlobalSearchPresenter(
+ roomListService: RoomListService = FakeRoomListService(),
+ messageSearchService: MessageSearchService = FakeMessageSearchService(),
+ featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
+ latestEventFormatter: RoomLatestEventFormatter = FakeRoomLatestEventFormatter(),
+ dateFormatter: DateFormatter = FakeDateFormatter(),
+ fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(),
+ permalinkParser: PermalinkParser = FakePermalinkParser(),
+ matrixClient: MatrixClient = FakeMatrixClient(),
+): GlobalSearchPresenter {
+ return GlobalSearchPresenter(
+ roomListSearchDataSourceFactory = object : RoomListSearchDataSource.Factory {
+ override fun create(coroutineScope: CoroutineScope): RoomListSearchDataSource {
+ return RoomListSearchDataSource(
+ coroutineScope = coroutineScope,
+ roomListService = roomListService,
+ coroutineDispatchers = testCoroutineDispatchers(),
+ roomSummaryFactory = aRoomListRoomSummaryFactory(),
+ )
+ }
+ },
+ messageSearchService = messageSearchService,
+ featureFlagService = featureFlagService,
+ latestEventFormatter = latestEventFormatter,
+ dateFormatter = dateFormatter,
+ fileSizeFormatter = fileSizeFormatter,
+ permalinkParser = permalinkParser,
+ coroutineDispatchers = testCoroutineDispatchers(),
+ matrixClient = matrixClient,
+ )
+}
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
index 046303ca4f..d8ab7c71c8 100644
--- 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
@@ -11,6 +11,9 @@ package io.element.android.features.invite.api
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow
+/**
+ * Remembers which invitations the user has already looked at, so that only genuinely new ones are highlighted in the room list.
+ */
interface SeenInvitesStore {
/**
* Returns a flow of seen room IDs of invitation.
@@ -32,7 +35,7 @@ interface SeenInvitesStore {
suspend fun markAsUnSeen(roomId: RoomId)
/**
- * Delete the store.
+ * Delete the store, so every invitation counts as unseen again.
*/
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
index 2caaacab2b..5691b19ef7 100644
--- 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
@@ -10,6 +10,9 @@ package io.element.android.features.invite.api.acceptdecline
import io.element.android.features.invite.api.InviteData
+/**
+ * Events sent to the accept/decline invite presenter, from whichever screen is showing the invitation.
+ */
interface AcceptDeclineInviteEvents {
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/acceptdecline/AcceptDeclineInviteStateProvider.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteStatePreviewParam.kt
similarity index 100%
rename from features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteStateProvider.kt
rename to features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteStatePreviewParam.kt
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
index b6eec03a6f..6e9d78082b 100644
--- 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
@@ -12,7 +12,20 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.matrix.api.core.RoomId
+/**
+ * Renders the confirmation dialogs and error states of accepting or declining an invitation; it draws nothing until such an event is sent.
+ *
+ * Embedded by every screen that can act on an invitation, so the behaviour stays identical between the invite list and a room preview.
+ */
fun interface AcceptDeclineInviteView {
+ /**
+ * Draws whichever dialog the current state calls for.
+ *
+ * @param state the state produced by the accept/decline presenter.
+ * @param onAcceptInviteSuccess called once the room has been joined, so the host screen can navigate into it.
+ * @param onDeclineInviteSuccess called once the invitation has been declined.
+ * @param modifier layout modifier for the container.
+ */
@Composable
fun Render(
state: AcceptDeclineInviteState,
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
index 4174d99222..3b9d512d80 100644
--- 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
@@ -10,13 +10,19 @@ package io.element.android.features.invite.api.declineandblock
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.features.invite.api.InviteData
import io.element.android.libraries.architecture.FeatureEntryPoint
-fun interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint {
+interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint {
fun createNode(
parentNode: Node,
buildContext: BuildContext,
inviteData: InviteData,
+ callback: Callback,
): Node
+
+ interface Callback : Plugin {
+ fun onDeclineSuccess()
+ }
}
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/AcceptDeclineInviteStatePreviewParam.kt
similarity index 95%
rename from features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteStateProvider.kt
rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteStatePreviewParam.kt
index db2e76c1c3..ad9bf01667 100644
--- 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/AcceptDeclineInviteStatePreviewParam.kt
@@ -18,7 +18,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.matrix.api.core.RoomId
-open class AcceptDeclineInviteStateProvider : PreviewParameterProvider {
+open class AcceptDeclineInviteStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
anAcceptDeclineInviteState(),
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
index da035f0ff1..c3df984604 100644
--- 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
@@ -123,7 +123,7 @@ private fun DeclineConfirmationDialog(
@PreviewsDayNight
@Composable
-internal fun AcceptDeclineInviteViewPreview(@PreviewParameter(AcceptDeclineInviteStateProvider::class) state: AcceptDeclineInviteState) =
+internal fun AcceptDeclineInviteViewPreview(@PreviewParameter(AcceptDeclineInviteStatePreviewParam::class) state: AcceptDeclineInviteState) =
ElementPreview {
AcceptDeclineInviteView(
state = state,
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
index 2fe5c23088..89a2aaf238 100644
--- 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
@@ -17,7 +17,9 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.invite.api.InviteData
+import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
@@ -31,6 +33,7 @@ class DeclineAndBlockNode(
data class Inputs(val inviteData: InviteData) : NodeInputs
private val inviteData = inputs().inviteData
+ private val callback: DeclineInviteAndBlockEntryPoint.Callback = callback()
private val presenter = presenterFactory.create(inviteData)
@Composable
@@ -39,6 +42,7 @@ class DeclineAndBlockNode(
DeclineAndBlockView(
state = state,
onBackClick = ::navigateUp,
+ onDeclineSuccess = callback::onDeclineSuccess,
modifier = modifier
)
}
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/DeclineAndBlockStatePreviewParam.kt
similarity index 93%
rename from features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockStateProvider.kt
rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockStatePreviewParam.kt
index 2e78214306..704fc9f045 100644
--- 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/DeclineAndBlockStatePreviewParam.kt
@@ -11,7 +11,7 @@ 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 {
+open class DeclineAndBlockStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aDeclineAndBlockState(),
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
index 1d14df024a..c30742de12 100644
--- 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
@@ -46,6 +46,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun DeclineAndBlockView(
state: DeclineAndBlockState,
onBackClick: () -> Unit,
+ onDeclineSuccess: () -> Unit,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
@@ -53,7 +54,7 @@ fun DeclineAndBlockView(
val isDeclining = state.declineAction is AsyncAction.Loading
AsyncActionView(
async = state.declineAction,
- onSuccess = { onBackClick() },
+ onSuccess = { onDeclineSuccess() },
errorMessage = { stringResource(CommonStrings.error_unknown) },
onRetry = { state.eventSink(DeclineAndBlockEvents.Decline) },
onErrorDismiss = { state.eventSink(DeclineAndBlockEvents.ClearDeclineAction) }
@@ -144,10 +145,11 @@ fun DeclineAndBlockView(
@PreviewsDayNight
@Composable
internal fun DeclineAndBlockViewPreview(
- @PreviewParameter(DeclineAndBlockStateProvider::class) state: DeclineAndBlockState
+ @PreviewParameter(DeclineAndBlockStatePreviewParam::class) state: DeclineAndBlockState
) = ElementPreview {
DeclineAndBlockView(
state = state,
onBackClick = {},
+ onDeclineSuccess = {},
)
}
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
index d6e93fb805..4a5734af45 100644
--- 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
@@ -22,8 +22,9 @@ class DefaultDeclineAndBlockEntryPoint : DeclineInviteAndBlockEntryPoint {
parentNode: Node,
buildContext: BuildContext,
inviteData: InviteData,
+ callback: DeclineInviteAndBlockEntryPoint.Callback,
): Node {
val inputs = DeclineAndBlockNode.Inputs(inviteData)
- return parentNode.createNode(buildContext, plugins = listOf(inputs))
+ return parentNode.createNode(buildContext, plugins = listOf(inputs, callback))
}
}
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/InviteBindingContainer.kt
similarity index 89%
rename from features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/di/InviteModule.kt
rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/di/InviteBindingContainer.kt
index f78c0358a6..f7cd346755 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/InviteBindingContainer.kt
@@ -22,9 +22,9 @@ import io.element.android.libraries.matrix.api.MatrixClient
@ContributesTo(SessionScope::class)
@BindingContainer
-interface InviteModule {
+interface InviteBindingContainer {
@Binds
- fun bindAcceptDeclinePresenter(presenter: AcceptDeclineInvitePresenter): Presenter
+ fun bindAcceptDeclineInvitePresenter(presenter: AcceptDeclineInvitePresenter): Presenter
companion object {
@Provides
diff --git a/features/invite/impl/src/main/res/values-ar/translations.xml b/features/invite/impl/src/main/res/values-ar/translations.xml
new file mode 100644
index 0000000000..31ed141c3e
--- /dev/null
+++ b/features/invite/impl/src/main/res/values-ar/translations.xml
@@ -0,0 +1,18 @@
+
+
+ "لن ترى أي رسائل أو دعوات إلى غرف من هذا المستخدم"
+ "حظر المستخدم"
+ "أبلغ موفّر حسابك عن هذه الغرفة."
+ "صِف سبب الإبلاغ…"
+ "رفض وحظر"
+ "أمتأكد من رغبتك برفض دعوة للانضمام إلى %1$s؟"
+ "رفض الدعوة"
+ "أمتأكد من رغبتك برفض المحادثة الخاصة مع %1$s؟"
+ "رفض المحادثة"
+ "لا دعوات"
+ "دعاك %1$s (%2$s)"
+ "نعم، ارفض واحظر"
+ "هل أنت متأكد من رغبتك في رفض الدعوة للانضمام إلى هذه الغرفة؟ سيمنع هذا أيضًا %1$s من التواصل معك أو دعوتك إلى غرف."
+ "رفض الدعوة والحظر"
+ "رفض وحظر"
+
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 9cbad94f91..9e245d7e9f 100644
--- a/features/invite/impl/src/main/res/values-fa/translations.xml
+++ b/features/invite/impl/src/main/res/values-fa/translations.xml
@@ -1,6 +1,8 @@
+ "شما هیچ پیام یا دعوتنامهای از این کاربر نخواهید دید."
"انسداد کاربر"
+ "این اتاق را به ارائه دهنده حساب خود گزارش دهید."
"شرح دلیل گزارش…"
"رد و انسداد"
"مطمئنید که میخواهید دعوت پیوستن به %1$s را رد کنید؟"
@@ -10,6 +12,7 @@
"بدون دعوت"
"%1$s (%2$s) دعوتتان کرد"
"بله. رد و انسداد"
+ "آیا مطمئنید که میخواهید دعوت به عضویت در این اتاق را رد کنید؟ این کار همچنین مانع از تماس %1$s با شما یا دعوت شما به اتاقها میشود."
"رد دعوت و انسداد"
"رد و انسداد"
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
index 55c31a09d2..5cfb091e82 100644
--- 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
@@ -17,6 +17,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.invite.impl.R
+import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
@@ -41,6 +42,20 @@ class DeclineAndBlockViewTest : RobolectricTest() {
}
}
+ @Test
+ fun `a successful decline invokes the expected callback`() = runAndroidComposeUiTest {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce {
+ setDeclineAndBlockView(
+ aDeclineAndBlockState(
+ declineAction = AsyncAction.Success(Unit),
+ eventSink = eventsRecorder,
+ ),
+ onDeclineSuccess = it,
+ )
+ }
+ }
+
@Test
fun `clicking on decline when enabled emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
@@ -112,11 +127,13 @@ class DeclineAndBlockViewTest : RobolectricTest() {
private fun AndroidComposeUiTest.setDeclineAndBlockView(
state: DeclineAndBlockState,
onBackClick: () -> Unit = EnsureNeverCalled(),
+ onDeclineSuccess: () -> Unit = EnsureNeverCalled(),
) {
setContent {
DeclineAndBlockView(
state = state,
onBackClick = onBackClick,
+ onDeclineSuccess = onDeclineSuccess,
)
}
}
diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt
index 13bb13dccf..f09cda2337 100644
--- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt
+++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt
@@ -11,7 +11,9 @@ package io.element.android.features.invite.impl.declineandblock
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
+import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
import io.element.android.features.invite.test.anInviteData
+import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
@@ -31,12 +33,17 @@ class DefaultDeclineAndBlockEntryPointTest {
)
}
val inviteData = anInviteData()
+ val callback = object : DeclineInviteAndBlockEntryPoint.Callback {
+ override fun onDeclineSuccess() = lambdaError()
+ }
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
- inviteData = inviteData
+ inviteData = inviteData,
+ callback = callback,
)
assertThat(result).isInstanceOf(DeclineAndBlockNode::class.java)
assertThat(result.plugins).contains(DeclineAndBlockNode.Inputs(inviteData))
+ assertThat(result.plugins).contains(callback)
}
}
diff --git a/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt
index 52d4d17790..19625f1473 100644
--- a/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt
+++ b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt
@@ -19,6 +19,7 @@ class FakeDeclineInviteAndBlockEntryPoint : DeclineInviteAndBlockEntryPoint {
parentNode: Node,
buildContext: BuildContext,
inviteData: InviteData,
+ callback: DeclineInviteAndBlockEntryPoint.Callback,
): Node {
lambdaError()
}
diff --git a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleEvents.kt b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleEvents.kt
index 0422fac4f1..9570ab8152 100644
--- a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleEvents.kt
+++ b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleEvents.kt
@@ -8,6 +8,9 @@
package io.element.android.features.invitepeople.api
+/**
+ * Events the invite people UI sends to its presenter through [InvitePeopleState.eventSink].
+ */
interface InvitePeopleEvents {
data object SendInvites : InvitePeopleEvents
data object CloseSearch : InvitePeopleEvents
diff --git a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeoplePresenter.kt b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeoplePresenter.kt
index 0be0798680..1863397e48 100644
--- a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeoplePresenter.kt
+++ b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeoplePresenter.kt
@@ -13,7 +13,14 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
interface InvitePeoplePresenter : Presenter {
+ /**
+ * Creates a presenter for one invite flow.
+ */
interface Factory {
+ /**
+ * @param joinedRoom the room to invite into, or `null` when the room does not exist yet, as when inviting from a direct message.
+ * @param roomId the id of the room to invite into.
+ */
fun create(
joinedRoom: JoinedRoom?,
roomId: RoomId,
diff --git a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleRenderer.kt b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleRenderer.kt
index 7bad81ab65..0bbaa45a2f 100644
--- a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleRenderer.kt
+++ b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleRenderer.kt
@@ -11,7 +11,16 @@ package io.element.android.features.invitepeople.api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+/**
+ * Renders the invite people UI, so that other screens can embed it without depending on its implementation.
+ */
interface InvitePeopleRenderer {
+ /**
+ * Draws the invite list and its search field.
+ *
+ * @param state the state produced by [InvitePeoplePresenter].
+ * @param modifier layout modifier for the container.
+ */
@Composable
fun Render(
state: InvitePeopleState,
diff --git a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleState.kt b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleState.kt
index d14042cff7..0863ec7212 100644
--- a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleState.kt
+++ b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleState.kt
@@ -11,10 +11,22 @@ package io.element.android.features.invitepeople.api
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
+/**
+ * State of the invite people UI, produced by an [InvitePeoplePresenter] and consumed by an [InvitePeopleRenderer].
+ */
interface InvitePeopleState {
+ /** Whether the send button should be enabled, i.e. at least one user is selected and the user has the permission. */
val canInvite: Boolean
+
+ /** Whether the search field currently has focus, which the host screen uses to adapt its own layout. */
val isSearchActive: Boolean
+
+ /** Progress of the invite request, so the UI can show a loader and surface failures. */
val sendInvitesAction: AsyncAction
+
+ /** Progress of turning a direct message into a room, which happens when inviting a third person into a DM. */
val createRoomFromDmAction: AsyncAction
+
+ /** Where the UI sends its events. */
val eventSink: (InvitePeopleEvents) -> Unit
}
diff --git a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleStateProvider.kt b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleStatePreviewParam.kt
similarity index 95%
rename from features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleStateProvider.kt
rename to features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleStatePreviewParam.kt
index b233ed07ef..860efc2771 100644
--- a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleStateProvider.kt
+++ b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleStatePreviewParam.kt
@@ -12,7 +12,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
-class InvitePeopleStateProvider : PreviewParameterProvider {
+class InvitePeopleStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aPreviewInvitePeopleState(),
diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStateProvider.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStatePreviewParam.kt
similarity index 98%
rename from features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStateProvider.kt
rename to features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStatePreviewParam.kt
index 1e41114406..9aad913856 100644
--- a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStateProvider.kt
+++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStatePreviewParam.kt
@@ -26,7 +26,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
-internal class DefaultInvitePeopleStateProvider : PreviewParameterProvider {
+internal class DefaultInvitePeopleStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aDefaultInvitePeopleState(),
diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt
index 0ee3559aa3..7dced41b13 100644
--- a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt
+++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt
@@ -316,7 +316,7 @@ private fun InvitePeopleConfirmModal(
@PreviewsDayNight
@Composable
-internal fun InvitePeopleViewPreview(@PreviewParameter(DefaultInvitePeopleStateProvider::class) state: DefaultInvitePeopleState) =
+internal fun InvitePeopleViewPreview(@PreviewParameter(DefaultInvitePeopleStatePreviewParam::class) state: DefaultInvitePeopleState) =
ElementPreview {
InvitePeopleView(state = state)
}
diff --git a/features/invitepeople/impl/src/main/res/values-ar/translations.xml b/features/invitepeople/impl/src/main/res/values-ar/translations.xml
new file mode 100644
index 0000000000..fa28cb13b9
--- /dev/null
+++ b/features/invitepeople/impl/src/main/res/values-ar/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "أنت عضو بالفعل"
+ "مَدعُوّ بالفعل"
+ "ليست لديك حاليًا أي دردشات مع جهات الاتصال هذه. أكّد دعوتهم إلى هذه الغرفة قبل المتابعة."
+ "ليست لديك حاليًا أي دردشات مع جهة الاتصال هذه. أكّد دعوتها إلى هذه الغرفة قبل المتابعة."
+ "هل تريد دعوة جهات اتصال جديدة إلى هذه الغرفة؟"
+ "هل تريد دعوة جهة اتصال جديدة إلى هذه الغرفة؟"
+
diff --git a/features/invitepeople/impl/src/main/res/values-de/translations.xml b/features/invitepeople/impl/src/main/res/values-de/translations.xml
index 182d9f289a..d0c2da50b4 100644
--- a/features/invitepeople/impl/src/main/res/values-de/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-de/translations.xml
@@ -2,4 +2,8 @@
"Bereits Mitglied"
"Bereits eingeladen"
+ "Du hast derzeit keine Chats mit diesen Kontakten. Bestätige, dass du sie in diesen Chat einladen möchtest, bevor du fortfährst."
+ "Du hast derzeit keine Chats mit diesem Kontakt. Bestätige, dass du ihn in diesen Chat einladen möchtest, bevor du fortfährst."
+ "Möchtest du neue Kontakte in diesen Chat einladen?"
+ "Willst du einen neuen Kontakt in diesen Chat einladen?"
diff --git a/features/invitepeople/impl/src/main/res/values-es/translations.xml b/features/invitepeople/impl/src/main/res/values-es/translations.xml
index e62bb211e5..b9058446b4 100644
--- a/features/invitepeople/impl/src/main/res/values-es/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-es/translations.xml
@@ -1,5 +1,9 @@
- "Ya eres miembro"
+ "Ya es miembro"
"Ya estás invitado"
+ "De momento no tienes ningún chat con estos contactos. Confirma que quieres invitarlos a esta sala antes de continuar."
+ "De momento no tienes ningún chat con este contacto. Confirma que quieres invitarlo a esta sala antes de continuar."
+ "¿Invitar contactos nuevos a esta sala?"
+ "¿Invitar contacto nuevo a esta sala?"
diff --git a/features/invitepeople/impl/src/main/res/values-fa/translations.xml b/features/invitepeople/impl/src/main/res/values-fa/translations.xml
index 544f01b8c8..100abe539c 100644
--- a/features/invitepeople/impl/src/main/res/values-fa/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-fa/translations.xml
@@ -2,4 +2,8 @@
"از پیش عضو است"
"از پیش دعوت شده"
+ "شما در حال حاضر هیچ گفتوگویی با این مخاطبین ندارید. قبل از ادامه، دعوت آنها را به این اتاق تأیید کنید."
+ "شما در حال حاضر هیچ گفتوگویی با این مخاطب ندارید. قبل از ادامه، دعوت او را به این اتاق تأیید کنید."
+ "مخاطبین جدید را به این اتاق دعوت کنید؟"
+ "مخاطب جدید را به این اتاق دعوت میکنید؟"
diff --git a/features/invitepeople/impl/src/main/res/values-zh-rTW/translations.xml b/features/invitepeople/impl/src/main/res/values-zh-rTW/translations.xml
index c8117ec283..5197f7758c 100644
--- a/features/invitepeople/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-zh-rTW/translations.xml
@@ -2,4 +2,8 @@
"已是成員"
"已邀請"
+ "您目前與這些聯絡人沒有任何聊天紀錄。請確認是否要邀請他們加入此聊天室,然後再繼續。"
+ "您目前與此聯絡人沒有任何聊天紀錄。請確認是否要邀請對方加入此聊天室,然後再繼續。"
+ "要邀請新聯絡人加入此聊天室嗎?"
+ "要邀請新聯絡人加入此聊天室嗎?"
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt
index 93f8f53412..493c180731 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt
@@ -69,6 +69,9 @@ class JoinRoomFlowNode(
parentNode = this,
buildContext = buildContext,
inviteData = navTarget.inviteData,
+ callback = object : DeclineInviteAndBlockEntryPoint.Callback {
+ override fun onDeclineSuccess() = navigateUp()
+ },
)
NavTarget.Root -> rootNode(buildContext)
}
@@ -99,7 +102,7 @@ class JoinRoomFlowNode(
acceptDeclineInviteView.Render(
state = state.acceptDeclineInviteState,
onAcceptInviteSuccess = {},
- onDeclineInviteSuccess = {},
+ onDeclineInviteSuccess = { navigateUp() },
modifier = Modifier
)
}
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStatePreviewParam.kt
similarity index 99%
rename from features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt
rename to features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStatePreviewParam.kt
index 4bfa741321..f47377b16b 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStatePreviewParam.kt
@@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.InviteSender
import kotlinx.collections.immutable.toImmutableList
-open class JoinRoomStateProvider : PreviewParameterProvider {
+open class JoinRoomStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aJoinRoomState(
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt
index 977308178d..f1adf1705f 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt
@@ -642,7 +642,7 @@ private fun JoinRoomTopBar(
@PreviewsDayNight
@Composable
-internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class) state: JoinRoomState) = ElementPreview {
+internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStatePreviewParam::class) state: JoinRoomState) = ElementPreview {
JoinRoomView(
state = state,
onBackClick = { },
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomBindingContainer.kt
similarity index 98%
rename from features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt
rename to features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomBindingContainer.kt
index 3304169b1a..3ab9cbfa97 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomBindingContainer.kt
@@ -27,7 +27,7 @@ import java.util.Optional
@BindingContainer
@ContributesTo(SessionScope::class)
-object JoinRoomModule {
+object JoinRoomBindingContainer {
@Provides
fun providesJoinRoomPresenterFactory(
client: MatrixClient,
diff --git a/features/joinroom/impl/src/main/res/values-ar/translations.xml b/features/joinroom/impl/src/main/res/values-ar/translations.xml
new file mode 100644
index 0000000000..84a81ad6a5
--- /dev/null
+++ b/features/joinroom/impl/src/main/res/values-ar/translations.xml
@@ -0,0 +1,34 @@
+
+
+ "لقد حظرك %1$s."
+ "تم حظرك"
+ "السبب: %1$s."
+ "إلغاء الطلب"
+ "نعم، ألغِ"
+ "هل أنت متأكد من رغبتك في إلغاء طلبك للانضمام إلى هذه الغرفة؟"
+ "إلغاء طلب الانضمام"
+ "نعم، ارفض واحظر"
+ "هل أنت متأكد من رغبتك في رفض الدعوة للانضمام إلى هذه الغرفة؟ سيمنع هذا أيضًا %1$s من التواصل معك أو دعوتك إلى غرف."
+ "رفض الدعوة والحظر"
+ "رفض وحظر"
+ "فشل الانضمام"
+ "إما أنك تحتاج إلى دعوة للانضمام، أو قد توجد قيود على الوصول."
+ "نسيان"
+ "تحتاج إلى دعوة للانضمام"
+ "بدعوة من"
+ "إنضمام"
+ "قد تحتاج إلى دعوة أو إلى أن تكون عضوًا في مساحة لتتمكن من الانضمام."
+ "إرسال طلب الانضمام"
+ "الأحرف المسموح بها: %1$d من %2$d"
+ "رسالة (اختيارية)"
+ "ستتلقى دعوة للانضمام إلى الغرفة إذا تم قبول طلبك."
+ "تم إرسال طلب الانضمام"
+ "تعذّر عرض معاينة الغرفة. قد يكون ذلك بسبب مشكلات في الشبكة أو الخادم."
+ "تعذّر عرض معاينة هذه الغرفة"
+ "لا يدعم %1$s المساحات بعد. يمكنك الوصول إلى المساحات عبر الويب."
+ "المساحات غير مدعومة بعد"
+ "انقر على الزر أدناه وسيتم إشعار مسؤول الغرفة. ستتمكن من الانضمام إلى المحادثة بعد الموافقة."
+ "يجب أن تكون عضوًا في هذه الغرفة لعرض سجل الرسائل."
+ "هل تريد الانضمام إلى هذه الغرفة؟"
+ "المعاينة غير متاحة"
+
diff --git a/features/joinroom/impl/src/main/res/values-es/translations.xml b/features/joinroom/impl/src/main/res/values-es/translations.xml
index 698a6eb1c9..6773318776 100644
--- a/features/joinroom/impl/src/main/res/values-es/translations.xml
+++ b/features/joinroom/impl/src/main/res/values-es/translations.xml
@@ -1,7 +1,7 @@
- "Has sido baneado por %1$s."
- "Has sido baneado."
+ "Has sido vetado por %1$s."
+ "Has sido vetado"
"Motivo: %1$s."
"Cancelar solicitud"
"Sí, cancelar"
@@ -15,9 +15,11 @@
"O es necesario estar invitado para unirse o puede haber restricciones de acceso."
"Olvidar"
"Necesitas una invitación para unirte"
+ "Te invitó"
"Unirse"
"Es posible que necesites ser invitado o ser miembro de un espacio para poder unirte."
"Enviar solicitud de unión"
+ "%1$d de %2$d caracteres permitidos"
"Mensaje (opcional)"
"Recibirás una invitación para unirte a la sala si se acepta tu solicitud."
"Solicitud de unión enviada"
diff --git a/features/joinroom/impl/src/main/res/values-fa/translations.xml b/features/joinroom/impl/src/main/res/values-fa/translations.xml
index 3f9fc99877..d5102b939f 100644
--- a/features/joinroom/impl/src/main/res/values-fa/translations.xml
+++ b/features/joinroom/impl/src/main/res/values-fa/translations.xml
@@ -1,23 +1,30 @@
- "به دست %1$s از این اتاق محروم شدید."
- "از این اتاق محروم شدید"
+ "شما توسط %1$s مسدود شدهاید."
+ "شما محروم شدید"
"دلیل: %1$s."
"لغو درخواست"
"بله. لغو"
+ "آیا مطمئن هستید که میخواهید درخواست عضویت خود در این اتاق را لغو کنید؟"
"لغو درخواست پیوستن"
"بله. رد و انسداد"
+ "آیا مطمئنید که میخواهید دعوت به عضویت در این اتاق را رد کنید؟ این کار همچنین مانع از تماس %1$s با شما یا دعوت شما به اتاقها میشود."
"رد دعوت و انسداد"
"رد و انسداد"
"پیوستن شکست خورد"
- "فراموشی این اتاق"
- "برای پیوستن به این اتاق نیاز به دعوت دارید"
+ "یا باید دعوتنامه داشته باشید یا ممکن است محدودیتهایی برای دسترسی وجود داشته باشد."
+ "فراموش کردن"
+ "برای عضویت نیاز به دعوتنامه دارید"
"دعوت شده از سوی"
"پیوستن"
"برای پیوستن به فضا باید دعوت شده باشید."
"در زدن برای پیوستن"
+ "کاراکترهای مجاز %1$d از %2$d"
"پیام (اختیاری)"
+ "در صورت پذیرش درخواست، دعوتنامهای برای ورود به اتاق دریافت خواهید کرد."
"درخواست پیوستن فرستاده شد"
+ "ما نتوانستیم پیشنمایش اتاق را نمایش دهیم. این ممکن است به دلیل مشکلات شبکه یا سرور باشد."
+ "ما نتوانستیم پیشنمایش این اتاق را نمایش دهیم"
"%1$s هنوز از فضاها پشتیبانی نمیکند. میتوانید روی وب به فضاها دسترسی داشته باشید."
"فضاها هنوز پشتیبانی نمیشوند"
"زدن روی این دکمه برای آگاه شدن مدیر اتاق. پس از تأیید میتوانید به گفتوگو بپیوندید."
diff --git a/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/banner/KnockRequestsBannerRenderer.kt b/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/banner/KnockRequestsBannerRenderer.kt
index 34061db335..e6be68b95a 100644
--- a/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/banner/KnockRequestsBannerRenderer.kt
+++ b/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/banner/KnockRequestsBannerRenderer.kt
@@ -11,7 +11,16 @@ package io.element.android.features.knockrequests.api.banner
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+/**
+ * Renders the banner shown at the top of a room when people are waiting to join it.
+ */
interface KnockRequestsBannerRenderer {
+ /**
+ * Draws the banner, or nothing when there is no pending request or the user cannot moderate the room.
+ *
+ * @param modifier layout modifier for the container.
+ * @param onViewRequestsClick called when the user asks to see the full list of requests.
+ */
@Composable
fun View(modifier: Modifier, onViewRequestsClick: () -> Unit)
}
diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStatePreviewParam.kt
similarity index 96%
rename from features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt
rename to features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStatePreviewParam.kt
index 7210e783fe..fb05ae1fe0 100644
--- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt
+++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStatePreviewParam.kt
@@ -16,7 +16,7 @@ import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.designsystem.preview.USER_NAME_CHARLIE
import kotlinx.collections.immutable.toImmutableList
-class KnockRequestsBannerStateProvider : PreviewParameterProvider {
+class KnockRequestsBannerStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aKnockRequestsBannerState(),
diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt
index 431bd14d10..037ec16d00 100644
--- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt
+++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt
@@ -227,7 +227,7 @@ private fun KnockRequestAvatarListView(
@Composable
@PreviewsDayNight
-internal fun KnockRequestsBannerViewPreview(@PreviewParameter(KnockRequestsBannerStateProvider::class) state: KnockRequestsBannerState) = ElementPreview {
+internal fun KnockRequestsBannerViewPreview(@PreviewParameter(KnockRequestsBannerStatePreviewParam::class) state: KnockRequestsBannerState) = ElementPreview {
KnockRequestsBannerView(
state = state,
onViewRequestsClick = {},
diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/di/KnockRequestsBindingContainer.kt
similarity index 81%
rename from features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt
rename to features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/di/KnockRequestsBindingContainer.kt
index 73e4a2ddeb..04d5400831 100644
--- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt
+++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/di/KnockRequestsBindingContainer.kt
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.knockrequests.impl.data
+package io.element.android.features.knockrequests.impl.data.di
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
@@ -14,16 +14,17 @@ import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.element.android.features.knockrequests.api.KnockRequestPermissions
import io.element.android.features.knockrequests.api.knockRequestPermissions
+import io.element.android.features.knockrequests.impl.data.KnockRequestsService
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsFlow
@BindingContainer
@ContributesTo(RoomScope::class)
-object KnockRequestsModule {
+object KnockRequestsBindingContainer {
@Provides
@SingleIn(RoomScope::class)
- fun knockRequestsService(room: JoinedRoom): KnockRequestsService {
+ fun providesKnockRequestsService(room: JoinedRoom): KnockRequestsService {
return KnockRequestsService(
knockRequestsFlow = room.knockRequestsFlow,
permissionsFlow = room.permissionsFlow(KnockRequestPermissions.DEFAULT) { perms ->
diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStatePreviewParam.kt
similarity index 98%
rename from features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt
rename to features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStatePreviewParam.kt
index 85fc0675ad..b51223a5e9 100644
--- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt
+++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStatePreviewParam.kt
@@ -18,7 +18,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
-open class KnockRequestsListStateProvider : PreviewParameterProvider {
+open class KnockRequestsListStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aKnockRequestsListState(
diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt
index db40596892..d750927f74 100644
--- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt
+++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt
@@ -491,7 +491,7 @@ private fun KnockRequestsListTopBar(onBackClick: () -> Unit) {
@PreviewsDayNight
@Composable
internal fun KnockRequestsListViewPreview(
- @PreviewParameter(KnockRequestsListStateProvider::class) state: KnockRequestsListState
+ @PreviewParameter(KnockRequestsListStatePreviewParam::class) state: KnockRequestsListState
) = ElementPreview {
KnockRequestsListView(
state = state,
diff --git a/features/knockrequests/impl/src/main/res/values-ar/translations.xml b/features/knockrequests/impl/src/main/res/values-ar/translations.xml
new file mode 100644
index 0000000000..4ff4995748
--- /dev/null
+++ b/features/knockrequests/impl/src/main/res/values-ar/translations.xml
@@ -0,0 +1,40 @@
+
+
+ "نعم، قبول الكل"
+ "هل أنت متأكد من رغبتك في قبول جميع طلبات الانضمام؟"
+ "قبول جميع الطلبات"
+ "قبول الكل"
+ "تعذّر قبول جميع الطلبات. هل ترغب في المحاولة مرة أخرى؟"
+ "فشل قبول جميع الطلبات"
+ "جارٍ قبول جميع طلبات الانضمام"
+ "تعذّر قبول هذا الطلب. هل ترغب في المحاولة مرة أخرى؟"
+ "فشل قبول الطلب"
+ "جارٍ قبول طلب الانضمام"
+ "نعم، ارفض واحظر"
+ "هل أنت متأكد من رغبتك في رفض %1$s وحظره؟ لن يتمكن هذا المستخدم من طلب الوصول للانضمام إلى هذه الغرفة مرة أخرى."
+ "رفض الوصول والحظر"
+ "جارٍ رفض الوصول وحظره"
+ "نعم، ارفض"
+ "هل أنت متأكد من رغبتك في رفض طلب %1$s للانضمام إلى هذه الغرفة؟"
+ "رفض الوصول"
+ "رفض وحظر"
+ "تعذّر رفض هذا الطلب. هل ترغب في المحاولة مرة أخرى؟"
+ "فشل رفض الطلب"
+ "جارٍ رفض طلب الانضمام"
+ "عندما يطلب أحدهم الانضمام إلى الغرفة، ستتمكن من رؤية طلبه هنا."
+ "لا توجد طلبات انضمام معلّقة"
+ "جارٍ تحميل طلبات الانضمام…"
+ "طلبات الانضمام"
+
+ - "%1$s يريد الانضمام إلى هذه الغرفة"
+ - "%1$s +%2$d آخر يريدان الانضمام إلى هذه الغرفة"
+ - "%1$s +%2$d آخران يريدون الانضمام إلى هذه الغرفة"
+ - "%1$s +%2$d آخرون يريدون الانضمام إلى هذه الغرفة"
+ - "%1$s +%2$d آخرين يريدون الانضمام إلى هذه الغرفة"
+ - "%1$s +%2$d آخرين يريدون الانضمام إلى هذه الغرفة"
+
+ "عرض الكل"
+ "قبول"
+ "%1$s يريد الانضمام إلى هذه الغرفة"
+ "عرض"
+
diff --git a/features/knockrequests/impl/src/main/res/values-fa/translations.xml b/features/knockrequests/impl/src/main/res/values-fa/translations.xml
index 5b931e4f00..af786de6de 100644
--- a/features/knockrequests/impl/src/main/res/values-fa/translations.xml
+++ b/features/knockrequests/impl/src/main/res/values-fa/translations.xml
@@ -1,21 +1,34 @@
"بله. پذیرش همه"
+ "آیا مطمئن هستید که میخواهید همه درخواستهای عضویت را بپذیرید؟"
"پذیرش همهٔ درخواستها"
"پذیرش همه"
+ "ما نتوانستیم همه درخواستها را بپذیریم. آیا مایلید دوباره امتحان کنید؟"
"شکست در پذیرش همهٔ درخواستها"
"پذیرفتن همهٔ درخواستهای پیوستن"
+ "ما نتوانستیم این درخواست را بپذیریم. آیا مایلید دوباره امتحان کنید؟"
"شکست در پذیرش درخواست"
"پذیرفتن درخواست پیوستن"
"بله. رد و انسداد"
+ "آیا مطمئن هستید که میخواهید %1$s را رد و مسدود کنید؟ این کاربر دیگر نمیتواند درخواست دسترسی برای پیوستن به این اتاق را بدهد."
"رد و تحریم دسترسی"
"رد کردن و تحریم دسترسی"
"بله. رد شود"
+ "آیا مطمئن هستید که میخواهید درخواست %1$s برای پیوستن به این اتاق را رد کنید؟"
"رد کردن دسترسی"
"رد و تحریم"
+ "ما نمیتوانستیم این درخواست را رد کنیم. آیا مایلید دوباره امتحان کنید؟"
"شکتس در رد درخواست"
"در کردن درخواست پیوستن"
+ "وقتی کسی درخواست عضویت در اتاق را میدهد، میتوانید درخواست او را اینجا ببینید."
+ "درخواست عضویت در حال بررسی نیست"
+ "در حال بارگیری درخواستهای عضویت…"
"درخواستهای پیوستن"
+
+ - "%1$s +%2$d افراد دیگری هم میخواهند به این اتاق بپیوندند"
+ - "%1$s +%2$d دیگران میخواهند به این اتاق بپیوندند"
+
"دیدن همه"
"پذیرش"
"%1$s میخواهد به این اتاق بپیوندد"
diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt
index 6d228ee887..f0c3eaf7bf 100644
--- a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt
+++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt
@@ -10,6 +10,9 @@ package io.element.android.features.leaveroom.api
import io.element.android.libraries.matrix.api.core.RoomId
+/**
+ * Events sent to the leave room presenter through [LeaveRoomState.eventSink].
+ */
interface LeaveRoomEvent {
data class LeaveRoom(val roomId: RoomId, val needsConfirmation: Boolean) : LeaveRoomEvent
}
diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomRenderer.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomRenderer.kt
index 852193d874..8472e098ec 100644
--- a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomRenderer.kt
+++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomRenderer.kt
@@ -12,7 +12,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.matrix.api.core.RoomId
+/**
+ * Renders the confirmation dialogs of the leave room flow; it draws nothing until the host screen sends a leave event.
+ */
fun interface LeaveRoomRenderer {
+ /**
+ * Draws whichever dialog the current state calls for.
+ *
+ * @param state the state produced by the leave room presenter.
+ * @param onSelectNewOwners called when the user is the last owner of a room that still has other members, and must promote someone before leaving.
+ * @param modifier layout modifier for the container.
+ */
@Composable
fun Render(
state: LeaveRoomState,
diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt
index 92a5916858..b9150b59f0 100644
--- a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt
+++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt
@@ -10,7 +10,11 @@ package io.element.android.features.leaveroom.api
import androidx.compose.runtime.Immutable
+/**
+ * State of the leave room flow, which is only the confirmation dialogs: the screen it is hosted in owns the rest of the UI.
+ */
@Immutable
interface LeaveRoomState {
+ /** Where the host screen sends its events to start or confirm leaving a room. */
val eventSink: (LeaveRoomEvent) -> Unit
}
diff --git a/features/leaveroom/api/src/main/res/values-ar/translations.xml b/features/leaveroom/api/src/main/res/values-ar/translations.xml
new file mode 100644
index 0000000000..9253cb5365
--- /dev/null
+++ b/features/leaveroom/api/src/main/res/values-ar/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "هل أنت متأكد من رغبتك في مغادرة هذه المحادثة؟ هذه المحادثة ليست عامة ولن تتمكن من الانضمام إليها مرة أخرى دون دعوة."
+ "أأنت متأكد من رغبتك بخروج الغرفة؟ أنت الشخص الوحيد هنا. وإذا ما غادرت فلن يعود أحد قادراً على الدخول في المستقبل، بما فيهم أنت."
+ "أأنت متأكد من رغبتك بخروج الغرفة؟ هذه الغرفة ليست عامة ولن تعود قادراً على الدخول مجدداً بلا دعوة"
+ "اختيار المالكين"
+ "أنت المالك الوحيد لهذه الغرفة. يجب أن تنقل الملكية إلى شخص آخر قبل مغادرة الغرفة."
+ "نقل الملكية"
+ "أأنت متأكد من رغبتك بخروج الغرفة؟"
+
diff --git a/features/leaveroom/api/src/main/res/values-es/translations.xml b/features/leaveroom/api/src/main/res/values-es/translations.xml
index 81b169e5c9..59457c8dad 100644
--- a/features/leaveroom/api/src/main/res/values-es/translations.xml
+++ b/features/leaveroom/api/src/main/res/values-es/translations.xml
@@ -3,5 +3,8 @@
"¿Estás seguro de que quieres salir de esta conversación? Esta conversación no es pública y no podrás volver a unirte sin una invitación."
"¿Estás seguro de que quieres salir de esta sala? Eres la única persona aquí. Si te vas, nadie podrá unirse en el futuro, ni siquiera tú."
"¿Seguro que quieres salir de esta sala? Esta sala no es pública y no podrás volver a entrar sin una invitación."
+ "Elegir propietarios"
+ "Eres el único propietario de esta sala. Debes transferir la propiedad a otra persona antes de salir de la sala."
+ "Transferir la propiedad"
"¿Seguro que quieres salir de la sala?"
diff --git a/features/leaveroom/api/src/main/res/values-fa/translations.xml b/features/leaveroom/api/src/main/res/values-fa/translations.xml
index ec2cd89314..6391a70210 100644
--- a/features/leaveroom/api/src/main/res/values-fa/translations.xml
+++ b/features/leaveroom/api/src/main/res/values-fa/translations.xml
@@ -4,6 +4,7 @@
"مطمئنید که میخواهید این اتاق را ترک کنید؟ تنها فرد اینجا هستید. در صورت ترک، هیچکسی از جمله خودتان در آینده نخواهد توانست به آن بپیوندد."
"مطمئنید که میخواهید این اتاق را ترک کنید؟ این اتاق عمومی نبوده قادر نخواهید بود بدون دعوت دوباره بپیوندید."
"گزینش مالکان"
+ "شما تنها مالک این اتاق هستید. قبل از ترک اتاق، باید مالکیت را به شخص دیگری منتقل کنید."
"انتقال مالکیت"
"مطمئنید که میخواهید این اتاق را ترک کنید؟"
diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomStateProvider.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomStatePreviewParam.kt
similarity index 94%
rename from features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomStateProvider.kt
rename to features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomStatePreviewParam.kt
index 769015f301..5f68a6f192 100644
--- a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomStateProvider.kt
+++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomStatePreviewParam.kt
@@ -13,7 +13,7 @@ import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
-class InternalLeaveRoomStateProvider : PreviewParameterProvider {
+class InternalLeaveRoomStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aLeaveRoomState(),
diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt
index d11dc7e7e8..e2065bca1f 100644
--- a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt
+++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt
@@ -60,7 +60,13 @@ class LeaveRoomPresenter(
roomId: RoomId,
leaveAction: MutableState>,
) = launch(dispatchers.io) {
- client.getRoom(roomId)?.use { room ->
+ val room = client.getRoom(roomId)
+ if (room == null) {
+ Timber.e("Unable to find room $roomId to leave it")
+ leaveAction.value = AsyncAction.Failure(RoomNotFoundException(roomId))
+ return@launch
+ }
+ room.use {
val roomInfo = room.roomInfoFlow.first()
leaveAction.value = when {
roomInfo.isDm -> Confirmation.Dm(roomId)
@@ -78,7 +84,8 @@ class LeaveRoomPresenter(
leaveAction: MutableState>,
) = launch(dispatchers.io) {
leaveAction.runCatchingUpdatingState {
- client.getRoom(roomId)!!.use { room ->
+ val room = client.getRoom(roomId) ?: throw RoomNotFoundException(roomId)
+ room.use {
room
.leave()
.onSuccess { notificationConversationService.onLeftRoom(client.sessionId, roomId) }
@@ -100,3 +107,5 @@ class LeaveRoomPresenter(
}
}
}
+
+internal class RoomNotFoundException(roomId: RoomId) : IllegalStateException("Room $roomId not found")
diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomView.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomView.kt
index 131801b014..c3a916b141 100644
--- a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomView.kt
+++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomView.kt
@@ -139,7 +139,7 @@ private fun LeaveRoomProgressDialog(modifier: Modifier = Modifier) {
@PreviewsDayNight
@Composable
internal fun LeaveRoomViewPreview(
- @PreviewParameter(InternalLeaveRoomStateProvider::class) state: InternalLeaveRoomState
+ @PreviewParameter(InternalLeaveRoomStatePreviewParam::class) state: InternalLeaveRoomState
) = ElementPreview {
Box(
modifier = Modifier.size(300.dp, 300.dp),
diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/di/LeaveRoomModule.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/di/LeaveRoomBindingContainer.kt
similarity index 95%
rename from features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/di/LeaveRoomModule.kt
rename to features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/di/LeaveRoomBindingContainer.kt
index db5b1416ee..866af68cc8 100644
--- a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/di/LeaveRoomModule.kt
+++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/di/LeaveRoomBindingContainer.kt
@@ -18,7 +18,7 @@ import io.element.android.libraries.di.SessionScope
@ContributesTo(SessionScope::class)
@BindingContainer
-interface LeaveRoomModule {
+interface LeaveRoomBindingContainer {
@Binds
fun bindLeaveRoomPresenter(presenter: LeaveRoomPresenter): Presenter
}
diff --git a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt
index 90b7f2369f..f8f7a771e1 100644
--- a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt
+++ b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt
@@ -175,6 +175,32 @@ class LeaveBaseRoomPresenterTest {
}
}
+ @Test
+ fun `present - show error if the room to confirm leaving is not found`() = runTest {
+ val presenter = createLeaveRoomPresenter(client = FakeMatrixClient())
+ presenter.stateFlow().test {
+ val initialState = awaitItem()
+ initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
+ val errorState = awaitItem()
+ assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - show error if the room to leave is not found`() = runTest {
+ val presenter = createLeaveRoomPresenter(client = FakeMatrixClient())
+ presenter.stateFlow().test {
+ val initialState = awaitItem()
+ initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false))
+ val progressState = awaitItem()
+ assertThat(progressState.leaveAction).isEqualTo(AsyncAction.Loading)
+ val errorState = awaitItem()
+ assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
@Test
fun `present - reset state after error`() = runTest {
val presenter = createLeaveRoomPresenter(
diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListStateProvider.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListStatePreviewParam.kt
similarity index 95%
rename from features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListStateProvider.kt
rename to features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListStatePreviewParam.kt
index 6a74cc01eb..b984c43398 100644
--- a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListStateProvider.kt
+++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListStatePreviewParam.kt
@@ -15,7 +15,7 @@ import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
-open class DependencyLicensesListStateProvider : PreviewParameterProvider {
+open class DependencyLicensesListStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aDependencyLicensesListState(
diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt
index 943f57ab3b..17f82cd14e 100644
--- a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt
+++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt
@@ -126,7 +126,7 @@ fun DependencyLicensesListView(
@PreviewsDayNight
@Composable
internal fun DependencyLicensesListViewPreview(
- @PreviewParameter(DependencyLicensesListStateProvider::class) state: DependencyLicensesListState
+ @PreviewParameter(DependencyLicensesListStatePreviewParam::class) state: DependencyLicensesListState
) = ElementPreview {
DependencyLicensesListView(
state = state,
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt
index a8a8ff14b6..bff81a3e9e 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt
@@ -12,10 +12,12 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
import io.element.android.libraries.matrix.api.logs.LoggerTags
import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
+import kotlin.time.Duration.Companion.minutes
private val loggerTag = LoggerTag("LinkNewDesktopHandler", LoggerTags.linkNewDevice)
@@ -36,29 +39,64 @@ class LinkNewDesktopHandler(
LinkDesktopStep.Uninitialized
)
+ /**
+ * A [StateFlow] that emits the current step of the link desktop process.
+ * It starts with [LinkDesktopStep.Uninitialized] and will emit other steps as they occur.
+ */
val stepFlow: StateFlow
get() = linkDesktopStepFlow.asStateFlow()
private var currentJob: Job? = null
+ private var timerJob: Job? = null
private var handler: LinkDesktopHandler? = null
+ private fun resetJobs() {
+ currentJob?.cancel()
+ currentJob = null
+ timerJob?.cancel()
+ timerJob = null
+ }
+
+ /**
+ * Starts (or restarts) a timer that will emit an error in [stepFlow] after 2 minutes.
+ */
+ fun startTimer() {
+ timerJob?.cancel()
+ timerJob = sessionScope.launch {
+ delay(2.minutes)
+ linkDesktopStepFlow.emit(LinkDesktopStep.Error(ErrorType.Expired("Scanning QrCode took too long.")))
+ }
+ }
+
+ /**
+ * Creates a new [LinkDesktopHandler] and cancels any existing job.
+ * This should be called when the user wants to retry scanning a QR code.
+ */
fun createNewHandler() {
currentJob?.cancel()
currentJob = null
handler = matrixClient.createLinkDesktopHandler().getOrNull()
}
+ /**
+ * Resets the state of the handler and cancels any existing job.
+ * This should be called when the user wants to start over.
+ */
fun reset() {
- currentJob?.cancel()
- currentJob = null
+ resetJobs()
sessionScope.launch {
linkDesktopStepFlow.emit(LinkDesktopStep.Uninitialized)
}
}
+ /**
+ * Handles the scanned QR code data.
+ * This should be called when the user scans a QR code.
+ *
+ * @param data The scanned QR code data as a byte array.
+ */
fun onScannedCode(data: ByteArray) {
- currentJob?.cancel()
- currentJob = null
+ resetJobs()
val currentHandler = handler
if (currentHandler == null) {
Timber.tag(loggerTag.value).e("onScannedCode: Handler is not initialized. Call createNewHandler() first.")
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt
index d686b22860..b31aca8cb2 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt
@@ -56,6 +56,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@@ -102,6 +103,7 @@ class LinkNewDeviceFlowNode(
@Suppress("AssignedValueIsNeverRead")
linkDesktopHandlerJob = observeLinkNewDesktopHandler()
},
+ onResume = ::onResume,
onDestroy = {
linkMobileHandlerJob?.cancel()
linkDesktopHandlerJob?.cancel()
@@ -209,6 +211,18 @@ class LinkNewDeviceFlowNode(
.launchIn(sessionCoroutineScope)
}
+ private fun onResume() = sessionCoroutineScope.launch {
+ // Application is resumed, if the step is waiting for auth, send the confirmation
+ (linkNewMobileHandler.stepFlow.value as? LinkMobileStep.WaitingForAuth)?.let {
+ Timber.tag(tag.value).d("Resuming while waiting for auth on mobile, sending confirmation")
+ it.continuationMessageSender.confirm()
+ }
+ (linkNewDesktopHandler.stepFlow.value as? LinkDesktopStep.WaitingForAuth)?.let {
+ Timber.tag(tag.value).d("Resuming while waiting for auth on desktop, sending confirmation")
+ it.continuationMessageSender.confirm()
+ }
+ }
+
private fun navigateToError(errorType: ErrorType) {
// Map the error to an error screen
val error = when (errorType) {
@@ -249,6 +263,8 @@ class LinkNewDeviceFlowNode(
}
LinkDeviceType.Desktop -> {
linkNewDesktopHandler.reset()
+ // Ensure unlock state does not last longer than the timeout for scanning the QR code, so start the timer after unlock
+ linkNewDesktopHandler.startTimer()
backstack.push(NavTarget.DesktopNotice)
}
}
@@ -264,10 +280,14 @@ class LinkNewDeviceFlowNode(
NavTarget.DesktopNotice -> {
val callback = object : DesktopNoticeNode.Callback {
override fun navigateBack() {
+ // Stop the timer when the user leaves the screen (user will have to authenticate again)
+ linkNewDesktopHandler.reset()
backstack.pop()
}
override fun navigateToQrCodeScanner() {
+ // Start again the timer when the user enters the next screen (they clicked on "Ready to scan")
+ linkNewDesktopHandler.startTimer()
backstack.push(NavTarget.DesktopScanQrCode)
}
}
@@ -276,6 +296,8 @@ class LinkNewDeviceFlowNode(
NavTarget.DesktopScanQrCode -> {
val callback = object : ScanQrCodeNode.Callback {
override fun cancel() {
+ // start again the timer when the user goes back to the DesktopNoticeNode
+ linkNewDesktopHandler.startTimer()
backstack.pop()
}
}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt
index 39c9c6fef3..9436c0e901 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt
@@ -40,9 +40,19 @@ class LinkNewMobileHandler(
LinkMobileStep.Uninitialized
)
+ /**
+ * A [StateFlow] that emits the current step of the link mobile process.
+ * It starts with [LinkMobileStep.Uninitialized] and will emit other steps as they occur.
+ */
val stepFlow: StateFlow
get() = linkMobileStepFlow.asStateFlow()
+ /**
+ * Creates a new [LinkMobileHandler] and starts the linking process.
+ * If [forRotating] is true, it indicates that this is a rotation of the QR code.
+ *
+ * @param forRotating Whether this is a rotation of the QR code. Defaults to false.
+ */
fun createAndStartNewHandler(forRotating: Boolean = false) {
Timber.tag(loggerTag.value).d("createAndStartNewHandler()")
currentJob?.cancel()
@@ -62,6 +72,10 @@ class LinkNewMobileHandler(
}
}
+ /**
+ * Resets the handler and cancels any ongoing job.
+ * This will also emit [LinkMobileStep.Uninitialized] to the [stepFlow].
+ */
fun reset() {
currentJob?.cancel()
currentJob = null
@@ -70,10 +84,18 @@ class LinkNewMobileHandler(
}
}
+ /**
+ * Rotates the QR code by creating a new handler and starting the linking process.
+ * This is typically called when a timeout occurs for the current QR code.
+ */
fun rotateQrCode() {
createAndStartNewHandler(forRotating = true)
}
+ /**
+ * Handles the case where there are too many QR code rotations.
+ * This will reset the handler and emit an error to the [stepFlow].
+ */
fun onTooManyRotation() {
reset()
sessionScope.launch {
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStatePreviewParam.kt
similarity index 92%
rename from features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt
rename to features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStatePreviewParam.kt
index 194bd6fafc..c539563fc7 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStatePreviewParam.kt
@@ -13,7 +13,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.aPermissionsState
-open class DesktopNoticeStateProvider : PreviewParameterProvider {
+open class DesktopNoticeStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aDesktopNoticeState(),
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt
index a66ea7b391..935c848e42 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt
@@ -118,7 +118,7 @@ fun DesktopNoticeView(
@PreviewsDayNight
@Composable
internal fun DesktopNoticeViewPreview(
- @PreviewParameter(DesktopNoticeStateProvider::class) state: DesktopNoticeState,
+ @PreviewParameter(DesktopNoticeStatePreviewParam::class) state: DesktopNoticeState,
) = ElementPreview {
DesktopNoticeView(
state = state,
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypePreviewParam.kt
similarity index 90%
rename from features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt
rename to features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypePreviewParam.kt
index 5946eb9ab2..bfaf511e66 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypePreviewParam.kt
@@ -9,7 +9,7 @@ package io.element.android.features.linknewdevice.impl.screens.error
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-class ErrorScreenTypeProvider : PreviewParameterProvider {
+class ErrorScreenTypePreviewParam : PreviewParameterProvider {
override val values: Sequence = sequenceOf(
ErrorScreenType.Cancelled,
ErrorScreenType.Declined,
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt
index 7b830626f6..90e9c4616b 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt
@@ -165,7 +165,7 @@ private fun ColumnScope.Buttons(
@PreviewsDayNight
@Composable
-internal fun ErrorViewPreview(@PreviewParameter(ErrorScreenTypeProvider::class) errorScreenType: ErrorScreenType) {
+internal fun ErrorViewPreview(@PreviewParameter(ErrorScreenTypePreviewParam::class) errorScreenType: ErrorScreenType) {
ElementPreview {
ErrorView(
errorScreenType = errorScreenType,
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStatePreviewParam.kt
similarity index 93%
rename from features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt
rename to features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStatePreviewParam.kt
index 126bfeee52..56071f3e8f 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStatePreviewParam.kt
@@ -11,7 +11,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
-open class EnterNumberStateProvider : PreviewParameterProvider {
+open class EnterNumberStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aEnterNumberState(),
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt
index 240a3143af..b60fd285af 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt
@@ -117,7 +117,7 @@ fun EnterNumberView(
@PreviewsDayNight
@Composable
internal fun EnterNumberViewPreview(
- @PreviewParameter(EnterNumberStateProvider::class) state: EnterNumberState,
+ @PreviewParameter(EnterNumberStatePreviewParam::class) state: EnterNumberState,
) = ElementPreview {
EnterNumberView(
state = state,
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt
index 20bd50f488..5110f8938f 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt
@@ -20,6 +20,13 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
+/**
+ * Max number of QrCode rotation.
+ * When QrCode is rotated this number of time, a TimeOut error is emitted.
+ * Set to 0 to disable QrCode rotation.
+ */
+private const val MAX_QR_CODE_ROTATION = 0
+
@ContributesNode(SessionScope::class)
@AssistedInject
class ShowQrCodeNode(
@@ -39,6 +46,7 @@ class ShowQrCodeNode(
private val callback: Callback = callback()
private val showQrCodePresenter: ShowQrCodePresenter = showQrCodePresenterFactory.create(
initialData = inputs.data,
+ maxQrCodeRotation = MAX_QR_CODE_ROTATION,
)
@Composable
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt
index 21071a6831..1a5eac0522 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt
@@ -26,24 +26,29 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
+import kotlin.time.Duration.Companion.seconds
private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice)
@AssistedInject
class ShowQrCodePresenter(
@Assisted private val initialData: String,
+ @Assisted private val maxQrCodeRotation: Int,
private val linkNewMobileHandler: LinkNewMobileHandler,
) : Presenter {
@AssistedFactory
interface Factory {
- fun create(initialData: String): ShowQrCodePresenter
+ fun create(
+ initialData: String,
+ maxQrCodeRotation: Int,
+ ): ShowQrCodePresenter
}
private var loadingJob: Job? = null
@Composable
override fun present(): ShowQrCodeState {
- var qrCodeRotationCounter by remember { mutableIntStateOf(MAX_QR_CODE_ROTATION) }
+ var qrCodeRotationCounter by remember { mutableIntStateOf(maxQrCodeRotation) }
val state by produceState(
initialValue = ShowQrCodeState(
data = AsyncData.Success(initialData),
@@ -63,7 +68,7 @@ class ShowQrCodePresenter(
linkNewMobileHandler.rotateQrCode()
// Ensure that outdated data is not rendered too long while rotating QR code
loadingJob = launch {
- delay(1000)
+ delay(1.seconds)
value = ShowQrCodeState(
data = AsyncData.Loading(),
)
@@ -80,8 +85,4 @@ class ShowQrCodePresenter(
return state
}
-
- companion object {
- const val MAX_QR_CODE_ROTATION = 10
- }
}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStatePreviewParam.kt
similarity index 90%
rename from features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt
rename to features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStatePreviewParam.kt
index e6d33c2544..bc424746ae 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStatePreviewParam.kt
@@ -10,7 +10,7 @@ package io.element.android.features.linknewdevice.impl.screens.qrcode
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
-class ShowQrCodeStateProvider : PreviewParameterProvider {
+class ShowQrCodeStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aShowQrCodeState(),
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt
index f2cd07f4a5..4cd22e37d2 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt
@@ -117,7 +117,7 @@ private fun QrCodeOrLoading(
@PreviewsDayNight
@Composable
internal fun ShowQrCodeViewPreview(
- @PreviewParameter(ShowQrCodeStateProvider::class) state: ShowQrCodeState,
+ @PreviewParameter(ShowQrCodeStatePreviewParam::class) state: ShowQrCodeState,
) = ElementPreview {
ShowQrCodeView(
state = state,
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStatePreviewParam.kt
similarity index 94%
rename from features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt
rename to features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStatePreviewParam.kt
index f1bb7ad455..20721e4a85 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStatePreviewParam.kt
@@ -11,7 +11,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
-open class LinkNewDeviceRootStateProvider : PreviewParameterProvider {
+open class LinkNewDeviceRootStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aLinkNewDeviceRootState(),
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt
index 5778abfb2c..b27cce17d9 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt
@@ -140,7 +140,7 @@ fun LinkNewDeviceRootView(
@PreviewsDayNight
@Composable
internal fun LinkNewDeviceRootViewPreview(
- @PreviewParameter(LinkNewDeviceRootStateProvider::class) state: LinkNewDeviceRootState
+ @PreviewParameter(LinkNewDeviceRootStatePreviewParam::class) state: LinkNewDeviceRootState
) = ElementPreview {
LinkNewDeviceRootView(
state = state,
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStatePreviewParam.kt
similarity index 91%
rename from features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt
rename to features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStatePreviewParam.kt
index 40e7df8884..a088bf7bd3 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStatePreviewParam.kt
@@ -10,7 +10,7 @@ package io.element.android.features.linknewdevice.impl.screens.scan
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
-open class ScanQrCodeStateProvider : PreviewParameterProvider {
+open class ScanQrCodeStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aScanQrCodeState(),
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt
index 937aee08b4..4cd30fd904 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt
@@ -166,7 +166,7 @@ private fun ColumnScope.Buttons(
@PreviewsDayNight
@Composable
-internal fun ScanQrCodeViewPreview(@PreviewParameter(ScanQrCodeStateProvider::class) state: ScanQrCodeState) = ElementPreview {
+internal fun ScanQrCodeViewPreview(@PreviewParameter(ScanQrCodeStatePreviewParam::class) state: ScanQrCodeState) = ElementPreview {
ScanQrCodeView(
state = state,
onBackClick = {},
diff --git a/features/linknewdevice/impl/src/main/res/values-ar/translations.xml b/features/linknewdevice/impl/src/main/res/values-ar/translations.xml
new file mode 100644
index 0000000000..895a0e7efe
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-ar/translations.xml
@@ -0,0 +1,60 @@
+
+
+ "امسح رمز QR ضوئيًا"
+ "افتح %1$s على كمبيوتر محمول أو مكتبي"
+ "امسح رمز QR ضوئيًا باستخدام هذا الجهاز"
+ "جاهز للمسح الضوئي"
+ "افتح %1$s على كمبيوتر مكتبي للحصول على رمز QR"
+ "الأرقام غير متطابقة"
+ "أدخل الرمز المكوّن من رقمين"
+ "سيؤكد هذا أن الاتصال بجهازك الآخر آمن."
+ "أدخل الرقم المعروض على جهازك الآخر"
+ "لا يدعم موفّر حسابك %1$s."
+ "%1$s غير مدعوم"
+ "لا يدعم موفّر حسابك تسجيل الدخول إلى جهاز جديد باستخدام رمز QR."
+ "رمز QR غير مدعوم"
+ "تم إلغاء تسجيل الدخول على الجهاز الآخر."
+ "تم إلغاء طلب تسجيل الدخول"
+ "انتهت صلاحية تسجيل الدخول. يرجى المحاولة مرة أخرى."
+ "لم يكتمل تسجيل الدخول في الوقت المحدد"
+ "افتح %1$s على الجهاز الآخر"
+ "اختر %1$s"
+ "«تسجيل الدخول باستخدام رمز QR»"
+ "امسح رمز QR المعروض هنا باستخدام الجهاز الآخر"
+ "افتح %1$s على الجهاز الآخر"
+ "كمبيوتر مكتبي"
+ "جارٍ تحميل رمز QR…"
+ "جهاز محمول"
+ "ما نوع الجهاز الذي تريد ربطه؟"
+ "يرجى المحاولة مرة أخرى والتأكد من إدخال الرمز المكوّن من رقمين بشكل صحيح. إذا ظلت الأرقام غير متطابقة، فتواصل مع موفّر حسابك."
+ "الأرقام غير متطابقة"
+ "تعذّر إنشاء اتصال آمن بالجهاز الجديد. لا تزال أجهزتك الحالية آمنة ولا داعي للقلق بشأنها."
+ "ماذا الآن؟"
+ "حاول تسجيل الدخول مجددًا باستخدام رمز QR إذا كانت هذه مشكلة في الشبكة."
+ "إذا واجهت المشكلة نفسها، جرّب شبكة Wi-Fi مختلفة أو استخدم بيانات الهاتف المحمول بدلًا من Wi-Fi."
+ "إذا لم ينجح ذلك، سجّل الدخول يدويًا"
+ "الاتصال غير آمن"
+ "سيُطلب منك إدخال الرقمين الظاهرين على هذا الجهاز."
+ "أدخل الرقم أدناه على جهازك الآخر"
+ "تم إلغاء تسجيل الدخول على الجهاز الآخر."
+ "تم إلغاء طلب تسجيل الدخول"
+ "تم رفض تسجيل الدخول على الجهاز الآخر."
+ "تم رفض تسجيل الدخول"
+ "لست بحاجة إلى فعل أي شيء آخر."
+ "جهازك الآخر مسجّل الدخول بالفعل"
+ "انتهت صلاحية تسجيل الدخول. يرجى المحاولة مرة أخرى."
+ "لم يكتمل تسجيل الدخول في الوقت المحدد"
+ "جهازك الآخر لا يدعم تسجيل الدخول إلى %s باستخدام رمز QR.
+
+جرّب تسجيل الدخول يدويًا، أو امسح رمز QR ضوئيًا باستخدام جهاز آخر."
+ "رمز QR غير مدعوم"
+ "لا يدعم موفّر حسابك %1$s."
+ "%1$s غير مدعوم"
+ "استخدم رمز QR المعروض على الجهاز الآخر."
+ "حاول مرة أخرى"
+ "رمز QR غير صحيح"
+ "تحتاج إلى منح %1$s إذنًا لاستخدام كاميرا جهازك للمتابعة."
+ "اسمح بالوصول إلى الكاميرا لمسح رمز QR ضوئيًا"
+ "حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى."
+ "في انتظار جهازك الآخر"
+
diff --git a/features/linknewdevice/impl/src/main/res/values-es/translations.xml b/features/linknewdevice/impl/src/main/res/values-es/translations.xml
index c33dc3ad88..5593f4bfd6 100644
--- a/features/linknewdevice/impl/src/main/res/values-es/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-es/translations.xml
@@ -1,16 +1,34 @@
"Escanea el código QR"
+ "Abre %1$s en un ordenador"
"Escanea el código QR con este dispositivo"
"Listo para escanear"
+ "Abre %1$s en un ordenador para obtener el código QR"
+ "Solo escanea un código QR después de haber seguido los pasos anteriores en tu propio dispositivo. Nunca escanees un código que te haya enviado otra persona."
+ "Los números no coinciden"
+ "Introduce el código de 2 dígitos"
+ "Esto verificará que la conexión con tu otro dispositivo sea segura."
+ "Introduce el número que aparece en tu otro dispositivo"
"Tu proveedor de cuentas no es compatible con %1$s."
"%1$s no admitido"
+ "El proveedor de tu cuenta no admite el inicio de sesión en un nuevo dispositivo con un código QR."
"Código QR no admitido"
"El inicio de sesión se canceló en el otro dispositivo."
"Solicitud de inicio de sesión cancelada"
"El inicio de sesión ha caducado. Inténtalo de nuevo."
"El inicio de sesión no se completó a tiempo"
+ "Abre %1$s en el otro dispositivo"
"Selecciona %1$s"
+ "«Iniciar sesión con un código QR»"
+ "Escanea el código QR que se muestra aquí con el otro dispositivo"
+ "Abre %1$s en el otro dispositivo"
+ "Ordenador"
+ "Cargando código QR…"
+ "Dispositivo móvil"
+ "¿Qué tipo de dispositivo quieres vincular?"
+ "Inténtalo de nuevo y asegúrate de haber introducido correctamente el código de 2 dígitos. Si los números siguen sin coincidir, ponte en contacto con el proveedor de tu cuenta."
+ "Los números no coinciden"
"No se pudo establecer una conexión segura con el nuevo dispositivo. Tus dispositivos actuales siguen siendo seguros y no tienes que preocuparte por ellos."
"¿Y ahora qué?"
"Intenta iniciar sesión de nuevo con un código QR en caso de que se trate de un problema de red"
@@ -23,6 +41,8 @@
"Solicitud de inicio de sesión cancelada"
"El inicio de sesión se rechazó en el otro dispositivo."
"Inicio de sesión rechazado"
+ "No tienes que hacer nada más."
+ "Tu otro dispositivo ya está conectado"
"El inicio de sesión ha caducado. Inténtalo de nuevo."
"El inicio de sesión no se completó a tiempo"
"Tu otro dispositivo no admite el inicio de sesión en %s con un código QR.
diff --git a/features/linknewdevice/impl/src/main/res/values-et/translations.xml b/features/linknewdevice/impl/src/main/res/values-et/translations.xml
index 10f8af5e11..95f9f5f586 100644
--- a/features/linknewdevice/impl/src/main/res/values-et/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-et/translations.xml
@@ -5,6 +5,7 @@
"Skaneeri QR-koodi selle seadmega"
"Skaneerimiseks valmis"
"QR-koodi laadimiseks ava %1$s süle- või lauaarvutis"
+ "Skaneeri QR-koodi peale järgnevate sammude läbitegemist oma seadmes. Ära iialgi skaneeri QR-koodi, mille on saatnud keegi teine."
"Numbrid ei klapi"
"Sisesta kahekohaline kood"
"Sellega verifitseerime, et ühendus sinu teise seadmega on turvaline."
diff --git a/features/linknewdevice/impl/src/main/res/values-fa/translations.xml b/features/linknewdevice/impl/src/main/res/values-fa/translations.xml
index f7b0d7f3db..20c064410c 100644
--- a/features/linknewdevice/impl/src/main/res/values-fa/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-fa/translations.xml
@@ -1,16 +1,34 @@
"پویش کد پاس"
+ "%1$s را روی لپتاپ یا رایانه رومیزی باز کنید."
"پویش کد پاس با این افزاره"
"آمادهٔ پویش"
+ "برای دریافت کد QR، %1$s را روی رایانه رومیزی باز کنید."
+ "فقط پس از انجام مراحل بالا در دستگاه خود، کد QR را اسکن کنید. هرگز کدی را که توسط شخص دیگری ارسال شده است، اسکن نکنید."
+ "اعداد مطابقت ندارند"
+ "کد دو رقمی را وارد کنید"
+ "این کار تأیید میکند که اتصال به دستگاه دیگر شما ایمن است."
+ "عدد نمایش داده شده در دستگاه دیگر خود را وارد کنید"
"فراهم کنندهٔ حسابتان از %1$s پشتیبانی نمیکند."
"%1$s پشتیبانی نمیشود"
+ "ارائه دهنده حساب شما از ورود به دستگاه جدید با کد QR پشتیبانی نمیکند."
"کد پاس پشتیبانی نمیشود"
"ورود روی افزارهٔ دیگر لغو شد."
"درخواست ورد لغو شد"
"ورود منقضی شد. لطفاً دوباره تلاش کنید."
"ورود در زمان معیّن کامل نشد"
+ "%1$s را در دستگاه دیگر باز کنید"
"گزینش %1$s"
+ "«ورود با کد QR»"
+ "کد QR نشان داده شده در اینجا را با دستگاه دیگر اسکن کنید"
+ "%1$s را در دستگاه دیگر باز کنید"
+ "کامپیوتر رومیزی"
+ "در حال بارگذاری کد QR…"
+ "دستگاه همراه"
+ "چه نوع دستگاهی را میخواهید لینک کنید؟"
+ "لطفاً دوباره امتحان کنید و مطمئن شوید که کد دو رقمی را به درستی وارد کردهاید. اگر اعداد هنوز مطابقت ندارند، با ارائه دهنده حساب خود تماس بگیرید."
+ "اعداد مطابقت ندارند"
"نتوانست اتّصالی امن به افزارهٔ جدید بسازد. افزارههای موجودتان هنوز امنند و نیازی نیست نگرانشان باشید."
"اکنون چه؟"
"آزمودن ورود دوباره با کد پاس در صورت مشکل شبکه"
@@ -23,6 +41,8 @@
"درخواست ورد لغو شد"
"ورود به دست افزارهٔ دیگر رد شد."
"ورود رد شد"
+ "لازم نیست کار دیگری انجام دهید."
+ "دستگاه دیگر شما قبلاً وارد سیستم شده است"
"ورود منقضی شد. لطفاً دوباره تلاش کنید."
"ورود در زمان معیّن کامل نشد"
"افزارهٔ دیگرتان از ورود به %s با کد پاس پشتیبانی نمیکند.
diff --git a/features/linknewdevice/impl/src/main/res/values-fi/translations.xml b/features/linknewdevice/impl/src/main/res/values-fi/translations.xml
index 0ba5a30e58..c18b9bbfd0 100644
--- a/features/linknewdevice/impl/src/main/res/values-fi/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-fi/translations.xml
@@ -5,6 +5,7 @@
"Skannaa QR-koodi tällä laitteella"
"Valmis skannaamaan"
"Avaa %1$s pöytätietokoneella saadaksesi QR-koodin"
+ "Skannaa QR-koodi vasta, kun olet noudattanut yllä olevia ohjeita omalla laitteellasi. Älä koskaan skannaa toisen henkilön lähettämää koodia."
"Numerot eivät täsmää"
"Kirjoita 2-numeroinen koodi"
"Tämä varmistaa, että yhteys toiseen laitteeseesi on turvallinen."
diff --git a/features/linknewdevice/impl/src/main/res/values-fr/translations.xml b/features/linknewdevice/impl/src/main/res/values-fr/translations.xml
index 12a770af17..7e642578af 100644
--- a/features/linknewdevice/impl/src/main/res/values-fr/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-fr/translations.xml
@@ -5,6 +5,7 @@
"Scanner le code QR avec cet appareil"
"Prêt à scanner"
"Ouvrir %1$s sur un ordinateur pour obtenir le code QR"
+ "Ne scannez un code QR qu’après avoir suivi les étapes ci-dessus sur votre propre appareil. Ne scannez jamais un code envoyé par quelqu’un d’autre."
"Les nombres ne correspondent pas"
"Saisissez le code à 2 chiffres"
"Cela permettra de vérifier que la connexion à votre autre appareil est sécurisée."
diff --git a/features/linknewdevice/impl/src/main/res/values-hr/translations.xml b/features/linknewdevice/impl/src/main/res/values-hr/translations.xml
index 8561e63ad3..b77fcc5088 100644
--- a/features/linknewdevice/impl/src/main/res/values-hr/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-hr/translations.xml
@@ -5,6 +5,7 @@
"Skenirajte QR kod ovim uređajem"
"Spremno za skeniranje"
"Otvorite %1$s na stolnom računalu kako biste dobili QR kod"
+ "QR kod skenirajte tek nakon što ste na vlastitom uređaju slijedili gore navedene korake. Nikada ne skenirajte kod koji vam je poslala druga osoba."
"Brojevi se ne podudaraju"
"Unesite dvoznamenkasti kod"
"Time ćete potvrditi da je veza s vašim drugim uređajem sigurna."
diff --git a/features/linknewdevice/impl/src/main/res/values-hu/translations.xml b/features/linknewdevice/impl/src/main/res/values-hu/translations.xml
index 8cefed94cc..49556cf43a 100644
--- a/features/linknewdevice/impl/src/main/res/values-hu/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-hu/translations.xml
@@ -5,6 +5,7 @@
"Olvassa be a QR-kódot ezzel az eszközzel"
"Készen áll a beolvasásra"
"Nyissa meg az asztali %1$s alkalmazást, hogy megkapja a QR-kódot"
+ "Csak a fenti lépések végrehajtása után olvass be QR-kódot a saját eszközén. Soha ne olvasson be más által küldött kódot."
"A számok nem egyeznek"
"Írja be a kétjegyű kódot"
"Ezzel ellenőrizni fogja, hogy a másik eszközzel való kapcsolat biztonságos-e."
diff --git a/features/linknewdevice/impl/src/main/res/values-ro/translations.xml b/features/linknewdevice/impl/src/main/res/values-ro/translations.xml
index 57d439240d..5ba904c7fc 100644
--- a/features/linknewdevice/impl/src/main/res/values-ro/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-ro/translations.xml
@@ -5,6 +5,7 @@
"Scanați codul QR cu acest dispozitiv"
"Gata de scanare"
"Deschide %1$s pe un computer desktop pentru a obține codul QR"
+ "Scanați un cod QR doar după ce urmați pașii de mai sus pe propriul dispozitiv. Nu scanați niciodată un cod trimis de o altă persoană."
"Numerele nu se potrivesc"
"Introduceți codul de 2 cifre"
"Aceasta va verifica dacă conexiunea cu celălalt dispozitiv este sigură."
diff --git a/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml b/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml
index e18fbc13b6..04bea08c86 100644
--- a/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml
@@ -5,6 +5,7 @@
"使用此裝置掃描 QR code"
"準備掃描"
"在桌上型電腦上開啟 %1$s 以取得 QR code"
+ "請務必先在您的裝置上完成上述步驟,之後才掃描 QR code。切勿掃描他人傳送的 QR code。"
"數字不符"
"輸入兩位數代碼"
"這將確認您與另一台裝置之間的連線是否安全。"
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt
index f92cf66102..db7c3e8c01 100644
--- a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt
@@ -91,10 +91,36 @@ class ShowQrCodePresenterTest {
}
}
+ @Test
+ fun `present - when handler emits QrRotating, the presenter does not emit anything when rotation is disabled`() = runTest {
+ val linkMobileHandler = FakeLinkMobileHandler(
+ startResult = {},
+ )
+ val matrixClient = FakeMatrixClient(
+ sessionCoroutineScope = backgroundScope,
+ createLinkMobileHandlerResult = { Result.success(linkMobileHandler) },
+ )
+ val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
+ linkNewMobileHandler.createAndStartNewHandler()
+ createPresenter(
+ linkNewMobileHandler = linkNewMobileHandler,
+ maxQrCodeRotation = 0,
+ ).test {
+ awaitItem()
+ linkMobileHandler.emitStep(
+ LinkMobileStep.QrRotating
+ )
+ runCurrent()
+ expectNoEvents()
+ }
+ }
+
private fun createPresenter(
linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()),
+ maxQrCodeRotation: Int = 10,
) = ShowQrCodePresenter(
initialData = "DATA",
+ maxQrCodeRotation = maxQrCodeRotation,
linkNewMobileHandler = linkNewMobileHandler,
)
}
diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts
index f8377389f1..8830b39a22 100644
--- a/features/location/api/build.gradle.kts
+++ b/features/location/api/build.gradle.kts
@@ -70,6 +70,8 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.wellknown.api)
+ implementation(projects.features.enterprise.api)
implementation(libs.coil.compose)
implementation(libs.datetime)
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt
new file mode 100644
index 0000000000..752dd2401c
--- /dev/null
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.location.api
+
+import androidx.compose.runtime.staticCompositionLocalOf
+import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
+
+/**
+ * Provides a [MapTilerConfig] for the MapLibre maps.
+ */
+val LocalMapTilerConfig = staticCompositionLocalOf {
+ MapTilerConfig(
+ apiKey = BuildConfig.MAPTILER_API_KEY,
+ lightStyleId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
+ darkStyleId = BuildConfig.MAPTILER_DARK_MAP_ID,
+ baseUrl = BuildConfig.MAPTILER_BASE_URL
+ )
+}
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocationService.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocationService.kt
index 22049c9548..41c58c396f 100644
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocationService.kt
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocationService.kt
@@ -8,6 +8,10 @@
package io.element.android.features.location.api
+/**
+ * Tells whether the location features can be used at all, which depends on the build having a map provider configured.
+ */
interface LocationService {
+ /** Whether location sharing and map rendering are available; `false` disables the whole feature in the UI. */
fun isServiceAvailable(): Boolean
}
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt
index c16b1ae560..b3149964e4 100644
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt
@@ -37,9 +37,10 @@ import coil3.compose.rememberAsyncImagePainter
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import io.element.android.compound.theme.ElementTheme
+import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.internal.StaticMapPlaceholder
-import io.element.android.features.location.api.internal.StaticMapUrlBuilder
import io.element.android.features.location.api.internal.centerBottomEdge
+import io.element.android.features.location.api.internal.rememberStaticMapBuilder
import io.element.android.libraries.designsystem.components.LocationPin
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -57,6 +58,7 @@ import io.element.android.libraries.designsystem.utils.CommonDrawables
*/
@Composable
fun StaticMapView(
+ mapTilerConfig: MapTilerConfig?,
location: Location?,
zoom: Double,
pinVariant: PinVariant,
@@ -94,6 +96,7 @@ fun StaticMapView(
}
// Cases 3 & 4: Non-null location - fetch map
else -> LoadableMapContent(
+ mapTilerConfig = mapTilerConfig,
location = location,
zoom = zoom,
pinVariant = pinVariant,
@@ -124,6 +127,7 @@ private fun BoxWithConstraintsScope.StaleMapContent(
@Composable
private fun BoxWithConstraintsScope.LoadableMapContent(
+ mapTilerConfig: MapTilerConfig?,
location: Location,
zoom: Double,
pinVariant: PinVariant,
@@ -132,7 +136,7 @@ private fun BoxWithConstraintsScope.LoadableMapContent(
) {
val context = LocalContext.current
var retryHash by remember { mutableIntStateOf(0) }
- val builder = remember { StaticMapUrlBuilder() }
+ val builder = rememberStaticMapBuilder(mapTilerConfig)
val (painter, state, contentScale) = if (LocalInspectionMode.current) {
val painter = painterResource(CommonDrawables.sample_map)
@@ -203,6 +207,7 @@ private fun BoxWithConstraintsScope.LoadableMapContent(
@Composable
internal fun StaticMapViewPreview() = ElementPreview {
StaticMapView(
+ mapTilerConfig = null,
location = Location(0.0, 0.0),
zoom = 0.0,
contentDescription = null,
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt
index 666ca07a08..cd339f67b0 100644
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt
@@ -8,6 +8,7 @@
package io.element.android.features.location.api.internal
+import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.BuildConfig
import kotlin.math.roundToInt
@@ -29,6 +30,13 @@ internal class MapTilerStaticMapUrlBuilder(
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
)
+ constructor(mapTilerConfig: MapTilerConfig) : this(
+ baseUrl = (mapTilerConfig.baseUrl ?: BuildConfig.MAPTILER_BASE_URL).removeSuffix("/"),
+ apiKey = mapTilerConfig.apiKey.takeIf { it.isNotBlank() } ?: BuildConfig.MAPTILER_API_KEY,
+ lightMapId = mapTilerConfig.lightStyleId ?: BuildConfig.MAPTILER_LIGHT_MAP_ID,
+ darkMapId = mapTilerConfig.darkStyleId ?: BuildConfig.MAPTILER_DARK_MAP_ID,
+ )
+
override fun build(
lat: Double,
lon: Double,
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt
index 054805d1ab..d817a3307f 100644
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt
@@ -10,6 +10,7 @@
package io.element.android.features.location.api.internal
+import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.BuildConfig
internal class MapTilerTileServerStyleUriBuilder(
@@ -26,17 +27,17 @@ internal class MapTilerTileServerStyleUriBuilder(
)
override fun build(
- customMapStyleUrl: String?,
+ customMapTilerConfig: MapTilerConfig?,
darkMode: Boolean,
): String {
- return buildString {
- if (customMapStyleUrl.isNullOrBlank()) {
- val mapId = if (darkMode) darkMapId else lightMapId
- append("$baseUrl/$mapId/style.json")
- } else {
- append(customMapStyleUrl)
- }
- append("?key=$apiKey")
+ val baseUrl = customMapTilerConfig?.baseUrl.takeIf { !it.isNullOrBlank() } ?: baseUrl
+ val apiKey = customMapTilerConfig?.apiKey ?: apiKey
+ val mapId = if (darkMode) {
+ customMapTilerConfig?.darkStyleId ?: darkMapId
+ } else {
+ customMapTilerConfig?.lightStyleId ?: lightMapId
}
+
+ return "$baseUrl/$mapId/style.json?key=$apiKey"
}
}
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt
index 6636976828..31992b1dd6 100644
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt
@@ -8,10 +8,24 @@
package io.element.android.features.location.api.internal
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
/**
* Builds an URL for a 3rd party service provider static maps API.
*/
interface StaticMapUrlBuilder {
+ /**
+ * Builds the URL of a map image centred on the given coordinates.
+ *
+ * @param lat latitude of the centre of the map.
+ * @param lon longitude of the centre of the map.
+ * @param zoom zoom level to render at.
+ * @param darkMode whether to request the dark map style.
+ * @param width width of the image in density independent pixels.
+ * @param height height of the image in density independent pixels.
+ * @param density screen density, used to request a matching pixel size.
+ */
fun build(
lat: Double,
lon: Double,
@@ -22,7 +36,17 @@ interface StaticMapUrlBuilder {
density: Float,
): String
+ /** Whether a map provider is configured for this build; the URLs are unusable when it is not. */
fun isServiceAvailable(): Boolean
}
-fun StaticMapUrlBuilder(): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder()
+@Composable
+fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder {
+ return remember(mapTilerConfig) {
+ if (mapTilerConfig != null) {
+ MapTilerStaticMapUrlBuilder(mapTilerConfig)
+ } else {
+ MapTilerStaticMapUrlBuilder()
+ }
+ }
+}
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt
index 762d580f8e..67936525e5 100644
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt
@@ -11,6 +11,7 @@ package io.element.android.features.location.api.internal
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import io.element.android.compound.theme.ElementTheme
+import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
/**
* Builds a style URI for a MapLibre compatible tile server.
@@ -18,8 +19,14 @@ import io.element.android.compound.theme.ElementTheme
* Used for rendering dynamic maps.
*/
interface TileServerStyleUriBuilder {
+ /**
+ * Builds the style URI the map view loads its tiles from.
+ *
+ * @param customMapTilerConfig the custom MapTiler configuration received from the server, or null if not available.
+ * @param darkMode whether to request the dark map style.
+ */
fun build(
- customMapStyleUrl: String?,
+ customMapTilerConfig: MapTilerConfig?,
darkMode: Boolean,
): String
}
@@ -31,12 +38,12 @@ interface TileServerStyleUriBuilder {
*/
@Composable
fun rememberTileStyleUrl(
- customMapStyleUrl: String?,
+ customMapTileConfig: MapTilerConfig?,
): String {
val darkMode = !ElementTheme.isLightTheme
- return remember(darkMode, customMapStyleUrl) {
+ return remember(darkMode, customMapTileConfig) {
MapTilerTileServerStyleUriBuilder().build(
- customMapStyleUrl = customMapStyleUrl,
+ customMapTilerConfig = customMapTileConfig,
darkMode = darkMode,
)
}
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/live/ActiveLiveLocationShareManager.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/live/ActiveLiveLocationShareManager.kt
index cd6b8731c1..64c28209ca 100644
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/live/ActiveLiveLocationShareManager.kt
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/live/ActiveLiveLocationShareManager.kt
@@ -12,6 +12,9 @@ import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.StateFlow
import kotlin.time.Duration
+/**
+ * Owns the live location shares running on this device, across every room, and the foreground service that feeds them GPS updates.
+ */
interface ActiveLiveLocationShareManager {
/** All rooms currently sharing live location on this device. */
val sharingRoomIds: StateFlow>
@@ -27,6 +30,9 @@ interface ActiveLiveLocationShareManager {
* Starts live location sharing in the given room.
* Calls room.startLiveLocationShare() on the SDK, registers the share,
* and starts the foreground GPS service if not already running.
+ *
+ * @param roomId the room to start sharing the location in.
+ * @param duration how long the share should last before it expires on its own.
*/
suspend fun startShare(roomId: RoomId, duration: Duration): Result
@@ -34,6 +40,8 @@ interface ActiveLiveLocationShareManager {
* Stops live location sharing in the given room.
* Calls room.stopLiveLocationShare() on the SDK, removes the share,
* and stops the foreground service if no shares remain.
+ *
+ * @param roomId the room to stop sharing the location in.
*/
suspend fun stopShare(roomId: RoomId): Result
}
diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt
index 2e07204594..c153ab1ce4 100644
--- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt
+++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt
@@ -9,6 +9,7 @@
package io.element.android.features.location.api.internal
import com.google.common.truth.Truth.assertThat
+import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import org.junit.Test
class MapTilerTileServerStyleUriBuilderTest {
@@ -23,7 +24,7 @@ class MapTilerTileServerStyleUriBuilderTest {
fun `light map uri`() {
assertThat(
builder.build(
- customMapStyleUrl = null,
+ customMapTilerConfig = null,
darkMode = false,
)
).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey")
@@ -33,7 +34,7 @@ class MapTilerTileServerStyleUriBuilderTest {
fun `dark map uri`() {
assertThat(
builder.build(
- customMapStyleUrl = null,
+ customMapTilerConfig = null,
darkMode = true,
)
).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey")
@@ -43,19 +44,44 @@ class MapTilerTileServerStyleUriBuilderTest {
fun `custom map uri light`() {
assertThat(
builder.build(
- customMapStyleUrl = "https://custom.url/style.json",
+ customMapTilerConfig = MapTilerConfig(
+ apiKey = "API_KEY",
+ lightStyleId = "light",
+ darkStyleId = "dark",
+ baseUrl = "https://custom.url/style.json"
+ ),
darkMode = false,
)
- ).isEqualTo("https://custom.url/style.json?key=anApiKey")
+ ).isEqualTo("https://custom.url/style.json/light/style.json?key=API_KEY")
}
@Test
fun `custom map uri dark`() {
assertThat(
builder.build(
- customMapStyleUrl = "https://custom.url/style.json",
+ customMapTilerConfig = MapTilerConfig(
+ apiKey = "API_KEY",
+ lightStyleId = "light",
+ darkStyleId = "dark",
+ baseUrl = "https://custom.url/style.json"
+ ),
darkMode = true,
)
- ).isEqualTo("https://custom.url/style.json?key=anApiKey")
+ ).isEqualTo("https://custom.url/style.json/dark/style.json?key=API_KEY")
+ }
+
+ @Test
+ fun `blank custom map uri falls back to the built-in style`() {
+ assertThat(
+ builder.build(
+ customMapTilerConfig = MapTilerConfig(
+ apiKey = "API_KEY",
+ lightStyleId = "light",
+ darkStyleId = "dark",
+ baseUrl = " "
+ ),
+ darkMode = false,
+ )
+ ).isEqualTo("https://base.url/light/style.json?key=API_KEY")
}
}
diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts
index 165c32b7c5..43e4f58cab 100644
--- a/features/location/impl/build.gradle.kts
+++ b/features/location/impl/build.gradle.kts
@@ -26,6 +26,7 @@ setupDependencyInjection()
dependencies {
api(projects.features.location.api)
+ implementation(projects.features.enterprise.api)
implementation(projects.features.messages.api)
implementation(libs.maplibre.compose)
implementation(libs.coil)
@@ -45,6 +46,7 @@ dependencies {
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.sessionStorage.api)
+ implementation(projects.libraries.wellknown.api)
implementation(libs.androidx.datastore.preferences)
implementation(libs.datetime)
@@ -58,5 +60,7 @@ dependencies {
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.sessionStorage.test)
+ testImplementation(projects.libraries.wellknown.test)
testImplementation(projects.features.location.test)
+ testImplementation(projects.features.enterprise.test)
}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt
index 0eff1b6028..84c4ab4fba 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt
@@ -43,6 +43,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
+import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.libraries.architecture.AsyncData
@@ -66,7 +67,7 @@ import kotlin.math.roundToInt
* - Updating camera position padding based on sheet height
* - Rendering the MaplibreMap with proper ornament positioning
*
- * @param customMapStyleUrl Optional custom style URL for the map
+ * @param customMapTilerConfig Optional custom style URL for the map
* @param modifier Modifier for the root layout
* @param scaffoldState State for the bottom sheet scaffold
* @param cameraState The camera state for the map
@@ -83,7 +84,7 @@ import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapBottomSheetScaffold(
- customMapStyleUrl: AsyncData,
+ customMapTilerConfig: AsyncData,
modifier: Modifier = Modifier,
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberBottomSheetState(
@@ -137,11 +138,11 @@ fun MapBottomSheetScaffold(
val ornamentOptions = mapOptions.ornamentOptions.copy(padding = sheetPadding)
val mapOptions = mapOptions.copy(ornamentOptions = ornamentOptions)
Box {
- when (customMapStyleUrl) {
+ when (customMapTilerConfig) {
is AsyncData.Success -> {
MaplibreMap(
options = mapOptions,
- baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapStyleUrl.data)),
+ baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapTilerConfig.data)),
modifier = Modifier.fillMaxSize(),
cameraState = cameraState,
content = mapContent,
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt
index a3f3cac432..55f1e088d2 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt
@@ -22,6 +22,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Composer
+import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.location.impl.common.LocationConstraintsCheck
import io.element.android.features.location.impl.common.MapDefaults
@@ -72,6 +73,7 @@ class ShareLocationPresenter(
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val liveLocationStore: LiveLocationStore,
private val userLocationStateFactory: UserLocationState.Factory,
+ private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
) : Presenter {
@AssistedFactory
fun interface Factory {
@@ -92,9 +94,9 @@ class ShareLocationPresenter(
var pendingLiveLocationShare by remember { mutableStateOf(false) }
val startLiveLocationAction = remember { mutableStateOf>(AsyncAction.Uninitialized) }
val currentUser by client.userProfile.collectAsState()
- val customMapStyleUrl by produceState(AsyncData.Loading()) {
+ val customMapConfig by produceState(AsyncData.Loading()) {
// Ignore errors
- value = AsyncData.Success(client.getMapStyleUrl().getOrNull())
+ value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull())
}
val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms ->
perms.sendLiveLocationPermissions()
@@ -199,7 +201,7 @@ class ShareLocationPresenter(
}
return ShareLocationState(
- customMapStyleUrl = customMapStyleUrl,
+ customMapTilerConfig = customMapConfig,
currentUser = currentUser,
dialogState = dialogState,
trackUserLocation = trackUserPosition,
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt
index fb1b803435..b29228f866 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt
@@ -8,6 +8,7 @@
package io.element.android.features.location.impl.share
+import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.libraries.architecture.AsyncAction
@@ -16,7 +17,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class ShareLocationState(
- val customMapStyleUrl: AsyncData,
+ val customMapTilerConfig: AsyncData,
val currentUser: MatrixUser,
val dialogState: Dialog,
val trackUserLocation: Boolean,
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStatePreviewParam.kt
similarity index 91%
rename from features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt
rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStatePreviewParam.kt
index ed8d46afd3..908c7f0641 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStatePreviewParam.kt
@@ -9,6 +9,7 @@
package io.element.android.features.location.impl.share
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.libraries.architecture.AsyncAction
@@ -21,7 +22,7 @@ import kotlin.time.Duration.Companion.minutes
private const val APP_NAME = "ApplicationName"
-class ShareLocationStateProvider : PreviewParameterProvider {
+class ShareLocationStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aShareLocationState(
@@ -70,13 +71,13 @@ class ShareLocationStateProvider : PreviewParameterProvider
canShareLiveLocation = true,
),
aShareLocationState(
- customMapStyleUrl = AsyncData.Loading(),
+ customMapTilerConfig = AsyncData.Loading(),
),
)
}
fun aShareLocationState(
- customMapStyleUrl: AsyncData = AsyncData.Success(null),
+ customMapTilerConfig: AsyncData = AsyncData.Success(null),
currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")),
dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None,
trackUserPosition: Boolean = false,
@@ -87,7 +88,7 @@ fun aShareLocationState(
eventSink: (ShareLocationEvent) -> Unit = {},
): ShareLocationState {
return ShareLocationState(
- customMapStyleUrl = customMapStyleUrl,
+ customMapTilerConfig = customMapTilerConfig,
currentUser = currentUser,
dialogState = dialogState,
trackUserLocation = trackUserPosition,
@@ -95,6 +96,6 @@ fun aShareLocationState(
canShareLiveLocation = canShareLiveLocation,
appName = appName,
startLiveLocationAction = startLiveLocationAction,
- eventSink = eventSink
+ eventSink = eventSink,
)
}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt
index 8b7106e939..36827cc919 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt
@@ -118,7 +118,7 @@ fun ShareLocationView(
}
MapBottomSheetScaffold(
- customMapStyleUrl = state.customMapStyleUrl,
+ customMapTilerConfig = state.customMapTilerConfig,
cameraState = cameraState,
modifier = modifier,
scaffoldState = scaffoldState,
@@ -332,7 +332,7 @@ private fun LiveLocationDurationDialog(
@PreviewsDayNight
@Composable
internal fun ShareLocationViewPreview(
- @PreviewParameter(ShareLocationStateProvider::class) state: ShareLocationState
+ @PreviewParameter(ShareLocationStatePreviewParam::class) state: ShareLocationState
) = ElementPreview {
ShareLocationView(
state = state,
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt
index f187e91c53..bbec7b449f 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt
@@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
+import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
@@ -46,7 +47,6 @@ import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
-import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
@@ -57,17 +57,18 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
-@AssistedInject class ShowLocationPresenter(
+@AssistedInject
+class ShowLocationPresenter(
@Assisted private val mode: ShowLocationMode,
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val locationActions: LocationActions,
private val buildMeta: BuildMeta,
private val dateFormatter: DateFormatter,
private val stringProvider: StringProvider,
- private val client: MatrixClient,
private val joinedRoom: JoinedRoom,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val userLocationStateFactory: UserLocationState.Factory,
+ private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
) : Presenter {
@AssistedFactory fun interface Factory {
fun create(mode: ShowLocationMode): ShowLocationPresenter
@@ -85,9 +86,9 @@ import kotlinx.coroutines.launch
mutableStateOf(LocationConstraintsDialogState.None)
}
- val customMapStyleUrl by produceState(AsyncData.Loading()) {
+ val customMapTilerConfig by produceState(AsyncData.Loading()) {
// Ignore errors
- value = AsyncData.Success(client.getMapStyleUrl().getOrNull())
+ value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull())
}
fun checkLocationConstraints() {
@@ -220,7 +221,7 @@ import kotlinx.coroutines.launch
userLocationStateFactory.create(hasLocationPermission = permissionsState.isAnyGranted)
}
return ShowLocationState(
- customMapStyleUrl = customMapStyleUrl,
+ customMapTilerConfig = customMapTilerConfig,
dialogState = dialogState,
locationShares = locationShares,
focusedLocation = focusedLocation,
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt
index e5ddf589b0..0472eb94e9 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt
@@ -8,6 +8,7 @@
package io.element.android.features.location.impl.show
+import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.ui.LocationMarkerData
@@ -20,7 +21,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
import kotlinx.collections.immutable.ImmutableList
data class ShowLocationState(
- val customMapStyleUrl: AsyncData,
+ val customMapTilerConfig: AsyncData,
val isLive: Boolean,
val dialogState: LocationConstraintsDialogState,
val locationShares: ImmutableList,
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStatePreviewParam.kt
similarity index 90%
rename from features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt
rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStatePreviewParam.kt
index cd7ea108bb..8473eaeb04 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStatePreviewParam.kt
@@ -9,6 +9,7 @@
package io.element.android.features.location.impl.show
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
@@ -20,7 +21,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
import kotlinx.collections.immutable.toImmutableList
-class ShowLocationStateProvider : PreviewParameterProvider {
+class ShowLocationStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aShowLocationState(),
@@ -36,14 +37,14 @@ class ShowLocationStateProvider : PreviewParameterProvider {
constraintsDialogState = LocationConstraintsDialogState.LocationServiceDisabled,
),
aShowLocationState(isTrackMyLocation = true),
- aShowLocationState(customMapStyleUrl = AsyncData.Loading()),
+ aShowLocationState(customMapTilerConfig = AsyncData.Loading()),
)
}
private const val APP_NAME = "ApplicationName"
fun aShowLocationState(
- customMapStyleUrl: AsyncData = AsyncData.Success(null),
+ customMapTilerConfig: AsyncData = AsyncData.Success(null),
isLive: Boolean = false,
constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None,
locationShares: List = listOf(aLocationShareItem(isLive = isLive)),
@@ -55,7 +56,7 @@ fun aShowLocationState(
eventSink: (ShowLocationEvent) -> Unit = {},
): ShowLocationState {
return ShowLocationState(
- customMapStyleUrl = customMapStyleUrl,
+ customMapTilerConfig = customMapTilerConfig,
dialogState = constraintsDialogState,
locationShares = locationShares.toImmutableList(),
focusedLocation = focusedLocation,
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt
index 9fdbe3aac8..26eb1ee27a 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt
@@ -102,7 +102,7 @@ fun ShowLocationView(
}
}
MapBottomSheetScaffold(
- customMapStyleUrl = state.customMapStyleUrl,
+ customMapTilerConfig = state.customMapTilerConfig,
sheetDragHandle = if (state.isSheetDraggable) {
{ BottomSheetDefaults.DragHandle() }
} else {
@@ -200,7 +200,7 @@ fun ShowLocationView(
@PreviewsDayNight
@Composable
-internal fun ShowLocationViewPreview(@PreviewParameter(ShowLocationStateProvider::class) state: ShowLocationState) = ElementPreview {
+internal fun ShowLocationViewPreview(@PreviewParameter(ShowLocationStatePreviewParam::class) state: ShowLocationState) = ElementPreview {
ShowLocationView(
state = state,
onBackClick = {},
diff --git a/features/location/impl/src/main/res/values-ar/translations.xml b/features/location/impl/src/main/res/values-ar/translations.xml
new file mode 100644
index 0000000000..fb70505fbe
--- /dev/null
+++ b/features/location/impl/src/main/res/values-ar/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "سيُخزَّن سجل موقعك المباشر في الغرفة وسيكون مرئيًا للأعضاء بعد انتهاء الجلسة."
+ "اختر مدة مشاركة موقعك المباشر."
+ "ليست لديك صلاحيات لمشاركة موقعك المباشر في هذه الغرفة"
+
diff --git a/features/location/impl/src/main/res/values-de/translations.xml b/features/location/impl/src/main/res/values-de/translations.xml
index 1a1e208c3b..1d10fea641 100644
--- a/features/location/impl/src/main/res/values-de/translations.xml
+++ b/features/location/impl/src/main/res/values-de/translations.xml
@@ -1,4 +1,5 @@
+ "Der Verlauf deines Live-Standorts wird im Chat gespeichert und bleibt für die Mitglieder sichtbar, auch nachdem du die Freigabe beendet hast."
"Wie lange soll der Live-Standort geteilt werden?"
diff --git a/features/location/impl/src/main/res/values-es/translations.xml b/features/location/impl/src/main/res/values-es/translations.xml
new file mode 100644
index 0000000000..9fc15d3468
--- /dev/null
+++ b/features/location/impl/src/main/res/values-es/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Tu historial de ubicación en tiempo real se guardará en la sala y será visible para los miembros después de que termine la sesión."
+ "Elige por cuánto tiempo quieres compartir tu ubicación en tiempo real"
+ "No tienes permiso para compartir tu ubicación en tiempo real en esta sala."
+
diff --git a/features/location/impl/src/main/res/values-fa/translations.xml b/features/location/impl/src/main/res/values-fa/translations.xml
new file mode 100644
index 0000000000..5183ed2f43
--- /dev/null
+++ b/features/location/impl/src/main/res/values-fa/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "سابقه موقعیت مکانی زنده شما در اتاق ذخیره میشود و پس از پایان جلسه برای اعضا قابل مشاهده خواهد بود."
+ "مدت زمان اشتراکگذاری موقعیت مکانی زنده خود را انتخاب کنید."
+ "شما مجوز اشتراکگذاری موقعیت مکانی زنده خود را در این اتاق ندارید"
+
diff --git a/features/location/impl/src/main/res/values-zh-rTW/translations.xml b/features/location/impl/src/main/res/values-zh-rTW/translations.xml
index 27f507732d..d7647730de 100644
--- a/features/location/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/location/impl/src/main/res/values-zh-rTW/translations.xml
@@ -2,4 +2,5 @@
"您的即時位置歷史將儲存於聊天室中,並在工作階段結束後對其他成員可見。"
"選擇分享即時位置的時間長度。"
+ "您無權在此聊天室分享您的即時位置"
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt
index 49df684666..a37011e1d9 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt
@@ -58,6 +58,7 @@ class DefaultShareLocationEntryPointTest {
sessionId = room.sessionId,
),
userLocationStateFactory = FakeUserLocationStateFactory(),
+ customMapTilerConfigProvider = { Result.success(null) },
)
},
analyticsService = FakeAnalyticsService(),
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt
index f4cc135e9e..d249fdaf69 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt
@@ -19,6 +19,8 @@ import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
+import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
+import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
@@ -60,7 +62,6 @@ import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -77,20 +78,18 @@ class ShareLocationPresenterTest {
private val fakeMessageComposerContext = FakeMessageComposerContext()
private val fakeLocationActions = FakeLocationActions()
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
- private val fakeMatrixClient = FakeMatrixClient(
- sessionId = A_USER_ID,
- getMapStyleUrlResult = { Result.success(null) },
- )
+ private val fakeMatrixClient = FakeMatrixClient(sessionId = A_USER_ID)
private val durationFormatter = FakeDurationFormatter()
- private fun TestScope.createShareLocationPresenter(
+ private fun createShareLocationPresenter(
joinedRoom: JoinedRoom = FakeJoinedRoom(),
timelineMode: Timeline.Mode = Timeline.Mode.Live,
locationActions: FakeLocationActions = fakeLocationActions,
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId),
client: FakeMatrixClient = fakeMatrixClient,
+ customMapTilerConfigProvider: () -> Result = { Result.success(null) },
): ShareLocationPresenter = ShareLocationPresenter(
permissionsPresenterFactory = { fakePermissionsPresenter },
room = joinedRoom,
@@ -104,6 +103,7 @@ class ShareLocationPresenterTest {
liveLocationShareManager = liveLocationShareManager,
liveLocationStore = liveLocationStore,
userLocationStateFactory = FakeUserLocationStateFactory(),
+ customMapTilerConfigProvider = customMapTilerConfigProvider,
)
@Test
@@ -118,7 +118,7 @@ class ShareLocationPresenterTest {
val shareLocationPresenter = createShareLocationPresenter()
shareLocationPresenter.test {
val state = awaitFirstItem()
- assertThat(state.customMapStyleUrl.isLoading()).isFalse()
+ assertThat(state.customMapTilerConfig.isLoading()).isFalse()
assertThat(state.trackUserLocation).isTrue()
assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
}
@@ -126,18 +126,18 @@ class ShareLocationPresenterTest {
@Test
fun `present - non-null customMapStyleUrl`() = runTest {
+ val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY")
val shareLocationPresenter = createShareLocationPresenter(
client = FakeMatrixClient(
sessionId = A_USER_ID,
- getMapStyleUrlResult = { Result.success("aUrl") },
- )
+ ),
+ customMapTilerConfigProvider = { Result.success(mapTilerConfig) }
)
shareLocationPresenter.test {
- skipItems(1)
val state = awaitItem()
- assertThat(state.customMapStyleUrl.isLoading()).isTrue()
+ assertThat(state.customMapTilerConfig.isLoading()).isTrue()
val finalState = awaitItem()
- assertThat(finalState.customMapStyleUrl.dataOrNull()).isEqualTo("aUrl")
+ assertThat(finalState.customMapTilerConfig.dataOrNull()).isEqualTo(mapTilerConfig)
}
}
@@ -709,7 +709,7 @@ class ShareLocationPresenterTest {
}
private suspend fun ReceiveTurbine.awaitFirstItem(): T {
- skipItems(2)
+ skipItems(1)
return awaitItem()
}
}
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt
index 23b7e9bf4d..db5c6b9f9f 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt
@@ -20,7 +20,6 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.UserId
-import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.services.analytics.test.FakeAnalyticsService
@@ -41,8 +40,8 @@ class DefaultShowLocationEntryPointTest {
ShowLocationNode(
buildContext = buildContext,
plugins = plugins,
- presenterFactory = object : ShowLocationPresenter.Factory {
- override fun create(mode: ShowLocationMode) = ShowLocationPresenter(
+ presenterFactory = { mode ->
+ ShowLocationPresenter(
mode = mode,
permissionsPresenterFactory = { FakePermissionsPresenter() },
locationActions = FakeLocationActions(),
@@ -50,9 +49,9 @@ class DefaultShowLocationEntryPointTest {
dateFormatter = FakeDateFormatter(),
stringProvider = FakeStringProvider(),
joinedRoom = joinedRoom,
- client = FakeMatrixClient(),
liveLocationShareManager = FakeActiveLiveLocationShareManager(),
userLocationStateFactory = FakeUserLocationStateFactory(),
+ customMapTilerConfigProvider = { Result.success(null) },
)
},
analyticsService = FakeAnalyticsService(),
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt
index b579a79e4f..7d9b0d5e03 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt
@@ -12,6 +12,8 @@ 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.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
+import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.aPermissionsState
@@ -23,13 +25,11 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
-import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.test.A_USER_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.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
@@ -65,8 +65,8 @@ class ShowLocationPresenterTest {
),
locationActions: FakeLocationActions = fakeLocationActions,
joinedRoom: JoinedRoom = FakeJoinedRoom(),
- client: MatrixClient = FakeMatrixClient(),
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
+ customMapTilerConfigProvider: CustomMapTilerConfigProvider = { Result.success(null) },
) = ShowLocationPresenter(
mode = mode,
permissionsPresenterFactory = { fakePermissionsPresenter },
@@ -75,24 +75,20 @@ class ShowLocationPresenterTest {
dateFormatter = fakeDateFormatter,
stringProvider = FakeStringProvider(),
joinedRoom = joinedRoom,
- client = client,
liveLocationShareManager = liveLocationShareManager,
userLocationStateFactory = FakeUserLocationStateFactory(),
+ customMapTilerConfigProvider = customMapTilerConfigProvider,
)
@Test
fun `present - non-null customMapStyleUrl`() = runTest {
- val shareLocationPresenter = createShowLocationPresenter(
- client = FakeMatrixClient(
- sessionId = A_USER_ID,
- getMapStyleUrlResult = { Result.success("aUrl") },
- )
- )
+ val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY")
+ val shareLocationPresenter = createShowLocationPresenter(customMapTilerConfigProvider = { Result.success(mapTilerConfig) })
shareLocationPresenter.test {
val state = awaitItem()
- assertThat(state.customMapStyleUrl.isLoading()).isTrue()
+ assertThat(state.customMapTilerConfig.isLoading()).isTrue()
val finalState = awaitItem()
- assertThat(finalState.customMapStyleUrl.dataOrNull()).isEqualTo("aUrl")
+ assertThat(finalState.customMapTilerConfig.dataOrNull()).isEqualTo(mapTilerConfig)
}
}
@@ -115,6 +111,8 @@ class ShowLocationPresenterTest {
initialState.eventSink(ShowLocationEvent.Share(location))
assertThat(fakeLocationActions.sharedLocation).isEqualTo(location)
+
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -122,11 +120,8 @@ class ShowLocationPresenterTest {
fun `centers on user location`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
- val presenter = createShowLocationPresenter(
- client = FakeMatrixClient(
- getMapStyleUrlResult = { Result.success(null) }
- )
- )
+ val presenter = createShowLocationPresenter(customMapTilerConfigProvider = { Result.success(aMapTilerConfig()) })
+
presenter.test {
val initialState = awaitItem()
assertThat(initialState.isTrackMyLocation).isFalse()
@@ -281,6 +276,8 @@ class ShowLocationPresenterTest {
}.test {
val initialState = awaitItem()
assertThat(initialState.appName).isEqualTo("app name")
+
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -334,6 +331,8 @@ class ShowLocationPresenterTest {
val initialState = awaitItem()
assertThat(initialState.locationShares).isEmpty()
assertThat(initialState.isSheetDraggable).isFalse()
+
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -408,6 +407,9 @@ class ShowLocationPresenterTest {
val initialState = awaitItem()
assertThat(initialState.locationShares).isEmpty()
+ // Wait until we receive the custom map style URL result
+ skipItems(1)
+
// Emit a new live share
liveSharesFlow.value = listOf(
aLiveLocationShare(userId = userId)
@@ -447,6 +449,8 @@ class ShowLocationPresenterTest {
assertThat(item.avatarData.id).isEqualTo(senderId.value)
assertThat(item.avatarData.name).isEqualTo(senderName)
assertThat(item.avatarData.url).isEqualTo(avatarUrl)
+
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -456,6 +460,7 @@ class ShowLocationPresenterTest {
presenter.test {
val state = awaitItem()
assertThat(state.isSheetDraggable).isFalse()
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -465,6 +470,8 @@ class ShowLocationPresenterTest {
presenter.test {
val state = awaitItem()
assertThat(state.hideUserLocationPuck).isFalse()
+
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -476,6 +483,8 @@ class ShowLocationPresenterTest {
presenter.test {
val state = awaitItem()
assertThat(state.hideUserLocationPuck).isFalse()
+
+ cancelAndIgnoreRemainingEvents()
}
}
diff --git a/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenService.kt b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenService.kt
index bd644e69e9..59c148660e 100644
--- a/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenService.kt
+++ b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenService.kt
@@ -17,6 +17,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+/**
+ * Exposes the app lock state: whether a PIN or biometric lock is set up, whether it is mandatory, and whether the app is locked right now.
+ */
interface LockScreenService {
/**
* The current lock state of the app.
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenConfig.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenConfig.kt
index c246390a63..fa3ca9cfd9 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenConfig.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenConfig.kt
@@ -8,12 +8,7 @@
package io.element.android.features.lockscreen.impl
-import dev.zacsweers.metro.AppScope
-import dev.zacsweers.metro.BindingContainer
-import dev.zacsweers.metro.ContributesTo
-import dev.zacsweers.metro.Provides
import kotlin.time.Duration
-import io.element.android.appconfig.LockScreenConfig as AppConfigLockScreenConfig
data class LockScreenConfig(
val isPinMandatory: Boolean,
@@ -24,18 +19,3 @@ data class LockScreenConfig(
val isStrongBiometricsEnabled: Boolean,
val isWeakBiometricsEnabled: Boolean,
)
-
-@ContributesTo(AppScope::class)
-@BindingContainer
-object LockScreenConfigModule {
- @Provides
- fun providesLockScreenConfig(): LockScreenConfig = LockScreenConfig(
- isPinMandatory = AppConfigLockScreenConfig.IS_PIN_MANDATORY,
- forbiddenPinCodes = AppConfigLockScreenConfig.FORBIDDEN_PIN_CODES,
- pinSize = AppConfigLockScreenConfig.PIN_SIZE,
- maxPinCodeAttemptsBeforeLogout = AppConfigLockScreenConfig.MAX_PIN_CODE_ATTEMPTS_BEFORE_LOGOUT,
- gracePeriod = AppConfigLockScreenConfig.GRACE_PERIOD,
- isStrongBiometricsEnabled = AppConfigLockScreenConfig.IS_STRONG_BIOMETRICS_ENABLED,
- isWeakBiometricsEnabled = AppConfigLockScreenConfig.IS_WEAK_BIOMETRICS_ENABLED,
- )
-}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt
index 74d36b0834..f08d2fc1af 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt
@@ -59,7 +59,7 @@ class DefaultBiometricAuthentication(
private var cryptoObject: CryptoObject? = null
override suspend fun setup() {
- if (cryptoObject != null) return
+ Timber.d("Setup biometric authentication")
try {
val secretKey = ensureKey()
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
@@ -126,6 +126,9 @@ private class AuthenticationCallback(
if (this == null) return false
return runCatchingExceptions {
doFinal("biometric_challenge".toByteArray())
- }.isSuccess
+ }.onFailure {
+ Timber.e(it, "Failed to validate cipher")
+ }
+ .isSuccess
}
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/di/LockScreenConfigBindingContainer.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/di/LockScreenConfigBindingContainer.kt
new file mode 100644
index 0000000000..3acade5e86
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/di/LockScreenConfigBindingContainer.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.lockscreen.impl.di
+
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.BindingContainer
+import dev.zacsweers.metro.ContributesTo
+import dev.zacsweers.metro.Provides
+import io.element.android.features.lockscreen.impl.LockScreenConfig
+
+@ContributesTo(AppScope::class)
+@BindingContainer
+object LockScreenConfigBindingContainer {
+ @Provides
+ fun providesLockScreenConfig(): LockScreenConfig = LockScreenConfig(
+ isPinMandatory = io.element.android.appconfig.LockScreenConfig.IS_PIN_MANDATORY,
+ forbiddenPinCodes = io.element.android.appconfig.LockScreenConfig.FORBIDDEN_PIN_CODES,
+ pinSize = io.element.android.appconfig.LockScreenConfig.PIN_SIZE,
+ maxPinCodeAttemptsBeforeLogout = io.element.android.appconfig.LockScreenConfig.MAX_PIN_CODE_ATTEMPTS_BEFORE_LOGOUT,
+ gracePeriod = io.element.android.appconfig.LockScreenConfig.GRACE_PERIOD,
+ isStrongBiometricsEnabled = io.element.android.appconfig.LockScreenConfig.IS_STRONG_BIOMETRICS_ENABLED,
+ isWeakBiometricsEnabled = io.element.android.appconfig.LockScreenConfig.IS_WEAK_BIOMETRICS_ENABLED,
+ )
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsStateProvider.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsStatePreviewParam.kt
similarity index 91%
rename from features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsStateProvider.kt
rename to features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsStatePreviewParam.kt
index 43f20c1c52..8929138077 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsStateProvider.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsStatePreviewParam.kt
@@ -10,7 +10,7 @@ package io.element.android.features.lockscreen.impl.settings
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-open class LockScreenSettingsStateProvider : PreviewParameterProvider {
+open class LockScreenSettingsStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aLockScreenSettingsState(),
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt
index d8655e29a3..291906caf0 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt
@@ -84,7 +84,7 @@ fun LockScreenSettingsView(
@PreviewsDayNight
@Composable
internal fun LockScreenSettingsViewPreview(
- @PreviewParameter(LockScreenSettingsStateProvider::class) state: LockScreenSettingsState,
+ @PreviewParameter(LockScreenSettingsStatePreviewParam::class) state: LockScreenSettingsState,
) {
ElementPreview {
LockScreenSettingsView(
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricStateProvider.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricStatePreviewParam.kt
similarity index 87%
rename from features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricStateProvider.kt
rename to features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricStatePreviewParam.kt
index d725d2de97..6ad44f3273 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricStateProvider.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricStatePreviewParam.kt
@@ -10,7 +10,7 @@ package io.element.android.features.lockscreen.impl.setup.biometric
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-open class SetupBiometricStateProvider : PreviewParameterProvider {
+open class SetupBiometricStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aSetupBiometricState(),
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt
index 70a1046e36..13fad4e9d0 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt
@@ -79,7 +79,7 @@ private fun SetupBiometricFooter(
@Composable
@PreviewsDayNight
-internal fun SetupBiometricViewPreview(@PreviewParameter(SetupBiometricStateProvider::class) state: SetupBiometricState) {
+internal fun SetupBiometricViewPreview(@PreviewParameter(SetupBiometricStatePreviewParam::class) state: SetupBiometricState) {
ElementPreview {
SetupBiometricView(
state = state,
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinStateProvider.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinStatePreviewParam.kt
similarity index 95%
rename from features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinStateProvider.kt
rename to features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinStatePreviewParam.kt
index f50643e367..706d49ce82 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinStateProvider.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinStatePreviewParam.kt
@@ -12,7 +12,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure
-open class SetupPinStateProvider : PreviewParameterProvider {
+open class SetupPinStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aSetupPinState(),
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt
index 508d3c1fbb..7b0f86e386 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt
@@ -145,7 +145,7 @@ private fun SetupPinFailure.title(): String {
@Composable
@PreviewsDayNight
-internal fun SetupPinViewPreview(@PreviewParameter(SetupPinStateProvider::class) state: SetupPinState) {
+internal fun SetupPinViewPreview(@PreviewParameter(SetupPinStatePreviewParam::class) state: SetupPinState) {
ElementPreview {
SetupPinView(
state = state,
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStatePreviewParam.kt
similarity index 94%
rename from features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt
rename to features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStatePreviewParam.kt
index aef2c057a2..dcc61e83ed 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStatePreviewParam.kt
@@ -16,7 +16,7 @@ import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
-open class PinUnlockStateProvider : PreviewParameterProvider {
+open class PinUnlockStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aPinUnlockState(),
@@ -42,7 +42,7 @@ open class PinUnlockStateProvider : PreviewParameterProvider {
)
}
-open class PinUnlockStateCompactProvider : PreviewParameterProvider {
+open class PinUnlockStateCompactPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aPinUnlockState(),
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt
index 0292b29a0f..e83b106d88 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt
@@ -381,7 +381,7 @@ private fun PinUnlockFooter(
@Composable
@PreviewsDayNight
-internal fun PinUnlockViewInAppPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
+internal fun PinUnlockViewInAppPreview(@PreviewParameter(PinUnlockStatePreviewParam::class) state: PinUnlockState) {
ElementPreview {
PinUnlockView(
state = state,
@@ -393,7 +393,7 @@ internal fun PinUnlockViewInAppPreview(@PreviewParameter(PinUnlockStateProvider:
@Composable
@PreviewsDayNight
-internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
+internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStatePreviewParam::class) state: PinUnlockState) {
ElementPreview {
PinUnlockView(
state = state,
@@ -405,7 +405,7 @@ internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::clas
@Composable
@Preview(heightDp = 480, widthDp = 800)
-internal fun PinUnlockViewCompactPreview(@PreviewParameter(PinUnlockStateCompactProvider::class) state: PinUnlockState) {
+internal fun PinUnlockViewCompactPreview(@PreviewParameter(PinUnlockStateCompactPreviewParam::class) state: PinUnlockState) {
ElementPreview {
PinUnlockView(
state = state,
diff --git a/features/lockscreen/impl/src/main/res/values-ar/translations.xml b/features/lockscreen/impl/src/main/res/values-ar/translations.xml
new file mode 100644
index 0000000000..b277c420ea
--- /dev/null
+++ b/features/lockscreen/impl/src/main/res/values-ar/translations.xml
@@ -0,0 +1,46 @@
+
+
+ "المصادقة بالمقاييس الحيوية"
+ "إلغاء القفل باستخدام المقاييس الحيوية"
+ "إلغاء القفل باستخدام المقاييس الحيوية"
+ "تأكيد المصادقة الحيوية"
+ "هل نسيت رمز PIN؟"
+ "تغيير رمز PIN"
+ "السماح بإلغاء القفل باستخدام المقاييس الحيوية"
+ "إزالة رمز PIN"
+ "هل أنت متأكد من رغبتك في إزالة رمز PIN؟"
+ "إزالة رمز PIN؟"
+ "السماح بـ %1$s"
+ "أفضّل استخدام رمز PIN"
+ "وفّر بعض الوقت واستخدم %1$s لإلغاء قفل التطبيق في كل مرة"
+ "اختر رمز PIN"
+ "تأكيد رمز PIN"
+ "اقفل %1$s لإضافة مزيد من الأمان إلى محادثاتك.
+
+اختر شيئًا يسهل تذكره. إذا نسيت رمز PIN هذا، فسيتم تسجيل خروجك من التطبيق."
+ "لا يمكنك اختيار هذا كرمز PIN لأسباب أمنية"
+ "اختر رمز PIN مختلفًا"
+ "يُرجى إدخال رمز PIN نفسه مرتين"
+ "رمزا PIN غير متطابقين"
+ "ستحتاج إلى تسجيل الدخول مرة أخرى وإنشاء رمز PIN جديد للمتابعة"
+ "تتم إزالة هذا الجهاز"
+
+ - "لديك %1$d محاولات لإلغاء القفل"
+ - "لديك %1$d محاولة لإلغاء القفل"
+ - "لديك %1$d محاولتان لإلغاء القفل"
+ - "لديك %1$d محاولات لإلغاء القفل"
+ - "لديك %1$d محاولة لإلغاء القفل"
+ - "لديك %1$d محاولة لإلغاء القفل"
+
+
+ - "رمز PIN غير صحيح. لديك %1$d فرص أخرى"
+ - "رمز PIN غير صحيح. لديك %1$d فرصة أخرى"
+ - "رمز PIN غير صحيح. لديك %1$d فرصتان أخريان"
+ - "رمز PIN غير صحيح. لديك %1$d فرص أخرى"
+ - "رمز PIN غير صحيح. لديك %1$d فرصة أخرى"
+ - "رمز PIN غير صحيح. لديك %1$d فرصة أخرى"
+
+ "استخدام المقاييس الحيوية"
+ "استخدام رمز PIN"
+ "تجري إزالة الجهاز…"
+
diff --git a/features/lockscreen/impl/src/main/res/values-es/translations.xml b/features/lockscreen/impl/src/main/res/values-es/translations.xml
index c0e34a3763..085c1d1cbf 100644
--- a/features/lockscreen/impl/src/main/res/values-es/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-es/translations.xml
@@ -23,7 +23,7 @@ Elige algo que puedas recordar. Si olvidas este PIN, se cerrará la sesión de l
"Por favor ingresa el mismo PIN dos veces"
"Los PINs no coinciden"
"Tendrás que volver a iniciar sesión y crear un nuevo PIN para continuar"
- "Se está cerrando tu sesión"
+ "Se está eliminando este dispositivo"
- "Tienes %1$d intento de desbloqueo"
- "Tienes %1$d intentos de desbloqueo"
@@ -34,5 +34,5 @@ Elige algo que puedas recordar. Si olvidas este PIN, se cerrará la sesión de l
"Usar desbloqueo biométrico"
"Usar PIN"
- "Cerrando sesión…"
+ "Eliminando dispositivo…"
diff --git a/features/lockscreen/impl/src/main/res/values-pl/translations.xml b/features/lockscreen/impl/src/main/res/values-pl/translations.xml
index 29691987f6..e6faf72945 100644
--- a/features/lockscreen/impl/src/main/res/values-pl/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-pl/translations.xml
@@ -17,7 +17,7 @@
"Potwierdź PIN"
"Zablokuj %1$s, aby zwiększyć bezpieczeństwo swoich czatów.
-Wybierz coś łatwego do zapamiętania. Jeśli zapomnisz ten PIN, zostaniesz wylogowany z aplikacji."
+Wybierz coś łatwego do zapamiętania. Jeśli zapomnisz tego PINu, zostaniesz wylogowany z aplikacji."
"Nie możesz wybrać tego PIN\'u ze względów bezpieczeństwa"
"Wybierz inny kod PIN"
"Wprowadź ten sam kod PIN dwa razy"
diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginIntentResolver.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginIntentResolver.kt
index 79cfc35fbd..b2b7378be4 100644
--- a/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginIntentResolver.kt
+++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginIntentResolver.kt
@@ -8,6 +8,13 @@
package io.element.android.features.login.api
+/**
+ * Extracts the login parameters from a link, so that a deployment can hand out a URL that pre-fills the sign-in screen.
+ */
interface LoginIntentResolver {
+ /**
+ * @param uriString the link the app was opened with.
+ * @return the account provider and login hint to pre-fill, or `null` when the link is not a login link.
+ */
fun parse(uriString: String): LoginParams?
}
diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt
index fc804c14ff..0c35c1655f 100644
--- a/features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt
+++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt
@@ -8,6 +8,14 @@
package io.element.android.features.login.api.accesscontrol
+/**
+ * Enforces the restriction an enterprise deployment can put on which account providers the user may sign in to.
+ */
interface AccountProviderAccessControl {
+ /**
+ * Whether sign-in to this provider is permitted; `true` on a build with no such restriction.
+ *
+ * @param accountProviderUrl the server the user is trying to use.
+ */
suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String): Boolean
}
diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts
index 86a8e0e7bc..6cf8945e05 100644
--- a/features/login/impl/build.gradle.kts
+++ b/features/login/impl/build.gradle.kts
@@ -69,6 +69,7 @@ dependencies {
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.qrcode)
implementation(projects.libraries.oauth.api)
+ implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.wellknown.api)
implementation(libs.androidx.browser)
@@ -80,6 +81,7 @@ dependencies {
testImplementation(projects.features.login.test)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.features.preferences.test)
+ testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
index 978d28dfa3..bbfe7c47d8 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
@@ -23,7 +23,6 @@ import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
-import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
@@ -33,14 +32,11 @@ import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.classic.ElementClassicConnection
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
-import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderNode
import io.element.android.features.login.impl.screens.classic.ClassicFlowNode
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
-import io.element.android.features.login.impl.screens.createaccount.CreateAccountNode
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode
-import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.BackstackView
@@ -130,19 +126,10 @@ class LoginFlowNode(
@Parcelize
data object ChooseAccountProvider : NavTarget
- @Parcelize
- data object ChangeAccountProvider : NavTarget
-
- @Parcelize
- data object SearchAccountProvider : NavTarget
-
@Parcelize
data class LoginPassword(
val initialLogin: String = "",
) : NavTarget
-
- @Parcelize
- data class CreateAccount(val url: String) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -164,10 +151,6 @@ class LoginFlowNode(
override fun navigateToOAuth(oAuthDetails: OAuthDetails) {
navigateToMas(oAuthDetails)
}
-
- override fun navigateToCreateAccount(url: String) {
- backstack.push(NavTarget.CreateAccount(url))
- }
}
createNode(buildContext, listOf(callback))
}
@@ -201,10 +184,6 @@ class LoginFlowNode(
navigateToMas(oAuthDetails)
}
- override fun navigateToCreateAccount(url: String) {
- backstack.push(NavTarget.CreateAccount(url))
- }
-
override fun navigateToDeveloperSettings() {
backstack.push(NavTarget.AppDeveloperSettings)
}
@@ -247,10 +226,6 @@ class LoginFlowNode(
navigateToMas(oAuthDetails)
}
- override fun navigateToCreateAccount(url: String) {
- backstack.push(NavTarget.CreateAccount(url))
- }
-
override fun navigateToLoginPassword() {
backstack.push(NavTarget.LoginPassword())
}
@@ -274,62 +249,18 @@ class LoginFlowNode(
navigateToMas(oAuthDetails)
}
- override fun navigateToCreateAccount(url: String) {
- backstack.push(NavTarget.CreateAccount(url))
- }
-
override fun navigateToLoginPassword() {
backstack.push(NavTarget.LoginPassword())
}
-
- override fun navigateToChangeAccountProvider() {
- backstack.push(NavTarget.ChangeAccountProvider)
- }
}
createNode(buildContext, plugins = listOf(inputs, callback))
}
- NavTarget.ChangeAccountProvider -> {
- val callback = object : ChangeAccountProviderNode.Callback {
- override fun onDone() {
- // Go back to the Account Provider screen
- val confirmAccountProvider = backstack.elements.value.firstOrNull {
- it.key.navTarget is NavTarget.ConfirmAccountProvider
- }?.key?.navTarget ?: NavTarget.ConfirmAccountProvider(isAccountCreation = false)
- backstack.singleTop(confirmAccountProvider)
- }
-
- override fun navigateToSearchAccountProvider() {
- backstack.push(NavTarget.SearchAccountProvider)
- }
- }
-
- createNode(buildContext, plugins = listOf(callback))
- }
- NavTarget.SearchAccountProvider -> {
- val callback = object : SearchAccountProviderNode.Callback {
- override fun onDone() {
- // Go back to the Account Provider screen
- val confirmAccountProvider = backstack.elements.value.firstOrNull {
- it.key.navTarget is NavTarget.ConfirmAccountProvider
- }?.key?.navTarget ?: NavTarget.ConfirmAccountProvider(isAccountCreation = false)
- backstack.singleTop(confirmAccountProvider)
- }
- }
-
- createNode(buildContext, plugins = listOf(callback))
- }
is NavTarget.LoginPassword -> {
val inputs = LoginPasswordNode.Inputs(
initialLogin = navTarget.initialLogin,
)
createNode(buildContext, plugins = listOf(inputs))
}
- is NavTarget.CreateAccount -> {
- val inputs = CreateAccountNode.Inputs(
- url = navTarget.url,
- )
- createNode(buildContext, listOf(inputs))
- }
}
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt
index db56047e15..a3a475df2f 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt
@@ -11,15 +11,14 @@ package io.element.android.features.login.impl.accesscontrol
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.EnterpriseService
+import io.element.android.features.enterprise.api.IsEnterpriseBuild
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
-import io.element.android.libraries.core.uri.ensureProtocol
-import io.element.android.libraries.wellknown.api.WellknownRetriever
@ContributesBinding(AppScope::class)
class DefaultAccountProviderAccessControl(
+ private val isEnterpriseBuild: IsEnterpriseBuild,
private val enterpriseService: EnterpriseService,
- private val wellknownRetriever: WellknownRetriever,
) : AccountProviderAccessControl {
override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try {
assertIsAllowedToConnectToAccountProvider(
@@ -36,12 +35,9 @@ class DefaultAccountProviderAccessControl(
title: String,
accountProviderUrl: String,
) {
- if (enterpriseService.isEnterpriseBuild.not()) {
+ if (isEnterpriseBuild().not()) {
// Ensure that Element Pro is not required for this account provider
- val wellKnown = wellknownRetriever.getElementWellKnown(
- baseUrl = accountProviderUrl.ensureProtocol(),
- ).dataOrNull()
- if (wellKnown?.enforceElementPro == true) {
+ if (enterpriseService.isElementProEnforced(accountProviderUrl)) {
throw AccountProviderAccessException.NeedElementProException(
unauthorisedAccountProviderTitle = title,
applicationId = ELEMENT_PRO_APPLICATION_ID,
@@ -51,7 +47,7 @@ class DefaultAccountProviderAccessControl(
if (enterpriseService.isAllowedToConnectToHomeserver(accountProviderUrl).not()) {
throw AccountProviderAccessException.UnauthorizedAccountProviderException(
unauthorisedAccountProviderTitle = title,
- authorisedAccountProviderTitles = enterpriseService.defaultHomeserverList(),
+ authorisedAccountProviderTitles = enterpriseService.homeserverAllowList(),
)
}
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt
index 931959d4ea..6d85f2c5ff 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt
@@ -13,27 +13,64 @@ import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService
+import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
+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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
@SingleIn(AppScope::class)
@Inject
class AccountProviderDataSource(
- enterpriseService: EnterpriseService,
+ private val enterpriseService: EnterpriseService,
+ private val appPreferencesStore: AppPreferencesStore,
+ @AppCoroutineScope private val coroutineScope: CoroutineScope,
) {
- private val defaultAccountProvider = createAccountProvider(
- url = enterpriseService.defaultHomeserverList()
+ // The provider used when the user has not selected one: an enterprise/MDM-configured provider,
+ // else matrix.org. The most recently used provider (from history) can override it, see init.
+ private val configuredAccountProvider = createAccountProvider(
+ url = enterpriseService.homeserverAllowList()
.firstOrNull { it != EnterpriseService.ANY_ACCOUNT_PROVIDER }
?: AuthenticationConfig.MATRIX_ORG_URL
)
- private val accountProvider: MutableStateFlow = MutableStateFlow(defaultAccountProvider)
+ private val accountProvider: MutableStateFlow = MutableStateFlow(configuredAccountProvider)
val flow: StateFlow = accountProvider.asStateFlow()
+ // The account provider the user last explicitly selected (via [setAccountProvider] / [setUrl]).
+ // Unlike [flow], this is not recomputed by [reset], so it survives to be persisted to history on a
+ // successful sign-in even when the login flow is torn down in between (e.g. across an OAuth round-trip).
+ var lastSelectedAccountProviderUrl: String? = null
+ private set
+
+ init {
+ // Seed the default from the last used provider, unless the user has already selected one.
+ coroutineScope.launch {
+ val default = defaultAccountProvider()
+ accountProvider.update { current -> if (current == configuredAccountProvider) default else current }
+ }
+ }
+
suspend fun reset() {
- accountProvider.emit(defaultAccountProvider)
+ accountProvider.emit(defaultAccountProvider())
+ }
+
+ /**
+ * The provider to default to: the most recently used one from history when the user is free to
+ * connect to any provider, otherwise the enterprise/MDM-configured provider.
+ */
+ private suspend fun defaultAccountProvider(): AccountProvider {
+ if (!enterpriseService.canConnectToAnyHomeserver()) {
+ return configuredAccountProvider
+ }
+ val lastUsedProvider = appPreferencesStore.getHomeserverHistoryFlow().first().firstOrNull()
+ return lastUsedProvider?.let { createAccountProvider(it) } ?: configuredAccountProvider
}
suspend fun setUrl(url: String) {
@@ -41,6 +78,7 @@ class AccountProviderDataSource(
}
suspend fun setAccountProvider(data: AccountProvider) {
+ lastSelectedAccountProviderUrl = data.url
accountProvider.emit(data)
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt
deleted file mode 100644
index 8b9b22022e..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * 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.login.impl.accountprovider
-
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.layout.padding
-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.semantics.Role
-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.login.impl.R
-import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
-import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
-import io.element.android.libraries.designsystem.preview.ElementPreview
-import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
-import io.element.android.libraries.designsystem.theme.components.Text
-
-/**
- * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817
- */
-@Composable
-fun AccountProviderOtherView(
- onClick: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- Column(
- modifier = modifier
- .fillMaxWidth()
- .clickable(role = Role.Button) { onClick() }
- ) {
- HorizontalDivider()
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .heightIn(min = 44.dp)
- .padding(vertical = 4.dp, horizontal = 16.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- RoundedIconAtom(
- size = RoundedIconAtomSize.Medium,
- imageVector = CompoundIcons.Search(),
- tint = ElementTheme.colors.iconPrimary,
- )
- Text(
- modifier = Modifier
- .padding(start = 16.dp)
- .weight(1f),
- text = stringResource(R.string.screen_change_account_provider_other),
- style = ElementTheme.typography.fontBodyLgMedium,
- color = ElementTheme.colors.textPrimary,
- )
- }
- }
-}
-
-@PreviewsDayNight
-@Composable
-internal fun AccountProviderOtherViewPreview() = ElementPreview {
- AccountProviderOtherView(
- onClick = { },
- )
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderPreviewParam.kt
similarity index 93%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt
rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderPreviewParam.kt
index 745939233f..c14827d1a6 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderPreviewParam.kt
@@ -11,7 +11,7 @@ package io.element.android.features.login.impl.accountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.appconfig.AuthenticationConfig
-open class AccountProviderProvider : PreviewParameterProvider {
+open class AccountProviderPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
anAccountProvider(),
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt
index fcf4dac3b1..09b423f713 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt
@@ -116,7 +116,7 @@ fun AccountProviderView(
@PreviewsDayNight
@Composable
-internal fun AccountProviderViewPreview(@PreviewParameter(AccountProviderProvider::class) item: AccountProvider) = ElementPreview {
+internal fun AccountProviderViewPreview(@PreviewParameter(AccountProviderPreviewParam::class) item: AccountProvider) = ElementPreview {
AccountProviderView(
item = item,
onClick = { }
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt
new file mode 100644
index 0000000000..b3b99115cc
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.login.impl.accountprovider
+
+import dev.zacsweers.metro.Inject
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+
+/**
+ * Persist the account provider the user just authenticated against, so that it can be offered as
+ * the default (see [AccountProviderDataSource]) and as an autocomplete suggestion on the next
+ * sign-in. To be called from the manual sign-in / account creation flows only, on success.
+ *
+ * See [AppPreferencesStore.addHomeserverToHistory].
+ */
+@Inject
+class SaveAccountProviderToHistory(
+ private val accountProviderDataSource: AccountProviderDataSource,
+ private val appPreferencesStore: AppPreferencesStore,
+) {
+ suspend operator fun invoke() {
+ // Persist the provider the user actually selected, not the currently-exposed default: the latter can be
+ // reset back to the history default while the login flow is torn down across an OAuth round-trip.
+ val url = accountProviderDataSource.lastSelectedAccountProviderUrl
+ ?: accountProviderDataSource.flow.value.url
+ appPreferencesStore.addHomeserverToHistory(url)
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt
index 79f76d5fb5..b7b04beeb2 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt
@@ -22,6 +22,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.matrix.api.auth.MatrixAuthenticationService
+import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
@Inject
class ChangeServerPresenter(
@@ -32,7 +33,7 @@ class ChangeServerPresenter(
) : Presenter {
@Composable
override fun present(): ChangeServerState {
- val changeServerAction: MutableState> = remember {
+ val changeServerAction: MutableState> = remember {
mutableStateOf(AsyncData.Uninitialized)
}
@@ -59,7 +60,7 @@ class ChangeServerPresenter(
private suspend fun changeServer(
data: AccountProvider,
- changeServerAction: MutableState>,
+ changeServerAction: MutableState>,
) {
suspend {
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
@@ -72,6 +73,8 @@ class ChangeServerPresenter(
}
// Homeserver is valid, remember user choice
accountProviderDataSource.setAccountProvider(data)
+ // Return the resolved details so the caller can sign in without configuring the homeserver again.
+ details
}.runCatchingUpdatingState(changeServerAction, errorTransform = ChangeServerError::from)
}
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt
index 6d8ce25d75..3b4f4d9dad 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt
@@ -9,10 +9,13 @@
package io.element.android.features.login.impl.changeserver
import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
data class ChangeServerState(
- val changeServerAction: AsyncData,
+ // On success, carries the resolved homeserver details so the caller can proceed with login without
+ // configuring (and re-networking) the homeserver a second time.
+ val changeServerAction: AsyncData,
val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
val eventSink: (ChangeServerEvents) -> Unit
)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStatePreviewParam.kt
similarity index 89%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt
rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStatePreviewParam.kt
index 183158bceb..2654953ece 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStatePreviewParam.kt
@@ -11,9 +11,10 @@ package io.element.android.features.login.impl.changeserver
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
-open class ChangeServerStateProvider : PreviewParameterProvider {
+open class ChangeServerStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aChangeServerState(),
@@ -45,7 +46,7 @@ open class ChangeServerStateProvider : PreviewParameterProvider = AsyncData.Uninitialized,
+ changeServerAction: AsyncData = AsyncData.Uninitialized,
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
) = ChangeServerState(
changeServerAction = changeServerAction,
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt
index 30ae644a70..8e726c7fe1 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt
@@ -135,7 +135,7 @@ fun ChangeServerView(
@PreviewsDayNight
@Composable
-internal fun ChangeServerViewPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) = ElementPreview {
+internal fun ChangeServerViewPreview(@PreviewParameter(ChangeServerStatePreviewParam::class) state: ChangeServerState) = ElementPreview {
ChangeServerView(
state = state,
onLearnMoreClick = {},
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginBindingContainer.kt
similarity index 96%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt
rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginBindingContainer.kt
index 037acf859b..257587770f 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginBindingContainer.kt
@@ -20,7 +20,7 @@ import io.element.android.libraries.architecture.Presenter
@ContributesTo(AppScope::class)
@BindingContainer
-interface LoginModule {
+interface LoginBindingContainer {
@Binds
fun bindChangeServerPresenter(presenter: ChangeServerPresenter): Presenter
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerErrorProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerErrorPreviewParam.kt
similarity index 92%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerErrorProvider.kt
rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerErrorPreviewParam.kt
index 59fb42fe0c..76c8b18409 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerErrorProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerErrorPreviewParam.kt
@@ -10,7 +10,7 @@ package io.element.android.features.login.impl.error
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-class ChangeServerErrorProvider : PreviewParameterProvider {
+class ChangeServerErrorPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
ChangeServerError.InvalidServer,
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt
index 73400890d5..945b0bc580 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt
@@ -7,12 +7,18 @@
package io.element.android.features.login.impl.login
+import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
+
sealed interface LoginModeEvent {
data class Submit(
val isAccountCreation: Boolean,
val homeserverUrl: String,
val resolvedHomeserverUrl: String?,
val loginHint: String?,
+ // When the caller has already configured the homeserver (e.g. the account provider screen validated
+ // it first), these are its resolved details. When set, login reuses them instead of calling
+ // setHomeserver again, avoiding a redundant network round-trip.
+ val preConfiguredDetails: MatrixHomeServerDetails? = null,
) : LoginModeEvent
data object ClearError : LoginModeEvent
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt
index 34188095b0..59d156308f 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt
@@ -13,6 +13,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
+import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderPresenter
@@ -20,7 +21,6 @@ import io.element.android.features.login.impl.screens.classic.loginwithclassic.L
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderPresenter
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.screens.onboarding.OnBoardingPresenter
-import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
@@ -39,8 +39,8 @@ import io.element.android.libraries.oauth.api.OAuthActionFlow
class LoginModePresenter(
private val oAuthActionFlow: OAuthActionFlow,
private val authenticationService: MatrixAuthenticationService,
- private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever,
private val localNetworkPermissionGate: LocalNetworkPermissionGate,
+ private val saveAccountProviderToHistory: SaveAccountProviderToHistory,
) : Presenter {
@Composable
override fun present(): LoginModeState {
@@ -75,28 +75,28 @@ class LoginModePresenter(
private suspend fun performSubmit(request: LoginModeEvent.Submit, loginMode: MutableState>) {
suspend {
- authenticationService.setHomeserver(request.homeserverUrl).recoverCatching {
- // Fallback to the well-known-resolved URL if the primary URL failed and the caller supplied one.
- if (request.resolvedHomeserverUrl != null && request.resolvedHomeserverUrl != request.homeserverUrl) {
- authenticationService.setHomeserver(request.resolvedHomeserverUrl).getOrThrow()
- } else {
- throw it
- }
- }.map { matrixHomeServerDetails ->
- when {
- matrixHomeServerDetails.supportsOAuthLogin -> {
- val oAuthPrompt = if (request.isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login
- LoginMode.OAuth(
- authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = request.loginHint).getOrThrow()
- )
+ // Reuse the details the caller already resolved when it configured the homeserver, so we don't
+ // run setHomeserver (a network round-trip) again. Otherwise configure the homeserver ourselves.
+ val matrixHomeServerDetails = request.preConfiguredDetails
+ ?: authenticationService.setHomeserver(request.homeserverUrl).recoverCatching {
+ // Fallback to the well-known-resolved URL if the primary URL failed and the caller supplied one.
+ if (request.resolvedHomeserverUrl != null && request.resolvedHomeserverUrl != request.homeserverUrl) {
+ authenticationService.setHomeserver(request.resolvedHomeserverUrl).getOrThrow()
+ } else {
+ throw it
}
- request.isAccountCreation -> LoginMode.AccountCreation(
- webClientUrlForAuthenticationRetriever.retrieve(request.homeserverUrl)
+ }.getOrThrow()
+ when {
+ matrixHomeServerDetails.supportsOAuthLogin -> {
+ val oAuthPrompt = if (request.isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login
+ LoginMode.OAuth(
+ authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = request.loginHint).getOrThrow()
)
- matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin
- else -> error("Unsupported login flow")
}
- }.getOrThrow()
+ request.isAccountCreation -> throw AccountCreationNotSupported()
+ matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin
+ else -> error("Unsupported authentication flow")
+ }
}.runCatchingUpdatingState(
state = loginMode,
errorTransform = {
@@ -120,6 +120,7 @@ class LoginModePresenter(
.onSuccess { loginMode.value = AsyncData.Uninitialized }
.onFailure { loginMode.value = AsyncData.Failure(it) }
is OAuthAction.Success -> authenticationService.loginWithOAuth(action.url)
+ .onSuccess { saveAccountProviderToHistory() }
.onFailure { loginMode.value = AsyncData.Failure(it) }
}
oAuthActionFlow.reset()
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeState.kt
index 849ff31f85..86dd550ea6 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeState.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeState.kt
@@ -20,5 +20,4 @@ data class LoginModeState(
sealed interface LoginMode {
data object PasswordLogin : LoginMode
data class OAuth(val oAuthDetails: OAuthDetails) : LoginMode
- data class AccountCreation(val url: String) : LoginMode
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt
index 3549e17457..80784d2b13 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt
@@ -34,7 +34,6 @@ fun LoginModeView(
onLearnMoreClick: () -> Unit,
onOAuthDetails: (OAuthDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
- onCreateAccountContinue: (url: String) -> Unit
) {
val context = LocalContext.current
when (loginMode) {
@@ -120,7 +119,6 @@ fun LoginModeView(
when (val loginModeData = loginMode.data) {
is LoginMode.OAuth -> onOAuthDetails(loginModeData.oAuthDetails)
LoginMode.PasswordLogin -> onNeedLoginPassword()
- is LoginMode.AccountCreation -> onCreateAccountContinue(loginModeData.url)
}
// Also clear the data, to let the next screen be able to go back
onClearError()
@@ -131,7 +129,7 @@ fun LoginModeView(
@PreviewsDayNight
@Composable
-internal fun LoginModeViewPreview(@PreviewParameter(LoginModeViewErrorProvider::class) error: Throwable) {
+internal fun LoginModeViewPreview(@PreviewParameter(LoginModeViewErrorPreviewParam::class) error: Throwable) {
ElementPreview {
LoginModeView(
loginMode = AsyncData.Failure(error),
@@ -139,7 +137,6 @@ internal fun LoginModeViewPreview(@PreviewParameter(LoginModeViewErrorProvider::
onLearnMoreClick = {},
onOAuthDetails = {},
onNeedLoginPassword = {},
- onCreateAccountContinue = {}
)
}
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeViewErrorProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeViewErrorPreviewParam.kt
similarity index 80%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeViewErrorProvider.kt
rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeViewErrorPreviewParam.kt
index 513d0a8a04..4706beefbb 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeViewErrorProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeViewErrorPreviewParam.kt
@@ -9,11 +9,11 @@
package io.element.android.features.login.impl.login
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.features.login.impl.error.ChangeServerErrorProvider
+import io.element.android.features.login.impl.error.ChangeServerErrorPreviewParam
import io.element.android.libraries.matrix.api.auth.AuthenticationException
-class LoginModeViewErrorProvider : PreviewParameterProvider {
+class LoginModeViewErrorPreviewParam : PreviewParameterProvider {
override val values: Sequence
- get() = ChangeServerErrorProvider().values +
+ get() = ChangeServerErrorPreviewParam().values +
AuthenticationException.AccountAlreadyLoggedIn("@alice:matrix.org")
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt
deleted file mode 100644
index d9994deb9a..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.login.impl.resolver
-
-data class HomeserverData(
- // The computed homeserver url, for which a wellknown file has been retrieved, or just a valid Url
- val homeserverUrl: String,
-)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt
deleted file mode 100644
index 8c6cab79ce..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt
+++ /dev/null
@@ -1,82 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.login.impl.resolver
-
-import dev.zacsweers.metro.Inject
-import io.element.android.libraries.core.coroutine.CoroutineDispatchers
-import io.element.android.libraries.core.coroutine.parallelMap
-import io.element.android.libraries.core.uri.ensureProtocol
-import io.element.android.libraries.core.uri.isValidUrl
-import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
-import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
-import kotlinx.coroutines.currentCoroutineContext
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flow
-import kotlinx.coroutines.withContext
-import timber.log.Timber
-import java.util.Collections
-
-/**
- * Resolve homeserver base on search terms.
- */
-@Inject
-class HomeserverResolver(
- private val dispatchers: CoroutineDispatchers,
- private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker,
- private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
-) {
- fun resolve(userInput: String): Flow> = flow {
- val flowContext = currentCoroutineContext()
- val trimmedUserInput = userInput.trim()
- if (trimmedUserInput.length < 4) return@flow
- val candidateBase = trimmedUserInput.ensureProtocol().removeSuffix("/")
- val list = getUrlCandidates(candidateBase)
- val currentList = Collections.synchronizedList(mutableListOf())
- // Run all the requests in parallel
- withContext(dispatchers.io) {
- list.parallelMap { url ->
- // Skip the compatibility probe if we'd need ACCESS_LOCAL_NETWORK first —
- // otherwise the probe hangs on the TCP timeout for ~30s. Emit the URL as a
- // candidate directly; the actual sign-in flow triggers the permission prompt.
- val shouldRequestPermissionOrIsValid = localNetworkPermissionAdvisor.shouldRequestPermissionFor(url) ||
- homeServerLoginCompatibilityChecker.check(url)
- .onFailure { Timber.w(it, "Failed to check compatibility with homeserver $url") }
- .getOrNull()
- ?: return@parallelMap
-
- // Emit the list as soon as possible
- if (shouldRequestPermissionOrIsValid) {
- currentList.add(HomeserverData(homeserverUrl = url))
- withContext(flowContext) {
- emit(currentList.toList())
- }
- }
- }
- }
- // If list is empty, and candidateBase is a valid an URL, do not block the user.
- // A unsupported homeserver / homeserver not found error will be displayed if the user continues
- if (currentList.isEmpty() && candidateBase.isValidUrl()) {
- emit(listOf(HomeserverData(homeserverUrl = candidateBase)))
- }
- }
-
- private fun getUrlCandidates(data: String): List {
- return buildList {
- if (data.contains(".")) {
- // TLD detected?
- } else {
- add("$data.org")
- add("$data.com")
- add("$data.io")
- }
- // Always try what the user has entered
- add(data)
- }
- }
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt
deleted file mode 100644
index 018a25dc89..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.login.impl.screens.changeaccountprovider
-
-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
-import dev.zacsweers.metro.AppScope
-import dev.zacsweers.metro.Assisted
-import dev.zacsweers.metro.AssistedInject
-import io.element.android.annotations.ContributesNode
-import io.element.android.features.login.impl.util.openLearnMorePage
-import io.element.android.libraries.architecture.callback
-
-@ContributesNode(AppScope::class)
-@AssistedInject
-class ChangeAccountProviderNode(
- @Assisted buildContext: BuildContext,
- @Assisted plugins: List,
- private val presenter: ChangeAccountProviderPresenter,
-) : Node(buildContext, plugins = plugins) {
- interface Callback : Plugin {
- fun onDone()
- fun navigateToSearchAccountProvider()
- }
-
- private val callback: Callback = callback()
-
- @Composable
- override fun View(modifier: Modifier) {
- val state = presenter.present()
- val context = LocalContext.current
- ChangeAccountProviderView(
- state = state,
- modifier = modifier,
- onBackClick = ::navigateUp,
- onLearnMoreClick = { openLearnMorePage(context) },
- onSuccess = callback::onDone,
- onOtherProviderClick = callback::navigateToSearchAccountProvider,
- )
- }
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt
deleted file mode 100644
index 41605352ef..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.login.impl.screens.changeaccountprovider
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
-import dev.zacsweers.metro.Inject
-import io.element.android.appconfig.AuthenticationConfig
-import io.element.android.features.enterprise.api.EnterpriseService
-import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
-import io.element.android.features.login.impl.accountprovider.AccountProvider
-import io.element.android.features.login.impl.changeserver.ChangeServerState
-import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.core.uri.ensureProtocol
-import kotlinx.collections.immutable.toImmutableList
-
-@Inject
-class ChangeAccountProviderPresenter(
- private val changeServerPresenter: Presenter,
- private val enterpriseService: EnterpriseService,
-) : Presenter {
- @Composable
- override fun present(): ChangeAccountProviderState {
- val staticAccountProviderList = remember {
- enterpriseService.defaultHomeserverList()
- .filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER }
- .map { it.ensureProtocol() }
- .ifEmpty { listOf(AuthenticationConfig.MATRIX_ORG_URL) }
- .map { url ->
- AccountProvider(
- url = url,
- subtitle = null,
- isPublic = url == AuthenticationConfig.MATRIX_ORG_URL,
- isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL,
- )
- }
- .toImmutableList()
- }
-
- val canSearchForAccountProviders = remember {
- enterpriseService.canConnectToAnyHomeserver()
- }
-
- val changeServerState = changeServerPresenter.present()
- return ChangeAccountProviderState(
- accountProviders = staticAccountProviderList,
- canSearchForAccountProviders = canSearchForAccountProviders,
- changeServerState = changeServerState,
- )
- }
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt
deleted file mode 100644
index 3068a1cb57..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.login.impl.screens.changeaccountprovider
-
-import io.element.android.features.login.impl.accountprovider.AccountProvider
-import io.element.android.features.login.impl.changeserver.ChangeServerState
-import kotlinx.collections.immutable.ImmutableList
-
-data class ChangeAccountProviderState(
- val accountProviders: ImmutableList,
- val canSearchForAccountProviders: Boolean,
- val changeServerState: ChangeServerState,
-)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt
deleted file mode 100644
index 5cec1961a4..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.login.impl.screens.changeaccountprovider
-
-import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.features.login.impl.accountprovider.AccountProvider
-import io.element.android.features.login.impl.accountprovider.anAccountProvider
-import io.element.android.features.login.impl.changeserver.ChangeServerState
-import io.element.android.features.login.impl.changeserver.aChangeServerState
-import kotlinx.collections.immutable.toImmutableList
-
-open class ChangeAccountProviderStateProvider : PreviewParameterProvider {
- override val values: Sequence
- get() = sequenceOf(
- aChangeAccountProviderState(),
- aChangeAccountProviderState(canSearchForAccountProviders = false),
- // Add other state here
- )
-}
-
-fun aChangeAccountProviderState(
- accountProviders: List = listOf(
- anAccountProvider()
- ),
- canSearchForAccountProviders: Boolean = true,
- changeServerState: ChangeServerState = aChangeServerState(),
-) = ChangeAccountProviderState(
- accountProviders = accountProviders.toImmutableList(),
- canSearchForAccountProviders = canSearchForAccountProviders,
- changeServerState = changeServerState,
-)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt
deleted file mode 100644
index 212bab515d..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt
+++ /dev/null
@@ -1,129 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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:OptIn(ExperimentalMaterial3Api::class)
-
-package io.element.android.features.login.impl.screens.changeaccountprovider
-
-import androidx.compose.foundation.layout.Box
-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.height
-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.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.login.impl.R
-import io.element.android.features.login.impl.accountprovider.AccountProviderOtherView
-import io.element.android.features.login.impl.accountprovider.AccountProviderView
-import io.element.android.features.login.impl.changeserver.ChangeServerEvents
-import io.element.android.features.login.impl.changeserver.ChangeServerView
-import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
-import io.element.android.libraries.designsystem.components.BigIcon
-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.components.Scaffold
-import io.element.android.libraries.designsystem.theme.components.TopAppBar
-import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
-import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
-
-/**
- * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817
- */
-@Composable
-fun ChangeAccountProviderView(
- state: ChangeAccountProviderState,
- onBackClick: () -> Unit,
- onLearnMoreClick: () -> Unit,
- onSuccess: () -> Unit,
- onOtherProviderClick: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- Scaffold(
- modifier = modifier,
- topBar = {
- TopAppBar(
- title = {},
- navigationIcon = { BackButton(onClick = onBackClick) }
- )
- },
- contentWindowInsets = scaffoldScrollableContentInsets,
- ) { padding ->
- Box(
- modifier = Modifier
- .fillMaxSize()
- .imePadding()
- .padding(padding)
- .consumeWindowInsets(padding)
- ) {
- Column(
- modifier = Modifier
- .verticalScroll(state = rememberScrollState())
- .padding(lazyColumnContentPadding)
- ) {
- IconTitleSubtitleMolecule(
- modifier = Modifier.padding(top = 16.dp, bottom = 32.dp, start = 16.dp, end = 16.dp),
- iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()),
- title = stringResource(id = R.string.screen_change_account_provider_title),
- subTitle = stringResource(id = R.string.screen_change_account_provider_subtitle),
- )
-
- state.accountProviders.forEach { item ->
- val alteredItem = if (item.isMatrixOrg) {
- // Set the subtitle from the resource
- item.copy(
- subtitle = stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle),
- )
- } else {
- item
- }
- AccountProviderView(
- item = alteredItem,
- onClick = {
- state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(alteredItem))
- }
- )
- }
- // Other
- if (state.canSearchForAccountProviders) {
- AccountProviderOtherView(
- onClick = onOtherProviderClick
- )
- }
- Spacer(Modifier.height(32.dp))
- }
- ChangeServerView(
- state = state.changeServerState,
- onLearnMoreClick = onLearnMoreClick,
- onSuccess = onSuccess,
- )
- }
- }
-}
-
-@PreviewsDayNight
-@Composable
-internal fun ChangeAccountProviderViewPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) = ElementPreview {
- ChangeAccountProviderView(
- state = state,
- onBackClick = { },
- onLearnMoreClick = { },
- onSuccess = { },
- onOtherProviderClick = { },
- )
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderNode.kt
index 5f79f197d9..6432042818 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderNode.kt
@@ -32,7 +32,6 @@ class ChooseAccountProviderNode(
interface Callback : Plugin {
fun navigateToLoginPassword()
fun navigateToOAuth(oAuthDetails: OAuthDetails)
- fun navigateToCreateAccount(url: String)
}
private val callback: Callback = callback()
@@ -48,7 +47,6 @@ class ChooseAccountProviderNode(
onOAuthDetails = callback::navigateToOAuth,
onNeedLoginPassword = callback::navigateToLoginPassword,
onLearnMoreClick = { openLearnMorePage(context) },
- onCreateAccountContinue = callback::navigateToCreateAccount,
)
}
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt
index a9983a19d1..92c3ff0fff 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt
@@ -60,7 +60,7 @@ class ChooseAccountProviderPresenter(
val staticAccountProviderList = remember {
// The list cannot contains ANY_ACCOUNT_PROVIDER ("*") and cannot be empty at this point
- enterpriseService.defaultHomeserverList()
+ enterpriseService.homeserverAllowList()
.map { it.ensureProtocol() }
.map { url ->
AccountProvider(
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStatePreviewParam.kt
similarity index 96%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStateProvider.kt
rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStatePreviewParam.kt
index 3fea548fca..207f434b42 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStateProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStatePreviewParam.kt
@@ -16,7 +16,7 @@ import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.toImmutableList
-open class ChooseAccountProviderStateProvider : PreviewParameterProvider {
+open class ChooseAccountProviderStatePreviewParam : PreviewParameterProvider {
private val server1 = anAccountProvider(
url = "https://server1.io",
subtitle = null,
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt
index e858789bb4..fdc69ca295 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt
@@ -57,7 +57,6 @@ fun ChooseAccountProviderView(
onOAuthDetails: (OAuthDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
- onCreateAccountContinue: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val isLoading by remember(state.loginModeState.loginMode) {
@@ -137,7 +136,6 @@ fun ChooseAccountProviderView(
onLearnMoreClick = onLearnMoreClick,
onOAuthDetails = onOAuthDetails,
onNeedLoginPassword = onNeedLoginPassword,
- onCreateAccountContinue = onCreateAccountContinue,
)
}
}
@@ -154,13 +152,14 @@ fun ChooseAccountProviderView(
@PreviewsDayNight
@Composable
-internal fun ChooseAccountProviderViewPreview(@PreviewParameter(ChooseAccountProviderStateProvider::class) state: ChooseAccountProviderState) = ElementPreview {
+internal fun ChooseAccountProviderViewPreview(@PreviewParameter(
+ ChooseAccountProviderStatePreviewParam::class
+) state: ChooseAccountProviderState) = ElementPreview {
ChooseAccountProviderView(
state = state,
onBackClick = { },
onLearnMoreClick = { },
onOAuthDetails = { },
onNeedLoginPassword = { },
- onCreateAccountContinue = { },
)
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt
index cfbd86f363..160b500cf6 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt
@@ -55,7 +55,6 @@ class ClassicFlowNode(
fun navigateToOnBoarding(allowBackNavigation: Boolean)
fun navigateToLoginPassword()
fun navigateToOAuth(oAuthDetails: OAuthDetails)
- fun navigateToCreateAccount(url: String)
}
sealed interface NavTarget : Parcelable {
@@ -115,10 +114,6 @@ class ClassicFlowNode(
callback.navigateToOAuth(oAuthDetails)
}
- override fun navigateToCreateAccount(url: String) {
- callback.navigateToCreateAccount(url)
- }
-
override fun navigateToMissingKeyBackup() {
backstack.push(NavTarget.MissingKeyBackup)
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt
index d5acca38ae..ff56b0e8e8 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt
@@ -36,7 +36,6 @@ class LoginWithClassicNode(
fun navigateToOtherOptions()
fun navigateToLoginPassword()
fun navigateToOAuth(oAuthDetails: OAuthDetails)
- fun navigateToCreateAccount(url: String)
fun navigateToMissingKeyBackup()
}
@@ -63,7 +62,6 @@ class LoginWithClassicNode(
onOAuthDetails = callback::navigateToOAuth,
onNeedLoginPassword = callback::navigateToLoginPassword,
onLearnMoreClick = { openLearnMorePage(context) },
- onCreateAccountContinue = callback::navigateToCreateAccount,
)
}
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicStatePreviewParam.kt
similarity index 94%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicStateProvider.kt
rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicStatePreviewParam.kt
index 6dfc292f6f..056d3d704a 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicStateProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicStatePreviewParam.kt
@@ -15,7 +15,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.matrix.api.core.UserId
-open class LoginWithClassicStateProvider : PreviewParameterProvider {
+open class LoginWithClassicStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aLoginWithClassicState(),
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt
index 75181a068e..845c8ec8ee 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt
@@ -64,7 +64,6 @@ fun LoginWithClassicView(
onOAuthDetails: (OAuthDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
- onCreateAccountContinue: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val isLoading by remember(state.loginModeState.loginMode) {
@@ -204,7 +203,6 @@ fun LoginWithClassicView(
onLearnMoreClick = onLearnMoreClick,
onOAuthDetails = onOAuthDetails,
onNeedLoginPassword = onNeedLoginPassword,
- onCreateAccountContinue = onCreateAccountContinue,
)
LocalNetworkPermissionDialogView(
dialog = state.loginModeState.localNetworkPermissionDialog,
@@ -219,13 +217,12 @@ fun LoginWithClassicView(
@PreviewsDayNight
@Composable
-internal fun LoginWithClassicViewPreview(@PreviewParameter(LoginWithClassicStateProvider::class) state: LoginWithClassicState) = ElementPreview {
+internal fun LoginWithClassicViewPreview(@PreviewParameter(LoginWithClassicStatePreviewParam::class) state: LoginWithClassicState) = ElementPreview {
LoginWithClassicView(
state = state,
onOtherOptionsClick = {},
onOAuthDetails = {},
onNeedLoginPassword = {},
onLearnMoreClick = {},
- onCreateAccountContinue = {},
)
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStatePreviewParam.kt
similarity index 86%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStateProvider.kt
rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStatePreviewParam.kt
index 2c6a09b3ed..08d26bcee1 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStateProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStatePreviewParam.kt
@@ -9,7 +9,7 @@ package io.element.android.features.login.impl.screens.classic.missingkeybackup
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-open class MissingKeyBackupStateProvider : PreviewParameterProvider {
+open class MissingKeyBackupStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aMissingKeyBackupState(),
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupView.kt
index c4c9c5f286..9297fc4a51 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupView.kt
@@ -76,7 +76,7 @@ private fun ColumnScope.Buttons(
@PreviewsDayNight
@Composable
-internal fun MissingKeyBackupViewPreview(@PreviewParameter(MissingKeyBackupStateProvider::class) state: MissingKeyBackupState) = ElementPreview {
+internal fun MissingKeyBackupViewPreview(@PreviewParameter(MissingKeyBackupStatePreviewParam::class) state: MissingKeyBackupState) = ElementPreview {
MissingKeyBackupView(
state = state,
onBackClick = {},
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt
index d66606b22c..9093a8121f 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt
@@ -9,6 +9,10 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
sealed interface ConfirmAccountProviderEvents {
- data object Continue : ConfirmAccountProviderEvents
+ data class UserInputChanged(val accountProvider: String) : ConfirmAccountProviderEvents
+
+ // Carries the account provider to submit (the current field text, or the accepted autocomplete suggestion),
+ // captured from the view at click time so it can't lag behind fast input (e.g. keyboard autofill).
+ data class Continue(val accountProvider: String) : ConfirmAccountProviderEvents
data object ClearError : ConfirmAccountProviderEvents
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt
index 928a493dc1..e1a2d85412 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt
@@ -45,8 +45,6 @@ class ConfirmAccountProviderNode(
interface Callback : Plugin {
fun navigateToLoginPassword()
fun navigateToOAuth(oAuthDetails: OAuthDetails)
- fun navigateToCreateAccount(url: String)
- fun navigateToChangeAccountProvider()
}
private val callback: Callback = callback()
@@ -60,8 +58,6 @@ class ConfirmAccountProviderNode(
modifier = modifier,
onOAuthDetails = callback::navigateToOAuth,
onNeedLoginPassword = callback::navigateToLoginPassword,
- onCreateAccountContinue = callback::navigateToCreateAccount,
- onChange = callback::navigateToChangeAccountProvider,
onLearnMoreClick = { openLearnMorePage(context) },
)
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt
index 0111b1e7d7..5c66f8f446 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt
@@ -9,21 +9,38 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
+import io.element.android.appconfig.AuthenticationConfig
+import io.element.android.features.enterprise.api.EnterpriseService
+import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.changeserver.ChangeServerEvents
+import io.element.android.features.login.impl.changeserver.ChangeServerState
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.uri.ensureProtocol
+import io.element.android.libraries.matrix.api.core.MatrixPatterns
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
@AssistedInject
class ConfirmAccountProviderPresenter(
@Assisted private val params: Params,
private val accountProviderDataSource: AccountProviderDataSource,
private val loginModePresenter: Presenter,
+ private val changeServerPresenter: Presenter,
+ private val appPreferencesStore: AppPreferencesStore,
+ private val enterpriseService: EnterpriseService,
) : Presenter {
data class Params(
val isAccountCreation: Boolean,
@@ -38,26 +55,108 @@ class ConfirmAccountProviderPresenter(
override fun present(): ConfirmAccountProviderState {
val accountProvider by accountProviderDataSource.flow.collectAsState()
val loginModeState = loginModePresenter.present()
+ val changeServerState = changeServerPresenter.present()
+ val homeserverHistory by appPreferencesStore.getHomeserverHistoryFlow().collectAsState(emptyList())
+
+ // The account providers offered for autocomplete: previously-used ones first, then the
+ // enterprise/MDM-configured allow-list, then matrix.org (always available, even before any sign-in).
+ // The "*" wildcard is a routing marker, not a real provider, so it is filtered out. Everything is
+ // rendered without the https:// scheme (added back at connection time).
+ val autocompleteCandidates = remember(homeserverHistory) {
+ (homeserverHistory + enterpriseService.homeserverAllowList() + AuthenticationConfig.MATRIX_ORG_URL)
+ .filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER }
+ .map { it.withoutScheme() }
+ .distinct()
+ }
+
+ // Editable input, seeded from the current (history-defaulted) account provider until the user edits it.
+ // Displayed without the scheme, so the field shows e.g. "matrix.org" rather than "https://matrix.org".
+ var userInput by rememberSaveable { mutableStateOf(null) }
+ val accountProviderInput = userInput ?: accountProvider.url.withoutScheme()
+
+ // Offer the first candidate that the current input is a (case-insensitive) prefix of.
+ val accountProviderSuggestion = remember(accountProviderInput, autocompleteCandidates) {
+ val input = accountProviderInput.trim()
+ input.takeIf { it.isNotEmpty() }
+ ?.let { prefix ->
+ autocompleteCandidates.firstOrNull { it.length > prefix.length && it.startsWith(prefix, ignoreCase = true) }
+ }
+ }
+
+ // The account provider submitted via Continue (carried by the event, so it reflects the exact field
+ // text at click time); used to actually sign in once validation succeeds.
+ var submittedAccountProvider by remember { mutableStateOf(null) }
+ val latestSubmittedAccountProvider by rememberUpdatedState(submittedAccountProvider)
+
+ // A login hint (the full Matrix user ID) captured when the user enters one, so the authentication
+ // server can pre-fill their identity. Null when a plain account provider was entered.
+ var submittedLoginHint by remember { mutableStateOf(null) }
+ val latestSubmittedLoginHint by rememberUpdatedState(submittedLoginHint)
+
+ // Once the chosen account provider has been validated and persisted, proceed with the actual sign in / sign up.
+ // Reuse the details resolved while validating, so login does not configure (and re-network) the homeserver again.
+ LaunchedEffect(changeServerState.changeServerAction) {
+ val homeServerDetails = changeServerState.changeServerAction.dataOrNull()
+ if (homeServerDetails != null) {
+ loginModeState.eventSink(
+ LoginModeEvent.Submit(
+ isAccountCreation = params.isAccountCreation,
+ homeserverUrl = latestSubmittedAccountProvider.orEmpty().trim(),
+ resolvedHomeserverUrl = null,
+ loginHint = latestSubmittedLoginHint,
+ preConfiguredDetails = homeServerDetails,
+ )
+ )
+ }
+ }
fun handleEvent(event: ConfirmAccountProviderEvents) {
when (event) {
- ConfirmAccountProviderEvents.Continue -> loginModeState.eventSink(
- LoginModeEvent.Submit(
- isAccountCreation = params.isAccountCreation,
- homeserverUrl = accountProvider.url,
- resolvedHomeserverUrl = null,
- loginHint = null,
+ is ConfirmAccountProviderEvents.UserInputChanged -> {
+ userInput = event.accountProvider
+ }
+ // Validate (and persist) the chosen account provider before proceeding. This also enforces the
+ // account-provider access control, which the login submit does not run on its own.
+ is ConfirmAccountProviderEvents.Continue -> {
+ // Apply the accepted account provider (any accepted completion) back into the field so it
+ // renders the full server rather than the typed prefix, and survives the OAuth round-trip.
+ val accountProvider = event.accountProvider.trim()
+ userInput = accountProvider
+ // If the user entered a full Matrix user ID (e.g. "@alice:example.org"), sign in to its
+ // homeserver and pass the ID as a login hint so the authentication server can pre-fill it.
+ // Otherwise treat the input as the account provider host. Only the homeserver is ever
+ // persisted to history, never the user ID.
+ val isUserId = MatrixPatterns.isUserId(accountProvider)
+ // For a user ID the homeserver is the domain part (after the ':'), keeping any port.
+ val host = if (isUserId) accountProvider.substringAfter(":") else accountProvider
+ submittedLoginHint = "mxid:$accountProvider".takeIf { isUserId && !params.isAccountCreation }
+ // Default the scheme to https:// so entering a bare host (e.g. "matrix.org") works.
+ val accountProviderUrl = host.ensureProtocol()
+ submittedAccountProvider = accountProviderUrl
+ changeServerState.eventSink(
+ ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderUrl))
)
- )
- ConfirmAccountProviderEvents.ClearError -> loginModeState.eventSink(LoginModeEvent.ClearError)
+ }
+ ConfirmAccountProviderEvents.ClearError -> {
+ loginModeState.eventSink(LoginModeEvent.ClearError)
+ changeServerState.eventSink(ChangeServerEvents.ClearError)
+ }
}
}
return ConfirmAccountProviderState(
- accountProvider = accountProvider,
+ accountProviderInput = accountProviderInput,
+ accountProviderSuggestion = accountProviderSuggestion,
isAccountCreation = params.isAccountCreation,
loginModeState = loginModeState,
+ changeServerState = changeServerState,
eventSink = ::handleEvent,
)
}
}
+
+/**
+ * The host part of an account provider URL, i.e. the value with any `https://` / `http://` scheme removed.
+ * Returns the input unchanged when it has no scheme (e.g. `matrix.org`).
+ */
+private fun String.withoutScheme(): String = substringAfter("://")
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt
index 6e6deb8c2c..bf6b0cb0e7 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt
@@ -8,17 +8,29 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
-import io.element.android.features.login.impl.accountprovider.AccountProvider
+import io.element.android.features.login.impl.changeserver.ChangeServerState
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncData
data class ConfirmAccountProviderState(
- val accountProvider: AccountProvider,
+ val accountProviderInput: String,
+ // The full account provider from history that the current input is a prefix of, offered as inline autocomplete.
+ val accountProviderSuggestion: String?,
val isAccountCreation: Boolean,
val loginModeState: LoginModeState,
+ val changeServerState: ChangeServerState,
val eventSink: (ConfirmAccountProviderEvents) -> Unit
) {
val submitEnabled: Boolean
- get() = accountProvider.url.isNotEmpty() &&
- (loginModeState.loginMode is AsyncData.Uninitialized || loginModeState.loginMode is AsyncData.Loading)
+ get() = accountProviderInput.isNotBlank() &&
+ loginModeState.loginMode is AsyncData.Uninitialized &&
+ changeServerState.changeServerAction is AsyncData.Uninitialized
+
+ val isLoading: Boolean
+ get() = loginModeState.loginMode is AsyncData.Loading ||
+ changeServerState.changeServerAction is AsyncData.Loading
+
+ val isShowingError: Boolean
+ get() = loginModeState.loginMode is AsyncData.Failure ||
+ changeServerState.changeServerAction is AsyncData.Failure
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt
similarity index 66%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt
rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt
index f597ad6b54..ab6350bb4d 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt
@@ -9,17 +9,21 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.features.login.impl.accountprovider.AccountProvider
-import io.element.android.features.login.impl.accountprovider.anAccountProvider
+import io.element.android.features.login.impl.changeserver.ChangeServerState
+import io.element.android.features.login.impl.changeserver.aChangeServerState
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.libraries.architecture.AsyncData
-open class ConfirmAccountProviderStateProvider : PreviewParameterProvider {
+open class ConfirmAccountProviderStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aConfirmAccountProviderState(),
+ aConfirmAccountProviderState(
+ accountProviderInput = "element",
+ accountProviderSuggestion = "element.io",
+ ),
aConfirmAccountProviderState(
isAccountCreation = true,
),
@@ -31,13 +35,17 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider Unit = {},
) = ConfirmAccountProviderState(
- accountProvider = accountProvider,
+ accountProviderInput = accountProviderInput,
+ accountProviderSuggestion = accountProviderSuggestion,
isAccountCreation = isAccountCreation,
loginModeState = loginModeState,
+ changeServerState = changeServerState,
eventSink = eventSink
)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt
index 95fbff25ab..1dab5cd31c 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt
@@ -8,51 +8,110 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.LaunchedEffect
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 androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.OffsetMapping
+import androidx.compose.ui.text.input.TransformedText
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.withStyle
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.login.impl.R
+import io.element.android.features.login.impl.changeserver.ChangeServerView
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeView
-import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.components.form.textFieldState
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.TextButton
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
+/**
+ * Account provider entry screen, shared by sign in and account creation.
+ * Figma (Compound Android Templates):
+ * - Sign in: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=41-10251
+ * - Create account: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=41-10239
+ */
@Composable
fun ConfirmAccountProviderView(
state: ConfirmAccountProviderState,
onOAuthDetails: (OAuthDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
- onCreateAccountContinue: (url: String) -> Unit,
- onChange: () -> Unit,
modifier: Modifier = Modifier,
) {
- val isLoading by remember(state.loginModeState.loginMode) {
- derivedStateOf {
- state.loginModeState.loginMode is AsyncData.Loading
- }
- }
val eventSink = state.eventSink
+ val focusManager = LocalFocusManager.current
+ val focusRequester = remember { FocusRequester() }
+ val keyboardController = LocalSoftwareKeyboardController.current
+
+ var input by textFieldState(stateValue = state.accountProviderInput)
+
+ // Inline autocomplete: the un-typed remainder of the suggested account provider (rendered in grey and
+ // accepted on Continue / Enter). Only valid while the suggestion still starts with the current input.
+ val suggestionSuffix = state.accountProviderSuggestion
+ ?.takeIf { it.length > input.length && it.startsWith(input, ignoreCase = true) }
+ ?.substring(input.length)
+ .orEmpty()
+
+ // The Continue button lives in a footer slot that is not recomposed while the user types, so a direct
+ // capture of the field text (via ::submit) goes stale and can submit an empty/prefilled value. Reading
+ // the value through rememberUpdatedState keeps it current regardless of which composition scope reads it.
+ val accountProviderToSubmit by rememberUpdatedState(input + suggestionSuffix)
+
+ fun submit() {
+ // Dismiss the keyboard and release focus while the account provider is being validated.
+ focusManager.clearFocus(force = true)
+ // Submit the exact field text (plus any accepted suggestion).
+ eventSink(ConfirmAccountProviderEvents.Continue(accountProviderToSubmit))
+ }
+
+ // Once a validation / login error has been dismissed, return focus and the keyboard to the field so
+ // the user can correct the account provider and retry.
+ var wasShowingError by remember { mutableStateOf(false) }
+ LaunchedEffect(state.isShowingError) {
+ if (wasShowingError && !state.isShowingError) {
+ focusRequester.requestFocus()
+ keyboardController?.show()
+ }
+ wasShowingError = state.isShowingError
+ }
HeaderFooterPage(
modifier = modifier,
@@ -62,55 +121,113 @@ fun ConfirmAccountProviderView(
iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()),
title = stringResource(
id = if (state.isAccountCreation) {
- R.string.screen_account_provider_signup_title
+ R.string.screen_change_server_title_register
} else {
- R.string.screen_account_provider_signin_title
- },
- state.accountProvider.title
+ R.string.screen_change_server_title_login
+ }
),
- subTitle = stringResource(
- id = if (state.isAccountCreation) {
- R.string.screen_account_provider_signup_subtitle
- } else {
- R.string.screen_account_provider_signin_subtitle
- },
- )
+ subTitle = null,
)
},
footer = {
ButtonColumnMolecule {
Button(
text = stringResource(id = CommonStrings.action_continue),
- showProgress = isLoading,
- onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) },
- enabled = state.submitEnabled || isLoading,
+ showProgress = state.isLoading,
+ onClick = ::submit,
+ enabled = state.submitEnabled && !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
)
- TextButton(
- text = stringResource(id = R.string.screen_account_provider_change),
- onClick = onChange,
- enabled = !isLoading,
- modifier = Modifier
- .fillMaxWidth()
- .testTag(TestTags.loginChangeServer)
- )
}
}
) {
- LoginModeView(
- loginMode = state.loginModeState.loginMode,
- onClearError = {
- eventSink(ConfirmAccountProviderEvents.ClearError)
+ val ghostColor = ElementTheme.colors.textSecondary
+ val ghostTransformation = remember(suggestionSuffix, ghostColor) {
+ GhostSuffixVisualTransformation(suggestionSuffix, ghostColor)
+ }
+ TextField(
+ value = input,
+ onValueChange = {
+ input = it
+ eventSink(ConfirmAccountProviderEvents.UserInputChanged(it))
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 40.dp)
+ .focusRequester(focusRequester)
+ .testTag(TestTags.changeServerServer),
+ label = stringResource(id = R.string.screen_change_server_textfield_header),
+ placeholder = stringResource(id = R.string.screen_change_server_textfield_placeholder),
+ supportingText = stringResource(
+ id = if (state.isAccountCreation) {
+ R.string.screen_change_server_textfield_footer_register
+ } else {
+ R.string.screen_change_server_textfield_footer_login
+ }
+ ),
+ visualTransformation = ghostTransformation,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ if (suggestionSuffix.isNotEmpty()) {
+ // A completion is showing: accept it into the field and drop focus, dismissing the
+ // keyboard. This shows the completed server (with no stray mid-text cursor, since the
+ // field is no longer focused) ready to sign in with Continue. When no completion is
+ // showing, Done submits directly.
+ val accepted = input + suggestionSuffix
+ input = accepted
+ eventSink(ConfirmAccountProviderEvents.UserInputChanged(accepted))
+ focusManager.clearFocus(force = true)
+ } else {
+ submit()
+ }
+ }
+ ),
+ singleLine = true,
+ trailingIcon = if (input.isNotEmpty()) {
+ {
+ Box(
+ Modifier.clickable(
+ onClickLabel = stringResource(CommonStrings.action_clear),
+ role = Role.Button,
+ ) {
+ input = ""
+ eventSink(ConfirmAccountProviderEvents.UserInputChanged(""))
+ }
+ ) {
+ Icon(
+ imageVector = CompoundIcons.Close(),
+ contentDescription = stringResource(CommonStrings.action_clear)
+ )
+ }
+ }
+ } else {
+ null
},
- onLearnMoreClick = onLearnMoreClick,
- onOAuthDetails = onOAuthDetails,
- onNeedLoginPassword = onNeedLoginPassword,
- onCreateAccountContinue = onCreateAccountContinue,
)
}
+ // Renders the account-provider validation errors, progress and permission dialogs.
+ // The successful validation itself is observed by the presenter, which then proceeds with the login flow.
+ ChangeServerView(
+ state = state.changeServerState,
+ onLearnMoreClick = onLearnMoreClick,
+ onSuccess = {},
+ )
+
+ LoginModeView(
+ loginMode = state.loginModeState.loginMode,
+ onClearError = { eventSink(ConfirmAccountProviderEvents.ClearError) },
+ onLearnMoreClick = onLearnMoreClick,
+ onOAuthDetails = onOAuthDetails,
+ onNeedLoginPassword = onNeedLoginPassword,
+ )
+
LocalNetworkPermissionDialogView(
dialog = state.loginModeState.localNetworkPermissionDialog,
onSubmit = {
@@ -122,17 +239,38 @@ fun ConfirmAccountProviderView(
)
}
+/**
+ * Appends [suffix] (in [color]) after the user's text as a non-editable inline autocomplete hint.
+ * The underlying field value stays limited to what the user actually typed, so the cursor can never
+ * enter the suggested region.
+ */
+private class GhostSuffixVisualTransformation(
+ private val suffix: String,
+ private val color: Color,
+) : VisualTransformation {
+ override fun filter(text: AnnotatedString): TransformedText {
+ if (suffix.isEmpty()) return TransformedText(text, OffsetMapping.Identity)
+ val transformed = buildAnnotatedString {
+ append(text)
+ withStyle(SpanStyle(color = color)) { append(suffix) }
+ }
+ val offsetMapping = object : OffsetMapping {
+ override fun originalToTransformed(offset: Int) = offset
+ override fun transformedToOriginal(offset: Int) = offset.coerceAtMost(text.length)
+ }
+ return TransformedText(transformed, offsetMapping)
+ }
+}
+
@PreviewsDayNight
@Composable
internal fun ConfirmAccountProviderViewPreview(
- @PreviewParameter(ConfirmAccountProviderStateProvider::class) state: ConfirmAccountProviderState
+ @PreviewParameter(ConfirmAccountProviderStatePreviewParam::class) state: ConfirmAccountProviderState
) = ElementPreview {
ConfirmAccountProviderView(
state = state,
onOAuthDetails = {},
onNeedLoginPassword = {},
- onCreateAccountContinue = {},
onLearnMoreClick = {},
- onChange = {},
)
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountEvents.kt
deleted file mode 100644
index a51557347e..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountEvents.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2024, 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.login.impl.screens.createaccount
-
-sealed interface CreateAccountEvents {
- data class SetPageProgress(val progress: Int) : CreateAccountEvents
- data class OnMessageReceived(val message: String) : CreateAccountEvents
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountNode.kt
deleted file mode 100644
index e1972e5a0f..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountNode.kt
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2024, 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.login.impl.screens.createaccount
-
-import android.app.Activity
-import androidx.activity.compose.LocalActivity
-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 dev.zacsweers.metro.AppScope
-import dev.zacsweers.metro.Assisted
-import dev.zacsweers.metro.AssistedInject
-import io.element.android.annotations.ContributesNode
-import io.element.android.compound.theme.ElementTheme
-import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
-import io.element.android.libraries.architecture.NodeInputs
-import io.element.android.libraries.architecture.inputs
-
-@ContributesNode(AppScope::class)
-@AssistedInject
-class CreateAccountNode(
- @Assisted buildContext: BuildContext,
- @Assisted plugins: List,
- presenterFactory: CreateAccountPresenter.Factory,
-) : Node(buildContext, plugins = plugins) {
- data class Inputs(
- val url: String,
- ) : NodeInputs
-
- private val presenter = presenterFactory.create(inputs().url)
-
- private fun onOpenExternalUrl(activity: Activity, darkTheme: Boolean, url: String) {
- activity.openUrlInChromeCustomTab(null, darkTheme, url)
- }
-
- @Composable
- override fun View(modifier: Modifier) {
- val activity = requireNotNull(LocalActivity.current)
- val isDark = ElementTheme.isLightTheme.not()
- val state = presenter.present()
- CreateAccountView(
- state = state,
- modifier = modifier,
- onBackClick = ::navigateUp,
- onOpenExternalUrl = {
- onOpenExternalUrl(activity, isDark, it)
- },
- )
- }
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt
deleted file mode 100644
index bf7af111f4..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt
+++ /dev/null
@@ -1,82 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.login.impl.screens.createaccount
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import dev.zacsweers.metro.Assisted
-import dev.zacsweers.metro.AssistedFactory
-import dev.zacsweers.metro.AssistedInject
-import io.element.android.libraries.architecture.AsyncAction
-import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.core.extensions.flatMap
-import io.element.android.libraries.core.extensions.runCatchingExceptions
-import io.element.android.libraries.core.meta.BuildMeta
-import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
-import io.element.android.libraries.matrix.api.core.SessionId
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-
-@AssistedInject
-class CreateAccountPresenter(
- @Assisted private val url: String,
- private val authenticationService: MatrixAuthenticationService,
- private val messageParser: MessageParser,
- private val buildMeta: BuildMeta,
-) : Presenter {
- @AssistedFactory
- interface Factory {
- fun create(url: String): CreateAccountPresenter
- }
-
- @Composable
- override fun present(): CreateAccountState {
- val coroutineScope = rememberCoroutineScope()
- val pageProgress: MutableState = remember { mutableIntStateOf(0) }
- val createAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
-
- fun handleEvent(event: CreateAccountEvents) {
- when (event) {
- is CreateAccountEvents.SetPageProgress -> {
- pageProgress.value = event.progress
- }
- is CreateAccountEvents.OnMessageReceived -> {
- // Ignore unexpected message
- if (event.message.contains("isTrusted")) return
- coroutineScope.importSession(event.message, createAction)
- }
- }
- }
-
- return CreateAccountState(
- url = url,
- pageProgress = pageProgress.value,
- isDebugBuild = buildMeta.isDebuggable,
- createAction = createAction.value,
- eventSink = ::handleEvent,
- )
- }
-
- private fun CoroutineScope.importSession(message: String, loggedInState: MutableState>) = launch {
- loggedInState.value = AsyncAction.Loading
- runCatchingExceptions {
- messageParser.parse(message)
- }.flatMap { externalSession ->
- authenticationService.importCreatedSession(externalSession)
- }.onSuccess { sessionId ->
- loggedInState.value = AsyncAction.Success(sessionId)
- }.onFailure { failure ->
- loggedInState.value = AsyncAction.Failure(failure)
- }
- }
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountState.kt
deleted file mode 100644
index de7efe573e..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountState.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2024, 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.login.impl.screens.createaccount
-
-import io.element.android.libraries.architecture.AsyncAction
-import io.element.android.libraries.matrix.api.core.SessionId
-
-data class CreateAccountState(
- val url: String,
- val pageProgress: Int,
- val createAction: AsyncAction,
- val isDebugBuild: Boolean,
- val eventSink: (CreateAccountEvents) -> Unit
-)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountStateProvider.kt
deleted file mode 100644
index 976ceae70d..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountStateProvider.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.login.impl.screens.createaccount
-
-import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.libraries.architecture.AsyncAction
-import io.element.android.libraries.matrix.api.core.SessionId
-
-open class CreateAccountStateProvider : PreviewParameterProvider {
- override val values: Sequence
- get() = sequenceOf(
- aCreateAccountState(),
- aCreateAccountState(pageProgress = 33),
- aCreateAccountState(createAction = AsyncAction.Loading),
- aCreateAccountState(createAction = AsyncAction.Failure(RuntimeException("Failed to create account"))),
- )
-}
-
-private fun aCreateAccountState(
- pageProgress: Int = 100,
- createAction: AsyncAction = AsyncAction.Uninitialized,
-) = CreateAccountState(
- url = "https://example.com",
- isDebugBuild = true,
- pageProgress = pageProgress,
- createAction = createAction,
- eventSink = {}
-)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountView.kt
deleted file mode 100644
index 051188627a..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountView.kt
+++ /dev/null
@@ -1,176 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2024, 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.login.impl.screens.createaccount
-
-import android.annotation.SuppressLint
-import android.view.ViewGroup
-import android.webkit.JsResult
-import android.webkit.WebChromeClient
-import android.webkit.WebView
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.foundation.layout.Box
-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.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.platform.LocalInspectionMode
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.PreviewParameter
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.viewinterop.AndroidView
-import io.element.android.compound.theme.ElementTheme
-import io.element.android.features.login.impl.R
-import io.element.android.libraries.designsystem.components.async.AsyncActionView
-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.components.LinearProgressIndicator
-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.theme.progressIndicatorTrackColor
-import timber.log.Timber
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun CreateAccountView(
- state: CreateAccountState,
- onBackClick: () -> Unit,
- onOpenExternalUrl: (String) -> Unit,
- modifier: Modifier = Modifier,
-) {
- Scaffold(
- modifier = modifier,
- topBar = {
- TopAppBar(
- titleStr = stringResource(R.string.screen_create_account_title),
- navigationIcon = {
- BackButton(onClick = onBackClick)
- },
- )
- }
- ) { contentPadding ->
- Box(
- modifier = Modifier
- .padding(contentPadding)
- .consumeWindowInsets(contentPadding)
- .fillMaxSize()
- ) {
- CreateAccountWebView(
- modifier = Modifier
- .fillMaxSize(),
- state = state,
- onWebViewCreate = { webView ->
- WebViewMessageInterceptor(
- webView,
- state.isDebugBuild,
- onOpenExternalUrl = onOpenExternalUrl,
- onMessage = {
- state.eventSink(CreateAccountEvents.OnMessageReceived(it))
- },
- )
- }
- )
- AnimatedVisibility(
- visible = state.pageProgress != 100,
- // Disable enter animation
- enter = fadeIn(initialAlpha = 1f),
- exit = fadeOut(),
- ) {
- LinearProgressIndicator(
- modifier = Modifier
- .fillMaxWidth()
- .height(2.dp),
- progress = { state.pageProgress / 100f },
- trackColor = ElementTheme.colors.progressIndicatorTrackColor,
- )
- }
- }
- }
-
- AsyncActionView(
- async = state.createAction,
- onSuccess = {},
- onErrorDismiss = onBackClick,
- onRetry = null
- )
-}
-
-@Composable
-private fun CreateAccountWebView(
- state: CreateAccountState,
- onWebViewCreate: (WebView) -> Unit,
- modifier: Modifier = Modifier,
-) {
- if (LocalInspectionMode.current) {
- Box(modifier = modifier, contentAlignment = Alignment.Center) {
- Text("WebView - can't be previewed")
- }
- } else {
- AndroidView(
- modifier = modifier,
- factory = { context ->
- WebView(context).apply {
- onWebViewCreate(this)
- setup(state)
- }
- },
- update = { webView ->
- if (webView.url != state.url) {
- webView.loadUrl(state.url)
- }
- },
- onRelease = { webView ->
- webView.destroy()
- }
- )
- }
-}
-
-@SuppressLint("SetJavaScriptEnabled")
-private fun WebView.setup(state: CreateAccountState) {
- layoutParams = ViewGroup.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.MATCH_PARENT
- )
- with(settings) {
- javaScriptEnabled = true
- domStorageEnabled = true
- }
-
- webChromeClient = object : WebChromeClient() {
- override fun onProgressChanged(view: WebView?, newProgress: Int) {
- super.onProgressChanged(view, newProgress)
- state.eventSink(CreateAccountEvents.SetPageProgress(newProgress))
- }
-
- override fun onJsBeforeUnload(view: WebView?, url: String?, message: String?, result: JsResult?): Boolean {
- Timber.w("onJsBeforeUnload, cancelling the dialog, we will open external links in a Custom Chrome Tab")
- result?.confirm()
- return true
- }
- }
-}
-
-@PreviewsDayNight
-@Composable
-internal fun CreateAccountViewPreview(@PreviewParameter(CreateAccountStateProvider::class) state: CreateAccountState) = ElementPreview {
- CreateAccountView(
- state = state,
- onBackClick = {},
- onOpenExternalUrl = {},
- )
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MessageParser.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MessageParser.kt
deleted file mode 100644
index d7954373bc..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MessageParser.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2024, 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.login.impl.screens.createaccount
-
-import dev.zacsweers.metro.AppScope
-import dev.zacsweers.metro.ContributesBinding
-import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
-import io.element.android.libraries.androidutils.json.JsonProvider
-import io.element.android.libraries.matrix.api.auth.external.ExternalSession
-
-interface MessageParser {
- /**
- * Parse the message and return the ExternalSession object, or
- * throw an exception if the message is invalid.
- */
- fun parse(message: String): ExternalSession
-}
-
-@ContributesBinding(AppScope::class)
-class DefaultMessageParser(
- private val accountProviderDataSource: AccountProviderDataSource,
- private val json: JsonProvider,
-) : MessageParser {
- override fun parse(message: String): ExternalSession {
- val response = json().decodeFromString(MobileRegistrationResponse.serializer(), message)
- val userId = response.userId ?: error("No user ID in response")
- val homeServer = response.homeServer ?: accountProviderDataSource.flow.value.url
- val accessToken = response.accessToken ?: error("No access token in response")
- val deviceId = response.deviceId ?: error("No device ID in response")
- return ExternalSession(
- userId = userId,
- homeserverUrl = homeServer,
- accessToken = accessToken,
- deviceId = deviceId,
- refreshToken = null,
- )
- }
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MobileRegistrationResponse.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MobileRegistrationResponse.kt
deleted file mode 100644
index 5e78b1bd37..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MobileRegistrationResponse.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2024, 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.login.impl.screens.createaccount
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-/**
- * For ref:
- * https://github.com/element-hq/matrix-react-sdk/pull/42/files#diff-2bbba5a742004fd4e924a639ded444279f66f7ad890cb669fbc91ac6b8638c64R56
- */
-@Serializable
-data class MobileRegistrationResponse(
- @SerialName("user_id")
- val userId: String? = null,
- @SerialName("home_server")
- val homeServer: String? = null,
- @SerialName("access_token")
- val accessToken: String? = null,
- @SerialName("device_id")
- val deviceId: String? = null,
-)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/WebViewMessageInterceptor.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/WebViewMessageInterceptor.kt
deleted file mode 100644
index 20d503522b..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/WebViewMessageInterceptor.kt
+++ /dev/null
@@ -1,91 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.login.impl.screens.createaccount
-
-import android.graphics.Bitmap
-import android.webkit.JavascriptInterface
-import android.webkit.WebResourceRequest
-import android.webkit.WebView
-import android.webkit.WebViewClient
-import androidx.webkit.WebViewCompat
-import androidx.webkit.WebViewFeature
-
-class WebViewMessageInterceptor(
- webView: WebView,
- private val debugLog: Boolean,
- private val onOpenExternalUrl: (String) -> Unit,
- private val onMessage: (String) -> Unit,
-) {
- companion object {
- // We call both the WebMessageListener and the JavascriptInterface objects in JS with this
- // 'listenerName' so they can both receive the data from the WebView when
- // `${LISTENER_NAME}.postMessage(...)` is called
- const val LISTENER_NAME = "elementX"
- }
-
- init {
- webView.webViewClient = object : WebViewClient() {
- override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
- super.onPageStarted(view, url, favicon)
-
- // We inject this JS code when the page starts loading to attach a message listener to the window.
- view?.evaluateJavascript(
- """
- window.addEventListener(
- "mobileregistrationresponse",
- (event) => {
- let json = JSON.stringify(event.detail)
- ${"console.log('message sent: ' + json);".takeIf { debugLog }}
- $LISTENER_NAME.postMessage(json);
- },
- false,
- );
- """.trimIndent(),
- null
- )
- }
-
- override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
- request ?: return false
- // Load the URL in a Chrome Custom Tab, and return true to cancel the load
- onOpenExternalUrl(request.url.toString())
- return true
- }
- }
-
- // Use WebMessageListener if supported, otherwise use JavascriptInterface
- if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
- // Create a WebMessageListener, which will receive messages from the WebView and reply to them
- val webMessageListener = WebViewCompat.WebMessageListener { _, message, _, _, _ ->
- onMessageReceived(message.data)
- }
- WebViewCompat.addWebMessageListener(
- webView,
- LISTENER_NAME,
- setOf("*"),
- webMessageListener
- )
- } else {
- webView.addJavascriptInterface(
- object {
- @JavascriptInterface
- fun postMessage(json: String?) {
- onMessageReceived(json)
- }
- },
- LISTENER_NAME,
- )
- }
- }
-
- private fun onMessageReceived(json: String?) {
- // Here is where we would handle the messages from the WebView, passing them to the listener
- json?.let { onMessage(it) }
- }
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt
index f26f342a42..6e28bd8781 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt
@@ -20,6 +20,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@@ -33,6 +34,7 @@ class LoginPasswordPresenter(
private val initialLogin: String,
private val authenticationService: MatrixAuthenticationService,
private val accountProviderDataSource: AccountProviderDataSource,
+ private val saveAccountProviderToHistory: SaveAccountProviderToHistory,
) : Presenter {
@AssistedFactory
interface Factory {
@@ -83,6 +85,7 @@ class LoginPasswordPresenter(
loggedInState.value = AsyncData.Loading()
authenticationService.login(formState.login.trim(), formState.password)
.onSuccess { sessionId ->
+ saveAccountProviderToHistory()
loggedInState.value = AsyncData.Success(sessionId)
}
.onFailure { failure ->
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStatePreviewParam.kt
similarity index 94%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStateProvider.kt
rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStatePreviewParam.kt
index 2183790365..4d7201dbc8 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStateProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStatePreviewParam.kt
@@ -14,7 +14,7 @@ import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.SessionId
-open class LoginPasswordStateProvider : PreviewParameterProvider {
+open class LoginPasswordStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aLoginPasswordState(),
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt
index 06a1641644..a168b14cc8 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt
@@ -56,6 +56,7 @@ import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.form.textFieldState
+import io.element.android.libraries.designsystem.modifiers.bringIntoViewOnImeVisible
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -191,6 +192,7 @@ private fun LoginForm(
enabled = !isLoading,
modifier = Modifier
.fillMaxWidth()
+ .bringIntoViewOnImeVisible()
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginEmailUsername)
.semantics {
@@ -243,6 +245,7 @@ private fun LoginForm(
enabled = !isLoading,
modifier = Modifier
.fillMaxWidth()
+ .bringIntoViewOnImeVisible()
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginPassword)
.semantics {
@@ -274,10 +277,10 @@ private fun LoginForm(
}
/**
- * Ensure that the string does not contain any new line characters, which can happen when pasting values.
+ * Ensure that the string does not contain any line separator, which can happen when pasting values.
*/
private fun String.sanitize(): String {
- return replace("\n", "")
+ return filterNot { it == '\n' || it == '\r' }
}
@Composable
@@ -291,7 +294,7 @@ private fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
@PreviewsDayNight
@Composable
-internal fun LoginPasswordViewPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) = ElementPreview {
+internal fun LoginPasswordViewPreview(@PreviewParameter(LoginPasswordStatePreviewParam::class) state: LoginPasswordState) = ElementPreview {
LoginPasswordView(
state = state,
onBackClick = {},
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt
index 99f7e86fd3..244e684c5b 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt
@@ -41,7 +41,6 @@ class OnBoardingNode(
fun navigateToBugReport()
fun navigateToLoginPassword()
fun navigateToOAuth(oAuthDetails: OAuthDetails)
- fun navigateToCreateAccount(url: String)
fun navigateToDeveloperSettings()
fun onDone()
}
@@ -74,7 +73,6 @@ class OnBoardingNode(
onOAuthDetails = callback::navigateToOAuth,
onNeedLoginPassword = callback::navigateToLoginPassword,
onLearnMoreClick = { openLearnMorePage(context) },
- onCreateAccountContinue = callback::navigateToCreateAccount,
onBackClick = callback::onDone,
onDeveloperSettingsClick = callback::navigateToDeveloperSettings,
)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt
index 29e4b1afe5..e9bdb5e1e9 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt
@@ -60,15 +60,15 @@ class OnBoardingPresenter(
override fun present(): OnBoardingState {
val localCoroutineScope = rememberCoroutineScope()
val forcedAccountProvider = remember {
- // If defaultHomeserverList() returns a singleton list, this is the default account provider.
+ // If homeserverAllowList() returns a singleton list, this is the default account provider.
// In this case, the user can sign in using this homeserver, or use QrCode login
- enterpriseService.defaultHomeserverList().singleOrNull()
+ enterpriseService.homeserverAllowList().singleOrNull()
}
val canConnectToAnyHomeserver = remember {
enterpriseService.canConnectToAnyHomeserver()
}
val mustChooseAccountProvider = remember {
- !canConnectToAnyHomeserver && enterpriseService.defaultHomeserverList().size > 1
+ !canConnectToAnyHomeserver && enterpriseService.homeserverAllowList().size > 1
}
val linkAccountProvider by produceState(initialValue = null) {
// Account provider from the link, if allowed by the enterprise service
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStatePreviewParam.kt
similarity index 96%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt
rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStatePreviewParam.kt
index 41e4a81818..d04f85f2a0 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStatePreviewParam.kt
@@ -14,7 +14,7 @@ import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.libraries.designsystem.R
-open class OnBoardingStateProvider : PreviewParameterProvider {
+open class OnBoardingStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
anOnBoardingState(),
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt
index bf96b2dbe0..78faed7543 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt
@@ -71,7 +71,6 @@ fun OnBoardingView(
onOAuthDetails: (OAuthDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
- onCreateAccountContinue: (url: String) -> Unit,
onReportProblem: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -84,7 +83,6 @@ fun OnBoardingView(
onLearnMoreClick = onLearnMoreClick,
onOAuthDetails = onOAuthDetails,
onNeedLoginPassword = onNeedLoginPassword,
- onCreateAccountContinue = onCreateAccountContinue,
)
LocalNetworkPermissionDialogView(
dialog = state.loginModeState.localNetworkPermissionDialog,
@@ -353,7 +351,7 @@ private fun OnBoardingButtons(
@PreviewsDayNight
@Composable
internal fun OnBoardingViewPreview(
- @PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState
+ @PreviewParameter(OnBoardingStatePreviewParam::class) state: OnBoardingState
) = ElementPreview {
OnBoardingView(
state = state,
@@ -366,6 +364,5 @@ internal fun OnBoardingViewPreview(
onOAuthDetails = {},
onNeedLoginPassword = {},
onLearnMoreClick = {},
- onCreateAccountContinue = {},
)
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationStepProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationStepPreviewParam.kt
similarity index 87%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationStepProvider.kt
rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationStepPreviewParam.kt
index 17864f68a1..700c6a87b7 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationStepProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationStepPreviewParam.kt
@@ -10,7 +10,7 @@ package io.element.android.features.login.impl.screens.qrcode.confirmation
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-class QrCodeConfirmationStepProvider : PreviewParameterProvider {
+class QrCodeConfirmationStepPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
QrCodeConfirmationStep.DisplayCheckCode("12"),
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationView.kt
index 8de93bcb0f..2c29f4a667 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationView.kt
@@ -148,7 +148,7 @@ private fun Buttons(
@PreviewsDayNight
@Composable
-internal fun QrCodeConfirmationViewPreview(@PreviewParameter(QrCodeConfirmationStepProvider::class) step: QrCodeConfirmationStep) {
+internal fun QrCodeConfirmationViewPreview(@PreviewParameter(QrCodeConfirmationStepPreviewParam::class) step: QrCodeConfirmationStep) {
ElementPreview {
QrCodeConfirmationView(
step = step,
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorView.kt
index 6b5da8f300..4d58b9329a 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorView.kt
@@ -143,7 +143,7 @@ private fun ColumnScope.Buttons(
@PreviewsDayNight
@Composable
-internal fun QrCodeErrorViewPreview(@PreviewParameter(QrCodeErrorScreenTypeProvider::class) errorScreenType: QrCodeErrorScreenType) {
+internal fun QrCodeErrorViewPreview(@PreviewParameter(QrCodeErrorScreenTypePreviewParam::class) errorScreenType: QrCodeErrorScreenType) {
ElementPreview {
QrCodeErrorView(
errorScreenType = errorScreenType,
@@ -154,7 +154,7 @@ internal fun QrCodeErrorViewPreview(@PreviewParameter(QrCodeErrorScreenTypeProvi
}
}
-class QrCodeErrorScreenTypeProvider : PreviewParameterProvider {
+class QrCodeErrorScreenTypePreviewParam : PreviewParameterProvider {
override val values: Sequence = sequenceOf(
QrCodeErrorScreenType.Cancelled,
QrCodeErrorScreenType.Declined,
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroStatePreviewParam.kt
similarity index 93%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroStateProvider.kt
rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroStatePreviewParam.kt
index 22c7fd67f6..b9c286dc16 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroStateProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroStatePreviewParam.kt
@@ -13,7 +13,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.aPermissionsState
-open class QrCodeIntroStateProvider : PreviewParameterProvider {
+open class QrCodeIntroStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aQrCodeIntroState(),
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroView.kt
index 767dc49999..08a40b63c5 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroView.kt
@@ -98,7 +98,7 @@ private fun ColumnScope.Buttons(
@PreviewsDayNight
@Composable
-internal fun QrCodeIntroViewPreview(@PreviewParameter(QrCodeIntroStateProvider::class) state: QrCodeIntroState) = ElementPreview {
+internal fun QrCodeIntroViewPreview(@PreviewParameter(QrCodeIntroStatePreviewParam::class) state: QrCodeIntroState) = ElementPreview {
QrCodeIntroView(
state = state,
onBackClick = {},
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanStatePreviewParam.kt
similarity index 96%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanStateProvider.kt
rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanStatePreviewParam.kt
index 0d467efb71..19385b6df1 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanStateProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanStatePreviewParam.kt
@@ -14,7 +14,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
-open class QrCodeScanStateProvider : PreviewParameterProvider {
+open class QrCodeScanStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aQrCodeScanState(),
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt
index 6fa3d1b0c7..72d624a921 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt
@@ -218,7 +218,7 @@ private fun ColumnScope.Buttons(
@PreviewsDayNight
@Composable
-internal fun QrCodeScanViewPreview(@PreviewParameter(QrCodeScanStateProvider::class) state: QrCodeScanState) = ElementPreview {
+internal fun QrCodeScanViewPreview(@PreviewParameter(QrCodeScanStatePreviewParam::class) state: QrCodeScanState) = ElementPreview {
QrCodeScanView(
state = state,
onQrCodeDataReady = {},
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt
deleted file mode 100644
index 8816de3946..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.login.impl.screens.searchaccountprovider
-
-sealed interface SearchAccountProviderEvents {
- /**
- * The user has typed something, expect to get a list of matching account provider results
- * in the state.
- */
- data class UserInput(val input: String) : SearchAccountProviderEvents
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt
deleted file mode 100644
index ddbcc4c847..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.login.impl.screens.searchaccountprovider
-
-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
-import dev.zacsweers.metro.AppScope
-import dev.zacsweers.metro.Assisted
-import dev.zacsweers.metro.AssistedInject
-import io.element.android.annotations.ContributesNode
-import io.element.android.features.login.impl.util.openLearnMorePage
-import io.element.android.libraries.architecture.callback
-
-@ContributesNode(AppScope::class)
-@AssistedInject
-class SearchAccountProviderNode(
- @Assisted buildContext: BuildContext,
- @Assisted plugins: List,
- private val presenter: SearchAccountProviderPresenter,
-) : Node(buildContext, plugins = plugins) {
- interface Callback : Plugin {
- fun onDone()
- }
-
- private val callback: Callback = callback()
-
- @Composable
- override fun View(modifier: Modifier) {
- val state = presenter.present()
- val context = LocalContext.current
- SearchAccountProviderView(
- state = state,
- modifier = modifier,
- onBackClick = ::navigateUp,
- onLearnMoreClick = { openLearnMorePage(context) },
- onSuccess = callback::onDone,
- )
- }
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt
deleted file mode 100644
index 2efe4501c6..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.login.impl.screens.searchaccountprovider
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.runtime.setValue
-import dev.zacsweers.metro.Inject
-import io.element.android.features.login.impl.changeserver.ChangeServerState
-import io.element.android.features.login.impl.resolver.HomeserverData
-import io.element.android.features.login.impl.resolver.HomeserverResolver
-import io.element.android.libraries.architecture.AsyncData
-import io.element.android.libraries.architecture.Presenter
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-
-@Inject
-class SearchAccountProviderPresenter(
- private val homeserverResolver: HomeserverResolver,
- private val changeServerPresenter: Presenter,
-) : Presenter {
- @Composable
- override fun present(): SearchAccountProviderState {
- var userInput by rememberSaveable {
- mutableStateOf("")
- }
- val changeServerState = changeServerPresenter.present()
-
- val data: MutableState>> = remember {
- mutableStateOf(AsyncData.Uninitialized)
- }
-
- LaunchedEffect(userInput) {
- onUserInput(userInput, data)
- }
-
- fun handleEvent(event: SearchAccountProviderEvents) {
- when (event) {
- is SearchAccountProviderEvents.UserInput -> {
- userInput = event.input
- }
- }
- }
-
- return SearchAccountProviderState(
- userInput = userInput,
- userInputResult = data.value,
- changeServerState = changeServerState,
- eventSink = ::handleEvent,
- )
- }
-
- private fun CoroutineScope.onUserInput(userInput: String, data: MutableState>>) = launch {
- data.value = AsyncData.Uninitialized
- // Debounce
- delay(500)
- data.value = AsyncData.Loading()
- homeserverResolver.resolve(userInput).collect {
- data.value = AsyncData.Success(it)
- }
- if (data.value !is AsyncData.Success) {
- data.value = AsyncData.Uninitialized
- }
- }
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt
deleted file mode 100644
index dc02a997a6..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.login.impl.screens.searchaccountprovider
-
-import io.element.android.features.login.impl.changeserver.ChangeServerState
-import io.element.android.features.login.impl.resolver.HomeserverData
-import io.element.android.libraries.architecture.AsyncData
-
-data class SearchAccountProviderState(
- val userInput: String,
- val userInputResult: AsyncData>,
- val changeServerState: ChangeServerState,
- val eventSink: (SearchAccountProviderEvents) -> Unit
-)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt
deleted file mode 100644
index 2a8512c38a..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.login.impl.screens.searchaccountprovider
-
-import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.appconfig.AuthenticationConfig
-import io.element.android.features.login.impl.changeserver.aChangeServerState
-import io.element.android.features.login.impl.resolver.HomeserverData
-import io.element.android.libraries.architecture.AsyncData
-
-open class SearchAccountProviderStateProvider : PreviewParameterProvider {
- override val values: Sequence
- get() = sequenceOf(
- aSearchAccountProviderState(),
- aSearchAccountProviderState(userInputResult = AsyncData.Success(aHomeserverDataList())),
- // Add other state here
- )
-}
-
-fun aSearchAccountProviderState(
- userInput: String = "",
- userInputResult: AsyncData> = AsyncData.Uninitialized,
-) = SearchAccountProviderState(
- userInput = userInput,
- userInputResult = userInputResult,
- changeServerState = aChangeServerState(),
- eventSink = {}
-)
-
-fun aHomeserverDataList(): List {
- return listOf(
- aHomeserverData(homeserverUrl = AuthenticationConfig.MATRIX_ORG_URL),
- aHomeserverData(homeserverUrl = "https://no.sliding.sync"),
- aHomeserverData(homeserverUrl = "https://invalid"),
- )
-}
-
-fun aHomeserverData(
- homeserverUrl: String = AuthenticationConfig.MATRIX_ORG_URL,
-): HomeserverData {
- return HomeserverData(homeserverUrl = homeserverUrl)
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt
deleted file mode 100644
index 8335dde112..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt
+++ /dev/null
@@ -1,214 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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:OptIn(ExperimentalMaterial3Api::class)
-
-package io.element.android.features.login.impl.screens.searchaccountprovider
-
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Box
-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.imePadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.foundation.text.KeyboardActions
-import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Scaffold
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.FocusDirection
-import androidx.compose.ui.platform.LocalFocusManager
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.semantics.Role
-import androidx.compose.ui.text.input.ImeAction
-import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.tooling.preview.PreviewParameter
-import androidx.compose.ui.unit.dp
-import io.element.android.appconfig.AuthenticationConfig
-import io.element.android.compound.tokens.generated.CompoundIcons
-import io.element.android.features.login.impl.R
-import io.element.android.features.login.impl.accountprovider.AccountProvider
-import io.element.android.features.login.impl.accountprovider.AccountProviderView
-import io.element.android.features.login.impl.changeserver.ChangeServerEvents
-import io.element.android.features.login.impl.changeserver.ChangeServerView
-import io.element.android.features.login.impl.resolver.HomeserverData
-import io.element.android.libraries.architecture.AsyncData
-import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
-import io.element.android.libraries.designsystem.components.BigIcon
-import io.element.android.libraries.designsystem.components.button.BackButton
-import io.element.android.libraries.designsystem.components.form.textFieldState
-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.theme.components.CircularProgressIndicator
-import io.element.android.libraries.designsystem.theme.components.Icon
-import io.element.android.libraries.designsystem.theme.components.TextField
-import io.element.android.libraries.designsystem.theme.components.TopAppBar
-import io.element.android.libraries.testtags.TestTags
-import io.element.android.libraries.testtags.testTag
-import io.element.android.libraries.ui.strings.CommonStrings
-
-/**
- * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=611-61435
- */
-@Composable
-fun SearchAccountProviderView(
- state: SearchAccountProviderState,
- onBackClick: () -> Unit,
- onLearnMoreClick: () -> Unit,
- onSuccess: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- val eventSink = state.eventSink
- Scaffold(
- modifier = modifier,
- topBar = {
- TopAppBar(
- title = {},
- navigationIcon = { BackButton(onClick = onBackClick) }
- )
- }
- ) { padding ->
- Box(
- modifier = Modifier
- .fillMaxSize()
- .imePadding()
- .padding(padding)
- .consumeWindowInsets(padding)
- ) {
- LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
- item {
- IconTitleSubtitleMolecule(
- modifier = Modifier.padding(top = 16.dp, bottom = 40.dp, start = 16.dp, end = 16.dp),
- iconStyle = BigIcon.Style.Default(CompoundIcons.Search()),
- title = stringResource(id = R.string.screen_account_provider_form_title),
- subTitle = stringResource(id = R.string.screen_account_provider_form_subtitle),
- )
- }
- item {
- // TextInput
- var userInputState by textFieldState(stateValue = state.userInput)
- val focusManager = LocalFocusManager.current
- TextField(
- value = userInputState,
- // readOnly = isLoading,
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
- .onTabOrEnterKeyFocusNext(focusManager)
- .testTag(TestTags.changeServerServer),
- onValueChange = {
- userInputState = it
- eventSink(SearchAccountProviderEvents.UserInput(it))
- },
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Uri,
- imeAction = ImeAction.Done,
- ),
- keyboardActions = KeyboardActions(onDone = {
- focusManager.moveFocus(FocusDirection.Down)
- }),
- singleLine = true,
- trailingIcon = if (userInputState.isNotEmpty()) {
- {
- Box(
- Modifier.clickable(
- onClickLabel = stringResource(CommonStrings.action_clear),
- role = Role.Button,
- ) {
- userInputState = ""
- eventSink(SearchAccountProviderEvents.UserInput(""))
- }
- ) {
- Icon(
- imageVector = CompoundIcons.Close(),
- contentDescription = stringResource(CommonStrings.action_clear)
- )
- }
- }
- } else {
- null
- },
- supportingText = stringResource(id = R.string.screen_account_provider_form_notice),
- )
- }
-
- when (state.userInputResult) {
- is AsyncData.Failure -> {
- // Ignore errors (let the user type more chars)
- }
- is AsyncData.Loading -> {
- item {
- Box(
- modifier = Modifier
- .fillMaxSize()
- ) {
- CircularProgressIndicator(
- modifier = Modifier.align(Alignment.Center)
- )
- }
- }
- }
- is AsyncData.Success -> {
- items(state.userInputResult.data) { homeserverData ->
- val item = homeserverData.toAccountProvider()
- AccountProviderView(
- item = item,
- onClick = {
- state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(item))
- }
- )
- }
- }
- AsyncData.Uninitialized -> Unit
- }
- item {
- Spacer(Modifier.height(32.dp))
- }
- }
- ChangeServerView(
- state = state.changeServerState,
- onLearnMoreClick = onLearnMoreClick,
- onSuccess = onSuccess,
- )
- }
- }
-}
-
-@Composable
-private fun HomeserverData.toAccountProvider(): AccountProvider {
- val isMatrixOrg = homeserverUrl == AuthenticationConfig.MATRIX_ORG_URL
- return AccountProvider(
- url = homeserverUrl,
- subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null,
- // There is no need to know for other servers right now
- isPublic = isMatrixOrg,
- isMatrixOrg = isMatrixOrg,
- )
-}
-
-@PreviewsDayNight
-@Composable
-internal fun SearchAccountProviderViewPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) = ElementPreview {
- SearchAccountProviderView(
- state = state,
- onBackClick = {},
- onLearnMoreClick = {},
- onSuccess = {},
- )
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/web/WebClientUrlForAuthenticationRetriever.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/web/WebClientUrlForAuthenticationRetriever.kt
deleted file mode 100644
index df08cf468b..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/web/WebClientUrlForAuthenticationRetriever.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2024, 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.login.impl.web
-
-import androidx.core.net.toUri
-import dev.zacsweers.metro.AppScope
-import dev.zacsweers.metro.ContributesBinding
-import io.element.android.appconfig.AuthenticationConfig
-import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
-import io.element.android.libraries.wellknown.api.WellknownRetriever
-import timber.log.Timber
-
-interface WebClientUrlForAuthenticationRetriever {
- suspend fun retrieve(homeServerUrl: String): String
-}
-
-@ContributesBinding(AppScope::class)
-class DefaultWebClientUrlForAuthenticationRetriever(
- private val wellknownRetriever: WellknownRetriever,
-) : WebClientUrlForAuthenticationRetriever {
- override suspend fun retrieve(homeServerUrl: String): String {
- if (homeServerUrl != AuthenticationConfig.MATRIX_ORG_URL) {
- Timber.w("Temporary account creation flow is only supported on matrix.org")
- throw AccountCreationNotSupported()
- }
- val wellknown = wellknownRetriever.getElementWellKnown(homeServerUrl).dataOrNull()
- ?: throw AccountCreationNotSupported()
- val registrationHelperUrl = wellknown.registrationHelperUrl
- return if (registrationHelperUrl != null) {
- registrationHelperUrl.toUri()
- .buildUpon()
- .appendQueryParameter("hs_url", homeServerUrl)
- .build()
- .toString()
- } else {
- throw AccountCreationNotSupported()
- }
- }
-}
diff --git a/features/login/impl/src/main/res/values-ar/translations.xml b/features/login/impl/src/main/res/values-ar/translations.xml
new file mode 100644
index 0000000000..181b38ec24
--- /dev/null
+++ b/features/login/impl/src/main/res/values-ar/translations.xml
@@ -0,0 +1,109 @@
+
+
+ "تغيير مزوّد الحسابات"
+ "عنوان السيرفر"
+ "أدخِل عبارة بحث أو عنوان نطاق."
+ "ابحث عن شركة، أو مجتمع، أو سيرفر خاص."
+ "ابحث عن مزوّد حسابات"
+ "هذا المكان الذي تقطن فيه رسائلك — تماماً كاستخدامك مزوّد بريد إلكتروني للاحتفاظ برسائل البريد خاصّتك فيه."
+ "أنت على وشك تسجيل الدخول إلى %s"
+ "هذا المكان الذي تقطن فيه رسائلك — تماماً كاستخدامك مزوّد بريد إلكتروني للاحتفاظ برسائل البريد خاصّتك فيه."
+ "أنت على وشك إنشاء حساب في %s"
+ "إن Matrix.org سيرفر ضخم ومجاني في شبكة Matrix العامّة وُجِد من أجل مراسلات آمنة ولا مركزية، يُدار من قِبل مؤسسة Matrix.org."
+ "غير ذلك"
+ "استخدم مزوّد حسابات آخر كسيرفرك الخاص أو حساب عمل."
+ "تغيير مزوّد الحسابات"
+ "Google Play"
+ "تطبيق Element Pro مطلوب على %1$s. يرجى تنزيله من المتجر."
+ "Element Pro مطلوب"
+ "لم نستطع الوصول لهذا السيرفر. رجاءً تحقق إذا ما كنت أدخلت عنوان الـURL بشكل صحيح. إذا كان الـURL صحيحاً فراسل إدارة السيرفر خاصتك لمزيد من المساعدة."
+ "الخادم غير متاح بسبب مشكلة في ملف .well-known:
+%1$s"
+ "لا يدعم موفّر الحساب المحدد ميزة Sliding Sync. يلزم ترقية الخادم لاستخدام %1$s."
+ "غير مسموح لـ %1$s بالاتصال بـ %2$s."
+ "تم إعداد هذا التطبيق للسماح بـ: %1$s."
+ "موفّر الحساب %1$s غير مسموح به."
+ "عنوان الـ URL للسيرفر"
+ "أدخل عنوان نطاق."
+ "ما عنوان السيرفر خاصتك؟"
+ "اختر خادمك"
+ "إنشاء حساب"
+ "هذا الحساب محذوف."
+ "اسم المستخدم و/أو كلمة المرور غير صحيحة"
+ "معرّف المستخدم هذا غير صالح. التنسيق المتوقّع: \'@user:homeserver.org\'"
+ "تم إعداد هذا الخادم لاستخدام رموز التحديث. وهي غير مدعومة عند استخدام تسجيل الدخول بكلمة المرور."
+ "لا يتيح المخدِّم المختار تسجيلَ الدخول بكلمة سرّ أو OAuth. راجع المسؤول أو اختر مخدِّماً آخر."
+ "أدخِل تفاصيلك"
+ "Matrix عبارة عن شبكة مفتوحة للاتصالات الآمنة واللا مركزية."
+ "أهلاً بعودتك!"
+ "تسجيل الدخول إلى %1$s"
+ "فتح Element Classic"
+ "افتح Element Classic على جهازك"
+ "انتقل إلى الإعدادات > الأمان والخصوصية"
+ "في إدارة مفاتيح التشفير، اختر استرداد الرسائل المشفّرة"
+ "اتبع التعليمات لتفعيل تخزين مفاتيحك"
+ "عُد إلى %1$s"
+ "فعّل تخزين مفاتيحك قبل المتابعة إلى %1$s"
+ "الإصدار %1$s"
+ "جارٍ التحقق من الحساب"
+ "تسجيل الدخول يدوياً"
+ "تسجيل الدخول إلى %1$s"
+ "تسجيل الدخول باستخدام رمز QR"
+ "إنشاء حساب"
+ "مرحبًا بعودتك"
+ "مرحباً بك في أسرع %1$s على الإطلاق. مزوَّد من أجل سرعةٍ وبساطة."
+ "مرحباً بك في %1$s. المعزَّز، من أجل سرعةٍ وبساطة."
+ "Be in your element"
+ "جارٍ إنشاء اتصال آمن"
+ "تعذّر إنشاء اتصال آمن بالجهاز الجديد. لا تزال أجهزتك الحالية آمنة ولا داعي للقلق بشأنها."
+ "ماذا الآن؟"
+ "حاول تسجيل الدخول مجددًا باستخدام رمز QR إذا كانت هذه مشكلة في الشبكة."
+ "إذا واجهت المشكلة نفسها، جرّب شبكة Wi-Fi مختلفة أو استخدم بيانات الهاتف المحمول بدلًا من Wi-Fi."
+ "إذا لم ينجح ذلك، سجّل الدخول يدويًا"
+ "الاتصال غير آمن"
+ "سيُطلب منك إدخال الرقمين الظاهرين على هذا الجهاز."
+ "أدخل الرقم أدناه على جهازك الآخر"
+ "سجّل الدخول إلى جهازك الآخر ثم حاول مرة أخرى، أو استخدم جهازًا آخر تم تسجيل الدخول عليه بالفعل."
+ "لم يتم تسجيل الدخول على الجهاز الآخر"
+ "تم إلغاء تسجيل الدخول على الجهاز الآخر."
+ "تم إلغاء طلب تسجيل الدخول"
+ "تم رفض تسجيل الدخول على الجهاز الآخر."
+ "تم رفض تسجيل الدخول"
+ "لست بحاجة إلى فعل أي شيء آخر."
+ "جهازك الآخر مسجّل الدخول بالفعل"
+ "انتهت صلاحية تسجيل الدخول. يرجى المحاولة مرة أخرى."
+ "لم يكتمل تسجيل الدخول في الوقت المحدد"
+ "جهازك الآخر لا يدعم تسجيل الدخول إلى %s باستخدام رمز QR.
+
+جرّب تسجيل الدخول يدويًا، أو امسح رمز QR ضوئيًا باستخدام جهاز آخر."
+ "رمز QR غير مدعوم"
+ "لا يدعم موفّر حسابك %1$s."
+ "%1$s غير مدعوم"
+ "جاهز للمسح الضوئي"
+ "افتح %1$s على جهاز سطح مكتب"
+ "انقر على صورتك الرمزية"
+ "اختر %1$s"
+ "“ربط جهاز جديد”"
+ "امسح رمز QR ضوئيًا باستخدام هذا الجهاز"
+ "متاح فقط إذا كان موفّر حسابك يدعم ذلك."
+ "افتح %1$s على جهاز آخر للحصول على رمز QR"
+ "استخدم رمز QR المعروض على الجهاز الآخر."
+ "حاول مرة أخرى"
+ "رمز QR غير صحيح"
+ "الانتقال إلى إعدادات الكاميرا"
+ "تحتاج إلى منح %1$s إذنًا لاستخدام كاميرا جهازك للمتابعة."
+ "اسمح بالوصول إلى الكاميرا لمسح رمز QR ضوئيًا"
+ "امسح رمز QR ضوئيًا"
+ "البدء من جديد"
+ "حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى."
+ "في انتظار جهازك الآخر"
+ "قد يطلب موفّر حسابك الرمز التالي للتحقق من تسجيل الدخول."
+ "رمز التحقق الخاص بك"
+ "تغيير مزوّد الحسابات"
+ "سيرفر خاص بموظّفي Element."
+ "Matrix عبارة عن شبكة مفتوحة للاتصالات الآمنة واللا مركزية."
+ "هذا المكان الذي تقطن فيه رسائلك — تماماً كاستخدامك مزوّد بريد إلكتروني للاحتفاظ برسائل البريد خاصّتك فيه."
+ "أنت على وشك تسجيل الدخول إلى %1$s"
+ "اختر موفّر الحساب"
+ "أنت على وشك إنشاء حساب في %1$s"
+
diff --git a/features/login/impl/src/main/res/values-de/translations.xml b/features/login/impl/src/main/res/values-de/translations.xml
index 89e4412d0f..4f04c87d34 100644
--- a/features/login/impl/src/main/res/values-de/translations.xml
+++ b/features/login/impl/src/main/res/values-de/translations.xml
@@ -37,7 +37,14 @@
"Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation."
"Willkommen zurück!"
"Anmelden bei %1$s"
+ "Element Classic öffnen"
+ "Öffne Element Classic auf deinem Gerät"
+ "Geh zu Einstellungen > Sicherheit & Datenschutz"
+ "Folge den Anweisungen, um den Schlüsselspeicher zu aktivieren."
+ "Zurück zu %1$s"
+ "Aktiviere deinen Schlüsselspeicher, bevor du mit %1$s fortfährst."
"Version %1$s"
+ "Konto wird geprüft"
"Manuell anmelden"
"Anmelden bei %1$s"
"Mit QR-Code anmelden"
diff --git a/features/login/impl/src/main/res/values-es/translations.xml b/features/login/impl/src/main/res/values-es/translations.xml
index 3b9ffbe2dd..13a93164a6 100644
--- a/features/login/impl/src/main/res/values-es/translations.xml
+++ b/features/login/impl/src/main/res/values-es/translations.xml
@@ -13,6 +13,9 @@
"Otro"
"Usa un proveedor de cuentas diferente, como tu propio servidor privado o una cuenta de trabajo."
"Cambiar proveedor de cuentas"
+ "Google Play"
+ "%1$s requiere la aplicación Element Pro. Descárgala de la tienda."
+ "Se requiere Element Pro"
"No hemos podido acceder a este servidor base. Comprueba que has introducido correctamente la dirección. Si es correcta, ponte en contacto con el administrador de tu servidor base para obtener más ayuda."
"El servidor no está disponible debido a un problema en el archivo .well-known:
%1$s"
@@ -22,10 +25,18 @@
"No se permite el proveedor de cuentas %1$s."
"URL del servidor base"
"Introduce una dirección de dominio."
+ "Iniciar sesión"
+ "Crear cuenta"
"¿Cuál es la dirección de tu servidor?"
+ "Si tu cuenta te la proporciona tu empresa o una comunidad, cambia el proveedor de la cuenta (ejemplo: nombredeempresa.com)."
+ "Crea una cuenta gratuita en matrix.org. Si deseas crear una cuenta con otro proveedor, modifícala según corresponda (por ejemplo: communityname.org)."
+ "Proveedor de cuentas"
+ "ejemplo.com"
"Selecciona tu servidor"
+ "Introduce tu proveedor de cuenta"
+ "Elegir proveedor de cuentas"
"Crear cuenta"
- "Esta cuenta ha sido desactivada."
+ "Esta cuenta ha sido eliminada."
"Usuario y/o contraseña incorrectos"
"Este no es un id de usuario válido. Formato esperado: \'@user:homeserver.org\'"
"Este servidor está configurado para utilizar tokens de actualización. Estos no son compatibles cuando se utiliza el inicio de sesión basado en contraseña."
@@ -34,11 +45,20 @@
"Matrix es una red abierta para una comunicación segura y descentralizada."
"¡Hola de nuevo!"
"Iniciar sesión en %1$s"
+ "Abrir Element Classic"
+ "Abre Element Classic en tu dispositivo"
+ "Ve a Ajustes > Seguridad y privacidad"
+ "En «Gestión de claves criptográficas», selecciona «Recuperación de mensajes cifrados»"
+ "Sigue las instrucciones para activar tu almacén de claves"
+ "Vuelve a %1$s"
+ "Activa tu almacén de claves antes de continuar con %1$s"
"Versión %1$s"
+ "Comprobación de la cuenta"
"Iniciar sesión manualmente"
"Iniciar sesión en %1$s"
"Iniciar sesión con un código QR"
"Crear cuenta"
+ "¡Hola de nuevo!"
"Bienvenido al %1$s más rápido de todos los tiempos. Diseñado para la velocidad y la simplicidad."
"Bienvenido a %1$s. Vitaminado, para mayor rapidez y sencillez."
"Siéntete en tu Elemento"
@@ -57,6 +77,8 @@
"Solicitud de inicio de sesión cancelada"
"El inicio de sesión se rechazó en el otro dispositivo."
"Inicio de sesión rechazado"
+ "No tienes que hacer nada más."
+ "Tu otro dispositivo ya está conectado"
"El inicio de sesión ha caducado. Inténtalo de nuevo."
"El inicio de sesión no se completó a tiempo"
"Tu otro dispositivo no admite el inicio de sesión en %s con un código QR.
diff --git a/features/login/impl/src/main/res/values-et/translations.xml b/features/login/impl/src/main/res/values-et/translations.xml
index dc37ed3e38..a27bc18e3e 100644
--- a/features/login/impl/src/main/res/values-et/translations.xml
+++ b/features/login/impl/src/main/res/values-et/translations.xml
@@ -25,8 +25,16 @@
"%1$s teenusepakkuja pole lubatud."
"Koduserveri url"
"Sisesta domeeni aadress."
+ "Logi sisse"
+ "Loo kasutajakonto"
"Mis on sinu koduserveri aadress?"
+ "Kui sinu kasutajakonto paikneb äriühingu või kogukonna serveris, siis muuda kontoteenuse pakkujat (näiteks: minufirmanimi.ee)."
+ "Loo tasuta kasutajakonto matrix.org serverisse. Kui soovid luua kasutajakonto mõne teise teenusepakkuja juures, siis muuda seda vastavalt (näiteks: minukogukond.ee)."
+ "Teenusepakkuja"
+ "domeeninäidis.ee"
"Vali oma server"
+ "Sisesta oma kasutajakonto teenusepakkuja"
+ "Vali kasutajakonto teenusepakkuja"
"Loo kasutajakonto"
"See kasutajakonto on kustutatud."
"Vigane kasutajanimi ja/või salasõna"
diff --git a/features/login/impl/src/main/res/values-fa/translations.xml b/features/login/impl/src/main/res/values-fa/translations.xml
index 38c2c11eaa..ce0ab0da20 100644
--- a/features/login/impl/src/main/res/values-fa/translations.xml
+++ b/features/login/impl/src/main/res/values-fa/translations.xml
@@ -14,8 +14,14 @@
"استفاده از فراهم کنندهٔ حسابی دیگر چون کارساز خصوصی خوتان یا حسابی کاری."
"تغییر فراهم کنندهٔ حساب"
"پلی گپگل"
+ "برنامه Element Pro برای %1$s مورد نیاز است. لطفاً آن را از فروشگاه دانلود کنید."
+ "المنت پرو مورد نیاز است"
"ما نتوانستیم به این کارساز خانگی برسیم. لطفاً بررسی کنید که URL کارساز اصلی را به درستی وارد کرده اید. اگر URL صحیح است، برای کمک بیشتر با مدیر کارساز خانگی خود تماس بگیرید."
"سرور به دلیل مشکلی در فایل .well-known در دسترس نیست: %1$s"
+ "ارائه دهنده حساب انتخاب شده از همگام سازی کشویی پشتیبانی نمی کند. برای استفاده از %1$s، ارتقاء سرور لازم است."
+ "%1$s اجازه اتصال به %2$s را ندارد."
+ "این برنامه طوری پیکربندی شده است که اجازه موارد زیر را بدهد: %1$s."
+ "ارائه دهنده حساب %1$s مجاز نیست."
"نشانی کارساز خانگی"
"ورود نشانی دامنه."
"نشانی کارسازتان چیست؟"
@@ -30,11 +36,20 @@
"ماتریکس شبکهای بار برای ارتباطات نامتمرکز و امن است."
"خوش برگشتید!"
"ورود به %1$s"
+ "باز کردن Element کلاسیک"
+ "Element Classic را روی دستگاه خود باز کنید"
+ "به تنظیمات > امنیت و حریم خصوصی بروید"
+ "در مدیریت کلیدهای رمزنگاری، بازیابی پیامهای رمزگذاریشده را انتخاب کنید."
+ "برای فعال کردن فضای ذخیرهسازی کلید، دستورالعملها را دنبال کنید"
+ "به %1$s برگردید"
+ "قبل از ادامه به %1$s، فضای ذخیرهسازی کلید خود را فعال کنید"
"نگارش %1$s"
+ "درحال بررسی حساب…"
"ورود دستی"
"ورود به %1$s"
"ورود با کد QR"
"ایجاد حساب"
+ "خوش برگشتید"
"به سریعترین %1$s خوش آمدید. بازطرّاحی شده برای سرعت و سادگی."
"به %1$s خوش آمدید. بازطرّاحی شده برای سرعت و سادگی."
"در المنتتان باشید"
@@ -53,6 +68,8 @@
"درخواست ورد لغو شد"
"ورود به دست افزارهٔ دیگر رد شد."
"ورود رد شد"
+ "لازم نیست کار دیگری انجام دهید."
+ "دستگاه دیگر شما قبلاً وارد سیستم شده است"
"ورود منقضی شد. لطفاً دوباره تلاش کنید."
"ورود در زمان معیّن کامل نشد"
"افزارهٔ دیگرتان از ورود به %s با کد پاس پشتیبانی نمیکند.
@@ -67,6 +84,7 @@
"گزینش %1$s"
"«پیوند افزارهٔ جدید»"
"پویش کد پاس با این افزاره"
+ "فقط در صورتی موجود است که ارائه دهنده حساب شما از آن پشتیبانی کند."
"گشودن %1$s روی افزارهای دیگر برای گرفتن کد پاس"
"استفاده از کد پاس نشان داده روی افزارهٔ دیگر."
"تلاش دوباره"
@@ -85,5 +103,6 @@
"ماتریکس شبکهای بار برای ارتباطات نامتمرکز و امن است."
"جایی که گفتوگوهایتان خواهند زیست — درست مثل استفادهتان از فراهم کنندهٔ رایانامهای برای نگه داشتن رایانامههایتان."
"دارید به %1$s وارد میشوید"
+ "ارائه دهنده حساب را انتخاب کنید"
"دارید روی %1$s حساب میسازید"
diff --git a/features/login/impl/src/main/res/values-fi/translations.xml b/features/login/impl/src/main/res/values-fi/translations.xml
index 8561bcfce3..493ddee2f8 100644
--- a/features/login/impl/src/main/res/values-fi/translations.xml
+++ b/features/login/impl/src/main/res/values-fi/translations.xml
@@ -25,8 +25,16 @@
"Palveluntarjoaja %1$s ei ole sallittu."
"Kotipalvelimen osoite"
"Anna verkkotunnuksen osoite."
+ "Kirjaudu sisään"
+ "Luo tili"
"Mikä on palvelimesi osoite?"
+ "Jos tilisi on yrityksesi tai yhteisön tarjoama, vaihda palveluntarjoaja (esimerkki: companyname.com)."
+ "Luo tili matrix.org -palvelimelle ilmaiseksi. Jos haluat luoda tilin eri palveluntarjoajan kautta, muuta sitä vastaavasti (esimerkiksi: communityname.org)."
+ "Palveluntarjoaja"
+ "example.com"
"Valitse palvelimesi"
+ "Anna palveluntarjoajasi"
+ "Valitse palveluntarjoaja"
"Luo tili"
"Tämä tili on poistettu."
"Väärä käyttäjänimi ja/tai salasana"
diff --git a/features/login/impl/src/main/res/values-fr/translations.xml b/features/login/impl/src/main/res/values-fr/translations.xml
index 504517aad7..0af658d34f 100644
--- a/features/login/impl/src/main/res/values-fr/translations.xml
+++ b/features/login/impl/src/main/res/values-fr/translations.xml
@@ -25,8 +25,16 @@
"Le fournisseur de compte %1$s n’est pas autorisé."
"URL du serveur d’accueil"
"Saisissez une adresse de domaine."
+ "Se connecter"
+ "Créer un compte"
"Quelle est l’adresse de votre serveur ?"
+ "Si votre fournisseur de compte est fourni par votre entreprise ou une communauté, veuillez le saisir ici (exemple : monentreprise.com)."
+ "Créez un compte sur matrix.org gratuitement. Si vous souhaitez créer un compte auprès d’un autre fournisseur, veuillez modifier l’adresse ici (exemple : monassociation.org)."
+ "Fournisseur de compte"
+ "exemple.com"
"Choisissez votre serveur"
+ "Saisissez votre fournisseur de compte"
+ "Choisissez un fournisseur de compte"
"Créer un compte"
"Ce compte a été supprimé."
"Nom d’utilisateur et/ou mot de passe incorrects"
diff --git a/features/login/impl/src/main/res/values-in/translations.xml b/features/login/impl/src/main/res/values-in/translations.xml
index 1d29c5b619..34ba1bd0ed 100644
--- a/features/login/impl/src/main/res/values-in/translations.xml
+++ b/features/login/impl/src/main/res/values-in/translations.xml
@@ -25,8 +25,16 @@
"Penyedia akun %1$s tidak diizinkan."
"URL Homeserver"
"Masukkan alamat domain."
+ "Login"
+ "Buat akun"
"Apa alamat server Anda?"
+ "Jika akun Anda disediakan oleh perusahaan atau komunitas Anda, maka ubah penyedia akun (contoh: companyname.com)."
+ "Buat akun di matrix.org secara gratis. Jika anda ingin membuat akun dengan penyedia lain, ubah sesuai kebutuhan (contoh: communityname.org)."
+ "Penyedia akun"
+ "contoh.com"
"Pilih server Anda"
+ "Masukkan penyedia akun Anda"
+ "Pilih penyedia akun"
"Buat akun"
"Akun ini telah dinonaktifkan."
"Nama pengguna dan/atau kata sandi salah"
diff --git a/features/login/impl/src/main/res/values-ja/translations.xml b/features/login/impl/src/main/res/values-ja/translations.xml
index 42698e27dc..b4d6f16721 100644
--- a/features/login/impl/src/main/res/values-ja/translations.xml
+++ b/features/login/impl/src/main/res/values-ja/translations.xml
@@ -24,8 +24,16 @@
"アカウント提供元 %1$s は許可されていません。"
"ホームサーバーURL"
"ドメイン名を入力してください"
+ "サインイン"
+ "アカウントを作成"
"サーバーのアドレスは何ですか?"
+ "アカウントが会社やコミュニティから発行されている場合は、アカウント提供元を変更してください (例: kaisha-mei.com)。"
+ "matrix.org 上に無料でアカウントを作成できます。異なるアカウント提供元上にアカウントを作成したい場合は、それにならって変更してください (例: dantai-mei.org)。"
+ "アカウント提供元"
+ "example.com"
"サーバーを選択"
+ "アカウント提供元を入力"
+ "アカウント提供元を選択"
"アカウントを作成"
"アカウントは削除されました。"
"ユーザー名またはパスワードが違います"
diff --git a/features/login/impl/src/main/res/values-pl/translations.xml b/features/login/impl/src/main/res/values-pl/translations.xml
index 634d856cdf..81d8570e32 100644
--- a/features/login/impl/src/main/res/values-pl/translations.xml
+++ b/features/login/impl/src/main/res/values-pl/translations.xml
@@ -25,8 +25,16 @@
"Dostawca konta %1$s jest niedozwolony."
"URL serwera domowego"
"Wprowadź adres domeny."
+ "Zaloguj"
+ "Utwórz konto"
"Jaki jest adres Twojego serwera?"
+ "Jeśli uzyskałeś konto od firmy lub społeczności, wprowadź dostawcę konta (na przykład: nazwafirmy.com)"
+ "Załóż darmowe konto na matrix.org. Jeśli chcesz założyć konto u innego dostawcy wprowadź własny adres (na przykład: nazwaspołeczności.org)."
+ "Dostawca konta"
+ "przykład.com"
"Wybierz swój serwer"
+ "Wprowadź dostawcę swojego konta"
+ "Wybierz dostawcę konta"
"Utwórz konto"
"To konto zostało usunięte."
"Nieprawidłowa nazwa użytkownika i/lub hasło"
diff --git a/features/login/impl/src/main/res/values-ro/translations.xml b/features/login/impl/src/main/res/values-ro/translations.xml
index b44242abf8..6835c0a15d 100644
--- a/features/login/impl/src/main/res/values-ro/translations.xml
+++ b/features/login/impl/src/main/res/values-ro/translations.xml
@@ -25,8 +25,16 @@
"Furnizorul de cont %1$s nu este permis."
"Adresa URL a homeserver-ului"
"Introduceți o adresă de domeniu."
+ "Autentificare"
+ "Creați un cont"
"Care este adresa serverului dumneavoastră?"
+ "Dacă contul este furnizat de compania dumneavoastră sau de o comunitate, schimbați furnizorul contului (exemplu: numecompanie.com)."
+ "Creați un cont gratuit pe matrix.org. Dacă doriți să creați un cont la un alt furnizor, schimbați-l în consecință (exemplu: numecomunitate.org)."
+ "Furnizor de cont"
+ "exemplu.ro"
"Selectați serverul dumneavoastra"
+ "Introduceți furnizorul contului dumneavoastră"
+ "Alegeți un furnizor de cont"
"Creați un cont"
"Acest cont a fost șters."
"Utilizator și/sau parolă incorecte"
diff --git a/features/login/impl/src/main/res/values-sv/translations.xml b/features/login/impl/src/main/res/values-sv/translations.xml
index 396f2025b7..fe2d443ce3 100644
--- a/features/login/impl/src/main/res/values-sv/translations.xml
+++ b/features/login/impl/src/main/res/values-sv/translations.xml
@@ -28,7 +28,7 @@
"Vad är adressen till din server?"
"Välj din server"
"Skapa konto"
- "Detta konto har avaktiverats."
+ "Detta konto har raderats."
"Felaktigt användarnamn och/eller lösenord"
"Detta är inte en giltig användaridentifierare. Förväntat format: \'@användare:hemserver.org\'"
"Den här servern är konfigurerad för att använda uppdateringstokens. Dessa stöds inte när du använder lösenordsbaserad inloggning."
diff --git a/features/login/impl/src/main/res/values-uk/translations.xml b/features/login/impl/src/main/res/values-uk/translations.xml
index 60959c80d4..918caf5530 100644
--- a/features/login/impl/src/main/res/values-uk/translations.xml
+++ b/features/login/impl/src/main/res/values-uk/translations.xml
@@ -25,8 +25,16 @@
"Постачальник облікового запису %1$s не дозволений."
"URL-адреса домашнього сервера"
"Введіть адресу домену."
+ "Увійти"
+ "Створити обліковий запис"
"Яка адреса вашого сервера?"
+ "Якщо ваш обліковий запис надано вашою компанією або спільнотою, змініть постачальника облікового запису (наприклад: companyname.com)."
+ "Створіть обліковий запис на matrix.org безплатно. Якщо ви хочете створити обліковий запис у іншого постачальника, змініть його відповідно (наприклад: communityname.org)."
+ "Постачальник облікового запису"
+ "example.com"
"Виберіть свій сервер"
+ "Вкажіть постачальника вашого облікового запису"
+ "Оберіть постачальника облікового запису"
"Створити обліковий запис"
"Цей обліковий запис було видалено."
"Неправильне ім\'я користувача та/або пароль"
diff --git a/features/login/impl/src/main/res/values-zh-rTW/translations.xml b/features/login/impl/src/main/res/values-zh-rTW/translations.xml
index c4d16f5928..76477eb36e 100644
--- a/features/login/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/login/impl/src/main/res/values-zh-rTW/translations.xml
@@ -11,7 +11,7 @@
"您即將在 %s 建立帳號"
"Matrix.org 由 Matrix.org 基金會營運,是用於安全、去中心化通訊的公共 Matrix 網路上的大型免費伺服器。"
"其他"
- "使用不同的帳戶提供者,例如您自己的伺服器或工作帳號。"
+ "使用不同的帳號提供者,例如您自己的伺服器或工作帳號。"
"更改帳號提供者"
"Google Play"
"%1$s 需要 Element Pro 應用程式。請從商店下載。"
@@ -28,7 +28,7 @@
"您的伺服器地址?"
"選擇您的伺服器"
"建立帳號"
- "這個帳號已被停用。"
+ "此帳號已被刪除。"
"不正確的使用者名稱或密碼"
"此非有效的使用者識別字串。預期的格式:‘@user:homeserver.org’"
"此伺服器已設定為使用重新整理權杖。使用以密碼為基礎的登入方式時,不支援這些功能。"
diff --git a/features/login/impl/src/main/res/values-zh/translations.xml b/features/login/impl/src/main/res/values-zh/translations.xml
index 16afe7d5bd..85de302dc1 100644
--- a/features/login/impl/src/main/res/values-zh/translations.xml
+++ b/features/login/impl/src/main/res/values-zh/translations.xml
@@ -25,8 +25,16 @@
"账户提供者 %1$s 不被允许。"
"主服务器 URL"
"输入域名地址。"
+ "登录"
+ "创建账户"
"你的服务器地址是什么?"
+ "如果你的账户由你的公司或社区提供,则应更改账户提供者(例如:companyname.com)。"
+ "在 matrix.org 创建免费账户。如果你想使用其它服务提供者创建账户,请相应地更改域名(例如:communityname.org)。"
+ "账户提供者"
+ "example.com"
"选择服务器"
+ "输入你的账户提供者"
+ "选择账户提供者"
"创建账户"
"此账户已被删除。"
"用户名与(或)密码不正确"
diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml
index 10fb6ef04a..7e798081f5 100644
--- a/features/login/impl/src/main/res/values/localazy.xml
+++ b/features/login/impl/src/main/res/values/localazy.xml
@@ -25,8 +25,16 @@
"Account provider %1$s not allowed."
"Homeserver URL"
"Enter a domain address."
+ "Sign in"
+ "Create account"
"What is the address of your server?"
+ "If your account is provided by your company or a community, then change the account provider (example: companyname.com)."
+ "Create an account on matrix.org for free. If you want to create an account with a different provider, then change it accordingly (example: communityname.org)."
+ "Account provider"
+ "example.com"
"Select your server"
+ "Enter your account provider"
+ "Choose an account provider"
"Create account"
"This account has been deleted."
"Incorrect username and/or password"
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt
index a05194d008..8a0bf4c307 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt
@@ -12,12 +12,11 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.api.LoginEntryPoint
-import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource
import io.element.android.features.login.impl.classic.FakeElementClassicConnection
import io.element.android.features.preferences.test.FakePreferencesEntryPoint
-import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow
+import io.element.android.libraries.oauth.test.FakeOAuthActionFlow
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
@@ -38,7 +37,7 @@ class DefaultLoginEntryPointTest {
LoginFlowNode(
buildContext = buildContext,
plugins = plugins,
- accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
+ accountProviderDataSource = anAccountProviderDataSource(),
oAuthActionFlow = FakeOAuthActionFlow(),
appCoroutineScope = backgroundScope,
elementClassicConnection = FakeElementClassicConnection(),
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt
index e3cf1c0ea7..fbfae984fc 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt
@@ -11,13 +11,9 @@ package io.element.android.features.login.impl.accesscontrol
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
-import io.element.android.features.wellknown.test.FakeWellknownRetriever
-import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_URL
-import io.element.android.libraries.wellknown.api.ElementWellKnown
-import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertThrows
import org.junit.Test
@@ -28,9 +24,7 @@ class DefaultAccountProviderAccessControlTest {
val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = true,
- elementWellKnown = anElementWellKnown(
- enforceElementPro = true,
- ),
+ enterpriseService = FakeEnterpriseService(isElementProEnforcedResult = { true }),
)
accessControl.expectNeedElementProException()
}
@@ -41,9 +35,7 @@ class DefaultAccountProviderAccessControlTest {
isEnterpriseBuild = false,
// false here.
isAllowedToConnectToHomeserver = false,
- elementWellKnown = anElementWellKnown(
- enforceElementPro = true,
- ),
+ enterpriseService = FakeEnterpriseService(isElementProEnforcedResult = { true }),
)
accessControl.expectNeedElementProException()
}
@@ -53,8 +45,9 @@ class DefaultAccountProviderAccessControlTest {
val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = true,
- elementWellKnown = anElementWellKnown(
- enforceElementPro = false,
+ enterpriseService = FakeEnterpriseService(
+ isAllowedToConnectToHomeserverResult = { true },
+ isElementProEnforcedResult = { false }
),
)
accessControl.expectAllowed()
@@ -65,19 +58,15 @@ class DefaultAccountProviderAccessControlTest {
val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = true,
- elementWellKnown = anElementWellKnown(
- enforceElementPro = null,
- ),
)
accessControl.expectAllowed()
}
@Test
- fun `foss build should allow using account provider twith missing wellknown`() = runTest {
+ fun `foss build should allow using account provider with missing wellknown`() = runTest {
val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = true,
- elementWellKnown = null,
)
accessControl.expectAllowed()
}
@@ -88,8 +77,10 @@ class DefaultAccountProviderAccessControlTest {
isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = false,
allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2),
- elementWellKnown = anElementWellKnown(
- enforceElementPro = false,
+ enterpriseService = FakeEnterpriseService(
+ isAllowedToConnectToHomeserverResult = { false },
+ isElementProEnforcedResult = { false },
+ defaultHomeserverListResult = { listOf(AN_ACCOUNT_PROVIDER_2) },
),
)
accessControl.expectUnauthorizedAccountProviderException()
@@ -100,8 +91,9 @@ class DefaultAccountProviderAccessControlTest {
val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = true,
isAllowedToConnectToHomeserver = true,
- elementWellKnown = anElementWellKnown(
- enforceElementPro = true,
+ enterpriseService = FakeEnterpriseService(
+ isAllowedToConnectToHomeserverResult = { true },
+ isElementProEnforcedResult = { true },
),
)
accessControl.expectAllowed()
@@ -112,8 +104,9 @@ class DefaultAccountProviderAccessControlTest {
val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = true,
isAllowedToConnectToHomeserver = true,
- elementWellKnown = anElementWellKnown(
- enforceElementPro = false,
+ enterpriseService = FakeEnterpriseService(
+ isAllowedToConnectToHomeserverResult = { true },
+ isElementProEnforcedResult = { false },
),
)
accessControl.expectAllowed()
@@ -125,8 +118,10 @@ class DefaultAccountProviderAccessControlTest {
isEnterpriseBuild = true,
isAllowedToConnectToHomeserver = false,
allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2),
- elementWellKnown = anElementWellKnown(
- enforceElementPro = true,
+ enterpriseService = FakeEnterpriseService(
+ isAllowedToConnectToHomeserverResult = { false },
+ isElementProEnforcedResult = { true },
+ defaultHomeserverListResult = { listOf(AN_ACCOUNT_PROVIDER_2) },
),
)
accessControl.expectUnauthorizedAccountProviderException()
@@ -138,8 +133,10 @@ class DefaultAccountProviderAccessControlTest {
isEnterpriseBuild = true,
isAllowedToConnectToHomeserver = false,
allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2),
- elementWellKnown = anElementWellKnown(
- enforceElementPro = false,
+ enterpriseService = FakeEnterpriseService(
+ isAllowedToConnectToHomeserverResult = { false },
+ isElementProEnforcedResult = { false },
+ defaultHomeserverListResult = { listOf(AN_ACCOUNT_PROVIDER_2) },
),
)
accessControl.expectUnauthorizedAccountProviderException()
@@ -149,22 +146,14 @@ class DefaultAccountProviderAccessControlTest {
isEnterpriseBuild: Boolean = false,
isAllowedToConnectToHomeserver: Boolean = false,
allowedAccountProviders: List = emptyList(),
- elementWellKnown: ElementWellKnown? = null,
- ) = DefaultAccountProviderAccessControl(
- enterpriseService = FakeEnterpriseService(
- isEnterpriseBuild = isEnterpriseBuild,
+ enterpriseService: FakeEnterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { isAllowedToConnectToHomeserver },
defaultHomeserverListResult = { allowedAccountProviders },
- ),
- wellknownRetriever = FakeWellknownRetriever(
- getElementWellKnownResult = {
- if (elementWellKnown == null) {
- WellknownRetrieverResult.NotFound
- } else {
- WellknownRetrieverResult.Success(elementWellKnown)
- }
- },
- ),
+ isElementProEnforcedResult = { false },
+ )
+ ) = DefaultAccountProviderAccessControl(
+ isEnterpriseBuild = { isEnterpriseBuild },
+ enterpriseService = enterpriseService,
)
private fun DefaultAccountProviderAccessControl.expectNeedElementProException() {
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt
new file mode 100644
index 0000000000..25bc67144b
--- /dev/null
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.login.impl.accountprovider
+
+import io.element.android.features.enterprise.api.EnterpriseService
+import io.element.android.features.enterprise.test.FakeEnterpriseService
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+
+fun anAccountProviderDataSource(
+ enterpriseService: EnterpriseService = FakeEnterpriseService(),
+ appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
+ coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined),
+) = AccountProviderDataSource(
+ enterpriseService = enterpriseService,
+ appPreferencesStore = appPreferencesStore,
+ coroutineScope = coroutineScope,
+)
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt
index f86df13335..30e25c8c3e 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt
@@ -13,6 +13,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -24,7 +25,7 @@ class AccountProviderDataSourceTest {
@Test
fun `present - initial state`() = runTest {
- val sut = AccountProviderDataSource(FakeEnterpriseService())
+ val sut = anAccountProviderDataSource()
sut.flow.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
@@ -41,10 +42,10 @@ class AccountProviderDataSourceTest {
@Test
fun `present - initial state - matrix org`() = runTest {
- val sut = AccountProviderDataSource(
- FakeEnterpriseService(
+ val sut = anAccountProviderDataSource(
+ enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(AuthenticationConfig.MATRIX_ORG_URL) }
- )
+ ),
)
sut.flow.test {
val initialState = awaitItem()
@@ -62,10 +63,10 @@ class AccountProviderDataSourceTest {
@Test
fun `present - ensure that default homeserver is not star char`() = runTest {
- val sut = AccountProviderDataSource(
- FakeEnterpriseService(
+ val sut = anAccountProviderDataSource(
+ enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(EnterpriseService.ANY_ACCOUNT_PROVIDER, AuthenticationConfig.MATRIX_ORG_URL) }
- )
+ ),
)
sut.flow.test {
val initialState = awaitItem()
@@ -83,7 +84,7 @@ class AccountProviderDataSourceTest {
@Test
fun `present - user change and reset`() = runTest {
- val sut = AccountProviderDataSource(FakeEnterpriseService())
+ val sut = anAccountProviderDataSource()
sut.flow.test {
val initialState = awaitItem()
assertThat(initialState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
@@ -106,7 +107,7 @@ class AccountProviderDataSourceTest {
@Test
fun `present - set url and reset`() = runTest {
- val sut = AccountProviderDataSource(FakeEnterpriseService())
+ val sut = anAccountProviderDataSource()
sut.flow.test {
val initialState = awaitItem()
assertThat(initialState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
@@ -126,4 +127,47 @@ class AccountProviderDataSourceTest {
assertThat(resetState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
}
}
+
+ @Test
+ fun `present - defaults to the most recently used provider from history`() = runTest {
+ val sut = anAccountProviderDataSource(
+ appPreferencesStore = InMemoryAppPreferencesStore(
+ homeserverHistory = listOf("https://example.com", "https://matrix.org"),
+ ),
+ )
+ sut.flow.test {
+ assertThat(awaitItem().url).isEqualTo("https://example.com")
+ }
+ }
+
+ @Test
+ fun `present - history is ignored when the account provider is enforced`() = runTest {
+ val sut = anAccountProviderDataSource(
+ enterpriseService = FakeEnterpriseService(
+ defaultHomeserverListResult = { listOf("https://enforced.org") }
+ ),
+ appPreferencesStore = InMemoryAppPreferencesStore(
+ homeserverHistory = listOf("https://example.com"),
+ ),
+ )
+ sut.flow.test {
+ assertThat(awaitItem().url).isEqualTo("https://enforced.org")
+ }
+ }
+
+ @Test
+ fun `present - reset returns to the most recently used provider from history`() = runTest {
+ val sut = anAccountProviderDataSource(
+ appPreferencesStore = InMemoryAppPreferencesStore(
+ homeserverHistory = listOf("https://example.com"),
+ ),
+ )
+ sut.flow.test {
+ assertThat(awaitItem().url).isEqualTo("https://example.com")
+ sut.setUrl("https://other.com")
+ assertThat(awaitItem().url).isEqualTo("https://other.com")
+ sut.reset()
+ assertThat(awaitItem().url).isEqualTo("https://example.com")
+ }
+ }
}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt
new file mode 100644
index 0000000000..0af8188f3f
--- /dev/null
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.login.impl.accountprovider
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
+import io.element.android.tests.testutils.WarmUpRule
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class SaveAccountProviderToHistoryTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `invoke saves the current account provider url to history`() = runTest {
+ val appPreferencesStore = InMemoryAppPreferencesStore()
+ val accountProviderDataSource = anAccountProviderDataSource()
+ accountProviderDataSource.setUrl("https://example.com")
+ val sut = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore)
+
+ sut()
+
+ assertThat(appPreferencesStore.getHomeserverHistoryFlow().first())
+ .containsExactly("https://example.com")
+ }
+
+ @Test
+ fun `invoke saves the selected provider even after the exposed default has been reset`() = runTest {
+ val appPreferencesStore = InMemoryAppPreferencesStore()
+ val accountProviderDataSource = anAccountProviderDataSource(appPreferencesStore = appPreferencesStore)
+ accountProviderDataSource.setUrl("https://example.com")
+ // Simulate the login flow being torn down (e.g. across an OAuth round-trip), which reverts the
+ // exposed default; the user's selection must still be the one saved to history.
+ accountProviderDataSource.reset()
+ val sut = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore)
+
+ sut()
+
+ assertThat(appPreferencesStore.getHomeserverHistoryFlow().first())
+ .containsExactly("https://example.com")
+ }
+}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt
index 5d9040adac..ad997111e2 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt
@@ -14,12 +14,10 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
-import io.element.android.features.wellknown.test.FakeWellknownRetriever
-import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.architecture.AsyncData
-import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
@@ -28,9 +26,6 @@ import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPer
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
-import io.element.android.libraries.wellknown.api.ElementWellKnown
-import io.element.android.libraries.wellknown.api.WellknownRetriever
-import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@@ -63,6 +58,7 @@ class ChangeServerPresenterTest {
authenticationService = authenticationService,
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
+ isElementProEnforcedResult = { false },
),
).test {
val initialState = awaitItem()
@@ -71,7 +67,7 @@ class ChangeServerPresenterTest {
val loadingState = awaitItem()
assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
- assertThat(successState.changeServerAction).isEqualTo(AsyncData.Success(Unit))
+ assertThat(successState.changeServerAction).isEqualTo(AsyncData.Success(aMatrixHomeServerDetails(supportsOAuthLogin = true)))
}
}
@@ -85,6 +81,7 @@ class ChangeServerPresenterTest {
createPresenter(
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
+ isElementProEnforcedResult = { false },
),
authenticationService = authenticationService,
).test {
@@ -112,6 +109,7 @@ class ChangeServerPresenterTest {
createPresenter(
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
+ isElementProEnforcedResult = { false },
),
authenticationService = authenticationService,
).test {
@@ -135,6 +133,7 @@ class ChangeServerPresenterTest {
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = isAllowedToConnectToHomeserverResult,
defaultHomeserverListResult = { listOf("element.io") },
+ isElementProEnforcedResult = { false },
),
).test {
val initialState = awaitItem()
@@ -158,24 +157,19 @@ class ChangeServerPresenterTest {
@Test
fun `present - change server element pro required error`() = runTest {
- val getElementWellKnownResult = lambdaRecorder> {
- WellknownRetrieverResult.Success(
- anElementWellKnown(
- enforceElementPro = true,
- )
- )
- }
+ val isEnterpriseBuildLambda = lambdaRecorder { false }
createPresenter(
- wellknownRetriever = FakeWellknownRetriever(
- getElementWellKnownResult = getElementWellKnownResult,
- ),
+ isEnterpriseBuild = isEnterpriseBuildLambda,
+ enterpriseService = FakeEnterpriseService(
+ isElementProEnforcedResult = { true },
+ )
).test {
val initialState = awaitItem()
assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL)
initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(anAccountProvider))
- val loadingState = awaitItem()
- assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java)
+ // Skip loading state
+ skipItems(1)
val failureState = awaitItem()
assertThat(
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).unauthorisedAccountProviderTitle
@@ -183,9 +177,7 @@ class ChangeServerPresenterTest {
assertThat(
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId
).isEqualTo("io.element.enterprise")
- getElementWellKnownResult.assertions()
- .isCalledOnce()
- .with(value(A_HOMESERVER_URL.ensureProtocol()))
+ isEnterpriseBuildLambda.assertions().isCalledOnce()
}
}
@@ -239,7 +231,7 @@ class ChangeServerPresenterTest {
val permissionsPresenter = FakePermissionsPresenter()
createPresenter(
authenticationService = authenticationService,
- enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }),
+ enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }, isElementProEnforcedResult = { false }),
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true),
permissionsPresenter = permissionsPresenter,
).test {
@@ -253,23 +245,23 @@ class ChangeServerPresenterTest {
while (finalState.changeServerAction !is AsyncData.Success) {
finalState = awaitItem()
}
- assertThat(finalState.changeServerAction).isEqualTo(AsyncData.Success(Unit))
+ assertThat(finalState.changeServerAction).isEqualTo(AsyncData.Success(aMatrixHomeServerDetails(supportsOAuthLogin = true)))
}
}
private fun createPresenter(
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
- accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
+ accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
- wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
localNetworkPermissionAdvisor: FakeLocalNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
permissionsPresenter: FakePermissionsPresenter = FakePermissionsPresenter(),
+ isEnterpriseBuild: () -> Boolean = { false },
) = ChangeServerPresenter(
authenticationService = authenticationService,
accountProviderDataSource = accountProviderDataSource,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService,
- wellknownRetriever = wellknownRetriever,
+ isEnterpriseBuild = isEnterpriseBuild,
),
localNetworkPermissionGate = LocalNetworkPermissionGate(
advisor = localNetworkPermissionAdvisor,
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt
deleted file mode 100644
index 7d81f05c0c..0000000000
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt
+++ /dev/null
@@ -1,113 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.login.impl.screens.changeaccountprovider
-
-import com.google.common.truth.Truth.assertThat
-import io.element.android.features.enterprise.api.EnterpriseService
-import io.element.android.features.enterprise.test.FakeEnterpriseService
-import io.element.android.features.login.impl.accountprovider.AccountProvider
-import io.element.android.features.login.impl.changeserver.aChangeServerState
-import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
-import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
-import io.element.android.tests.testutils.WarmUpRule
-import io.element.android.tests.testutils.test
-import kotlinx.coroutines.test.runTest
-import org.junit.Rule
-import org.junit.Test
-
-class ChangeAccountProviderPresenterTest {
- @get:Rule
- val warmUpRule = WarmUpRule()
-
- @Test
- fun `present - initial state`() = runTest {
- val presenter = ChangeAccountProviderPresenter(
- changeServerPresenter = { aChangeServerState() },
- enterpriseService = FakeEnterpriseService(
- defaultHomeserverListResult = { emptyList() }
- ),
- )
- presenter.test {
- val initialState = awaitItem()
- assertThat(initialState.accountProviders).isEqualTo(
- listOf(
- AccountProvider(
- url = "https://matrix.org",
- title = "matrix.org",
- subtitle = null,
- isPublic = true,
- isMatrixOrg = true,
- )
- )
- )
- assertThat(initialState.canSearchForAccountProviders).isTrue()
- }
- }
-
- @Test
- fun `present - fixed list of account providers`() = runTest {
- val presenter = ChangeAccountProviderPresenter(
- changeServerPresenter = { aChangeServerState() },
- enterpriseService = FakeEnterpriseService(
- defaultHomeserverListResult = {
- listOf(AN_ACCOUNT_PROVIDER, AN_ACCOUNT_PROVIDER_2)
- }
- ),
- )
- presenter.test {
- val initialState = awaitItem()
- assertThat(initialState.accountProviders).isEqualTo(
- listOf(
- AccountProvider(
- url = "https://matrix.org",
- title = "matrix.org",
- subtitle = null,
- isPublic = true,
- isMatrixOrg = true,
- ),
- AccountProvider(
- url = "https://element.io",
- title = "element.io",
- subtitle = null,
- isPublic = false,
- isMatrixOrg = false,
- )
- )
- )
- assertThat(initialState.canSearchForAccountProviders).isFalse()
- }
- }
-
- @Test
- fun `present - opened list of account providers`() = runTest {
- val presenter = ChangeAccountProviderPresenter(
- changeServerPresenter = { aChangeServerState() },
- enterpriseService = FakeEnterpriseService(
- defaultHomeserverListResult = {
- listOf(AN_ACCOUNT_PROVIDER, EnterpriseService.ANY_ACCOUNT_PROVIDER)
- }
- ),
- )
- presenter.test {
- val initialState = awaitItem()
- assertThat(initialState.accountProviders).isEqualTo(
- listOf(
- AccountProvider(
- url = "https://matrix.org",
- title = "matrix.org",
- subtitle = null,
- isPublic = true,
- isMatrixOrg = true,
- )
- )
- )
- assertThat(initialState.canSearchForAccountProviders).isTrue()
- }
- }
-}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt
index f1ce703d1c..cd64638563 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt
@@ -84,7 +84,6 @@ class ChooseAccountProviderViewTest : RobolectricTest() {
onOAuthDetails: (OAuthDetails) -> Unit = EnsureNeverCalledWithParam(),
onNeedLoginPassword: () -> Unit = EnsureNeverCalled(),
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
- onCreateAccountContinue: (url: String) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
ChooseAccountProviderView(
@@ -93,7 +92,6 @@ class ChooseAccountProviderViewTest : RobolectricTest() {
onOAuthDetails = onOAuthDetails,
onNeedLoginPassword = onNeedLoginPassword,
onLearnMoreClick = onLearnMoreClick,
- onCreateAccountContinue = onCreateAccountContinue,
)
}
}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt
index 8d56386fa4..d4a1a74d99 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt
@@ -8,8 +8,8 @@
package io.element.android.features.login.impl.screens.classic.loginwithclassic
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource
import io.element.android.features.login.impl.classic.ElementClassicConnection
import io.element.android.features.login.impl.classic.ElementClassicConnectionState
import io.element.android.features.login.impl.classic.FakeElementClassicConnection
@@ -249,7 +249,7 @@ private fun createPresenter(
navigator: LoginWithClassicNavigator = FakeLoginWithClassicNavigator(),
loginModePresenter: LoginModePresenter = createLoginModePresenter(),
elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(),
- accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
+ accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(),
isEnterpriseBuild: Boolean = false,
) = LoginWithClassicPresenter(
userId = userId,
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt
index c20896870b..641eea1563 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt
@@ -8,15 +8,19 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
+import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AuthenticationConfig
+import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
+import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource
+import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
+import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.screens.onboarding.createLoginModePresenter
-import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
-import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.test.AN_EXCEPTION
@@ -24,7 +28,12 @@ import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationSer
import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
import io.element.android.libraries.oauth.api.OAuthAction
import io.element.android.libraries.oauth.api.OAuthActionFlow
-import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow
+import io.element.android.libraries.oauth.test.FakeOAuthActionFlow
+import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
+import io.element.android.libraries.permissions.test.FakePermissionsPresenter
+import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
@@ -42,7 +51,7 @@ class ConfirmAccountProviderPresenterTest {
val initialState = awaitItem()
assertThat(initialState.isAccountCreation).isFalse()
assertThat(initialState.submitEnabled).isTrue()
- assertThat(initialState.accountProvider.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
+ assertThat(initialState.accountProviderInput).isEqualTo("matrix.org")
assertThat(initialState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
}
}
@@ -59,14 +68,10 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
- val loadingState = awaitItem()
- assertThat(loadingState.submitEnabled).isTrue()
- assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
- val successState = awaitItem()
- assertThat(successState.submitEnabled).isFalse()
- assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin)
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -82,14 +87,10 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
- val loadingState = awaitItem()
- assertThat(loadingState.submitEnabled).isTrue()
- assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
- val successState = awaitItem()
- assertThat(successState.submitEnabled).isFalse()
- assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -107,18 +108,14 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
- val loadingState = awaitItem()
- assertThat(loadingState.submitEnabled).isTrue()
- assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
- val successState = awaitItem()
- assertThat(successState.submitEnabled).isFalse()
- assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
authenticationService.givenOAuthCancelError(AN_EXCEPTION)
defaultOAuthActionFlow.post(OAuthAction.GoBack())
- val cancelFailureState = awaitItem()
+ val cancelFailureState = awaitLoginMode { it is AsyncData.Failure }
assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -136,17 +133,13 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
- val loadingState = awaitItem()
- assertThat(loadingState.submitEnabled).isTrue()
- assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
- val successState = awaitItem()
- assertThat(successState.submitEnabled).isFalse()
- assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
defaultOAuthActionFlow.post(OAuthAction.GoBack())
- val cancelFinalState = awaitItem()
+ val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized }
assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -164,13 +157,12 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
- val loadingState = awaitItem()
- assertThat(loadingState.submitEnabled).isTrue()
- assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ awaitLoginMode { it is AsyncData.Loading }
defaultOAuthActionFlow.post(OAuthAction.GoBack(toUnblock = true))
- val cancelFinalState = awaitItem()
+ val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized }
assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -188,20 +180,14 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
- val loadingState = awaitItem()
- assertThat(loadingState.submitEnabled).isTrue()
- assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
- val successState = awaitItem()
- assertThat(successState.submitEnabled).isFalse()
- assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
authenticationService.givenLoginError(AN_EXCEPTION)
defaultOAuthActionFlow.post(OAuthAction.Success("aUrl"))
- val cancelLoadingState = awaitItem()
- assertThat(cancelLoadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
- val cancelFailureState = awaitItem()
+ val cancelFailureState = awaitLoginMode { it is AsyncData.Failure }
assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -219,17 +205,13 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
- val loadingState = awaitItem()
- assertThat(loadingState.submitEnabled).isTrue()
- assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
- val successState = awaitItem()
- assertThat(successState.submitEnabled).isFalse()
- assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
defaultOidcActionFlow.post(OAuthAction.Success("aUrl"))
- val successSuccessState = awaitItem()
+ val successSuccessState = awaitLoginMode { it is AsyncData.Loading }
assertThat(successSuccessState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -245,11 +227,11 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
- skipItems(1) // Loading
- val failureState = awaitItem()
- assertThat(failureState.submitEnabled).isFalse()
- assertThat(failureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ // The account provider validation fails, so the login is never attempted.
+ val failureState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure }
+ assertThat(failureState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -266,47 +248,41 @@ class ConfirmAccountProviderPresenterTest {
presenter.test {
val initialState = awaitItem()
- // Submit will return an error
- initialState.eventSink(ConfirmAccountProviderEvents.Continue)
-
- skipItems(1) // Loading
+ // Submit will return an error while validating the account provider
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
// Check an error was returned
- val submittedState = awaitItem()
- assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
+ val submittedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure }
// Assert the error is then cleared
submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
- val clearedState = awaitItem()
+ val clearedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Uninitialized }
assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
}
}
@Test
- fun `present - confirm account creation without oidc and without url generates an error`() = runTest {
+ fun `present - confirm account creation without oidc generates an error`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = {
- Result.success(aMatrixHomeServerDetails())
+ Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true))
},
)
val presenter = createConfirmAccountProviderPresenter(
params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true),
matrixAuthenticationService = authenticationService,
- webClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever {
- throw AccountCreationNotSupported()
- },
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink(ConfirmAccountProviderEvents.Continue)
- skipItems(1) // Loading
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
// Check an error was returned
- val submittedState = awaitItem()
+ val submittedState = awaitLoginMode { it is AsyncData.Failure }
assertThat(submittedState.loginModeState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java)
// Assert the error is then cleared
submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
- val clearedState = awaitItem()
+ val clearedState = awaitLoginMode { it is AsyncData.Uninitialized }
assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -323,72 +299,286 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink(ConfirmAccountProviderEvents.Continue)
- skipItems(1) // Loading
- val submittedState = awaitItem()
- assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ val submittedState = awaitLoginMode { it is AsyncData.Success }
assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
+ cancelAndIgnoreRemainingEvents()
}
}
@Test
- fun `present - confirm account creation with OAuth and url continues with OAuth`() = runTest {
- val aUrl = "aUrl"
+ fun `present - offers an autocomplete suggestion from the account provider history`() = runTest {
+ val presenter = createConfirmAccountProviderPresenter(
+ appPreferencesStore = InMemoryAppPreferencesStore(
+ homeserverHistory = listOf("https://randomcommunity.org"),
+ ),
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.accountProviderSuggestion).isNull()
+ initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random"))
+ val suggestionState = awaitState { it.accountProviderInput == "random" }
+ // Completion is offered without the https:// scheme, even though history stores the full URL.
+ assertThat(suggestionState.accountProviderSuggestion).isEqualTo("randomcommunity.org")
+ }
+ }
+
+ @Test
+ fun `present - continue accepts the autocomplete suggestion rather than the typed prefix`() = runTest {
+ val submittedUrls = mutableListOf()
val authenticationService = FakeMatrixAuthenticationService(
- setHomeserverResult = {
+ setHomeserverResult = { url ->
+ submittedUrls.add(url)
+ Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true))
+ },
+ )
+ val presenter = createConfirmAccountProviderPresenter(
+ matrixAuthenticationService = authenticationService,
+ appPreferencesStore = InMemoryAppPreferencesStore(
+ homeserverHistory = listOf("https://randomcommunity.org"),
+ ),
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random"))
+ val suggestionState = awaitState { it.accountProviderSuggestion != null }
+ suggestionState.eventSink(ConfirmAccountProviderEvents.Continue("randomcommunity.org"))
+ awaitLoginMode { it is AsyncData.Success }
+ assertThat(submittedUrls.first()).isEqualTo("https://randomcommunity.org")
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - continue applies the completed account provider back into the field`() = runTest {
+ val authenticationService = FakeMatrixAuthenticationService(
+ setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true)) },
+ )
+ val presenter = createConfirmAccountProviderPresenter(
+ matrixAuthenticationService = authenticationService,
+ appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = listOf("https://randomcommunity.org")),
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random"))
+ val suggestionState = awaitState { it.accountProviderSuggestion != null }
+ suggestionState.eventSink(ConfirmAccountProviderEvents.Continue("randomcommunity.org"))
+ // The field now renders the full accepted server rather than the typed prefix.
+ val appliedState = awaitState { it.accountProviderInput == "randomcommunity.org" }
+ assertThat(appliedState.accountProviderInput).isEqualTo("randomcommunity.org")
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - offers the enterprise allow-list servers as autocomplete suggestions`() = runTest {
+ val presenter = createConfirmAccountProviderPresenter(
+ appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()),
+ enterpriseService = FakeEnterpriseService(
+ // Preferred servers plus the "*" (any) wildcard: the wildcard is filtered out, the rest complete.
+ defaultHomeserverListResult = { listOf("https://element.io", EnterpriseService.ANY_ACCOUNT_PROVIDER) },
+ ),
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("ele"))
+ val suggestionState = awaitState { it.accountProviderInput == "ele" }
+ assertThat(suggestionState.accountProviderSuggestion).isEqualTo("element.io")
+ }
+ }
+
+ @Test
+ fun `present - offers matrix_org without the scheme as an autocomplete suggestion`() = runTest {
+ val presenter = createConfirmAccountProviderPresenter(
+ appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()),
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("matr"))
+ val suggestionState = awaitState { it.accountProviderInput == "matr" }
+ assertThat(suggestionState.accountProviderSuggestion).isEqualTo("matrix.org")
+ }
+ }
+
+ @Test
+ fun `present - does not autocomplete a scheme-prefixed input`() = runTest {
+ // Candidates are bare hosts, so a scheme-prefixed input never completes them - the user completes by
+ // typing the bare host. This also avoids ever suggesting an insecure http scheme.
+ val presenter = createConfirmAccountProviderPresenter(
+ appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()),
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://mat"))
+ assertThat(awaitState { it.accountProviderInput == "https://mat" }.accountProviderSuggestion).isNull()
+ initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("http://mat"))
+ assertThat(awaitState { it.accountProviderInput == "http://mat" }.accountProviderSuggestion).isNull()
+ }
+ }
+
+ @Test
+ fun `present - continue prepends https to a bare account provider`() = runTest {
+ val submittedUrls = mutableListOf()
+ val authenticationService = FakeMatrixAuthenticationService(
+ setHomeserverResult = { url ->
+ submittedUrls.add(url)
Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
},
)
val presenter = createConfirmAccountProviderPresenter(
- params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true),
matrixAuthenticationService = authenticationService,
- webClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever { aUrl },
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink(ConfirmAccountProviderEvents.Continue)
- skipItems(1) // Loading
- val submittedState = awaitItem()
- assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
- assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue("matrix.org"))
+ awaitLoginMode { it is AsyncData.Success }
+ assertThat(submittedUrls.first()).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
+ cancelAndIgnoreRemainingEvents()
}
}
@Test
- fun `present - confirm account creation without OAuth and with url continuing with url`() = runTest {
- val aUrl = "aUrl"
+ fun `present - continue configures the homeserver only once`() = runTest {
+ // The consolidated Continue flow validates the account provider and then signs in. Login reuses the
+ // details resolved during validation, so setHomeserver runs exactly once rather than once per phase.
+ val submittedUrls = mutableListOf()
val authenticationService = FakeMatrixAuthenticationService(
- setHomeserverResult = {
- Result.success(aMatrixHomeServerDetails())
+ setHomeserverResult = { url ->
+ submittedUrls.add(url)
+ Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
},
)
+ val presenter = createConfirmAccountProviderPresenter(
+ matrixAuthenticationService = authenticationService,
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
+ awaitLoginMode { it is AsyncData.Success }
+ assertThat(submittedUrls).containsExactly(AuthenticationConfig.MATRIX_ORG_URL)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - continue with a full matrix id signs in to its homeserver and passes a login hint`() = runTest {
+ val submittedUrls = mutableListOf()
+ val authenticationService = FakeMatrixAuthenticationService(
+ setHomeserverResult = { url ->
+ submittedUrls.add(url)
+ Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
+ },
+ )
+ val presenter = createConfirmAccountProviderPresenter(matrixAuthenticationService = authenticationService)
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue("@alice:example.org"))
+ awaitLoginMode { it is AsyncData.Success }
+ assertThat(submittedUrls.first()).isEqualTo("https://example.org")
+ assertThat(authenticationService.getOAuthUrlLoginHint).isEqualTo("mxid:@alice:example.org")
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - continue with a matrix id keeps the port in the homeserver`() = runTest {
+ val submittedUrls = mutableListOf()
+ val authenticationService = FakeMatrixAuthenticationService(
+ setHomeserverResult = { url ->
+ submittedUrls.add(url)
+ Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
+ },
+ )
+ val presenter = createConfirmAccountProviderPresenter(matrixAuthenticationService = authenticationService)
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue("@alice:example.org:8448"))
+ awaitLoginMode { it is AsyncData.Success }
+ assertThat(submittedUrls.first()).isEqualTo("https://example.org:8448")
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - continue with a plain account provider passes no login hint`() = runTest {
+ val authenticationService = FakeMatrixAuthenticationService(
+ setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) },
+ )
+ val presenter = createConfirmAccountProviderPresenter(matrixAuthenticationService = authenticationService)
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue("matrix.org"))
+ awaitLoginMode { it is AsyncData.Success }
+ assertThat(authenticationService.getOAuthUrlLoginHint).isNull()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - continue with a matrix id during account creation passes no login hint`() = runTest {
+ val authenticationService = FakeMatrixAuthenticationService(
+ setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) },
+ )
val presenter = createConfirmAccountProviderPresenter(
params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true),
matrixAuthenticationService = authenticationService,
- webClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever { aUrl },
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink(ConfirmAccountProviderEvents.Continue)
- skipItems(1) // Loading
- val submittedState = awaitItem()
- assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.AccountCreation(aUrl))
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue("@alice:example.org"))
+ awaitLoginMode { it is AsyncData.Success }
+ assertThat(authenticationService.getOAuthUrlLoginHint).isNull()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ /**
+ * Awaits until the emitted state's login mode matches [predicate], skipping the intermediate
+ * account-provider validation states, and returns that state.
+ */
+ private suspend fun ReceiveTurbine.awaitLoginMode(
+ predicate: (AsyncData) -> Boolean,
+ ): ConfirmAccountProviderState = awaitState { predicate(it.loginModeState.loginMode) }
+
+ private suspend fun ReceiveTurbine.awaitState(
+ predicate: (ConfirmAccountProviderState) -> Boolean,
+ ): ConfirmAccountProviderState {
+ while (true) {
+ val item = awaitItem()
+ if (predicate(item)) return item
}
}
private fun createConfirmAccountProviderPresenter(
params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false),
- accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
+ accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(),
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
defaultOAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(),
- webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
+ appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
+ enterpriseService: EnterpriseService = FakeEnterpriseService(),
) = ConfirmAccountProviderPresenter(
params = params,
accountProviderDataSource = accountProviderDataSource,
+ appPreferencesStore = appPreferencesStore,
+ enterpriseService = enterpriseService,
loginModePresenter = createLoginModePresenter(
authenticationService = matrixAuthenticationService,
oAuthActionFlow = defaultOAuthActionFlow,
- webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever,
+ ),
+ changeServerPresenter = ChangeServerPresenter(
+ authenticationService = matrixAuthenticationService,
+ accountProviderDataSource = accountProviderDataSource,
+ defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
+ enterpriseService = FakeEnterpriseService(
+ isAllowedToConnectToHomeserverResult = { true },
+ isElementProEnforcedResult = { false },
+ ),
+ isEnterpriseBuild = { false },
+ ),
+ localNetworkPermissionGate = LocalNetworkPermissionGate(
+ advisor = FakeLocalNetworkPermissionAdvisor(),
+ permissionsPresenterFactory = FakePermissionsPresenterFactory(FakePermissionsPresenter()),
+ ),
),
)
}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt
deleted file mode 100644
index c70a5c9a9a..0000000000
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt
+++ /dev/null
@@ -1,124 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2024, 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.login.impl.screens.createaccount
-
-import com.google.common.truth.Truth.assertThat
-import io.element.android.libraries.architecture.AsyncAction
-import io.element.android.libraries.core.meta.BuildMeta
-import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
-import io.element.android.libraries.matrix.api.auth.external.ExternalSession
-import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
-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.auth.FakeMatrixAuthenticationService
-import io.element.android.libraries.matrix.test.core.aBuildMeta
-import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
-import io.element.android.tests.testutils.WarmUpRule
-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 CreateAccountPresenterTest {
- @get:Rule
- val warmUpRule = WarmUpRule()
-
- @Test
- fun `present - initial state`() = runTest {
- val presenter = createPresenter()
- presenter.test {
- val initialState = awaitItem()
- assertThat(initialState.url).isEqualTo("aUrl")
- assertThat(initialState.pageProgress).isEqualTo(0)
- assertThat(initialState.createAction).isEqualTo(AsyncAction.Uninitialized)
- assertThat(initialState.isDebugBuild).isTrue()
- }
- }
-
- @Test
- fun `present - set up progress update the state`() = runTest {
- val presenter = createPresenter()
- presenter.test {
- val initialState = awaitItem()
- initialState.eventSink(CreateAccountEvents.SetPageProgress(33))
- assertThat(awaitItem().pageProgress).isEqualTo(33)
- }
- }
-
- @Test
- fun `present - receiving a message not able to be parsed change the state to error`() = runTest {
- val presenter = createPresenter(
- messageParser = FakeMessageParser { error("An error") }
- )
- presenter.test {
- val initialState = awaitItem()
- initialState.eventSink(CreateAccountEvents.OnMessageReceived(""))
- assertThat(awaitItem().createAction).isInstanceOf(AsyncAction.Failure::class.java)
- }
- }
-
- @Test
- fun `present - receiving a message containing isTrusted is ignored`() = runTest {
- val presenter = createPresenter()
- presenter.test {
- val initialState = awaitItem()
- initialState.eventSink(CreateAccountEvents.OnMessageReceived("isTrusted"))
- }
- }
-
- @Test
- fun `present - receiving a message able to be parsed change the state to success`() = runTest {
- val lambda = lambdaRecorder { _ -> anExternalSession() }
- val sessionVerificationService = FakeSessionVerificationService()
- val presenter = createPresenter(
- authenticationService = FakeMatrixAuthenticationService(
- importCreatedSessionLambda = { Result.success(A_SESSION_ID) }
- ),
- messageParser = FakeMessageParser(lambda),
- )
- presenter.test {
- val initialState = awaitItem()
- initialState.eventSink(CreateAccountEvents.OnMessageReceived("aMessage"))
- assertThat(awaitItem().createAction.isLoading()).isTrue()
- sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
- assertThat(awaitItem().createAction.dataOrNull()).isEqualTo(A_SESSION_ID)
- }
- lambda.assertions().isCalledOnce().with(value("aMessage"))
- }
-
- @Test
- fun `present - receiving a message able to be parsed but error in importing change the state to error`() = runTest {
- val presenter = createPresenter(
- authenticationService = FakeMatrixAuthenticationService(
- importCreatedSessionLambda = { Result.failure(AN_EXCEPTION) }
- ),
- messageParser = FakeMessageParser { anExternalSession() }
- )
- presenter.test {
- val initialState = awaitItem()
- initialState.eventSink(CreateAccountEvents.OnMessageReceived(""))
- assertThat(awaitItem().createAction.isLoading()).isTrue()
- assertThat(awaitItem().createAction.errorOrNull()).isNotNull()
- }
- }
-
- private fun createPresenter(
- url: String = "aUrl",
- authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
- messageParser: MessageParser = FakeMessageParser(),
- buildMeta: BuildMeta = aBuildMeta(),
- ) = CreateAccountPresenter(
- url = url,
- authenticationService = authenticationService,
- messageParser = messageParser,
- buildMeta = buildMeta,
- )
-}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/DefaultMessageParserTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/DefaultMessageParserTest.kt
deleted file mode 100644
index 680fda2838..0000000000
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/DefaultMessageParserTest.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2024, 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.login.impl.screens.createaccount
-
-import com.google.common.truth.Truth.assertThat
-import io.element.android.appconfig.AuthenticationConfig
-import io.element.android.features.enterprise.test.FakeEnterpriseService
-import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
-import io.element.android.libraries.androidutils.json.DefaultJsonProvider
-import io.element.android.libraries.matrix.api.auth.external.ExternalSession
-import kotlinx.serialization.SerializationException
-import org.junit.Assert.assertThrows
-import org.junit.Test
-
-class DefaultMessageParserTest {
- private val validMessage = """
- {
- "user_id": "user_id",
- "home_server": "home_server",
- "access_token": "access_token",
- "device_id": "device_id"
- }
- """.trimIndent()
-
- @Test
- fun `DefaultMessageParser is able to parse correct message`() {
- val sut = createDefaultMessageParser()
- assertThat(sut.parse(validMessage)).isEqualTo(
- anExternalSession(
- homeserverUrl = "home_server",
- )
- )
- }
-
- @Test
- fun `DefaultMessageParser should throw Exception in case of error`() {
- val sut = createDefaultMessageParser()
- // kotlinx.serialization.json.internal.JsonDecodingException
- assertThrows(SerializationException::class.java) { sut.parse("invalid json") }
- // missing userId
- assertThrows(IllegalStateException::class.java) { sut.parse(validMessage.replace(""""user_id": "user_id",""", "")) }
- // missing accessToken
- assertThrows(IllegalStateException::class.java) { sut.parse(validMessage.replace(""""access_token": "access_token",""", "")) }
- // missing deviceId
- assertThrows(IllegalStateException::class.java) {
- sut.parse(
- validMessage
- .replace(""""access_token": "access_token",""", """"access_token": "access_token"""")
- .replace(""""device_id": "device_id"""", "")
- )
- }
- }
-
- @Test
- fun `DefaultMessageParser should be successful even is homeserver url is missing`() {
- val sut = createDefaultMessageParser()
- // missing homeServer
- assertThat(sut.parse(validMessage.replace(""""home_server": "home_server",""", ""))).isEqualTo(
- anExternalSession(
- homeserverUrl = AuthenticationConfig.MATRIX_ORG_URL,
- )
- )
- }
-
- private fun createDefaultMessageParser(): DefaultMessageParser {
- return DefaultMessageParser(
- accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
- json = DefaultJsonProvider(),
- )
- }
-}
-
-internal fun anExternalSession(
- homeserverUrl: String = "home_server",
-) = ExternalSession(
- userId = "user_id",
- homeserverUrl = homeserverUrl,
- accessToken = "access_token",
- deviceId = "device_id",
- refreshToken = null,
-)
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/FakeMessageParser.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/FakeMessageParser.kt
deleted file mode 100644
index 62d6dd7dd4..0000000000
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/FakeMessageParser.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2024, 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.login.impl.screens.createaccount
-
-import io.element.android.libraries.matrix.api.auth.external.ExternalSession
-import io.element.android.tests.testutils.lambda.lambdaError
-
-class FakeMessageParser(
- private val parseResult: (String) -> ExternalSession = { lambdaError() }
-) : MessageParser {
- override fun parse(message: String): ExternalSession {
- return parseResult(message)
- }
-}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt
index 31a835cb8c..4dd2859dee 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt
@@ -10,8 +10,9 @@ package io.element.android.features.login.impl.screens.loginpassword
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AuthenticationConfig
-import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory
+import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.AN_EXCEPTION
@@ -21,8 +22,11 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.A_USER_NAME_2
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -101,6 +105,32 @@ class LoginPasswordPresenterTest {
}
}
+ @Test
+ fun `present - successful login saves the account provider to history`() = runTest {
+ val authenticationService = FakeMatrixAuthenticationService(
+ setHomeserverResult = {
+ Result.success(aMatrixHomeServerDetails())
+ },
+ )
+ val appPreferencesStore = InMemoryAppPreferencesStore()
+ createLoginPasswordPresenter(
+ authenticationService = authenticationService,
+ appPreferencesStore = appPreferencesStore,
+ ).test {
+ val initialState = awaitItem()
+ initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME))
+ initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
+ skipItems(1)
+ val loginAndPasswordState = awaitItem()
+ loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit)
+ skipItems(1)
+ val loggedInState = awaitItem()
+ assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Success(A_SESSION_ID))
+ assertThat(appPreferencesStore.getHomeserverHistoryFlow().first())
+ .containsExactly(AuthenticationConfig.MATRIX_ORG_URL)
+ }
+ }
+
@Test
fun `present - submit with error`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
@@ -157,10 +187,12 @@ class LoginPasswordPresenterTest {
private fun createLoginPasswordPresenter(
initialLogin: String = "",
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
- accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
+ accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(),
+ appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
): LoginPasswordPresenter = LoginPasswordPresenter(
initialLogin = initialLogin,
authenticationService = authenticationService,
accountProviderDataSource = accountProviderDataSource,
+ saveAccountProviderToHistory = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore),
)
}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt
index 3a128f2e4f..d76c94d5ea 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt
@@ -81,6 +81,21 @@ class LoginPasswordViewTest : RobolectricTest() {
)
}
+ @Test
+ fun `changing login removes carriage returns the expected event`() = runAndroidComposeUiTest {
+ val eventsRecorder = EventsRecorder()
+ setLoginPasswordView(
+ aLoginPasswordState(
+ eventSink = eventsRecorder,
+ ),
+ )
+ val userNameHint = activity!!.getString(CommonStrings.common_username)
+ onNodeWithText(userNameHint).performTextInput("a\r\nb")
+ eventsRecorder.assertSingle(
+ LoginPasswordEvents.SetLogin("ab")
+ )
+ }
+
@Test
fun `clearing login invokes the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
@@ -112,6 +127,21 @@ class LoginPasswordViewTest : RobolectricTest() {
)
}
+ @Test
+ fun `changing password removes carriage returns the expected event`() = runAndroidComposeUiTest {
+ val eventsRecorder = EventsRecorder()
+ setLoginPasswordView(
+ aLoginPasswordState(
+ eventSink = eventsRecorder,
+ ),
+ )
+ val passwordHint = activity!!.getString(CommonStrings.common_password)
+ onNodeWithText(passwordHint).performTextInput("a\r\nb")
+ eventsRecorder.assertSingle(
+ LoginPasswordEvents.SetPassword("ab")
+ )
+ }
+
@Test
fun `reveal password makes the password visible`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt
index 79268aa1e4..3c2e4b574f 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt
@@ -12,14 +12,14 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.enterprise.api.EnterpriseService
+import io.element.android.features.enterprise.api.IsEnterpriseBuild
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory
+import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.login.impl.login.LoginModePresenter
-import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
-import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
-import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@@ -33,12 +33,15 @@ import io.element.android.libraries.matrix.test.A_LOGIN_HINT
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.oauth.api.OAuthActionFlow
-import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow
+import io.element.android.libraries.oauth.test.FakeOAuthActionFlow
+import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
+import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
+import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
-import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.Flow
@@ -183,6 +186,7 @@ class OnBoardingPresenterTest {
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) },
isAllowedToConnectToHomeserverResult = { true },
+ isElementProEnforcedResult = { false },
),
)
presenter.test {
@@ -247,7 +251,7 @@ class OnBoardingPresenterTest {
Result.failure(AN_EXCEPTION)
},
)
- val accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService())
+ val accountProviderDataSource = anAccountProviderDataSource()
val presenter = createPresenter(
params = OnBoardingNode.Params(
accountProvider = A_HOMESERVER_URL,
@@ -256,6 +260,7 @@ class OnBoardingPresenterTest {
),
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
+ isElementProEnforcedResult = { false },
),
loginModePresenter = createLoginModePresenter(
authenticationService = authenticationService,
@@ -292,19 +297,19 @@ private fun createPresenter(
),
buildMeta: BuildMeta = aBuildMeta(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
- wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
+ isEnterpriseBuild: IsEnterpriseBuild = { false },
rageshakeFeatureAvailability: () -> Flow = { flowOf(true) },
loginModePresenter: LoginModePresenter = createLoginModePresenter(),
onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider = OnBoardingLogoResIdProvider { null },
sessionStore: SessionStore = InMemorySessionStore(),
- accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
+ accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(),
) = OnBoardingPresenter(
params = params,
buildMeta = buildMeta,
enterpriseService = enterpriseService,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService,
- wellknownRetriever = wellknownRetriever,
+ isEnterpriseBuild = isEnterpriseBuild,
),
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
loginModePresenter = loginModePresenter,
@@ -316,17 +321,18 @@ private fun createPresenter(
fun createLoginModePresenter(
oAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(),
authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
- webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor =
- io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor(),
- permissionsPresenterFactory: io.element.android.libraries.permissions.api.PermissionsPresenter.Factory =
- io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory(),
+ FakeLocalNetworkPermissionAdvisor(),
+ permissionsPresenterFactory: PermissionsPresenter.Factory =
+ FakePermissionsPresenterFactory(),
+ saveAccountProviderToHistory: SaveAccountProviderToHistory =
+ SaveAccountProviderToHistory(anAccountProviderDataSource(), InMemoryAppPreferencesStore()),
): LoginModePresenter = LoginModePresenter(
oAuthActionFlow = oAuthActionFlow,
authenticationService = authenticationService,
- webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever,
localNetworkPermissionGate = LocalNetworkPermissionGate(
advisor = localNetworkPermissionAdvisor,
permissionsPresenterFactory = permissionsPresenterFactory,
),
+ saveAccountProviderToHistory = saveAccountProviderToHistory,
)
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt
index 1bdcb9bda1..dfa4119711 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt
@@ -234,22 +234,6 @@ class OnboardingViewTest : RobolectricTestParameter() {
eventSink.assertSingle(OnBoardingEvents.ClearError)
}
- @Test
- fun `when success AccountCreation - the expected callback is invoked and the event is received`() = runAndroidComposeUiTest {
- val eventSink = EventsRecorder()
- val oAuthDetails = OAuthDetails("aUrl")
- ensureCalledOnceWithParam(oAuthDetails.url) { callback ->
- setOnboardingView(
- state = anOnBoardingState(
- loginModeState = aLoginModeState(loginMode = AsyncData.Success(LoginMode.AccountCreation("aUrl"))),
- eventSink = eventSink,
- ),
- onCreateAccountContinue = callback,
- )
- }
- eventSink.assertSingle(OnBoardingEvents.ClearError)
- }
-
private fun AndroidComposeUiTest.setOnboardingView(
state: OnBoardingState,
onBackClick: () -> Unit = EnsureNeverCalled(),
@@ -261,7 +245,6 @@ class OnboardingViewTest : RobolectricTestParameter() {
onOAuthDetails: (OAuthDetails) -> Unit = EnsureNeverCalledWithParam(),
onNeedLoginPassword: () -> Unit = EnsureNeverCalled(),
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
- onCreateAccountContinue: (url: String) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
OnBoardingView(
@@ -275,7 +258,6 @@ class OnboardingViewTest : RobolectricTestParameter() {
onOAuthDetails = onOAuthDetails,
onNeedLoginPassword = onNeedLoginPassword,
onLearnMoreClick = onLearnMoreClick,
- onCreateAccountContinue = onCreateAccountContinue,
)
}
}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt
deleted file mode 100644
index 29c5d4a86c..0000000000
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt
+++ /dev/null
@@ -1,179 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.login.impl.screens.searchaccountprovider
-
-import com.google.common.truth.Truth.assertThat
-import io.element.android.features.login.impl.changeserver.aChangeServerState
-import io.element.android.features.login.impl.resolver.HomeserverResolver
-import io.element.android.libraries.architecture.AsyncData
-import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker
-import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
-import io.element.android.tests.testutils.WarmUpRule
-import io.element.android.tests.testutils.lambda.lambdaRecorder
-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.test.runTest
-import org.junit.Rule
-import org.junit.Test
-
-class SearchAccountProviderPresenterTest {
- @get:Rule
- val warmUpRule = WarmUpRule()
-
- @Test
- fun `present - initial state`() = runTest {
- val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(true) })
- val presenter = SearchAccountProviderPresenter(
- homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
- changeServerPresenter = { aChangeServerState() }
- )
- presenter.test {
- val initialState = awaitItem()
- assertThat(initialState.userInput).isEmpty()
- assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized)
- }
- }
-
- @Test
- fun `present - error while checking login compatibility`() = runTest {
- val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.failure(IllegalStateException("Oops")) })
- val presenter = SearchAccountProviderPresenter(
- homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
- changeServerPresenter = { aChangeServerState() }
- )
- presenter.test {
- val initialState = awaitItem()
- initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("https://test.org"))
- val withInputState = awaitItem()
- assertThat(withInputState.userInput).isEqualTo("https://test.org")
- assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized)
- assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java)
- assertThat(awaitItem().userInputResult).isEqualTo(
- AsyncData.Success(
- listOf(
- aHomeserverData(homeserverUrl = "https://test.org")
- )
- )
- )
- }
- }
-
- @Test
- fun `present - enter text no result`() = runTest {
- val fakeWellknownRetriever = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(false) })
- val presenter = SearchAccountProviderPresenter(
- homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever, FakeLocalNetworkPermissionAdvisor()),
- changeServerPresenter = { aChangeServerState() }
- )
- presenter.test {
- val initialState = awaitItem()
- initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test"))
- val withInputState = awaitItem()
- assertThat(withInputState.userInput).isEqualTo("test")
- assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized)
- assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java)
- assertThat(awaitItem().userInputResult).isEqualTo(
- AsyncData.Success(
- listOf(
- aHomeserverData(homeserverUrl = "https://test"),
- )
- )
- )
- }
- }
-
- @Test
- fun `present - enter text one result with wellknown`() = runTest {
- val checkResult = lambdaRecorder> {
- when (it) {
- "https://test.org" -> Result.success(false)
- "https://test.com" -> Result.success(false)
- "https://test.io" -> Result.success(true)
- "https://test" -> Result.success(false)
- else -> error("should not happen")
- }
- }
- val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult)
- val presenter = SearchAccountProviderPresenter(
- homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
- changeServerPresenter = { aChangeServerState() }
- )
- presenter.test {
- val initialState = awaitItem()
- initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test"))
- val withInputState = awaitItem()
- assertThat(withInputState.userInput).isEqualTo("test")
- assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized)
- assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java)
- assertThat(awaitItem().userInputResult).isEqualTo(
- AsyncData.Success(
- listOf(
- aHomeserverData(homeserverUrl = "https://test.io")
- )
- )
- )
- checkResult.assertions().isCalledExactly(4)
- .withSequence(
- listOf(value("https://test.org")),
- listOf(value("https://test.com")),
- listOf(value("https://test.io")),
- listOf(value("https://test")),
- )
- }
- }
-
- @Test
- fun `present - enter text two results with wellknown`() = runTest {
- val checkResult = lambdaRecorder> {
- when (it) {
- "https://test.org" -> Result.success(true)
- "https://test.com" -> Result.success(false)
- "https://test.io" -> Result.success(true)
- "https://test" -> Result.success(false)
- else -> error("should not happen")
- }
- }
- val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult)
- val presenter = SearchAccountProviderPresenter(
- homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
- changeServerPresenter = { aChangeServerState() }
- )
- presenter.test {
- val initialState = awaitItem()
- initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test"))
- val withInputState = awaitItem()
- assertThat(withInputState.userInput).isEqualTo("test")
- assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized)
- assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java)
- assertThat(awaitItem().userInputResult).isEqualTo(
- AsyncData.Success(
- listOf(
- aHomeserverData(homeserverUrl = "https://test.org"),
- )
- )
- )
- assertThat(awaitItem().userInputResult).isEqualTo(
- AsyncData.Success(
- listOf(
- aHomeserverData(homeserverUrl = "https://test.org"),
- aHomeserverData(homeserverUrl = "https://test.io"),
- )
- )
- )
- checkResult.assertions().isCalledExactly(4)
- .withSequence(
- listOf(value("https://test.org")),
- listOf(value("https://test.com")),
- listOf(value("https://test.io")),
- listOf(value("https://test")),
- )
- }
- }
-}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/web/FakeWebClientUrlForAuthenticationRetriever.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/web/FakeWebClientUrlForAuthenticationRetriever.kt
deleted file mode 100644
index f52af0adf3..0000000000
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/web/FakeWebClientUrlForAuthenticationRetriever.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2024, 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.login.impl.web
-
-import io.element.android.tests.testutils.lambda.lambdaError
-
-class FakeWebClientUrlForAuthenticationRetriever(
- private val retrieveLambda: suspend (homeServerUrl: String) -> String = { lambdaError() }
-) : WebClientUrlForAuthenticationRetriever {
- override suspend fun retrieve(homeServerUrl: String): String {
- return retrieveLambda(homeServerUrl)
- }
-}
diff --git a/features/login/test/build.gradle.kts b/features/login/test/build.gradle.kts
index 7073648dd2..4cbf0910db 100644
--- a/features/login/test/build.gradle.kts
+++ b/features/login/test/build.gradle.kts
@@ -15,6 +15,7 @@ android {
}
dependencies {
+ implementation(projects.libraries.architecture)
implementation(projects.features.login.api)
implementation(projects.tests.testutils)
}
diff --git a/features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginEntryPoint.kt b/features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginEntryPoint.kt
new file mode 100644
index 0000000000..2c94144890
--- /dev/null
+++ b/features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginEntryPoint.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.login.test
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import io.element.android.features.login.api.LoginEntryPoint
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeLoginEntryPoint(
+ private val createNodeResult: (BuildContext, LoginEntryPoint.Params) -> Node = { _, _ -> lambdaError() },
+) : LoginEntryPoint {
+ override fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ params: LoginEntryPoint.Params,
+ callback: LoginEntryPoint.Callback,
+ ): Node = createNodeResult(buildContext, params)
+}
diff --git a/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt b/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt
new file mode 100644
index 0000000000..9ebd3be7fc
--- /dev/null
+++ b/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.login.test.accesscontrol
+
+import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeAccountProviderAccessControl(
+ private val isAllowedToConnectToAccountProviderResult: (String) -> Boolean = { lambdaError() },
+) : AccountProviderAccessControl {
+ override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String): Boolean {
+ return isAllowedToConnectToAccountProviderResult(accountProviderUrl)
+ }
+}
diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutStateProvider.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutStatePreviewParam.kt
similarity index 92%
rename from features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutStateProvider.kt
rename to features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutStatePreviewParam.kt
index feaf21ef4a..c208978fd9 100644
--- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutStateProvider.kt
+++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutStatePreviewParam.kt
@@ -11,7 +11,7 @@ package io.element.android.features.logout.api.direct
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
-open class DirectLogoutStateProvider : PreviewParameterProvider {
+open class DirectLogoutStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aDirectLogoutState(),
diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutView.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutView.kt
index 7841b85a5a..affd2be94d 100644
--- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutView.kt
+++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutView.kt
@@ -10,7 +10,17 @@ package io.element.android.features.logout.api.direct
import androidx.compose.runtime.Composable
+/**
+ * Renders the confirmation dialogs of logging out directly, in particular the warning shown when the user would lose their room keys.
+ *
+ * It draws nothing until the host screen sends a logout event.
+ */
fun interface DirectLogoutView {
+ /**
+ * Draws whichever dialog the current state calls for.
+ *
+ * @param state the state produced by the direct logout presenter.
+ */
@Composable
fun Render(state: DirectLogoutState)
}
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStatePreviewParam.kt
similarity index 97%
rename from features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt
rename to features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStatePreviewParam.kt
index a3b59860b5..7bd55faa2c 100644
--- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStatePreviewParam.kt
@@ -15,7 +15,7 @@ import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.encryption.SteadyStateException
-open class LogoutStateProvider : PreviewParameterProvider {
+open class LogoutStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aLogoutState(),
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt
index 9700e54f5c..befabe7421 100644
--- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt
@@ -186,7 +186,7 @@ private fun Content(
@PreviewsDayNight
@Composable
internal fun LogoutViewPreview(
- @PreviewParameter(LogoutStateProvider::class) state: LogoutState,
+ @PreviewParameter(LogoutStatePreviewParam::class) state: LogoutState,
) = ElementPreview {
LogoutView(
state,
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/di/LogoutModule.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/di/LogoutBindingContainer.kt
similarity index 95%
rename from features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/di/LogoutModule.kt
rename to features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/di/LogoutBindingContainer.kt
index 9b38fde22e..879d0fc5cf 100644
--- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/di/LogoutModule.kt
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/di/LogoutBindingContainer.kt
@@ -18,7 +18,7 @@ import io.element.android.libraries.di.SessionScope
@ContributesTo(SessionScope::class)
@BindingContainer
-interface LogoutModule {
+interface LogoutBindingContainer {
@Binds
fun bindDirectLogoutPresenter(presenter: DirectLogoutPresenter): Presenter
}
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt
index 7fb483a447..a8cad6ffba 100644
--- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt
@@ -13,7 +13,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import dev.zacsweers.metro.ContributesBinding
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.DirectLogoutStateProvider
+import io.element.android.features.logout.api.direct.DirectLogoutStatePreviewParam
import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.features.logout.impl.ui.LogoutActionDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -43,7 +43,7 @@ class DefaultDirectLogoutView : DirectLogoutView {
@PreviewsDayNight
@Composable
internal fun DefaultDirectLogoutViewPreview(
- @PreviewParameter(DirectLogoutStateProvider::class) state: DirectLogoutState,
+ @PreviewParameter(DirectLogoutStatePreviewParam::class) state: DirectLogoutState,
) = ElementPreview {
DefaultDirectLogoutView().Render(state = state)
}
diff --git a/features/logout/impl/src/main/res/values-ar/translations.xml b/features/logout/impl/src/main/res/values-ar/translations.xml
new file mode 100644
index 0000000000..6825a5c54d
--- /dev/null
+++ b/features/logout/impl/src/main/res/values-ar/translations.xml
@@ -0,0 +1,18 @@
+
+
+ "أمتأكد من إزالة هذا الجهاز؟"
+ "إزالة هذا الجهاز"
+ "إزالة هذا الجهاز"
+ "تجري إزالة الجهاز…"
+ "هذا هو جهازك الوحيد. إذا أزلته، فستحتاج إلى مفتاح استرداد لتأكيد هويتك الرقمية واستعادة محادثاتك المشفّرة في المرة التالية التي تسجّل فيها الدخول."
+ "أنت على وشك فقدان الوصول إلى محادثاتك المشفّرة"
+ "كان النسخ الاحتياطي لمفاتيحك لا يزال جاريًا عندما أصبحت غير متصل. أعد الاتصال حتى يمكن نسخ مفاتيحك احتياطيًا قبل إزالة هذا الجهاز."
+ "لا يزال النسخ الاحتياطي لمفاتيحك جاريًا"
+ "يُرجى الانتظار حتى تكتمل هذه العملية قبل إزالة هذا الجهاز."
+ "لا يزال النسخ الاحتياطي لمفاتيحك جاريًا"
+ "إزالة هذا الجهاز"
+ "هذا هو جهازك الوحيد. إذا أزلته، فستحتاج إلى مفتاح استرداد لتأكيد هويتك الرقمية واستعادة محادثاتك المشفّرة في المرة التالية التي تسجّل فيها الدخول."
+ "أنت على وشك فقدان الوصول إلى محادثاتك المشفّرة"
+ "هذا هو جهازك الوحيد. إذا أزلته، فستحتاج إلى مفتاح استرداد لتأكيد هويتك الرقمية واستعادة محادثاتك المشفّرة في المرة التالية التي تسجّل فيها الدخول."
+ "تأكد من أن لديك إمكانية الوصول إلى مفتاح الاسترداد الخاص بك قبل إزالة هذا الجهاز"
+
diff --git a/features/logout/impl/src/main/res/values-es/translations.xml b/features/logout/impl/src/main/res/values-es/translations.xml
index 94d0ec56a8..6c451c0834 100644
--- a/features/logout/impl/src/main/res/values-es/translations.xml
+++ b/features/logout/impl/src/main/res/values-es/translations.xml
@@ -1,18 +1,18 @@
- "¿Estás seguro de que quieres cerrar sesión?"
- "Cerrar sesión"
- "Cerrar sesión"
- "Cerrando sesión…"
- "Estás a punto de cerrar tu última sesión. Si cierras sesión ahora, perderás el acceso a tus mensajes cifrados."
- "Has desactivado la copia de seguridad"
- "Se estaba haciendo una copia de seguridad de tus claves cuando te desconectaste. Vuelve a conectarte para que se haga una copia de seguridad de tus claves antes de desconectarte."
+ "¿Estás seguro de que quieres eliminar este dispositivo?"
+ "Eliminar este dispositivo"
+ "Eliminar este dispositivo"
+ "Eliminando dispositivo…"
+ "Este es tu único dispositivo. Si lo eliminas, necesitarás una clave de recuperación para poder confirmar tu identidad digital y restaurar tus chats cifrados la próxima vez que inicies sesión."
+ "Estás a punto de perder acceso a tus chats cifrados"
+ "Se estaba haciendo una copia de seguridad de tus claves cuando te desconectaste. Vuelve a conectarte para que se haga una copia de seguridad de tus claves antes de eliminar este dispositivo."
"Se sigue guardando una copia de seguridad de tus claves"
- "Espera a que se complete antes de cerrar sesión."
+ "Espera a que se complete antes de eliminar este dispositivo."
"Se sigue guardando una copia de seguridad de tus claves"
- "Cerrar sesión"
- "Estás a punto de cerrar tu última sesión. Si cierras sesión ahora, perderás el acceso a tus mensajes cifrados."
- "La recuperación no está configurada"
- "Estás a punto de cerrar tu última sesión. Si cierras la sesión ahora, podrías perder el acceso a tus mensajes cifrados."
- "¿Has guardado tu clave de recuperación?"
+ "Eliminar este dispositivo"
+ "Este es tu único dispositivo. Si lo eliminas, necesitarás una clave de recuperación para poder confirmar tu identidad digital y restaurar tus chats cifrados la próxima vez que inicies sesión."
+ "Estás a punto de perder el acceso a tus chats cifrados."
+ "Este es tu único dispositivo. Si lo eliminas, necesitarás una clave de recuperación para poder confirmar tu identidad digital y restaurar tus chats cifrados la próxima vez que inicies sesión."
+ "Asegúrate de tener acceso a tu clave de recuperación antes de eliminar este dispositivo"
diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt
index 534af8a5f9..12af1b32f2 100644
--- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt
+++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt
@@ -17,5 +17,6 @@ import io.element.android.libraries.textcomposer.model.MessageComposerMode
* the composer is in a thread, if it's editing a message, etc.
*/
interface MessageComposerContext {
+ /** What the composer is currently doing: writing a new message, editing one, replying, or composing in a thread. */
val composerMode: MessageComposerMode
}
diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt
index 3eecd54f3e..dd49dd0465 100644
--- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt
+++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt
@@ -50,7 +50,16 @@ interface MessagesEntryPoint : FeatureEntryPoint {
callback: Callback,
): Node
+ /**
+ * Lets callers outside this feature drive an already created messages node, for navigation that has to happen after it is attached.
+ */
interface NodeProxy {
+ /**
+ * Opens a thread on top of the messages screen, suspending until the thread node is attached.
+ *
+ * @param threadId the thread to open.
+ * @param focusedEventId the event to scroll to within the thread, or `null` to open at the latest message.
+ */
suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?)
}
}
diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/pinned/PinnedEventsTimelineProvider.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/pinned/PinnedEventsTimelineProvider.kt
index 849b97479e..550373ed00 100644
--- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/pinned/PinnedEventsTimelineProvider.kt
+++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/pinned/PinnedEventsTimelineProvider.kt
@@ -10,4 +10,9 @@ package io.element.android.features.messages.api.pinned
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
+/**
+ * A [TimelineProvider] whose active timeline holds the pinned events of the room, rather than its live events.
+ *
+ * It exists as its own type so that it can be injected where only the pinned timeline is wanted.
+ */
interface PinnedEventsTimelineProvider : TimelineProvider
diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt
index f44c996cac..baeb37e417 100644
--- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt
+++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt
@@ -11,9 +11,16 @@ package io.element.android.features.messages.api.timeline
import androidx.compose.runtime.Composable
import io.element.android.wysiwyg.utils.HtmlConverter
+/**
+ * Provides the converter that turns message HTML into styled text.
+ *
+ * The converter depends on the current theme and typography, so [Update] must be composed before [provide] is called.
+ */
interface HtmlConverterProvider {
+ /** Rebuilds the converter from the current Compose theme; call this once, high in the composition of a screen that renders messages. */
@Composable
fun Update()
+ /** Returns the converter built by the last [Update] call. */
fun provide(): HtmlConverter
}
diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerPresenter.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerPresenter.kt
index 848f9fe82d..832d029e40 100644
--- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerPresenter.kt
+++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerPresenter.kt
@@ -12,7 +12,13 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.timeline.Timeline
fun interface VoiceMessageComposerPresenter : Presenter {
+ /**
+ * Creates a presenter for one voice message composer.
+ */
interface Factory {
+ /**
+ * @param timelineMode the timeline the recording will be sent to, which matters for threads in particular.
+ */
fun create(timelineMode: Timeline.Mode): VoiceMessageComposerPresenter
}
}
diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStateProvider.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStatePreviewParam.kt
similarity index 93%
rename from features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStateProvider.kt
rename to features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStatePreviewParam.kt
index 7b264a3ddb..2cf698dd84 100644
--- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStateProvider.kt
+++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStatePreviewParam.kt
@@ -13,7 +13,7 @@ import io.element.android.libraries.designsystem.components.media.WaveFormSample
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import kotlin.time.Duration.Companion.seconds
-open class VoiceMessageComposerStateProvider : PreviewParameterProvider {
+open class VoiceMessageComposerStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(duration = 61.seconds, levels = WaveFormSamples.allRangeWaveForm)),
diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts
index b40aebab39..0b51a2b80d 100644
--- a/features/messages/impl/build.gradle.kts
+++ b/features/messages/impl/build.gradle.kts
@@ -61,6 +61,7 @@ dependencies {
implementation(projects.libraries.push.api)
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.testtags)
+ implementation(projects.libraries.wellknown.api)
implementation(projects.features.networkmonitor.api)
implementation(projects.features.contentscanner.api)
implementation(projects.services.analytics.compose)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
index dd3afb0ea6..4af0c05955 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
@@ -400,9 +400,23 @@ class MessagesPresenter(
TimelineItemAction.Pin -> handlePinAction(targetEvent)
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
TimelineItemAction.ViewInTimeline -> Unit
+ TimelineItemAction.RetrySending -> handleRetrySending(targetEvent)
}
}
+ private suspend fun handleRetrySending(targetEvent: TimelineItem.Event) {
+ val sendHandle = targetEvent.sendhandle ?: return Unit.also {
+ Timber.w("No send handle for event ${targetEvent.eventOrTransactionId}")
+ }
+ sendHandle.retry()
+ .onSuccess {
+ Timber.d("Succeed to add the message back to the send queue")
+ }
+ .onFailure {
+ Timber.e(it, "Failed to add the message back to the send queue")
+ }
+ }
+
private suspend fun handleRemoveCaption(targetEvent: TimelineItem.Event) {
timelineController.invokeOnCurrentTimeline {
editCaption(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStatePreviewParam.kt
similarity index 99%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStatePreviewParam.kt
index 7315ace96e..8ab7e64121 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStatePreviewParam.kt
@@ -57,7 +57,7 @@ import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
-open class MessagesStateProvider : PreviewParameterProvider {
+open class MessagesStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aMessagesState(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
index 5911de7372..16ca023d50 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
@@ -93,6 +93,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
+import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogView
import io.element.android.features.messages.impl.topbars.MessagesViewTopBar
import io.element.android.features.messages.impl.topbars.ThreadTopBar
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
@@ -178,8 +179,8 @@ fun MessagesView(
fun onContentClick(event: TimelineItem.Event) {
Timber.v("onMessageClick= ${event.id}")
- val eventId = event.eventId ?: return
- if (eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return
+ val eventId = event.eventId
+ if (eventId != null && eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return
val hideKeyboard = onEventContentClick(state.timelineState.isLive, event)
if (hideKeyboard) {
@@ -429,6 +430,31 @@ fun MessagesView(
},
state = state.linkState,
)
+
+ SendFailureDialogView(
+ sendFailureDialogState = state.timelineState.sendFailureDialogState,
+ onDismiss = {
+ state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog)
+ },
+ onRetry = { event ->
+ state.eventSink(
+ MessagesEvent.HandleAction(
+ action = TimelineItemAction.RetrySending,
+ event = event,
+ )
+ )
+ state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog)
+ },
+ onRemoveMessage = { event ->
+ state.eventSink(
+ MessagesEvent.HandleAction(
+ action = TimelineItemAction.Redact,
+ event = event,
+ )
+ )
+ state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog)
+ },
+ )
}
@Composable
@@ -590,13 +616,19 @@ private fun MessagesViewComposerBottomSheetContents(
when {
state.successorRoom != null -> {
SuccessorRoomBanner(
- modifier = Modifier.fillMaxWidth().padding(contentPadding),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(contentPadding),
roomSuccessor = state.successorRoom,
onRoomSuccessorClick = onRoomSuccessorClick
)
}
state.userEventPermissions.canSendMessage -> {
- Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(contentPadding)
+ ) {
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
if (state.composerState.suggestions.isEmpty() &&
state.composerState.textEditorState is TextEditorState.Markdown) {
@@ -662,7 +694,7 @@ private fun SuccessorRoomBanner(
@PreviewsDayNight
@Composable
-internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) = ElementPreview {
+internal fun MessagesViewPreview(@PreviewParameter(MessagesStatePreviewParam::class) state: MessagesState) = ElementPreview {
MessagesView(
state = state,
onBackClick = {},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStatePreviewParam.kt
similarity index 99%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStatePreviewParam.kt
index 56bac5be33..8474b4833d 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStatePreviewParam.kt
@@ -28,7 +28,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
-open class ActionListStateProvider : PreviewParameterProvider {
+open class ActionListStatePreviewParam : PreviewParameterProvider {
private val suggestedEmojis = persistentListOf("👍️", "👎️", "🔥", "❤️", "👏")
override val values: Sequence
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt
index 669a696111..24a325b641 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt
@@ -514,7 +514,7 @@ private fun EmojiButton(
@PreviewsDayNight
@Composable
internal fun ActionListViewContentPreview(
- @PreviewParameter(ActionListStateProvider::class) state: ActionListState
+ @PreviewParameter(ActionListStatePreviewParam::class) state: ActionListState
) = ElementPreview {
ActionListViewContent(
state = state,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt
index 25e75beb99..3c64223e59 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt
@@ -36,4 +36,7 @@ enum class TimelineItemAction(
EndPoll(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end),
Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin),
Unpin(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin),
+
+ // Note: this action is not used in UI for now
+ RetrySending(CommonStrings.action_retry, CompoundDrawables.ic_compound_send_solid),
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt
index 0a0d9b17af..2f6fa4e4e0 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt
@@ -32,6 +32,8 @@ class TimelineItemActionComparator : Comparator {
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
TimelineItemAction.Redact,
+ // Not rendered in the action list, but has to be listed here for exhaustiveness.
+ TimelineItemAction.RetrySending,
)
override fun compare(o1: TimelineItemAction, o2: TimelineItemAction): Int {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStatePreviewParam.kt
similarity index 98%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStatePreviewParam.kt
index a7d4e14436..cdcc37008d 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStatePreviewParam.kt
@@ -30,7 +30,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.io.File
-open class AttachmentsPreviewStateProvider : PreviewParameterProvider {
+open class AttachmentsPreviewStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
anAttachmentsPreviewState(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt
index 077d086720..86b8a85557 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt
@@ -553,7 +553,7 @@ private fun AttachmentsPreviewBottomActions(
// Only preview in dark, dark theme is forced on the Node.
@Preview
@Composable
-internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
+internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStatePreviewParam::class) state: AttachmentsPreviewState) = ElementPreviewDark {
AttachmentsPreviewView(
state = state,
localMediaRenderer = SampleMediaRenderer(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStatePreviewParam.kt
similarity index 97%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStateProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStatePreviewParam.kt
index 7c542fd1a8..fb9901e7ec 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStatePreviewParam.kt
@@ -12,7 +12,7 @@ import androidx.core.net.toUri
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
-open class AttachmentImageEditorStateProvider : PreviewParameterProvider {
+open class AttachmentImageEditorStatePreviewParam : PreviewParameterProvider {
private val caterpillarCrop = NormalizedCropRect(
left = 0.3f,
top = 0.3f,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt
index a5317b96c6..70a3375fa8 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt
@@ -676,7 +676,7 @@ private fun DrawScope.drawEdgeHandle(
@Preview
@Composable
internal fun AttachmentImageEditorViewPreview(
- @PreviewParameter(AttachmentImageEditorStateProvider::class) state: AttachmentImageEditorState,
+ @PreviewParameter(AttachmentImageEditorStatePreviewParam::class) state: AttachmentImageEditorState,
) = ElementPreviewDark {
AttachmentImageEditorView(
state = state,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePreviewParam.kt
similarity index 97%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePreviewParam.kt
index be53de5f66..bc1d587709 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePreviewParam.kt
@@ -18,7 +18,7 @@ import io.element.android.libraries.matrix.ui.room.IdentityRoomMember
import io.element.android.libraries.matrix.ui.room.RoomMemberIdentityStateChange
import kotlinx.collections.immutable.toImmutableList
-class IdentityChangeStateProvider : PreviewParameterProvider {
+class IdentityChangeStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
anIdentityChangeState(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt
index 21684206b5..1c7bb38588 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt
@@ -122,7 +122,7 @@ private fun ViolationAlert(
@PreviewsDayNight
@Composable
internal fun IdentityChangeStateViewPreview(
- @PreviewParameter(IdentityChangeStateProvider::class) state: IdentityChangeState,
+ @PreviewParameter(IdentityChangeStatePreviewParam::class) state: IdentityChangeState,
) = ElementPreview {
IdentityChangeStateView(
state = state,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt
index 4e9bf9249a..5e5afb7f60 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt
@@ -20,7 +20,7 @@ import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
@PreviewsDayNight
@Composable
internal fun MessagesViewWithIdentityChangePreview(
- @PreviewParameter(IdentityChangeStateProvider::class) identityChangeState: IdentityChangeState
+ @PreviewParameter(IdentityChangeStatePreviewParam::class) identityChangeState: IdentityChangeState
) = ElementPreview {
MessagesView(
state = aMessagesState(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureStatePreviewParam.kt
similarity index 93%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureStateProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureStatePreviewParam.kt
index ee4b749c8f..624882b509 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureStatePreviewParam.kt
@@ -13,7 +13,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
-open class ResolveVerifiedUserSendFailureStateProvider : PreviewParameterProvider {
+open class ResolveVerifiedUserSendFailureStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aResolveVerifiedUserSendFailureState(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureView.kt
index a430d4621f..2d745df474 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureView.kt
@@ -158,7 +158,7 @@ private fun VerifiedUserSendFailure.resolveAction(): String {
@PreviewsDayNight
@Composable
internal fun ResolveVerifiedUserSendFailureViewPreview(
- @PreviewParameter(ResolveVerifiedUserSendFailureStateProvider::class) state: ResolveVerifiedUserSendFailureState
+ @PreviewParameter(ResolveVerifiedUserSendFailureStatePreviewParam::class) state: ResolveVerifiedUserSendFailureState
) = ElementPreview {
ResolveVerifiedUserSendFailureView(state)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindingContainer.kt
similarity index 98%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindingContainer.kt
index a345e09fa2..7e67c027d7 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindingContainer.kt
@@ -34,7 +34,7 @@ import io.element.android.libraries.di.RoomScope
@ContributesTo(RoomScope::class)
@BindingContainer
-interface MessagesBindsModule {
+interface MessagesBindingContainer {
@Binds
fun bindPinnedMessagesBannerPresenter(presenter: PinnedMessagesBannerPresenter): Presenter
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesProvidesModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesProvidesBindingContainer.kt
similarity index 85%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesProvidesModule.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesProvidesBindingContainer.kt
index 856a14cdb0..f66c26061b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesProvidesModule.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesProvidesBindingContainer.kt
@@ -18,8 +18,8 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
@ContributesTo(RoomScope::class)
@BindingContainer
-object MessagesProvidesModule {
+object MessagesProvidesBindingContainer {
@Provides
@LiveTimeline
- fun provideLiveTimeline(joinedRoom: JoinedRoom): Timeline = joinedRoom.liveTimeline
+ fun providesLiveTimeline(joinedRoom: JoinedRoom): Timeline = joinedRoom.liveTimeline
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkStatePreviewParam.kt
similarity index 93%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkStateProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkStatePreviewParam.kt
index 2e021ec276..f72d9e2517 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkStatePreviewParam.kt
@@ -12,7 +12,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.wysiwyg.link.Link
-open class LinkStateProvider : PreviewParameterProvider {
+open class LinkStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aLinkState(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkView.kt
index 69e6ee8673..950765f40f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkView.kt
@@ -66,7 +66,7 @@ fun LinkView(
@PreviewsDayNight
@Composable
-internal fun LinkViewPreview(@PreviewParameter(LinkStateProvider::class) state: LinkState) = ElementPreview {
+internal fun LinkViewPreview(@PreviewParameter(LinkStatePreviewParam::class) state: LinkState) = ElementPreview {
LinkView(
state = state,
onLinkValid = {},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
index 18252ee17d..2974b99696 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
@@ -86,6 +86,7 @@ import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Message
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion
+import io.element.android.libraries.textcomposer.model.SuggestionType
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
import io.element.android.services.analytics.api.AnalyticsService
@@ -103,7 +104,9 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -425,6 +428,7 @@ class MessageComposerPresenter(
textEditorState = textEditorState,
isFullScreen = isFullScreen.value,
mode = messageComposerContext.composerMode,
+ isInThreadTimeline = isInThread,
showAttachmentSourcePicker = showAttachmentSourcePicker,
showTextFormatting = showTextFormatting,
canShareLocation = canShareLocation.value,
@@ -458,6 +462,14 @@ class MessageComposerPresenter(
val mentionTriggerFlow = merge(mentionStartTrigger, mentionCompletionTrigger)
+ // Refresh the room members, which are only loaded on demand, when a mention starts
+ launch {
+ suggestionSearchTrigger
+ .distinctUntilChangedBy { it?.type }
+ .filter { it?.type == SuggestionType.Mention }
+ .collect { room.updateMembers() }
+ }
+
val roomAliasSuggestionsFlow = roomAliasSuggestionsDataSource
.getAllRoomAliasSuggestions()
.stateIn(this, SharingStarted.Lazily, emptyList())
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
index f3fdb3d59a..b4da119d96 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
@@ -21,6 +21,7 @@ data class MessageComposerState(
val textEditorState: TextEditorState,
val isFullScreen: Boolean,
val mode: MessageComposerMode,
+ val isInThreadTimeline: Boolean,
val showAttachmentSourcePicker: Boolean,
val showTextFormatting: Boolean,
val canShareLocation: Boolean,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStatePreviewParam.kt
similarity index 91%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStatePreviewParam.kt
index ef9cd7933b..31ef5fed50 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStatePreviewParam.kt
@@ -18,7 +18,7 @@ import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
-open class MessageComposerStateProvider : PreviewParameterProvider {
+open class MessageComposerStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aMessageComposerState(),
@@ -29,6 +29,7 @@ fun aMessageComposerState(
textEditorState: TextEditorState = aTextEditorStateRich(),
isFullScreen: Boolean = false,
mode: MessageComposerMode = MessageComposerMode.Normal,
+ isInThreadTimeline: Boolean = false,
showTextFormatting: Boolean = false,
showAttachmentSourcePicker: Boolean = false,
canShareLocation: Boolean = true,
@@ -39,6 +40,7 @@ fun aMessageComposerState(
textEditorState = textEditorState,
isFullScreen = isFullScreen,
mode = mode,
+ isInThreadTimeline = isInThreadTimeline,
showTextFormatting = showTextFormatting,
showAttachmentSourcePicker = showAttachmentSourcePicker,
canShareLocation = canShareLocation,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
index 245b23cf8e..58842b6824 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
@@ -20,7 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
-import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider
+import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStatePreviewParam
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -102,6 +102,7 @@ internal fun MessageComposerView(
onRequestFocus = ::onRequestFocus,
onSendMessage = ::sendMessage,
composerMode = state.mode,
+ isInThreadTimeline = state.isInThreadTimeline,
showTextFormatting = state.showTextFormatting,
onResetComposerMode = ::onCloseSpecialMode,
onAddAttachment = ::onAddAttachment,
@@ -128,7 +129,7 @@ internal fun MessageComposerView(
@PreviewsDayNight
@Composable
internal fun MessageComposerViewPreview(
- @PreviewParameter(MessageComposerStateProvider::class) state: MessageComposerState,
+ @PreviewParameter(MessageComposerStatePreviewParam::class) state: MessageComposerState,
) = ElementPreview {
Column {
MessageComposerView(
@@ -148,7 +149,7 @@ internal fun MessageComposerViewPreview(
@PreviewsDayNight
@Composable
internal fun MessageComposerViewVoicePreview(
- @PreviewParameter(VoiceMessageComposerStateProvider::class) state: VoiceMessageComposerState,
+ @PreviewParameter(VoiceMessageComposerStatePreviewParam::class) state: VoiceMessageComposerState,
) = ElementPreview {
Column {
MessageComposerView(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/DisplayablePinnedEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/DisplayablePinnedEvents.kt
new file mode 100644
index 0000000000..55c4b61ed1
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/DisplayablePinnedEvents.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.pinned
+
+import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
+import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
+import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
+import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
+import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
+
+internal fun List.keepDisplayablePinnedEvents(): List {
+ val items = filter { it.isDisplayableAsPinnedEvent() }
+ return items.filterIndexed { index, item ->
+ !item.isDayDivider() || items.getOrNull(index + 1) is MatrixTimelineItem.Event
+ }
+}
+
+internal fun EventContent.isDisplayableAsPinnedEvent(): Boolean {
+ return when (this) {
+ UnknownContent,
+ is FailedToParseMessageLikeContent,
+ is FailedToParseStateContent -> false
+ else -> true
+ }
+}
+
+private fun MatrixTimelineItem.isDisplayableAsPinnedEvent(): Boolean {
+ return this !is MatrixTimelineItem.Event || event.content.isDisplayableAsPinnedEvent()
+}
+
+private fun MatrixTimelineItem.isDayDivider(): Boolean {
+ return this is MatrixTimelineItem.Virtual && virtual is VirtualTimelineItem.DayDivider
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItemFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItemFactory.kt
index d1d53b36b4..0420416bff 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItemFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItemFactory.kt
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.ui.text.AnnotatedString
import dev.zacsweers.metro.Inject
+import io.element.android.features.messages.impl.pinned.isDisplayableAsPinnedEvent
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
@@ -24,6 +25,7 @@ class PinnedMessagesBannerItemFactory(
when (timelineItem) {
is MatrixTimelineItem.Event -> {
val eventId = timelineItem.eventId ?: return@withContext null
+ if (!timelineItem.event.content.isDisplayableAsPinnedEvent()) return@withContext null
val formatted = formatter.format(timelineItem.event)
PinnedMessagesBannerItem(
eventId = eventId,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerStatePreviewParam.kt
similarity index 96%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerStateProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerStatePreviewParam.kt
index 2a2c30bf6b..37660d0000 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerStatePreviewParam.kt
@@ -13,7 +13,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.EventId
import kotlin.random.Random
-internal class PinnedMessagesBannerStateProvider : PreviewParameterProvider {
+internal class PinnedMessagesBannerStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aHiddenPinnedMessagesBannerState(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt
index bc80d6b014..70a98d4b4e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt
@@ -294,7 +294,9 @@ private class ExitOnScrollBehavior : PinnedMessagesBannerViewScrollBehavior {
@PreviewsDayNight
@Composable
-internal fun PinnedMessagesBannerViewPreview(@PreviewParameter(PinnedMessagesBannerStateProvider::class) state: PinnedMessagesBannerState) = ElementPreview {
+internal fun PinnedMessagesBannerViewPreview(@PreviewParameter(
+ PinnedMessagesBannerStatePreviewParam::class
+) state: PinnedMessagesBannerState) = ElementPreview {
PinnedMessagesBannerView(
state = state,
onClick = {},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt
index 73ba08b29d..9be11d244a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt
@@ -11,8 +11,10 @@ package io.element.android.features.messages.impl.pinned.list
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.ThreadId
+import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
sealed interface PinnedMessagesListEvent {
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : PinnedMessagesListEvent
data class OpenThread(val threadRootId: ThreadId) : PinnedMessagesListEvent
+ data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : PinnedMessagesListEvent
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt
index 06d97f167c..36ae68de9b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt
@@ -30,6 +30,7 @@ import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
@@ -50,6 +51,7 @@ class PinnedMessagesListNode(
actionListPresenterFactory: ActionListPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val permalinkParser: PermalinkParser,
+ private val emojiPickerRenderer: EmojiPickerRenderer,
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
interface Callback : Plugin {
fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean)
@@ -134,6 +136,7 @@ class PinnedMessagesListNode(
toastMessage = toastMessage,
)
},
+ emojiPickerRenderer = emojiPickerRenderer,
modifier = modifier
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt
index 6e8974b871..748fa084f8 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt
@@ -28,7 +28,10 @@ import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider
+import io.element.android.features.messages.impl.pinned.keepDisplayablePinnedEvents
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
+import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
+import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -46,6 +49,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.roomMembers
+import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@@ -70,6 +74,8 @@ class PinnedMessagesListPresenter(
private val linkPresenter: Presenter,
private val snackbarDispatcher: SnackbarDispatcher,
@Assisted private val actionListPresenter: Presenter,
+ private val customReactionPresenter: Presenter,
+ private val reactionSummaryPresenter: Presenter,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
@@ -87,7 +93,7 @@ class PinnedMessagesListPresenter(
private val timelineItemsFactory: TimelineItemsFactory = timelineItemsFactoryCreator.create(
config = TimelineItemsFactoryConfig(
computeReadReceipts = false,
- computeReactions = false,
+ computeReactions = true,
)
)
@@ -95,6 +101,9 @@ class PinnedMessagesListPresenter(
override fun present(): PinnedMessagesListState {
htmlConverterProvider.Update()
val roomInfo by room.roomInfoFlow.collectAsState()
+ val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
+ perms.userEventPermissions()
+ }
val timelineRoomInfo by remember {
derivedStateOf {
TimelineRoomInfo(
@@ -102,7 +111,7 @@ class PinnedMessagesListPresenter(
name = roomInfo.name,
// We don't need to compute those values
userHasPermissionToSendMessage = false,
- userHasPermissionToSendReaction = false,
+ userHasPermissionToSendReaction = userEventPermissions.canSendReaction,
// We do not care about the call state here.
roomCallState = aStandByCallState(),
// don't compute this value or the pin icon will be shown
@@ -118,9 +127,8 @@ class PinnedMessagesListPresenter(
}
val timelineProtectionState = timelineProtectionPresenter.present()
val linkState = linkPresenter.present()
- val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
- perms.userEventPermissions()
- }
+ val customReactionState = customReactionPresenter.present()
+ val reactionSummaryState = reactionSummaryPresenter.present()
val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false)
@@ -137,6 +145,7 @@ class PinnedMessagesListPresenter(
when (event) {
is PinnedMessagesListEvent.HandleAction -> sessionCoroutineScope.handleTimelineAction(event.action, event.event)
is PinnedMessagesListEvent.OpenThread -> navigator.navigateToThread(event.threadRootId)
+ is PinnedMessagesListEvent.ToggleReaction -> sessionCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId)
}
}
@@ -147,10 +156,20 @@ class PinnedMessagesListPresenter(
displayThreadSummaries = displayThreadSummaries,
userEventPermissions = userEventPermissions,
timelineItems = pinnedMessageItems,
+ customReactionState = customReactionState,
+ reactionSummaryState = reactionSummaryState,
eventSink = ::handleEvent,
)
}
+ private fun CoroutineScope.toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId) {
+ launch {
+ timelineProvider.invokeOnTimeline {
+ toggleReaction(emoji, eventOrTransactionId)
+ }
+ }
+ }
+
private fun CoroutineScope.handleTimelineAction(
action: TimelineItemAction,
targetEvent: TimelineItem.Event,
@@ -207,7 +226,7 @@ class PinnedMessagesListPresenter(
val timelineItemsFlow = asyncTimeline.data.timelineItems
combine(timelineItemsFlow, room.membersStateFlow) { items, membersState ->
timelineItemsFactory.replaceWith(
- timelineItems = items,
+ timelineItems = items.keepDisplayablePinnedEvents(),
roomMembers = membersState.roomMembers().orEmpty(),
renderReadReceipts = false,
)
@@ -233,13 +252,15 @@ class PinnedMessagesListPresenter(
linkState: LinkState,
userEventPermissions: UserEventPermissions,
timelineItems: AsyncData>,
+ customReactionState: CustomReactionState,
+ reactionSummaryState: ReactionSummaryState,
eventSink: (PinnedMessagesListEvent) -> Unit
): PinnedMessagesListState {
return when (timelineItems) {
AsyncData.Uninitialized, is AsyncData.Loading -> PinnedMessagesListState.Loading
is AsyncData.Failure -> PinnedMessagesListState.Failed
is AsyncData.Success -> {
- if (timelineItems.data.isEmpty()) {
+ if (timelineItems.data.all { it is TimelineItem.Virtual }) {
PinnedMessagesListState.Empty
} else {
val actionListState = actionListPresenter.present()
@@ -251,6 +272,8 @@ class PinnedMessagesListPresenter(
userEventPermissions = userEventPermissions,
timelineItems = timelineItems.data,
actionListState = actionListState,
+ customReactionState = customReactionState,
+ reactionSummaryState = reactionSummaryState,
eventSink = eventSink
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt
index bf37029eff..6c55a0d90a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt
@@ -16,6 +16,8 @@ import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
+import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
+import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.libraries.ui.strings.CommonPlurals
@@ -34,6 +36,8 @@ sealed interface PinnedMessagesListState {
val timelineItems: ImmutableList,
val actionListState: ActionListState,
val linkState: LinkState,
+ val customReactionState: CustomReactionState,
+ val reactionSummaryState: ReactionSummaryState,
val displayThreadSummaries: Boolean,
val eventSink: (PinnedMessagesListEvent) -> Unit,
) : PinnedMessagesListState {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStatePreviewParam.kt
similarity index 88%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStatePreviewParam.kt
index bfa9820a44..3d01906df8 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStatePreviewParam.kt
@@ -10,6 +10,8 @@ package io.element.android.features.messages.impl.pinned.list
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.UserEventPermissions
+import io.element.android.features.messages.impl.aCustomReactionState
+import io.element.android.features.messages.impl.aReactionSummaryState
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.link.LinkState
@@ -19,6 +21,8 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemDaySepara
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
+import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
+import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
@@ -30,7 +34,7 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
-open class PinnedMessagesListStateProvider : PreviewParameterProvider {
+open class PinnedMessagesListStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aFailedPinnedMessagesListState(),
@@ -92,6 +96,8 @@ fun aLoadedPinnedMessagesListState(
linkState: LinkState = aLinkState(),
timelineItems: List = emptyList(),
actionListState: ActionListState = anActionListState(),
+ customReactionState: CustomReactionState = aCustomReactionState(),
+ reactionSummaryState: ReactionSummaryState = aReactionSummaryState(),
aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT,
displayThreadSummaries: Boolean = false,
eventSink: (PinnedMessagesListEvent) -> Unit = {}
@@ -101,6 +107,8 @@ fun aLoadedPinnedMessagesListState(
linkState = linkState,
timelineItems = timelineItems.toImmutableList(),
actionListState = actionListState,
+ customReactionState = customReactionState,
+ reactionSummaryState = reactionSummaryState,
userEventPermissions = aUserEventPermissions,
displayThreadSummaries = displayThreadSummaries,
eventSink = eventSink,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt
index 4f69bacd28..5daec3e275 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt
@@ -33,8 +33,12 @@ import io.element.android.features.messages.impl.link.LinkEvent
import io.element.android.features.messages.impl.link.LinkView
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
+import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
+import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
+import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
+import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
@@ -50,6 +54,8 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
+import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
+import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
@@ -66,6 +72,7 @@ fun PinnedMessagesListView(
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
+ emojiPickerRenderer: EmojiPickerRenderer,
modifier: Modifier = Modifier,
) {
Scaffold(
@@ -89,6 +96,7 @@ fun PinnedMessagesListView(
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onErrorDismiss = onBackClick,
+ emojiPickerRenderer = emojiPickerRenderer,
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding),
@@ -121,6 +129,7 @@ private fun PinnedMessagesListContent(
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onErrorDismiss: () -> Unit,
+ emojiPickerRenderer: EmojiPickerRenderer,
modifier: Modifier = Modifier,
) {
Box(modifier.fillMaxSize()) {
@@ -141,6 +150,7 @@ private fun PinnedMessagesListContent(
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
+ emojiPickerRenderer = emojiPickerRenderer,
)
PinnedMessagesListState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -180,6 +190,7 @@ private fun PinnedMessagesListLoaded(
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
+ emojiPickerRenderer: EmojiPickerRenderer,
modifier: Modifier = Modifier,
) {
fun onActionSelected(timelineItemAction: TimelineItemAction, event: TimelineItem.Event) {
@@ -203,11 +214,25 @@ private fun PinnedMessagesListLoaded(
)
}
+ fun onReactionClick(emoji: String, event: TimelineItem.Event) {
+ state.eventSink(PinnedMessagesListEvent.ToggleReaction(emoji, event.eventOrTransactionId))
+ }
+
+ fun onReactionLongClick(emoji: String, event: TimelineItem.Event) {
+ event.eventId?.let { eventId ->
+ state.reactionSummaryState.eventSink(ReactionSummaryEvent.ShowReactionSummary(eventId, event.reactionsState.reactions, emoji))
+ }
+ }
+
+ fun onMoreReactionsClick(event: TimelineItem.Event) {
+ state.customReactionState.eventSink(CustomReactionEvent.ShowCustomReactionSheet(event))
+ }
+
ActionListView(
state = state.actionListState,
onSelectAction = ::onActionSelected,
- onCustomReactionClick = {},
- onEmojiReactionClick = { _, _ -> },
+ onCustomReactionClick = ::onMoreReactionsClick,
+ onEmojiReactionClick = ::onReactionClick,
onVerifiedUserSendFailureClick = {}
)
@@ -239,9 +264,9 @@ private fun PinnedMessagesListLoaded(
onLongClick = ::onMessageLongClick,
displayThreadSummaries = displayThreadSummaries,
inReplyToClick = {},
- onReactionClick = { _, _ -> },
- onReactionLongClick = { _, _ -> },
- onMoreReactionsClick = {},
+ onReactionClick = ::onReactionClick,
+ onReactionLongClick = ::onReactionLongClick,
+ onMoreReactionsClick = ::onMoreReactionsClick,
onReadReceiptClick = {},
onSwipeToReply = {},
onJoinCallClick = {},
@@ -273,6 +298,14 @@ private fun PinnedMessagesListLoaded(
state.linkState,
onLinkValid = onLinkClick,
)
+ CustomReactionBottomSheet(
+ state = state.customReactionState,
+ onSelectEmoji = { eventOrTransactionId, emoji ->
+ state.eventSink(PinnedMessagesListEvent.ToggleReaction(emoji.unicode, eventOrTransactionId))
+ },
+ emojiPickerRenderer = emojiPickerRenderer,
+ )
+ ReactionSummaryView(state = state.reactionSummaryState)
}
@Composable
@@ -312,7 +345,7 @@ private fun TimelineItemEventContentViewWrapper(
@PreviewsDayNight
@Composable
-internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListStateProvider::class) state: PinnedMessagesListState) =
+internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListStatePreviewParam::class) state: PinnedMessagesListState) =
ElementPreview {
PinnedMessagesListView(
state = state,
@@ -322,5 +355,6 @@ internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListS
onUserDataClick = {},
onLinkClick = {},
onLinkLongClick = {},
+ emojiPickerRenderer = NoOpEmojiPickerRenderer,
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStatePreviewParam.kt
similarity index 94%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStateProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStatePreviewParam.kt
index 0e2b4b0c50..1f24b3f480 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStatePreviewParam.kt
@@ -11,7 +11,7 @@ package io.element.android.features.messages.impl.report
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
-open class ReportMessageStateProvider : PreviewParameterProvider {
+open class ReportMessageStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aReportMessageState(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt
index dde3f13558..a3cf4f09da 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt
@@ -137,7 +137,7 @@ fun ReportMessageView(
@PreviewsDayNight
@Composable
-internal fun ReportMessageViewPreview(@PreviewParameter(ReportMessageStateProvider::class) state: ReportMessageState) = ElementPreview {
+internal fun ReportMessageViewPreview(@PreviewParameter(ReportMessageStatePreviewParam::class) state: ReportMessageState) = ElementPreview {
ReportMessageView(
onBackClick = {},
state = state,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt
index b56268eb02..b7fd9f572f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt
@@ -28,6 +28,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
+import io.element.android.features.messages.impl.MessagesEvent
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.MessagesPresenter
import io.element.android.features.messages.impl.MessagesState
@@ -40,6 +41,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelinePresenter
+import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -54,6 +56,7 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@@ -91,6 +94,7 @@ class ThreadedMessagesNode(
private val permalinkParser: PermalinkParser,
private val appNavigationStateService: AppNavigationStateService,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
+ private val emojiPickerRenderer: EmojiPickerRenderer,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
data class Inputs(
val threadRootEventId: ThreadId,
@@ -322,7 +326,15 @@ class ThreadedMessagesNode(
onViewAllPinnedMessagesClick = {},
modifier = modifier,
knockRequestsBannerView = {},
- customReactionBottomSheet = {},
+ customReactionBottomSheet = {
+ CustomReactionBottomSheet(
+ state = state.customReactionState,
+ onSelectEmoji = { uniqueId, emoji ->
+ state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId))
+ },
+ emojiPickerRenderer = emojiPickerRenderer,
+ )
+ },
onThreadsListClick = {},
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt
index f51103197d..de4031d1a4 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt
@@ -34,6 +34,9 @@ sealed interface TimelineEvent {
*/
sealed interface TimelineItemEvent : TimelineEvent
+ data class ShowSendFailureDialog(val event: TimelineItem.Event) : TimelineItemEvent
+ data object HideSendFailureDialog : TimelineEvent
+
data class ComputeVerifiedUserSendFailure(val event: TimelineItem.Event) : TimelineItemEvent
data class ShowShieldDialog(val messageShieldData: MessageShieldData) : TimelineItemEvent
data class LoadMore(val direction: Timeline.PaginationDirection) : TimelineItemEvent
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
index da274445fd..3317d8f05c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
@@ -37,6 +37,7 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
+import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.userEventPermissions
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
@@ -56,6 +57,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsSta
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems
@@ -142,6 +144,7 @@ class TimelinePresenter(
val newEventState = remember { mutableStateOf(NewEventState.None) }
val messageShieldDialogData: MutableState = remember { mutableStateOf(null) }
+ var sendFailureDialogState by remember { mutableStateOf(SendFailureDialogState.Hidden) }
// Forces [JumpToUnreadState.Hidden] until the next RoomInfo push. Set after a
// [TimelineEvent.MarkAllAsRead] await completes so the FAB hides without waiting for
@@ -166,9 +169,14 @@ class TimelinePresenter(
fun handleEvent(event: TimelineEvent) {
when (event) {
is TimelineEvent.LoadMore -> {
- if (event.direction == Timeline.PaginationDirection.FORWARDS && timelineMode is Timeline.Mode.Thread) {
- // Do not paginate forwards in thread mode, as it's not supported
- return
+ if (event.direction == Timeline.PaginationDirection.FORWARDS) {
+ if (timelineMode is Timeline.Mode.Thread) {
+ // Do not paginate forwards in thread mode, as it's not supported
+ return
+ }
+ if (focusRequestState.value.isPending()) {
+ return
+ }
}
localScope.launch {
timelineController.paginate(direction = event.direction)
@@ -246,6 +254,47 @@ class TimelinePresenter(
suppressJumpToUnread.value = true
}
is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData
+ is TimelineEvent.ShowSendFailureDialog -> {
+ val sendStateFailure = event.event.localSendState as? LocalEventSendState.Failed ?: return
+ when (sendStateFailure) {
+ is LocalEventSendState.Failed.VerifiedUser -> {
+ // defer to the resolveVerifiedUserSendFailureState to handle this case,
+ // as it will show a dialog with the right information
+ resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event))
+ }
+ is LocalEventSendState.Failed.InvalidMimeType -> {
+ sendFailureDialogState = SendFailureDialogState.Show(
+ event = event.event,
+ sendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType(sendStateFailure.mimeType),
+ )
+ }
+ LocalEventSendState.Failed.MissingMediaContent -> {
+ sendFailureDialogState = SendFailureDialogState.Show(
+ event = event.event,
+ sendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent,
+ )
+ }
+ LocalEventSendState.Failed.SendingFromUnverifiedDevice -> {
+ sendFailureDialogState = SendFailureDialogState.Show(
+ event = event.event,
+ sendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice,
+ )
+ }
+ is LocalEventSendState.Failed.Unknown -> {
+ sendFailureDialogState = SendFailureDialogState.Show(
+ event = event.event,
+ sendFailureType = if (sendStateFailure.error.isNotBlank()) {
+ SendFailureDialogState.SendFailureType.Error(sendStateFailure.error)
+ } else {
+ SendFailureDialogState.SendFailureType.Unknown
+ }
+ )
+ }
+ }
+ }
+ is TimelineEvent.HideSendFailureDialog -> {
+ sendFailureDialogState = SendFailureDialogState.Hidden
+ }
is TimelineEvent.ComputeVerifiedUserSendFailure -> {
resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event))
}
@@ -410,6 +459,7 @@ class TimelinePresenter(
focusRequestState = focusRequestState.value,
messageShieldDialogData = messageShieldDialogData.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
+ sendFailureDialogState = sendFailureDialogState,
displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread.value,
@@ -539,6 +589,16 @@ private fun FocusRequestState.onFocusEventRender(): FocusRequestState {
}
}
+private fun FocusRequestState.isPending(): Boolean {
+ return when (this) {
+ is FocusRequestState.Requested,
+ is FocusRequestState.Loading -> true
+ is FocusRequestState.Success -> !rendered
+ FocusRequestState.None,
+ is FocusRequestState.Failure -> false
+ }
+}
+
// Workaround for not having the server names available, get possible server names from the user ids of the room members
private fun calculateServerNamesForRoom(room: JoinedRoom): List {
// If we have no room members, return right ahead
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
index 7836c17563..1ebc96f15f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
@@ -13,6 +13,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.resolve.Reso
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
+import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.core.EventId
@@ -32,6 +33,7 @@ data class TimelineState(
// If not null, info will be rendered in a dialog
val messageShieldDialogData: MessageShieldData?,
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
+ val sendFailureDialogState: SendFailureDialogState,
val displayThreadSummaries: Boolean,
val displayJumpToUnread: Boolean,
val jumpToUnread: JumpToUnreadState,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
index 61b5c690a4..89545b5d0b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
@@ -25,6 +25,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
+import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.features.roomcall.api.aStandByCallState
@@ -57,6 +58,7 @@ fun aTimelineState(
isLive: Boolean = true,
messageShield: MessageShield? = null,
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
+ sendFailureDialogState: SendFailureDialogState = SendFailureDialogState.Hidden,
displayThreadSummaries: Boolean = false,
displayJumpToUnread: Boolean = false,
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
@@ -78,6 +80,7 @@ fun aTimelineState(
focusRequestState = focusRequestState,
messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
+ sendFailureDialogState = sendFailureDialogState,
displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
index 78c067eed1..5e7fd34723 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
@@ -90,7 +90,7 @@ import io.element.android.features.messages.impl.timeline.focus.FocusRequestStat
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
-import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentPreviewParam
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.androidutils.system.copyToClipboard
@@ -582,7 +582,7 @@ private fun JumpToPositionButton(
@PreviewsDayNight
@Composable
internal fun TimelineViewPreview(
- @PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
+ @PreviewParameter(TimelineItemEventContentPreviewParam::class) content: TimelineItemEventContent
) = ElementPreview {
val timelineItems = aTimelineItemList(content)
val timelineEvents = timelineItems.filterIsInstance()
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/CallMenuItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/CallMenuItem.kt
index 28f5011652..d4b9885ea5 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/CallMenuItem.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/CallMenuItem.kt
@@ -25,7 +25,7 @@ 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.roomcall.api.RoomCallState
-import io.element.android.features.roomcall.api.RoomCallStateProvider
+import io.element.android.features.roomcall.api.RoomCallStatePreviewParam
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
@@ -133,7 +133,7 @@ private fun OnGoingCallMenuItem(
@PreviewsDayNight
@Composable
internal fun CallMenuItemPreview(
- @PreviewParameter(RoomCallStateProvider::class) roomCallState: RoomCallState
+ @PreviewParameter(RoomCallStatePreviewParam::class) roomCallState: RoomCallState
) = ElementPreview {
CallMenuItem(
roomCallState = roomCallState,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt
index 1ebdc34c6c..0dcf489fe3 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt
@@ -38,7 +38,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
-import io.element.android.features.messages.impl.timeline.model.bubble.BubbleStateProvider
+import io.element.android.features.messages.impl.timeline.model.bubble.BubbleStatePreviewParam
import io.element.android.libraries.core.extensions.to01
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
@@ -192,7 +192,7 @@ object MessageEventBubbleDefaults {
@PreviewsDayNight
@Composable
-internal fun MessageEventBubblePreview(@PreviewParameter(BubbleStateProvider::class) state: BubbleState) = ElementPreview {
+internal fun MessageEventBubblePreview(@PreviewParameter(BubbleStatePreviewParam::class) state: BubbleState) = ElementPreview {
// Due to position offset, surround with a Box
Box(
modifier = Modifier
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt
index dc8e171298..931e0de801 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt
@@ -41,7 +41,7 @@ import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionDetails
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
-import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider
+import io.element.android.features.messages.impl.timeline.model.AggregatedReactionPreviewParam
import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
@@ -199,7 +199,7 @@ private fun ReactionContent(
@PreviewsDayNight
@Composable
-internal fun MessagesReactionButtonPreview(@PreviewParameter(AggregatedReactionProvider::class) reaction: AggregatedReaction) = ElementPreview {
+internal fun MessagesReactionButtonPreview(@PreviewParameter(AggregatedReactionPreviewParam::class) reaction: AggregatedReaction) = ElementPreview {
MessagesReactionButton(
content = MessagesReactionsButtonContent.Reaction(reaction),
onClick = {},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt
index b6af359269..d7492ca40b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt
@@ -56,10 +56,11 @@ fun TimelineEventTimestampView(
val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser
val onClickLabel = when {
shield != null -> stringResource(CommonStrings.a11y_view_details)
- hasError && isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu)
+ isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu)
+ hasError -> stringResource(CommonStrings.a11y_view_details)
else -> null
}
- val clickableModifier = remember(shield, hasError) {
+ val clickableModifier = remember(event) {
when {
shield != null -> {
Modifier.clickable(
@@ -70,10 +71,9 @@ fun TimelineEventTimestampView(
}
hasError -> Modifier
.clickable(
- enabled = isVerifiedUserSendFailure,
onClickLabel = onClickLabel,
) {
- eventSink(TimelineEvent.ComputeVerifiedUserSendFailure(event))
+ eventSink(TimelineEvent.ShowSendFailureDialog(event))
}
else -> Modifier
}
@@ -134,7 +134,9 @@ fun TimelineEventTimestampView(
@PreviewsDayNight
@Composable
-internal fun TimelineEventTimestampViewPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = ElementPreview {
+internal fun TimelineEventTimestampViewPreview(@PreviewParameter(
+ TimelineItemEventForTimestampViewPreviewParam::class
+) event: TimelineItem.Event) = ElementPreview {
TimelineEventTimestampView(
event = event,
eventSink = {},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventContentForGalleryViewProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventContentForGalleryViewPreviewParam.kt
similarity index 98%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventContentForGalleryViewProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventContentForGalleryViewPreviewParam.kt
index ed790da8a2..0f7da4209f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventContentForGalleryViewProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventContentForGalleryViewPreviewParam.kt
@@ -16,7 +16,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem
import kotlin.time.Duration
-class TimelineItemEventContentForGalleryViewProvider :
+class TimelineItemEventContentForGalleryViewPreviewParam :
PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventForTimestampViewProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventForTimestampViewPreviewParam.kt
similarity index 95%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventForTimestampViewProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventForTimestampViewPreviewParam.kt
index 3689c84875..118edae373 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventForTimestampViewProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventForTimestampViewPreviewParam.kt
@@ -16,7 +16,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
-class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider {
+class TimelineItemEventForTimestampViewPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aTimelineItemEvent(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
index fed2ab6577..b0be2dde88 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
@@ -125,6 +125,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources
+import io.element.android.libraries.matrix.api.timeline.item.event.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
@@ -207,11 +208,7 @@ fun TimelineItemEventRow(
}
fun onUserDataClick() {
- val sender = MatrixUser(
- userId = event.senderId,
- displayName = event.senderProfile.getDisplayName(),
- avatarUrl = event.senderProfile.getAvatarUrl(),
- )
+ val sender = event.senderProfile.toMatrixUser(event.senderId)
onUserDataClick(sender)
}
@@ -804,15 +801,15 @@ private fun MessageEventBubbleContent(
.padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(shape)
- val talkbackCompatModifier = if (isTalkbackActive()) {
+ val talkbackCompatModifier = when {
// Use z-index to make the replied to text being read after the message
// Usually, you'd use traversalIndex for that, but it's not working for some reason
- inReplyToModifier.zIndex(1f)
- } else {
- inReplyToModifier.clickable(onClick = inReplyToClick)
+ isTalkbackActive() -> inReplyToModifier.zIndex(1f)
+ inReplyTo is InReplyToDetails.Error -> inReplyToModifier
+ else -> inReplyToModifier.clickable(onClick = inReplyToClick)
}
- val contentHasError = currentContentValidationState.hasError()
+ val contentHasError = currentContentValidationState.hasError() || inReplyTo is InReplyToDetails.Error
val borderColor = if (contentHasError) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary
val backgroundColor = if (contentHasError) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault
Box(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowDisambiguatedPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowDisambiguatedPreview.kt
index 4b6223fd97..94d29d2191 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowDisambiguatedPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowDisambiguatedPreview.kt
@@ -13,12 +13,12 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
-import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsDisambiguatedProvider
+import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsDisambiguatedPreviewParam
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowDisambiguatedPreview(
- @PreviewParameter(InReplyToDetailsDisambiguatedProvider::class) inReplyToDetails: InReplyToDetails,
+ @PreviewParameter(InReplyToDetailsDisambiguatedPreviewParam::class) inReplyToDetails: InReplyToDetails,
) = ElementPreview {
TimelineItemEventRowWithReplyContentToPreview(
inReplyToDetails = inReplyToDetails,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowGalleryPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowGalleryPreview.kt
index 22c3f354f2..b942695470 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowGalleryPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowGalleryPreview.kt
@@ -19,7 +19,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowWithGalleryPreview(
- @PreviewParameter(TimelineItemEventContentForGalleryViewProvider::class) content: TimelineItemEventContent,
+ @PreviewParameter(TimelineItemEventContentForGalleryViewPreviewParam::class) content: TimelineItemEventContent,
) = ElementPreview {
Column {
sequenceOf(false, true).forEach { isMine ->
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt
index c588b1d071..ae0077786b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt
@@ -20,7 +20,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowTimestampPreview(
- @PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event
+ @PreviewParameter(TimelineItemEventForTimestampViewPreviewParam::class) event: TimelineItem.Event
) = ElementPreview {
Column {
when (event.content) {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithRRPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithRRPreview.kt
index 6ea1161605..379a5b3547 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithRRPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithRRPreview.kt
@@ -14,7 +14,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
-import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewStateForTimelineItemEventRowProvider
+import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewStateForTimelineItemEventRowPreviewParam
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -25,7 +25,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowWithRRPreview(
- @PreviewParameter(ReadReceiptViewStateForTimelineItemEventRowProvider::class) state: ReadReceiptViewState,
+ @PreviewParameter(ReadReceiptViewStateForTimelineItemEventRowPreviewParam::class) state: ReadReceiptViewState,
) = ElementPreview {
Column {
// A message from someone else
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyInformativePreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyInformativePreview.kt
index cdc60a9e7e..374561e553 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyInformativePreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyInformativePreview.kt
@@ -13,12 +13,12 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
-import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsInformativeProvider
+import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsInformativePreviewParam
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowWithReplyInformativePreview(
- @PreviewParameter(InReplyToDetailsInformativeProvider::class) inReplyToDetails: InReplyToDetails,
+ @PreviewParameter(InReplyToDetailsInformativePreviewParam::class) inReplyToDetails: InReplyToDetails,
) = ElementPreview {
TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyOtherPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyOtherPreview.kt
index 3071fe114a..f7d2de2ced 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyOtherPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyOtherPreview.kt
@@ -13,12 +13,12 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
-import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsOtherProvider
+import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsOtherPreviewParam
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowWithReplyOtherPreview(
- @PreviewParameter(InReplyToDetailsOtherProvider::class) inReplyToDetails: InReplyToDetails,
+ @PreviewParameter(InReplyToDetailsOtherPreviewParam::class) inReplyToDetails: InReplyToDetails,
) = ElementPreview {
TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt
index 7ddcadfe3c..aba6971f1d 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt
@@ -21,12 +21,12 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
-import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider
+import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsPreviewParam
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowWithReplyPreview(
- @PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails,
+ @PreviewParameter(InReplyToDetailsPreviewParam::class) inReplyToDetails: InReplyToDetails,
) = ElementPreview {
TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt
index c90a0e5acc..a14d3b3893 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt
@@ -8,11 +8,16 @@
package io.element.android.features.messages.impl.timeline.components.customreaction
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import io.element.android.emojibasebindings.Emoji
@@ -54,6 +59,7 @@ fun CustomReactionBottomSheet(
modifier = modifier,
scrollable = false,
) {
+ ExpandSheetWhenImeIsVisible(sheetState)
emojiPickerRenderer.Render(
state = state.target.emojiPickerState,
onSelectEmoji = ::onEmojiSelectedDismiss,
@@ -67,3 +73,14 @@ fun CustomReactionBottomSheet(
}
}
}
+
+@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
+@Composable
+internal fun ExpandSheetWhenImeIsVisible(sheetState: SheetState) {
+ val isImeVisible = WindowInsets.isImeVisible
+ LaunchedEffect(isImeVisible) {
+ if (isImeVisible) {
+ sheetState.expand()
+ }
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentPreviewParam.kt
similarity index 96%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentPreviewParam.kt
index c63d68e062..a42eec8504 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentPreviewParam.kt
@@ -13,7 +13,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem
import io.element.android.libraries.matrix.api.media.MediaSource
-class TimelineItemAttachmentsContentProvider : PreviewParameterProvider {
+class TimelineItemAttachmentsContentPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aTimelineItemAttachmentsContent(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt
index f3745b2c5a..232c107698 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt
@@ -265,7 +265,7 @@ private fun AttachmentListItem(
@PreviewsDayNight
@Composable
internal fun TimelineItemAttachmentsListViewPreview(
- @PreviewParameter(TimelineItemAttachmentsContentProvider::class) content: TimelineItemAttachmentsContent
+ @PreviewParameter(TimelineItemAttachmentsContentPreviewParam::class) content: TimelineItemAttachmentsContent
) = ElementPreview {
TimelineItemAttachmentsListView(
eventId = EventId("\$eventId"),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt
index 221ba2ca6f..d47ca552b8 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt
@@ -14,7 +14,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
-import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentPreviewParam
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
@@ -38,7 +38,7 @@ fun TimelineItemAudioView(
@PreviewsDayNight
@Composable
-internal fun TimelineItemAudioViewPreview(@PreviewParameter(TimelineItemAudioContentProvider::class) content: TimelineItemAudioContent) =
+internal fun TimelineItemAudioViewPreview(@PreviewParameter(TimelineItemAudioContentPreviewParam::class) content: TimelineItemAudioContent) =
ElementTimelineItemPreview {
TimelineItemAudioView(
content,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt
index 48b3acfbb8..6b9022d3cf 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt
@@ -14,7 +14,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
-import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContentProvider
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContentPreviewParam
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -75,7 +75,7 @@ fun TimelineItemEncryptedView(
@PreviewsDayNight
@Composable
internal fun TimelineItemEncryptedViewPreview(
- @PreviewParameter(TimelineItemEncryptedContentProvider::class) content: TimelineItemEncryptedContent
+ @PreviewParameter(TimelineItemEncryptedContentPreviewParam::class) content: TimelineItemEncryptedContent
) = ElementPreview {
TimelineItemEncryptedView(
content = content,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt
index cd0632282e..77df9ba923 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt
@@ -15,7 +15,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
-import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentPreviewParam
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.ui.strings.CommonStrings
@@ -43,7 +43,7 @@ fun TimelineItemFileView(
@PreviewsDayNight
@Composable
-internal fun TimelineItemFileViewPreview(@PreviewParameter(TimelineItemFileContentProvider::class) content: TimelineItemFileContent) {
+internal fun TimelineItemFileViewPreview(@PreviewParameter(TimelineItemFileContentPreviewParam::class) content: TimelineItemFileContent) {
ElementTimelineItemPreview {
TimelineItemFileView(
content,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentPreviewParam.kt
similarity index 97%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentPreviewParam.kt
index 9d88d8c0fb..9fb34afaea 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentPreviewParam.kt
@@ -15,7 +15,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
-class TimelineItemGalleryContentProvider : PreviewParameterProvider {
+class TimelineItemGalleryContentPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aTimelineItemGalleryContent(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt
index 3212d789f9..e433f6ef8e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt
@@ -390,7 +390,7 @@ private fun RemainingCountOverlay(count: Int) {
@PreviewsDayNight
@Composable
internal fun TimelineItemGalleryViewPreview(
- @PreviewParameter(TimelineItemGalleryContentProvider::class) content: TimelineItemGalleryContent,
+ @PreviewParameter(TimelineItemGalleryContentPreviewParam::class) content: TimelineItemGalleryContent,
) = ElementPreview {
TimelineItemGalleryView(
eventId = null,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt
index 3e79fd6fd9..ce12690534 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt
@@ -33,7 +33,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
-import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentPreviewParam
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
@@ -125,7 +125,7 @@ fun TimelineItemImageView(
@PreviewsDayNight
@Composable
-internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageContentProvider::class) content: TimelineItemImageContent) = ElementPreview {
+internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageContentPreviewParam::class) content: TimelineItemImageContent) = ElementPreview {
TimelineItemImageView(
content = content,
hideMediaContent = false,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt
index 4ab4ee84a6..3ff8a62455 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt
@@ -28,9 +28,10 @@ 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.location.api.LocalMapTilerConfig
import io.element.android.features.location.api.StaticMapView
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
-import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentPreviewParam
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
@@ -50,6 +51,7 @@ fun TimelineItemLocationView(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 188.dp),
+ mapTilerConfig = LocalMapTilerConfig.current,
pinVariant = content.pinVariant,
location = content.location,
zoom = 15.0,
@@ -160,7 +162,7 @@ private fun LiveLocationOverlay(
@PreviewsDayNight
@Composable
-internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) =
+internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocationContentPreviewParam::class) content: TimelineItemLocationContent) =
ElementPreview {
TimelineItemLocationView(
content = content,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt
index 6b8886c592..041f3ad5a5 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt
@@ -13,7 +13,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
-import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContentProvider
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContentPreviewParam
import io.element.android.features.poll.api.pollcontent.PollContentView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -57,7 +57,7 @@ fun TimelineItemPollView(
@PreviewsDayNight
@Composable
-internal fun TimelineItemPollViewPreview(@PreviewParameter(TimelineItemPollContentProvider::class) content: TimelineItemPollContent) =
+internal fun TimelineItemPollViewPreview(@PreviewParameter(TimelineItemPollContentPreviewParam::class) content: TimelineItemPollContent) =
ElementPreview {
TimelineItemPollView(
content = content,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt
index 7e240f8b51..c7878c00f5 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt
@@ -28,7 +28,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter
import coil3.compose.AsyncImage
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
-import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentPreviewParam
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange
@@ -123,7 +123,9 @@ fun TimelineItemStickerView(
@PreviewsDayNight
@Composable
-internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemStickerContentProvider::class) content: TimelineItemStickerContent) = ElementPreview {
+internal fun TimelineItemStickerViewPreview(@PreviewParameter(
+ TimelineItemStickerContentPreviewParam::class
+) content: TimelineItemStickerContent) = ElementPreview {
TimelineItemStickerView(
content = content,
hideMediaContent = false,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt
index 1a4be5d2e5..a7d54e819b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt
@@ -24,7 +24,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
-import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentPreviewParam
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.utils.containsOnlyEmojis
import io.element.android.libraries.androidutils.text.LinkifyHelper
@@ -82,7 +82,7 @@ internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent):
@PreviewsDayNight
@Composable
internal fun TimelineItemTextViewPreview(
- @PreviewParameter(TimelineItemTextBasedContentProvider::class) content: TimelineItemTextBasedContent
+ @PreviewParameter(TimelineItemTextBasedContentPreviewParam::class) content: TimelineItemTextBasedContent
) = ElementPreview {
TimelineItemTextView(
content = content,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt
index b47220fa5e..5b3803713e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt
@@ -37,7 +37,7 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
-import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentPreviewParam
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
@@ -150,7 +150,7 @@ fun TimelineItemVideoView(
@PreviewsDayNight
@Composable
-internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoContentProvider::class) content: TimelineItemVideoContent) = ElementPreview {
+internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoContentPreviewParam::class) content: TimelineItemVideoContent) = ElementPreview {
TimelineItemVideoView(
content = content,
hideMediaContent = false,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt
index 15387f51c0..a05ac78f84 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt
@@ -42,7 +42,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
-import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentPreviewParam
import io.element.android.libraries.designsystem.atomic.atoms.PlaybackSpeedButton
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -56,7 +56,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
-import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
+import io.element.android.libraries.voiceplayer.api.VoiceMessageStatePreviewParam
import kotlinx.coroutines.delay
@Composable
@@ -261,9 +261,9 @@ private fun CustomIconButton(
)
}
-open class TimelineItemVoiceViewParametersProvider : PreviewParameterProvider {
- private val voiceMessageStateProvider = VoiceMessageStateProvider()
- private val timelineItemVoiceContentProvider = TimelineItemVoiceContentProvider()
+open class TimelineItemVoiceViewParametersPreviewParam : PreviewParameterProvider {
+ private val voiceMessageStateProvider = VoiceMessageStatePreviewParam()
+ private val timelineItemVoiceContentProvider = TimelineItemVoiceContentPreviewParam()
override val values: Sequence
get() = timelineItemVoiceContentProvider.values.flatMap { content ->
voiceMessageStateProvider.values.map { state ->
@@ -283,7 +283,7 @@ data class TimelineItemVoiceViewParameters(
@PreviewsDayNight
@Composable
internal fun TimelineItemVoiceViewPreview(
- @PreviewParameter(TimelineItemVoiceViewParametersProvider::class) timelineItemVoiceViewParameters: TimelineItemVoiceViewParameters,
+ @PreviewParameter(TimelineItemVoiceViewParametersPreviewParam::class) timelineItemVoiceViewParameters: TimelineItemVoiceViewParameters,
) = ElementPreview {
TimelineItemVoiceView(
state = timelineItemVoiceViewParameters.state,
@@ -296,7 +296,7 @@ internal fun TimelineItemVoiceViewPreview(
@PreviewsDayNight
@Composable
internal fun TimelineItemVoiceViewUnifiedPreview() = ElementPreview {
- val timelineItemVoiceViewParametersProvider = TimelineItemVoiceViewParametersProvider()
+ val timelineItemVoiceViewParametersProvider = TimelineItemVoiceViewParametersPreviewParam()
Column {
timelineItemVoiceViewParametersProvider.values.forEach {
TimelineItemVoiceView(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStatePreviewParam.kt
similarity index 91%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStateProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStatePreviewParam.kt
index 82974c8908..a617394723 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStatePreviewParam.kt
@@ -12,7 +12,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.libraries.matrix.api.core.EventId
-open class ReactionSummaryStateProvider : PreviewParameterProvider {
+open class ReactionSummaryStatePreviewParam : PreviewParameterProvider {
override val values = sequenceOf(aReactionSummaryState())
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt
index 2a902fd692..5b6c7fda37 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt
@@ -293,7 +293,7 @@ private fun SenderRow(
@PreviewsDayNight
@Composable
internal fun ReactionSummaryViewContentPreview(
- @PreviewParameter(ReactionSummaryStateProvider::class) state: ReactionSummaryState
+ @PreviewParameter(ReactionSummaryStatePreviewParam::class) state: ReactionSummaryState
) = ElementPreview {
ReactionSummaryViewContent(summary = state.target as ReactionSummaryState.Summary)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateForTimelineItemEventRowProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateForTimelineItemEventRowPreviewParam.kt
similarity index 95%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateForTimelineItemEventRowProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateForTimelineItemEventRowPreviewParam.kt
index c5140c4c03..a3556a9b03 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateForTimelineItemEventRowProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateForTimelineItemEventRowPreviewParam.kt
@@ -13,7 +13,7 @@ import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
-class ReadReceiptViewStateForTimelineItemEventRowProvider :
+class ReadReceiptViewStateForTimelineItemEventRowPreviewParam :
PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStatePreviewParam.kt
similarity index 96%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStatePreviewParam.kt
index 64a7cf34ff..751a88cb39 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStatePreviewParam.kt
@@ -17,7 +17,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import kotlinx.collections.immutable.toImmutableList
-class ReadReceiptViewStateProvider : PreviewParameterProvider {
+class ReadReceiptViewStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aReadReceiptViewState(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt
index c9b712eba7..17a9b0c5eb 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt
@@ -202,7 +202,7 @@ private fun computeReceiptDescription(receipts: ImmutableList):
@PreviewsDayNight
@Composable
internal fun TimelineItemReadReceiptViewPreview(
- @PreviewParameter(ReadReceiptViewStateProvider::class) state: ReadReceiptViewState,
+ @PreviewParameter(ReadReceiptViewStatePreviewParam::class) state: ReadReceiptViewState,
) = ElementPreview {
TimelineItemReadReceiptView(
state = state,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt
index 407cd29026..ad30a3765e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt
@@ -117,7 +117,7 @@ private fun ReadReceiptBottomSheetContent(
@PreviewsDayNight
@Composable
-internal fun ReadReceiptBottomSheetPreview(@PreviewParameter(ReadReceiptBottomSheetStateProvider::class) state: ReadReceiptBottomSheetState) {
+internal fun ReadReceiptBottomSheetPreview(@PreviewParameter(ReadReceiptBottomSheetStatePreviewParam::class) state: ReadReceiptBottomSheetState) {
ElementPreview(fillMaxSize = true) {
ReadReceiptBottomSheet(
state = state,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStatePreviewParam.kt
similarity index 87%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStateProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStatePreviewParam.kt
index ff297a4f8d..86c2beb707 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStatePreviewParam.kt
@@ -10,14 +10,14 @@ package io.element.android.features.messages.impl.timeline.components.receipt.bo
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
-import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewStateProvider
+import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewStatePreviewParam
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import kotlinx.collections.immutable.toImmutableList
-class ReadReceiptBottomSheetStateProvider : PreviewParameterProvider {
- // Reuse the provider ReadReceiptViewStateProvider
- private val readReceiptViewStateProvider = ReadReceiptViewStateProvider()
+class ReadReceiptBottomSheetStatePreviewParam : PreviewParameterProvider {
+ // Reuse the provider ReadReceiptViewStatePreviewParam
+ private val readReceiptViewStateProvider = ReadReceiptViewStatePreviewParam()
override val values: Sequence = readReceiptViewStateProvider.values
.filter { it.sendState is LocalEventSendState.Sent }
.map { readReceiptViewState ->
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineItemDaySeparatorView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineItemDaySeparatorView.kt
index 30befea07d..f2a62aa4a4 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineItemDaySeparatorView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineItemDaySeparatorView.kt
@@ -21,7 +21,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.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
-import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModelProvider
+import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModelPreviewParam
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
@@ -53,7 +53,7 @@ internal fun TimelineItemDaySeparatorView(
@PreviewsDayNight
@Composable
internal fun TimelineItemDaySeparatorViewPreview(
- @PreviewParameter(TimelineItemDaySeparatorModelProvider::class) model: TimelineItemDaySeparatorModel
+ @PreviewParameter(TimelineItemDaySeparatorModelPreviewParam::class) model: TimelineItemDaySeparatorModel
) = ElementPreview {
TimelineItemDaySeparatorView(
model = model,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactories.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactories.kt
index 1063157bef..2234593c4b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactories.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactories.kt
@@ -10,28 +10,13 @@ package io.element.android.features.messages.impl.timeline.di
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
-import dev.zacsweers.metro.BindingContainer
-import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Inject
-import dev.zacsweers.metro.Multibinds
import dev.zacsweers.metro.SingleIn
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
import kotlin.reflect.KClass
-/**
- * Container that declares the [TimelineItemPresenterFactory] map multi binding.
- *
- * Its sole purpose is to support the case of an empty map multibinding.
- */
-@BindingContainer
-@ContributesTo(RoomScope::class)
-interface TimelineItemPresenterFactoriesModule {
- @Multibinds
- fun multiBindTimelineItemPresenterFactories(): @JvmSuppressWildcards Map, TimelineItemPresenterFactory<*, *>>
-}
-
/**
* Room level caching layer for the [TimelineItemPresenterFactory] instances.
*
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactoriesBindingContainer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactoriesBindingContainer.kt
new file mode 100644
index 0000000000..65dcd6dee0
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactoriesBindingContainer.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.timeline.di
+
+import dev.zacsweers.metro.BindingContainer
+import dev.zacsweers.metro.ContributesTo
+import dev.zacsweers.metro.Multibinds
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
+import io.element.android.libraries.di.RoomScope
+import kotlin.reflect.KClass
+
+/**
+ * Container that declares the [TimelineItemPresenterFactory] map multi binding.
+ *
+ * Its sole purpose is to support the case of an empty map multibinding.
+ */
+@BindingContainer
+@ContributesTo(RoomScope::class)
+interface TimelineItemPresenterFactoriesBindingContainer {
+ @Multibinds
+ fun multiBindTimelineItemPresenterFactories(): @JvmSuppressWildcards Map, TimelineItemPresenterFactory<*, *>>
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt
index 1679a6030a..9bf7aa3c32 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt
@@ -21,6 +21,9 @@ import io.element.android.libraries.androidutils.diff.MutableListDiffCache
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
+import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
@@ -110,7 +113,11 @@ class TimelineItemsFactory(
): TimelineItem? {
val timelineItem =
when (val currentTimelineItem = timelineItems[index]) {
- is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers, renderReadReceipts)
+ is MatrixTimelineItem.Event -> if (currentTimelineItem.event.isKeyVerificationRequest()) {
+ null
+ } else {
+ eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers, renderReadReceipts)
+ }
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
MatrixTimelineItem.Other -> null
}
@@ -118,3 +125,8 @@ class TimelineItemsFactory(
return timelineItem
}
}
+
+private fun EventTimelineItem.isKeyVerificationRequest(): Boolean {
+ val messageType = (content as? MessageContent)?.type
+ return messageType is OtherMessageType && messageType.isKeyVerificationRequest
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
index f29f23e903..af3e18e3cc 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
@@ -38,6 +38,8 @@ import io.element.android.libraries.matrix.ui.messages.reply.map
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
+private const val GROUPING_TIMEOUT_MS = 5 * 60 * 1000L
+
@AssistedInject
class TimelineItemEventFactory(
@Assisted private val config: TimelineItemsFactoryConfig,
@@ -221,8 +223,14 @@ class TimelineItemEventFactory(
val previousSender = prevTimelineItem?.event?.sender
val nextSender = nextTimelineItem?.event?.sender
- val previousIsGroupable = prevTimelineItem?.canBeDisplayedInBubbleBlock().orTrue()
- val nextIsGroupable = nextTimelineItem?.canBeDisplayedInBubbleBlock().orTrue()
+ val previousIsGroupable = prevTimelineItem?.let {
+ it.canBeDisplayedInBubbleBlock() &&
+ currentTimelineItem.event.timestamp - it.event.timestamp <= GROUPING_TIMEOUT_MS
+ }.orTrue()
+ val nextIsGroupable = nextTimelineItem?.let {
+ it.canBeDisplayedInBubbleBlock() &&
+ it.event.timestamp - currentTimelineItem.event.timestamp <= GROUPING_TIMEOUT_MS
+ }.orTrue()
return when {
previousSender != currentSender && nextSender == currentSender -> {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStatePreviewParam.kt
similarity index 93%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStatePreviewParam.kt
index 0a6fb70cc5..c28e9c4d92 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStatePreviewParam.kt
@@ -13,7 +13,7 @@ import io.element.android.features.messages.impl.timeline.FocusRequestState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.errors.FocusEventException
-open class FocusRequestStateProvider : PreviewParameterProvider {
+open class FocusRequestStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
FocusRequestState.Loading(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateView.kt
index 3dff7ac4f7..2bda2fe4bf 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateView.kt
@@ -55,7 +55,7 @@ fun FocusRequestStateView(
@PreviewsDayNight
@Composable
internal fun FocusRequestStateViewPreview(
- @PreviewParameter(FocusRequestStateProvider::class) state: FocusRequestState,
+ @PreviewParameter(FocusRequestStatePreviewParam::class) state: FocusRequestState,
) = ElementPreview {
FocusRequestStateView(
focusRequestState = state,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionPreviewParam.kt
similarity index 95%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionPreviewParam.kt
index e5eb52c91e..f7ede5ebd7 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionPreviewParam.kt
@@ -15,7 +15,7 @@ import java.text.DateFormat
import java.util.Date
import java.util.TimeZone
-open class AggregatedReactionProvider : PreviewParameterProvider {
+open class AggregatedReactionPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(false, true).flatMap {
sequenceOf(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReactionsProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReactionsProvider.kt
index 3190e61a75..35d4f21dda 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReactionsProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReactionsProvider.kt
@@ -11,6 +11,6 @@ package io.element.android.features.messages.impl.timeline.model
import kotlinx.collections.immutable.toImmutableList
fun aTimelineItemReactions() = TimelineItemReactions(
- // Use values from AggregatedReactionProvider
- reactions = AggregatedReactionProvider().values.toImmutableList()
+ // Use values from AggregatedReactionPreviewParam
+ reactions = AggregatedReactionPreviewParam().values.toImmutableList()
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/bubble/BubbleStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/bubble/BubbleStatePreviewParam.kt
similarity index 94%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/bubble/BubbleStateProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/bubble/BubbleStatePreviewParam.kt
index cdd24ed264..4a7a9f8187 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/bubble/BubbleStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/bubble/BubbleStatePreviewParam.kt
@@ -13,7 +13,7 @@ import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
-open class BubbleStateProvider : PreviewParameterProvider {
+open class BubbleStatePreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
TimelineItemGroupPosition.First,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentPreviewParam.kt
similarity index 92%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentPreviewParam.kt
index 3b60a48c78..600288012b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentPreviewParam.kt
@@ -13,7 +13,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlin.time.Duration.Companion.milliseconds
-open class TimelineItemAudioContentProvider : PreviewParameterProvider {
+open class TimelineItemAudioContentPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aTimelineItemAudioContent("A sound.mp3"),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContentPreviewParam.kt
similarity index 96%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContentProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContentPreviewParam.kt
index bb53da3b7b..a85553360a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContentPreviewParam.kt
@@ -12,7 +12,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause
-open class TimelineItemEncryptedContentProvider : PreviewParameterProvider {
+open class TimelineItemEncryptedContentPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aTimelineItemEncryptedContent(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
index c60e3847da..01146a070b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
@@ -58,10 +58,12 @@ fun TimelineItemEventContent.canBeForwarded(): Boolean =
is TimelineItemFileContent,
is TimelineItemAudioContent,
is TimelineItemVideoContent,
- is TimelineItemLocationContent,
is TimelineItemVoiceContent,
is TimelineItemGalleryContent,
is TimelineItemAttachmentsContent -> true
+ // Live location shares can't be forwarded, the SDK rejects them, so we only show the option for static locations
+ // See https://github.com/element-hq/element-x-android/issues/7190
+ is TimelineItemLocationContent -> mode is TimelineItemLocationContent.Mode.Static
// Stickers can't be forwarded (yet) so we don't show the option
// See https://github.com/element-hq/element-x-android/issues/2161
is TimelineItemStickerContent -> false
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentPreviewParam.kt
similarity index 96%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentPreviewParam.kt
index 1d4c7c04ec..0dd91fccfe 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentPreviewParam.kt
@@ -18,7 +18,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecry
import kotlinx.collections.immutable.toImmutableList
import org.jsoup.nodes.Document
-class TimelineItemEventContentProvider : PreviewParameterProvider {
+class TimelineItemEventContentPreviewParam : PreviewParameterProvider {
override val values = sequenceOf(
aTimelineItemEmoteContent(),
aTimelineItemEncryptedContent(),
@@ -45,7 +45,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider {
+class TimelineItemTextBasedContentPreviewParam : PreviewParameterProvider {
private fun buildSpanned(text: String) = buildSpannedString {
inSpans(StyleSpan(Typeface.BOLD)) {
append("Rich Text")
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentPreviewParam.kt
similarity index 92%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentPreviewParam.kt
index e2021e9646..277f461edc 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentPreviewParam.kt
@@ -12,7 +12,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource
-open class TimelineItemFileContentProvider : PreviewParameterProvider {
+open class TimelineItemFileContentPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aTimelineItemFileContent(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentPreviewParam.kt
similarity index 93%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentPreviewParam.kt
index 6d3b114428..cd93c7c006 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentPreviewParam.kt
@@ -13,7 +13,7 @@ import androidx.media3.common.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
-open class TimelineItemImageContentProvider : PreviewParameterProvider {
+open class TimelineItemImageContentPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aTimelineItemImageContent(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentPreviewParam.kt
similarity index 95%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentPreviewParam.kt
index 6c095c828e..e4f2691f27 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentPreviewParam.kt
@@ -14,7 +14,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
-open class TimelineItemLocationContentProvider : PreviewParameterProvider {
+open class TimelineItemLocationContentPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aTimelineItemLocationContent(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentPreviewParam.kt
similarity index 94%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentPreviewParam.kt
index 040a3cd7fe..7c74f7f7f1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentPreviewParam.kt
@@ -15,7 +15,7 @@ import io.element.android.features.poll.api.pollcontent.aPollQuestion
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollKind
-open class TimelineItemPollContentProvider : PreviewParameterProvider {
+open class TimelineItemPollContentPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aTimelineItemPollContent(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentPreviewParam.kt
similarity index 92%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentPreviewParam.kt
index eb564cd803..def748d97b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentPreviewParam.kt
@@ -13,7 +13,7 @@ import androidx.media3.common.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
-open class TimelineItemStickerContentProvider : PreviewParameterProvider {
+open class TimelineItemStickerContentPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aTimelineItemStickerContent(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentPreviewParam.kt
similarity index 94%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentPreviewParam.kt
index 627dbf3dd7..46de9ea89a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentPreviewParam.kt
@@ -14,7 +14,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import kotlin.time.Duration.Companion.milliseconds
-open class TimelineItemVideoContentProvider : PreviewParameterProvider {
+open class TimelineItemVideoContentPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aTimelineItemVideoContent(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentPreviewParam.kt
similarity index 95%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentPreviewParam.kt
index d596963ebc..9b9a044791 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentPreviewParam.kt
@@ -17,7 +17,7 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
-open class TimelineItemVoiceContentProvider : PreviewParameterProvider {
+open class TimelineItemVoiceContentPreviewParam : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aTimelineItemVoiceContent(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModelProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModelPreviewParam.kt
similarity index 85%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModelProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModelPreviewParam.kt
index d31d35d62f..2fb4e7ac63 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModelProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModelPreviewParam.kt
@@ -10,7 +10,7 @@ package io.element.android.features.messages.impl.timeline.model.virtual
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-class TimelineItemDaySeparatorModelProvider : PreviewParameterProvider {
+class TimelineItemDaySeparatorModelPreviewParam : PreviewParameterProvider {
override val values = sequenceOf(
aTimelineItemDaySeparatorModel("Today"),
aTimelineItemDaySeparatorModel("March 6, 2023")
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/AspectRatioProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/AspectRatioPreviewParam.kt
similarity index 88%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/AspectRatioProvider.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/AspectRatioPreviewParam.kt
index 0ebd63439a..43118d96fd 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/AspectRatioProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/AspectRatioPreviewParam.kt
@@ -10,7 +10,7 @@ package io.element.android.features.messages.impl.timeline.protection
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-class AspectRatioProvider : PreviewParameterProvider {
+class AspectRatioPreviewParam : PreviewParameterProvider