Merge branch 'develop' into feature/fga/timeline-multi-forward

This commit is contained in:
ganfra
2026-09-29 17:15:40 +02:00
1583 changed files with 15749 additions and 7431 deletions
+2 -2
View File
@@ -13,8 +13,8 @@
"hostRules": [ "hostRules": [
{ {
"matchHost": "https://repo1.maven.org/maven2/", "matchHost": "https://repo1.maven.org/maven2/",
"concurrentRequestLimit": 10, "concurrentRequestLimit": 5,
"maxRequestsPerSecond": 10, "maxRequestsPerSecond": 5,
"timeout": 10000, "timeout": 10000,
} }
], ],
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
- name: Clone submodules - name: Clone submodules
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
- name: ⏬ Checkout with LFS - name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
name: Use JDK 21 name: Use JDK 21
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
ref: ${{ github.ref }} ref: ${{ github.ref }}
persist-credentials: false persist-credentials: false
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
name: Use JDK 21 name: Use JDK 21
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+2 -2
View File
@@ -37,7 +37,7 @@ jobs:
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
@@ -80,7 +80,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+6 -6
View File
@@ -69,7 +69,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
@@ -108,7 +108,7 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
@@ -149,7 +149,7 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
@@ -183,7 +183,7 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
@@ -228,7 +228,7 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
@@ -269,7 +269,7 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: ☕️ Use JDK 21 - name: ☕️ Use JDK 21
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+2 -2
View File
@@ -38,7 +38,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
@@ -88,7 +88,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
@@ -57,12 +57,6 @@ echo "Deleting previous screenshots"
echo "Record screenshots" echo "Record screenshots"
./gradlew recordPaparazziDebug --stacktrace $GRADLE_ARGS ./gradlew recordPaparazziDebug --stacktrace $GRADLE_ARGS
echo "Deleting previous screenshots"
./gradlew removeOldScreenshots --stacktrace --warn $GRADLE_ARGS
echo "Record screenshots (Compound)"
./gradlew :libraries:compound:recordRoborazziDebug --stacktrace -PpreDexEnable=false --max-workers 4 --warn $GRADLE_ARGS
echo "Committing changes" echo "Committing changes"
git config http.sslVerify false git config http.sslVerify false
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+3 -4
View File
@@ -63,7 +63,7 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: ☕️ Use JDK 21 - name: ☕️ Use JDK 21
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
@@ -76,7 +76,7 @@ jobs:
run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
- name: ⚙️ Run screenshot tests - name: ⚙️ Run screenshot tests
run: ./gradlew :tests:uitests:verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
- name: ⚙️ Generate kover coverage report - name: ⚙️ Generate kover coverage report
run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
@@ -100,7 +100,6 @@ jobs:
name: tests-and-screenshot-tests-results name: tests-and-screenshot-tests-results
path: | path: |
**/build/paparazzi/failures/ **/build/paparazzi/failures/
**/build/roborazzi/failures/
**/build/reports/tests/*UnitTest/ **/build/reports/tests/*UnitTest/
- name: 🚫 Modify summary on error - name: 🚫 Modify summary on error
@@ -114,7 +113,7 @@ jobs:
# https://github.com/codecov/codecov-action # https://github.com/codecov/codecov-action
- name: ☂️ Upload coverage reports to codecov - name: ☂️ Upload coverage reports to codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with: with:
fail_ci_if_error: true fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
+7
View File
@@ -7,6 +7,13 @@ appId: ${MAESTRO_APP_ID}
- tapOn: - tapOn:
id: "login-continue" id: "login-continue"
## MAS page ## MAS page
# Click on "Use another account" if MAS is already logged in:
- runFlow:
when:
visible: "Use another account"
commands:
- tapOn:
text: "Use another account"
- retry: - retry:
maxRetries: 3 maxRetries: 3
commands: commands:
+3
View File
@@ -2,6 +2,9 @@ appId: ${MAESTRO_APP_ID}
--- ---
- tapOn: - tapOn:
id: "home_screen-settings" id: "home_screen-settings"
- tapOn:
id: "settings-user_profile"
- assertVisible: "Your account"
- tapOn: "Remove this device" - tapOn: "Remove this device"
- takeScreenshot: build/maestro/900-SignOutScreen - takeScreenshot: build/maestro/900-SignOutScreen
- back - back
@@ -8,6 +8,12 @@ appId: ${MAESTRO_APP_ID}
- tapOn: "Add description…" - tapOn: "Add description…"
- inputText: "aRoomTopic" - inputText: "aRoomTopic"
- tapOn: "Create" - tapOn: "Create"
# It can take ages to create room on matrix.org
- extendedWaitUntil:
timeout: 30000
visible: "Invite people"
- takeScreenshot: build/maestro/320-createAndDeleteRoom - takeScreenshot: build/maestro/320-createAndDeleteRoom
- tapOn: "Search for someone" - tapOn: "Search for someone"
- inputText: ${MAESTRO_INVITEE1_MXID} - inputText: ${MAESTRO_INVITEE1_MXID}
+1 -1
View File
@@ -1,7 +1,7 @@
appId: ${MAESTRO_APP_ID} appId: ${MAESTRO_APP_ID}
--- ---
- runFlow: cleanUpRoomList.yaml - runFlow: cleanUpRoomList.yaml
- runFlow: searchRoomList.yaml #- runFlow: searchRoomList.yaml
- takeScreenshot: build/maestro/300-RoomList - takeScreenshot: build/maestro/300-RoomList
- runFlow: timeline/timeline.yaml - runFlow: timeline/timeline.yaml
- runFlow: roomContextMenu.yaml - runFlow: roomContextMenu.yaml
@@ -3,5 +3,5 @@ appId: ${MAESTRO_APP_ID}
- takeScreenshot: build/maestro/520-Timeline - takeScreenshot: build/maestro/520-Timeline
- tapOn: "Add attachment" - tapOn: "Add attachment"
- tapOn: "Share location" - tapOn: "Share location"
- tapOn: "Share selected location" - tapOn: "Share my location"
- takeScreenshot: build/maestro/521-Timeline - takeScreenshot: build/maestro/521-Timeline
+61 -8
View File
@@ -4,9 +4,12 @@ appId: ${MAESTRO_APP_ID}
id: "home_screen-settings" id: "home_screen-settings"
- assertVisible: "Settings" - assertVisible: "Settings"
- takeScreenshot: build/maestro/600-Settings - takeScreenshot: build/maestro/600-Settings
## Account settings
- tapOn: - tapOn:
text: "Analytics" id: "settings-user_profile"
- assertVisible: "Share analytics data" - assertVisible: "Edit profile"
- takeScreenshot: build/maestro/610-AccountEditProfile
- back - back
- tapOn: - tapOn:
@@ -14,16 +17,27 @@ appId: ${MAESTRO_APP_ID}
- assertVisible: "Enable notifications on this device" - assertVisible: "Enable notifications on this device"
- back - back
- tapOn:
text: "Encryption"
- assertVisible: "Key storage"
- back
- tapOn:
text: "Moderation & safety"
- assertVisible: "Your appearance"
- back
- tapOn: - tapOn:
text: "Report a problem" text: "Report a problem"
- assertVisible: "Report a problem" - assertVisible: "Report a problem"
- back - back
## App settings section
- assertVisible: "Appearance"
- tapOn: - tapOn:
text: "About" text: "Media upload quality"
- assertVisible: "Copyright" - assertVisible: "Media upload quality"
- assertVisible: "Acceptable use policy"
- assertVisible: "Privacy policy"
- back - back
- tapOn: - tapOn:
@@ -32,14 +46,53 @@ appId: ${MAESTRO_APP_ID}
- hideKeyboard - hideKeyboard
- back - back
- scrollUntilVisible:
direction: DOWN
element:
text: "Location sharing"
- tapOn: - tapOn:
text: "Advanced settings" text: "Location sharing"
- assertVisible: "View source" - assertVisible: "Live location updates"
- back - back
## General section
- scrollUntilVisible:
direction: DOWN
element:
text: "Analytics"
- tapOn:
text: "Analytics"
- assertVisible: "Share analytics data"
- back
- scrollUntilVisible:
direction: DOWN
element:
text: "Labs"
- tapOn:
text: "Labs"
- assertVisible: "Feeling experimental?"
- back
- scrollUntilVisible:
direction: DOWN
element:
text: "About"
- tapOn:
text: "About"
- assertVisible: "Copyright"
- assertVisible: "Acceptable use policy"
- assertVisible: "Privacy policy"
- back
- scrollUntilVisible:
direction: DOWN
element:
text: "Developer options"
- tapOn: - tapOn:
text: "Developer options" text: "Developer options"
- assertVisible: "Feature flags" - assertVisible: "Feature flags"
- assertVisible: "View source"
- back - back
- back - back
+63
View File
@@ -1,3 +1,66 @@
Changes in Element X v26.09.3
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.09.3 -->
## What's Changed
### ✨ Features
* Integrate the new timeline renderer behind a feature flag by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7722
* Add headers (`h1-h6`) tag support for the new timeline renderer by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7754
### 🙌 Improvements
* Add smooth animation to voice messages play progress by @bxdxnn in https://github.com/element-hq/element-x-android/pull/7698
* Add linkification to the new timeline renderer by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7731
### 🐛 Bugfixes
* Make global search use a `Joined` filter for room results by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7695
* Fix flaky test in `TimelinePresenterTest` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7702
* Close the threaded timelines created to create a poll, share a location or send media by @bmarty in https://github.com/element-hq/element-x-android/pull/7705
* Start sync manually after completing the auth flow by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7734
* Fix forwarding an event not working in threads by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7743
* Fix blank screen at launch when the homeserver expires the sliding sync session by @bmarty in https://github.com/element-hq/element-x-android/pull/7692
* Fix unread notification count in room list by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7749
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7699
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7736
### 🧱 Build
* Reduce parallel JVMs spawned for tests by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7701
* Replace git flow with regular git commands in the release script V2 by @bmarty in https://github.com/element-hq/element-x-android/pull/7707
* Remove Roborazzi, use Paparazzi for Compound by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7735
* Fix regex for invalid placeholders by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7738
* Add a script to install and launch an APK given a PR URL by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7752
### 🚧 In development 🚧
* New timeline event renderer: add new modules by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7721
* Add recent search results to global search by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7742
### Dependency upgrades
* Update dependency io.sentry:sentry-android to v8.55.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7685
* Update coil to v3.6.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7693
* Update kotlinpoet to v2.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7709
* Update plugin sonarqube to v7.5.0.8588 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7671
* Update roborazzi to v1.74.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7712
* Update dependency io.element.android:element-call-embedded to v0.26.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7715
* Use patched Konsist version (forked, 0.18.0) by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7719
* Update dependency net.zetetic:sqlcipher-android to v4.19.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7716
* Update metro to v1.4.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7711
* Update actions/setup-java action to v6.0.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7728
* Update dependency androidx.compose:compose-bom to v2026.09.00 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7730
* Update dependency androidx.sqlite:sqlite-ktx to v2.7.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7729
* Update dependencyAnalysis to v3.19.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7755
* Update media3 to v1.11.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7756
* Upgrade SDK bindings to `v26.09.26` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7780
### Others
* Let the user give a reason when removing a message by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7538
* Iterate on client creation by @bmarty in https://github.com/element-hq/element-x-android/pull/7591
* Iteration on Acount provider by @bmarty in https://github.com/element-hq/element-x-android/pull/7703
* Do not report detekt LargeClass issues on test classes by @bmarty in https://github.com/element-hq/element-x-android/pull/7713
* Let the user give an optional reason when removing a message by @bmarty in https://github.com/element-hq/element-x-android/pull/7679
* Make NotificationResultProcessor.emit wait until the batch is processed by @Johennes in https://github.com/element-hq/element-x-android/pull/7640
* Settings iteration: move user status and update multi-account section by @bmarty in https://github.com/element-hq/element-x-android/pull/7741
* Update remove message confirmation dialog. by @bmarty in https://github.com/element-hq/element-x-android/pull/7757
* Add a script to end all the sessions of a Matrix account using MAS by @bmarty in https://github.com/element-hq/element-x-android/pull/7762
* Settings iteration: split account settings and application settings. by @bmarty in https://github.com/element-hq/element-x-android/pull/7750
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.09.2...v26.09.3
Changes in Element X v26.09.2 Changes in Element X v26.09.2
============================= =============================
+12 -7
View File
@@ -21,6 +21,7 @@ import extension.allFeaturesImpl
import extension.allLibrariesImpl import extension.allLibrariesImpl
import extension.allServicesImpl import extension.allServicesImpl
import extension.buildConfigFieldStr import extension.buildConfigFieldStr
import extension.environmentOrGradleValue
import extension.locales import extension.locales
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies import extension.testCommonDependencies
@@ -88,13 +89,14 @@ android {
storePassword = "android" storePassword = "android"
} }
register("nightly") { register("nightly") {
keyAlias = System.getenv("ELEMENT_ANDROID_NIGHTLY_KEYID") keyAlias = environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_KEYID", "signing.element.nightly.keyId")
?: project.property("signing.element.nightly.keyId") as? String? keyPassword = environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD", "signing.element.nightly.keyPassword")
keyPassword = System.getenv("ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD") storeFile = file(
?: project.property("signing.element.nightly.keyPassword") as? String? environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_STOREFILE", "signing.element.nightly.storeFile")
storeFile = file("./signature/nightly.keystore") ?.ifBlank { null }
storePassword = System.getenv("ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD") ?: "./signature/nightly.keystore"
?: project.property("signing.element.nightly.storePassword") as? String? )
storePassword = environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD", "signing.element.nightly.storePassword")
} }
} }
@@ -340,6 +342,9 @@ licensee {
ignoreDependencies("com.github.matrix-org", "matrix-analytics-events") ignoreDependencies("com.github.matrix-org", "matrix-analytics-events")
// Ignore dependency that are not third-party licenses to us. // Ignore dependency that are not third-party licenses to us.
ignoreDependencies(groupId = "io.element.android") ignoreDependencies(groupId = "io.element.android")
// The rustls-platform-verifier dependency does not provide a license file in their maven repo, but there are MIT and Apache licenses in
// the source code repo: see https://github.com/rustls/rustls-platform-verifier
ignoreDependencies(groupId = "org.rustls", artifactId = "rustls-platform-verifier")
} }
fun Project.configureLicensesTasks(reportingExtension: ReportingExtension) { fun Project.configureLicensesTasks(reportingExtension: ReportingExtension) {
@@ -13,6 +13,7 @@ import android.system.Os
import androidx.startup.Initializer import androidx.startup.Initializer
import io.element.android.features.rageshake.api.logs.createWriteToFilesConfiguration import io.element.android.features.rageshake.api.logs.createWriteToFilesConfiguration
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.tracing.TracingConfiguration import io.element.android.libraries.matrix.api.tracing.TracingConfiguration
import io.element.android.x.di.AppBindings import io.element.android.x.di.AppBindings
@@ -41,9 +42,18 @@ class PlatformInitializer : Initializer<Unit> {
sdkSentryDsn = appBindings.sentrySdkDsn()?.value?.takeIf { it.isNotBlank() }, sdkSentryDsn = appBindings.sentrySdkDsn()?.value?.takeIf { it.isNotBlank() },
) )
bugReporter.setCurrentTracingLogLevel(logLevel.name) bugReporter.setCurrentTracingLogLevel(logLevel.name)
// Init the tracing service with the configuration so we have logging enabled as soon as possible
platformService.init(tracingConfiguration) platformService.init(tracingConfiguration)
// Also set env variable for rust back trace // Also set env variable for rust back trace
Os.setenv("RUST_BACKTRACE", "1", true) Os.setenv("RUST_BACKTRACE", "1", true)
// Init the SDK with the Android Context, so it can be used with rustls and other Android specific features
runCatchingExceptions {
org.matrix.rustsdk.Android.init(context)
}.onFailure {
Timber.e(it, "Failed to init the SDK with an Android Context. The app probably won't work properly.")
}
} }
override fun dependencies(): List<Class<out Initializer<*>>> = mutableListOf() override fun dependencies(): List<Class<out Initializer<*>>> = mutableListOf()
@@ -30,5 +30,384 @@
<domain includeSubdomains="true">home</domain> <domain includeSubdomains="true">home</domain>
<domain includeSubdomains="true">lan</domain> <domain includeSubdomains="true">lan</domain>
<domain includeSubdomains="true">localdomain</domain> <domain includeSubdomains="true">localdomain</domain>
<!-- Domains below are imported from rustls-platform-verifier 0.2.0 by tools/dependencies/update_rustls_platform_verifier.py -->
<!-- They are needed to fetch the certificate revocation lists over cleartext -->
<domain includeSubdomains="true">abitabdv.crl.certum.pl</domain>
<domain includeSubdomains="true">abitabev.crl.certum.pl</domain>
<domain includeSubdomains="true">abitabov.crl.certum.pl</domain>
<domain includeSubdomains="true">alpiro.crl.sectigo.com</domain>
<domain includeSubdomains="true">anticdv.crl.certum.pl</domain>
<domain includeSubdomains="true">antrustovsslg2r30ca.crl.certum.pl</domain>
<domain includeSubdomains="true">autontrustprodvtlsg2r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">bitcert.crl.sectigo.com</domain>
<domain includeSubdomains="true">btdvtlsr35g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">c.cf-b.ssl.com</domain>
<domain includeSubdomains="true">c.cf-i.ssl.com</domain>
<domain includeSubdomains="true">c.pki.goog</domain>
<domain includeSubdomains="true">ca1.agid.gov.it</domain>
<domain includeSubdomains="true">cdn.d-trust-cloudcrl.net</domain>
<domain includeSubdomains="true">cdp.disig.sk</domain>
<domain includeSubdomains="true">cdp.elektronicznypodpis.pl</domain>
<domain includeSubdomains="true">cdp.geotrust.com</domain>
<domain includeSubdomains="true">cdp.rapidssl.com</domain>
<domain includeSubdomains="true">cdp.thawte.com</domain>
<domain includeSubdomains="true">cdp1.disig.sk</domain>
<domain includeSubdomains="true">cdp1.pca.dfn.de</domain>
<domain includeSubdomains="true">cdpa.digitalcertvalidation.com</domain>
<domain includeSubdomains="true">cdpb.digitalcertvalidation.com</domain>
<domain includeSubdomains="true">cdpc.digitalcertvalidation.com</domain>
<domain includeSubdomains="true">cdpd.digitalcertvalidation.com</domain>
<domain includeSubdomains="true">cdpe.digitalcertvalidation.com</domain>
<domain includeSubdomains="true">cdpf.digitalcertvalidation.com</domain>
<domain includeSubdomains="true">cdpg.digitalcertvalidation.com</domain>
<domain includeSubdomains="true">cdph.digitalcertvalidation.com</domain>
<domain includeSubdomains="true">cdpi.digitalcertvalidation.com</domain>
<domain includeSubdomains="true">certcloud.crl.trust-provider.com</domain>
<domain includeSubdomains="true">certdata.crl.sectigo.com</domain>
<domain includeSubdomains="true">certera.crl.sectigo.com</domain>
<domain includeSubdomains="true">certificates.godaddy.com</domain>
<domain includeSubdomains="true">certum-crl.wt.trustasia.com</domain>
<domain includeSubdomains="true">certum.crl.sheca.com</domain>
<domain includeSubdomains="true">certumdvtlsg2e39ca.crl.certum.pl</domain>
<domain includeSubdomains="true">certumdvtlsg2r39ca.crl.certum.pl</domain>
<domain includeSubdomains="true">certumevtlsg2e39ca.crl.certum.pl</domain>
<domain includeSubdomains="true">certumevtlsg2r39ca.crl.certum.pl</domain>
<domain includeSubdomains="true">certumovtlsg2e39ca.crl.certum.pl</domain>
<domain includeSubdomains="true">certumovtlsg2r39ca.crl.certum.pl</domain>
<domain includeSubdomains="true">cfcadveccca.crl.certum.pl</domain>
<domain includeSubdomains="true">cfcadvrsaca.crl.certum.pl</domain>
<domain includeSubdomains="true">cfcaeveccca.crl.certum.pl</domain>
<domain includeSubdomains="true">cfcaevrsaca.crl.certum.pl</domain>
<domain includeSubdomains="true">cfcaoveccca.crl.certum.pl</domain>
<domain includeSubdomains="true">cfcaovrsaca.crl.certum.pl</domain>
<domain includeSubdomains="true">cnssldvtlsg2r36ca.crl.certum.pl</domain>
<domain includeSubdomains="true">cnsslovtlsg2r36ca.crl.certum.pl</domain>
<domain includeSubdomains="true">crl-01.r1.ica.amazontrust.com</domain>
<domain includeSubdomains="true">crl-01.r1.ica.amznts.eu</domain>
<domain includeSubdomains="true">crl-c.emsign.com</domain>
<domain includeSubdomains="true">crl-pro.litessl.com</domain>
<domain includeSubdomains="true">crl.anf.es</domain>
<domain includeSubdomains="true">crl.apple.com</domain>
<domain includeSubdomains="true">crl.businessid.telesec.de</domain>
<domain includeSubdomains="true">crl.buypass.no</domain>
<domain includeSubdomains="true">crl.catrust.oemssl.cn</domain>
<domain includeSubdomains="true">crl.certigna.fr</domain>
<domain includeSubdomains="true">crl.certsign.ro</domain>
<domain includeSubdomains="true">crl.certum.pl</domain>
<domain includeSubdomains="true">crl.cfca.com.cn</domain>
<domain includeSubdomains="true">crl.cntrus.oemssl.cn</domain>
<domain includeSubdomains="true">crl.cnwebtrust.oemssl.cn</domain>
<domain includeSubdomains="true">crl.comodoca.com</domain>
<domain includeSubdomains="true">crl.crlocsp.cn</domain>
<domain includeSubdomains="true">crl.cybertrust.ne.jp</domain>
<domain includeSubdomains="true">crl.d-trust.net</domain>
<domain includeSubdomains="true">crl.dhimyotis.com</domain>
<domain includeSubdomains="true">crl.digicert.cn</domain>
<domain includeSubdomains="true">crl.digicert.eu</domain>
<domain includeSubdomains="true">crl.e-szigno.hu</domain>
<domain includeSubdomains="true">crl.e2m01.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2m01.eu.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2m02.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2m03.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2m04.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2s14.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2s15.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2s16.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2s17.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2s18.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2s19.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2s20.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e2s21.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3m01.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3m01.eu.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3m02.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3m03.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3m04.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3s22.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3s23.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3s24.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3s25.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3s26.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3s27.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3s28.amazontrust.com</domain>
<domain includeSubdomains="true">crl.e3s29.amazontrust.com</domain>
<domain includeSubdomains="true">crl.emsign.com</domain>
<domain includeSubdomains="true">crl.ensuredca.com</domain>
<domain includeSubdomains="true">crl.firmaprofesional.com</domain>
<domain includeSubdomains="true">crl.gdca.com.cn</domain>
<domain includeSubdomains="true">crl.global.sheca.com</domain>
<domain includeSubdomains="true">crl.globalsign.com</domain>
<domain includeSubdomains="true">crl.globalsign.net</domain>
<domain includeSubdomains="true">crl.godaddy.com</domain>
<domain includeSubdomains="true">crl.harica.gr</domain>
<domain includeSubdomains="true">crl.izenpe.com</domain>
<domain includeSubdomains="true">crl.litessl.com</domain>
<domain includeSubdomains="true">crl.netsolssl.com</domain>
<domain includeSubdomains="true">crl.oem.trustca.net</domain>
<domain includeSubdomains="true">crl.pcsp.telesec.de</domain>
<domain includeSubdomains="true">crl.pki.goog</domain>
<domain includeSubdomains="true">crl.quovadisglobal.com</domain>
<domain includeSubdomains="true">crl.r1001.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2001.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2m01.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2m01.eu.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2m02.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2m03.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2m04.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2s18.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2s19.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2s20.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2s21.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2s22.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2s23.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2s24.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r2s25.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r3001.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r4001.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r4m01.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r4m02.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r4m03.amazontrust.com</domain>
<domain includeSubdomains="true">crl.r4m04.amazontrust.com</domain>
<domain includeSubdomains="true">crl.root-e1.certainly.com</domain>
<domain includeSubdomains="true">crl.root-r1.certainly.com</domain>
<domain includeSubdomains="true">crl.rootca1.amazontrust.com</domain>
<domain includeSubdomains="true">crl.rootca2.amazontrust.com</domain>
<domain includeSubdomains="true">crl.rootca3.amazontrust.com</domain>
<domain includeSubdomains="true">crl.rootca4.amazontrust.com</domain>
<domain includeSubdomains="true">crl.rztrust.oemssl.cn</domain>
<domain includeSubdomains="true">crl.sbca.telesec.de</domain>
<domain includeSubdomains="true">crl.sectigo.com</domain>
<domain includeSubdomains="true">crl.sectigochina.com</domain>
<domain includeSubdomains="true">crl.serverid.telesec.de</domain>
<domain includeSubdomains="true">crl.starfieldtech.com</domain>
<domain includeSubdomains="true">crl.swisssign.ch</domain>
<domain includeSubdomains="true">crl.trust-provider.cn</domain>
<domain includeSubdomains="true">crl.trust-provider.com</domain>
<domain includeSubdomains="true">crl.tuntrust.tn</domain>
<domain includeSubdomains="true">crl.usertrust.com</domain>
<domain includeSubdomains="true">crl.wt.trustasia.com</domain>
<domain includeSubdomains="true">crl.xinchacha.oemssl.cn</domain>
<domain includeSubdomains="true">crl.zw.oemssl.cn</domain>
<domain includeSubdomains="true">crl05.actalis.it</domain>
<domain includeSubdomains="true">crl06.actalis.it</domain>
<domain includeSubdomains="true">crl09.actalis.it</domain>
<domain includeSubdomains="true">crl1.ecert.gov.hk</domain>
<domain includeSubdomains="true">crl13.actalis.it</domain>
<domain includeSubdomains="true">crl14.actalis.it</domain>
<domain includeSubdomains="true">crl15.actalis.it</domain>
<domain includeSubdomains="true">crl2.microsoft.com</domain>
<domain includeSubdomains="true">crl3.digicert.com</domain>
<domain includeSubdomains="true">crl4.digicert.com</domain>
<domain includeSubdomains="true">crls.certainly.com</domain>
<domain includeSubdomains="true">crls.ssl.com</domain>
<domain includeSubdomains="true">cyberfolks2.crl.certum.pl</domain>
<domain includeSubdomains="true">depo.kamusm.gov.tr</domain>
<domain includeSubdomains="true">digitaltrust.crl.sectigo.com</domain>
<domain includeSubdomains="true">dnencrypt.crl.sectigo.com</domain>
<domain includeSubdomains="true">dvtlsca2025-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">dvtlsca2026-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">e5.c.lencr.org</domain>
<domain includeSubdomains="true">e6.c.lencr.org</domain>
<domain includeSubdomains="true">e7.c.lencr.org</domain>
<domain includeSubdomains="true">e8.c.lencr.org</domain>
<domain includeSubdomains="true">ec2ca2017-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">ec2ca2017-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">ec2ca2017-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">ec2sslca2017-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">ec2sslca2017-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">ec2sslca2017-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">ec3ca2017-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">ec3ca2017-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">ec3ca2017-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">ec3sslca2017-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">ec3sslca2017-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">ec3sslca2017-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">eca.hinet.net</domain>
<domain includeSubdomains="true">edvtlsca2023-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">edvtlsca2025-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">edvtlsca2026-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">emudhra.crl.sectigo.com</domain>
<domain includeSubdomains="true">eosslca2017-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">eosslca2017-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">eosslca2017-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">eovtlsca2023-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">eovtlsca2025-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">eovtlsca2026-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">eqca2017-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">eqca2017-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">eqca2017-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">eqcpca2017-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">eqcpca2017-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">eqcpca2017-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">eqoca2017-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">eqoca2017-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">eqoca2017-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">eqtlsca2018-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">eqtlsca2018-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">eqtlsca2018-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">eqtlsca2023-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">eqtlsca2025-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">eqtlsca2026-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">esafer.crl.sectigo.com</domain>
<domain includeSubdomains="true">eue2m1.crl.root.amznts.eu</domain>
<domain includeSubdomains="true">eue3m1.crl.root.amznts.eu</domain>
<domain includeSubdomains="true">eur2m1.crl.root.amznts.eu</domain>
<domain includeSubdomains="true">eveccca.crl.certum.pl</domain>
<domain includeSubdomains="true">evrsaca.crl.certum.pl</domain>
<domain includeSubdomains="true">gdcadv.crl.certum.pl</domain>
<domain includeSubdomains="true">gdcaev.crl.certum.pl</domain>
<domain includeSubdomains="true">gdcaov.crl.certum.pl</domain>
<domain includeSubdomains="true">gdcatrustauthdvtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">gdcatrustauthevtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">gdcatrustauthovtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">geant.crl.sectigo.com</domain>
<domain includeSubdomains="true">genious.crl.sectigo.com</domain>
<domain includeSubdomains="true">geossldvtlsr34g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">geosslevtlsr34g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">geosslovtlsr34g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">globaltrust.crl.sectigo.com</domain>
<domain includeSubdomains="true">globessl.crl.sectigo.com</domain>
<domain includeSubdomains="true">gogetssldv.crl.certum.pl</domain>
<domain includeSubdomains="true">gogetsslev.crl.certum.pl</domain>
<domain includeSubdomains="true">gogetsslov.crl.certum.pl</domain>
<domain includeSubdomains="true">homepldvtlsg2r35ca.crl.certum.pl</domain>
<domain includeSubdomains="true">homepldvtlsg2r36ca.crl.certum.pl</domain>
<domain includeSubdomains="true">homeplovtlsg2r35ca.crl.certum.pl</domain>
<domain includeSubdomains="true">homeplovtlsg2r36ca.crl.certum.pl</domain>
<domain includeSubdomains="true">httpcrl.trust.telia.com</domain>
<domain includeSubdomains="true">ica.navercloudtrust.com</domain>
<domain includeSubdomains="true">ica.navercorp.com</domain>
<domain includeSubdomains="true">issauth.crl.sectigo.com</domain>
<domain includeSubdomains="true">itrusdv2.crl.certum.pl</domain>
<domain includeSubdomains="true">itrusdvtlsr35g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">itrusov2.crl.certum.pl</domain>
<domain includeSubdomains="true">itrusovtlsr35g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">itso.crl.sectigo.com</domain>
<domain includeSubdomains="true">joyssldvtlsg2r33ca.crl.certum.pl</domain>
<domain includeSubdomains="true">joysslovtlsg2r33ca.crl.certum.pl</domain>
<domain includeSubdomains="true">kingnettechdv.crl.certum.pl</domain>
<domain includeSubdomains="true">kingnettechev.crl.certum.pl</domain>
<domain includeSubdomains="true">kingnettechov.crl.certum.pl</domain>
<domain includeSubdomains="true">kr.crl.digicert.com</domain>
<domain includeSubdomains="true">lh2.crl.certum.pl</domain>
<domain includeSubdomains="true">namecheap.crl.sectigo.com</domain>
<domain includeSubdomains="true">nazwassldvtlsg2e29ca.crl.certum.pl</domain>
<domain includeSubdomains="true">nazwassldvtlsg2r29ca.crl.certum.pl</domain>
<domain includeSubdomains="true">netartcadv2.crl.certum.pl</domain>
<domain includeSubdomains="true">netartssldvtlsg2e31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">nijimo.crl.sectigo.com</domain>
<domain includeSubdomains="true">nyalabscadv.crl.certum.pl</domain>
<domain includeSubdomains="true">nyatworkdv.crl.certum.pl</domain>
<domain includeSubdomains="true">omitsecurity.crl.sectigo.com</domain>
<domain includeSubdomains="true">onesign.crl.sectigo.com</domain>
<domain includeSubdomains="true">osslca2016-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">osslca2016-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">osslca2016-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">ovtlsca2025-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">ovtlsca2026-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">pki-crl.atos.net</domain>
<domain includeSubdomains="true">pki.telesec.de</domain>
<domain includeSubdomains="true">pkipro.certsign.ro</domain>
<domain includeSubdomains="true">positiwise.crl.sectigo.com</domain>
<domain includeSubdomains="true">psw.crl.sectigo.com</domain>
<domain includeSubdomains="true">public.wisekey.com</domain>
<domain includeSubdomains="true">qiduodv.crl.certum.pl</domain>
<domain includeSubdomains="true">qtlsca2018-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">qtlsca2018-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">qtlsca2018-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">qtlsca2026-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">r10.c.lencr.org</domain>
<domain includeSubdomains="true">r11.c.lencr.org</domain>
<domain includeSubdomains="true">r12.c.lencr.org</domain>
<domain includeSubdomains="true">r13.c.lencr.org</domain>
<domain includeSubdomains="true">rca.navercloudtrust.com</domain>
<domain includeSubdomains="true">rca.navercorp.com</domain>
<domain includeSubdomains="true">repo.pubcert.jprs.jp</domain>
<domain includeSubdomains="true">repo1.secomtrust.net</domain>
<domain includeSubdomains="true">repository.secomtrust.net</domain>
<domain includeSubdomains="true">repository.tls.hinet.net</domain>
<domain includeSubdomains="true">rootca.twca.com.tw</domain>
<domain includeSubdomains="true">rootglobaldv.crl.certum.pl</domain>
<domain includeSubdomains="true">rootnetworksdv2.crl.certum.pl</domain>
<domain includeSubdomains="true">shenzhendv.crl.certum.pl</domain>
<domain includeSubdomains="true">shenzhenev.crl.certum.pl</domain>
<domain includeSubdomains="true">shenzhenov.crl.certum.pl</domain>
<domain includeSubdomains="true">shoperdvtlsg2r34ca.crl.certum.pl</domain>
<domain includeSubdomains="true">shuididv.crl.certum.pl</domain>
<domain includeSubdomains="true">shuidiev.crl.certum.pl</domain>
<domain includeSubdomains="true">shuidiov.crl.certum.pl</domain>
<domain includeSubdomains="true">soomadv.crl.certum.pl</domain>
<domain includeSubdomains="true">ssl2ca2016-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">ssl2ca2016-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">ssl2ca2016-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">sslca2014-crl1.e-szigno.hu</domain>
<domain includeSubdomains="true">sslca2014-crl2.e-szigno.hu</domain>
<domain includeSubdomains="true">sslca2014-crl3.e-szigno.hu</domain>
<domain includeSubdomains="true">sslcom.crl.certum.pl</domain>
<domain includeSubdomains="true">ssllimiteddv.crl.certum.pl</domain>
<domain includeSubdomains="true">sslserver.twca.com.tw</domain>
<domain includeSubdomains="true">subca.crl.certum.pl</domain>
<domain includeSubdomains="true">titrust.crl.sectigo.com</domain>
<domain includeSubdomains="true">tlcdvtlsr34g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">tlcevtlsr34g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">tlcovtlsr34g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">tlsrootca2025-crl.e-szigno.hu</domain>
<domain includeSubdomains="true">trustasia.crl.certum.pl</domain>
<domain includeSubdomains="true">trustasiadv.crl.certum.pl</domain>
<domain includeSubdomains="true">trustasiadvtlsr35g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">trustasiaev.crl.certum.pl</domain>
<domain includeSubdomains="true">trustasiaevtlse35g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">trustasiaevtlsr35g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">trustasiaov.crl.certum.pl</domain>
<domain includeSubdomains="true">trustasiaovtlse35g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">trustasiaovtlsr35g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">trustocean.crl.certum.pl</domain>
<domain includeSubdomains="true">tstlser20.crl.telesec.de</domain>
<domain includeSubdomains="true">tstlsrr23.crl.telesec.de</domain>
<domain includeSubdomains="true">unitedtrustov.crl.certum.pl</domain>
<domain includeSubdomains="true">validation.identrust.com</domain>
<domain includeSubdomains="true">verokey.crl.sectigo.com</domain>
<domain includeSubdomains="true">vtrusdvtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">vtrusovtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">webnic.crl.sectigo.com</domain>
<domain includeSubdomains="true">whitessldvtlsg2e36ca.crl.certum.pl</domain>
<domain includeSubdomains="true">wosign.crl.certum.pl</domain>
<domain includeSubdomains="true">wotrus-dvca.crl.certum.pl</domain>
<domain includeSubdomains="true">wotrus-evca.crl.certum.pl</domain>
<domain includeSubdomains="true">wotrus-ovca.crl.certum.pl</domain>
<domain includeSubdomains="true">wotrus.crl.sectigo.com</domain>
<domain includeSubdomains="true">wtca-cafiles.itrus.com.cn</domain>
<domain includeSubdomains="true">wtca-crl.itrus.com.cn</domain>
<domain includeSubdomains="true">www.accv.es</domain>
<domain includeSubdomains="true">www.cert.fnmt.es</domain>
<domain includeSubdomains="true">www.d-trust.net</domain>
<domain includeSubdomains="true">www.microsoft.com</domain>
<domain includeSubdomains="true">x1.c.lencr.org</domain>
<domain includeSubdomains="true">x2.c.lencr.org</domain>
<domain includeSubdomains="true">xcctrustdvtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">xcctrustevtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">xcctrustovtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">xinchacha2dv.crl.certum.pl</domain>
<domain includeSubdomains="true">xinchacha2ov.crl.certum.pl</domain>
<domain includeSubdomains="true">xinchachatrustdvtlsg2r34ca.crl.certum.pl</domain>
<domain includeSubdomains="true">xinchachatrustevtlsg2r34ca.crl.certum.pl</domain>
<domain includeSubdomains="true">xinchachatrustovtlsg2r34ca.crl.certum.pl</domain>
<domain includeSubdomains="true">xinnetdvtlsr34g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">xinnetevtlsr34g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">xinnetovtlsr34g2ca.crl.certum.pl</domain>
<domain includeSubdomains="true">ye.c.lencr.org</domain>
<domain includeSubdomains="true">ye1.c.lencr.org</domain>
<domain includeSubdomains="true">ye2.c.lencr.org</domain>
<domain includeSubdomains="true">yektadv.crl.certum.pl</domain>
<domain includeSubdomains="true">yektaov.crl.certum.pl</domain>
<domain includeSubdomains="true">yr.c.lencr.org</domain>
<domain includeSubdomains="true">yr1.c.lencr.org</domain>
<domain includeSubdomains="true">yr2.c.lencr.org</domain>
<domain includeSubdomains="true">zerossl.crl.sectigo.com</domain>
<domain includeSubdomains="true">ziwit.crl.sectigo.com</domain>
<domain includeSubdomains="true">zycatrustdvtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">zycatrustevtlsg3r31ca.crl.certum.pl</domain>
<domain includeSubdomains="true">zycatrustovtlsg3r31ca.crl.certum.pl</domain>
</domain-config> </domain-config>
</network-security-config> </network-security-config>
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<!-- Ref: https://developer.android.com/training/articles/security-config.html -->
<!-- By default, do not allow clearText traffic -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
<certificates
src="user"
tools:ignore="AcceptsUserCertificates" />
</trust-anchors>
</base-config>
<!-- Allow clearText traffic on some specified host -->
<domain-config cleartextTrafficPermitted="true">
<!-- Localhost -->
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<!-- Localhost for Android emulator -->
<domain includeSubdomains="true">10.0.2.2</domain>
<!-- Onion services -->
<domain includeSubdomains="true">onion</domain>
<!-- Domains that are used for LANs -->
<!-- These are IANA recognized special use domain names, see https://www.iana.org/assignments/special-use-domain-names/special-use-domain-names.xhtml -->
<domain includeSubdomains="true">home.arpa</domain>
<domain includeSubdomains="true">local</domain> <!-- Note this has been reserved for use with mDNS -->
<domain includeSubdomains="true">test</domain>
<!-- These are observed in the wild either by convention or RFCs that have not been accepted, and are not currently TLDs -->
<domain includeSubdomains="true">home</domain>
<domain includeSubdomains="true">lan</domain>
<domain includeSubdomains="true">localdomain</domain>
</domain-config>
</network-security-config>
@@ -9,15 +9,8 @@
package io.element.android.appconfig package io.element.android.appconfig
object AuthenticationConfig { object AuthenticationConfig {
const val MATRIX_ORG_URL = "https://matrix.org"
/** /**
* URL with some docs that explain what's sliding sync and how to add it to your home server. * URL with some docs that explain what's sliding sync and how to add it to your home server.
*/ */
const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md" const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
/**
* Force a sliding sync proxy url, if not null, the proxy url in the .well-known file will be ignored.
*/
val SLIDING_SYNC_PROXY_URL: String? = null
} }
@@ -96,6 +96,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS
import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
import io.element.android.libraries.ui.common.nodes.EmptyState
import io.element.android.libraries.ui.common.nodes.emptyNode import io.element.android.libraries.ui.common.nodes.emptyNode
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@@ -113,6 +114,7 @@ import java.time.Duration
import java.time.Instant import java.time.Instant
import java.util.Optional import java.util.Optional
import java.util.UUID import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.toKotlinDuration import kotlin.time.toKotlinDuration
@@ -324,7 +326,9 @@ class LoggedInFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Placeholder -> emptyNode(buildContext) // Rendered while the FtueState is Unknown. Render a loading state rather than nothing at all,
// since there is no guarantee that this state will be left.
NavTarget.Placeholder -> emptyNode(buildContext, state = EmptyState(delayBeforeShowingContent = 500.milliseconds))
NavTarget.LoggedInPermanent -> { NavTarget.LoggedInPermanent -> {
val callback = object : LoggedInNode.Callback { val callback = object : LoggedInNode.Callback {
override fun navigateToNotificationTroubleshoot() { override fun navigateToNotificationTroubleshoot() {
@@ -52,11 +52,11 @@ import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
@@ -410,7 +410,8 @@ class RootFlowNode(
} }
private suspend fun onLoginLink(params: LoginParams) { private suspend fun onLoginLink(params: LoginParams) {
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) { val accountProvider = AccountProvider.Generic(params.accountProvider)
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(accountProvider)) {
// Is there a session already? // Is there a session already?
val sessions = sessionStore.getAllSessions() val sessions = sessionStore.getAllSessions()
if (sessions.isNotEmpty()) { if (sessions.isNotEmpty()) {
@@ -28,10 +28,14 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -60,6 +64,12 @@ class SyncOrchestrator(
private val started = AtomicBoolean(false) private val started = AtomicBoolean(false)
/**
* Number of consecutive attempts at restarting a sync service which stopped on its own.
* Reset once the sync service has been running for [RESTART_BACKOFF_RESET_DELAY].
*/
private val restartAttempt = AtomicInteger(0)
/** /**
* Starting observing the app state and network state to start/stop the sync service. * Starting observing the app state and network state to start/stop the sync service.
* *
@@ -91,6 +101,17 @@ class SyncOrchestrator(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun observeStates() = coroutineScope.launch { internal fun observeStates() = coroutineScope.launch {
Timber.tag(tag).d("start observing the app and network state") Timber.tag(tag).d("start observing the app and network state")
// Reset the restart backoff once the sync service has been steadily running for a while. Note that
// SyncService.startSync() moves the state to Running right away, so resetting on any Running value
// would defeat the backoff when the sync service dies again immediately after each restart.
syncService.syncState
.debounce(RESTART_BACKOFF_RESET_DELAY)
.onEach { syncState ->
if (syncState == SyncState.Running) {
restartAttempt.set(0)
}
}
.launchIn(this)
val isAppActiveFlows = listOf( val isAppActiveFlows = listOf(
appForegroundStateService.isInForeground, appForegroundStateService.isInForeground,
appForegroundStateService.isInCall, appForegroundStateService.isInCall,
@@ -117,14 +138,26 @@ class SyncOrchestrator(
SyncStateAction.StopSync SyncStateAction.StopSync
} else if (syncState == SyncState.Idle && isAppActive && isNetworkAvailable) { } else if (syncState == SyncState.Idle && isAppActive && isNetworkAvailable) {
SyncStateAction.StartSync SyncStateAction.StartSync
} else if (syncState == SyncState.Error && isAppActive && isNetworkAvailable) {
// The sync service has given up, for instance because the homeserver has expired our sliding sync
// session and answered 400 M_UNKNOWN_POS. Nothing else will ever restart it, so do it from here,
// else the application is stuck until its data is cleared.
SyncStateAction.RestartSync
} else { } else {
SyncStateAction.NoOp SyncStateAction.NoOp
} }
} }
.distinctUntilChanged() .distinctUntilChanged()
.debounce { action -> .debounce { action ->
when (action) {
// Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often // Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often
if (action == SyncStateAction.StopSync) 3.seconds else 0.seconds SyncStateAction.StopSync -> 3.seconds
// Back off before restarting a dead sync service, to avoid hammering a failing homeserver.
// A state change in the meantime cancels the pending restart.
SyncStateAction.RestartSync -> restartDelay(restartAttempt.get())
SyncStateAction.StartSync,
SyncStateAction.NoOp -> 0.seconds
}
} }
.onCompletion { .onCompletion {
Timber.tag(tag).d("has been stopped") Timber.tag(tag).d("has been stopped")
@@ -134,6 +167,11 @@ class SyncOrchestrator(
SyncStateAction.StartSync -> { SyncStateAction.StartSync -> {
syncService.startSync() syncService.startSync()
} }
SyncStateAction.RestartSync -> {
val attempt = restartAttempt.incrementAndGet()
Timber.tag(tag).w("sync service is dead, restarting it (attempt $attempt)")
syncService.startSync()
}
SyncStateAction.StopSync -> { SyncStateAction.StopSync -> {
syncService.stopSync() syncService.stopSync()
} }
@@ -143,8 +181,35 @@ class SyncOrchestrator(
} }
} }
@VisibleForTesting
internal val FIRST_RESTART_DELAY = 1.seconds
/**
* Exponential backoff, from [FIRST_RESTART_DELAY], doubling on every attempt, up to [FIRST_RESTART_DELAY * 32].
*/
@VisibleForTesting
internal val RESTART_BACKOFF_DELAYS = listOf(
FIRST_RESTART_DELAY,
FIRST_RESTART_DELAY * 2,
FIRST_RESTART_DELAY * 4,
FIRST_RESTART_DELAY * 8,
FIRST_RESTART_DELAY * 16,
FIRST_RESTART_DELAY * 32,
)
/**
* Duration during which the sync service has to stay running before the restart backoff is reset.
*/
@VisibleForTesting
internal val RESTART_BACKOFF_RESET_DELAY = 30.seconds
private fun restartDelay(attempt: Int): Duration {
return RESTART_BACKOFF_DELAYS[attempt.coerceIn(0, RESTART_BACKOFF_DELAYS.lastIndex)]
}
private enum class SyncStateAction { private enum class SyncStateAction {
StartSync, StartSync,
RestartSync,
StopSync, StopSync,
NoOp, NoOp,
} }
@@ -2,5 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Odhlásit se a upgradovat"</string> <string name="banner_migrate_to_native_sliding_sync_action">"Odhlásit se a upgradovat"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s již nepodporuje starý protokol. Odhlaste se a znovu přihlaste, abyste mohli pokračovat v používání aplikace."</string> <string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s již nepodporuje starý protokol. Odhlaste se a znovu přihlaste, abyste mohli pokračovat v používání aplikace."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Váš domovský server již nepodporuje starý protokol. Chcete-li pokračovat v používání aplikace, odhlaste se a znovu se přihlaste."</string> <string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Váš poskytovatel účtu již nepodporuje starý protokol. Chcete-li aplikaci nadále používat, odhlaste se a znovu se přihlaste."</string>
</resources> </resources>
@@ -2,5 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Kirjaudu Ulos &amp; Päivitä"</string> <string name="banner_migrate_to_native_sliding_sync_action">"Kirjaudu Ulos &amp; Päivitä"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s ei enää tue vanhaa protokollaa. Kirjaudu ulos ja takaisin sisään jatkaaksesi sovelluksen käyttöä."</string> <string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s ei enää tue vanhaa protokollaa. Kirjaudu ulos ja takaisin sisään jatkaaksesi sovelluksen käyttöä."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Kotipalvelimesi ei enää tue vanhaa protokollaa. Kirjaudu ulos ja takaisin sisään jatkaaksesi sovelluksen käyttöä."</string> <string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Palveluntarjoajasi ei enää tue vanhaa protokollaa. Kirjaudu ulos ja takaisin sisään jatkaaksesi sovelluksen käyttöä."</string>
</resources> </resources>
@@ -2,5 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"ログアウトしてアップグレード"</string> <string name="banner_migrate_to_native_sliding_sync_action">"ログアウトしてアップグレード"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s は古いプロトコルに非対応になりました。アプリを引き続き使用するには、ログアウトしてから再度ログインしてください。"</string> <string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s は古いプロトコルに非対応になりました。アプリを引き続き使用するには、ログアウトしてから再度ログインしてください。"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"使用しているホームサーバーは古いプロトコルに非対応になりました。アプリケーションを引き続き使用するには、ログアウトしてから再度ログインしてください。"</string> <string name="banner_migrate_to_native_sliding_sync_force_logout_title">"使用しているアカウント提供元は古いプロトコルに非対応になりました。アプリケーションを引き続き使用するには、ログアウトしてから再度ログインしてください。"</string>
</resources> </resources>
@@ -2,5 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"登出並升級"</string> <string name="banner_migrate_to_native_sliding_sync_action">"登出並升級"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s 不再支援舊版通訊協定。請登出並重新登入以繼續使用應用程式。"</string> <string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s 不再支援舊版通訊協定。請登出並重新登入以繼續使用應用程式。"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"您的家伺服器不再支援舊協定。請登出並重新登入以繼續使用應用程式。"</string> <string name="banner_migrate_to_native_sliding_sync_force_logout_title">"您的帳號服務提供者已不再支援舊版通訊協定。請先登出,然後重新登入,以繼續使用此應用程式。"</string>
</resources> </resources>
@@ -8,6 +8,9 @@
package io.element.android.appnav package io.element.android.appnav
import io.element.android.appnav.session.FIRST_RESTART_DELAY
import io.element.android.appnav.session.RESTART_BACKOFF_DELAYS
import io.element.android.appnav.session.RESTART_BACKOFF_RESET_DELAY
import io.element.android.appnav.session.SyncOrchestrator import io.element.android.appnav.session.SyncOrchestrator
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
@@ -414,6 +417,131 @@ class SyncOrchestratorTest {
startSyncRecorder.assertions().isNeverCalled() startSyncRecorder.assertions().isNeverCalled()
} }
@Test
fun `when the sync service is in error, it will be restarted after a delay`() = runTest {
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
startSyncLambda = startSyncRecorder
}
val syncOrchestrator = createSyncOrchestrator(
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true),
)
// We start observing
syncOrchestrator.observeStates()
// The restart does not happen right away, we back off first
advanceTimeBy(FIRST_RESTART_DELAY / 2)
startSyncRecorder.assertions().isNeverCalled()
// But it does happen once the backoff has elapsed
advanceTimeBy(FIRST_RESTART_DELAY)
startSyncRecorder.assertions().isCalledOnce()
}
@Test
fun `when the sync service keeps dying, the delay between the restarts increases`() = runTest {
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
startSyncLambda = startSyncRecorder
}
val syncOrchestrator = createSyncOrchestrator(
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true),
)
// We start observing
syncOrchestrator.observeStates()
advanceTimeBy(FIRST_RESTART_DELAY * 2)
startSyncRecorder.assertions().isCalledOnce()
// The sync service starts, but dies again straight away
syncService.emitSyncState(SyncState.Running)
advanceTimeBy(200.milliseconds)
syncService.emitSyncState(SyncState.Error)
// The second restart is not attempted after the first delay anymore
advanceTimeBy(FIRST_RESTART_DELAY + 200.milliseconds)
startSyncRecorder.assertions().isCalledOnce()
// But after twice that delay
advanceTimeBy(FIRST_RESTART_DELAY)
startSyncRecorder.assertions().isCalledExactly(2)
}
@Test
fun `when the sync service recovers, the delay between the restarts is reset`() = runTest {
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
startSyncLambda = startSyncRecorder
}
val syncOrchestrator = createSyncOrchestrator(
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true),
)
// We start observing
syncOrchestrator.observeStates()
advanceTimeBy(FIRST_RESTART_DELAY * 2)
startSyncRecorder.assertions().isCalledOnce()
// The sync service is now running, and keeps running for a while
syncService.emitSyncState(SyncState.Running)
advanceTimeBy(RESTART_BACKOFF_RESET_DELAY + 1.seconds)
startSyncRecorder.assertions().isCalledOnce()
// It dies again, and is restarted after the first delay, not a longer one
syncService.emitSyncState(SyncState.Error)
advanceTimeBy(FIRST_RESTART_DELAY / 2)
startSyncRecorder.assertions().isCalledOnce()
advanceTimeBy(FIRST_RESTART_DELAY)
startSyncRecorder.assertions().isCalledExactly(2)
}
@Test
fun `when the sync service is in error but the app is in background, it is not restarted`() = runTest {
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
startSyncLambda = startSyncRecorder
}
val syncOrchestrator = createSyncOrchestrator(
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = false),
)
// We start observing
syncOrchestrator.observeStates()
advanceTimeBy(RESTART_BACKOFF_DELAYS.last() * 2)
startSyncRecorder.assertions().isNeverCalled()
}
@Test
fun `when the sync service is in error but there is no network, it is not restarted`() = runTest {
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
startSyncLambda = startSyncRecorder
}
val syncOrchestrator = createSyncOrchestrator(
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected),
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true),
)
// We start observing
syncOrchestrator.observeStates()
advanceTimeBy(RESTART_BACKOFF_DELAYS.last() * 2)
startSyncRecorder.assertions().isNeverCalled()
}
private fun TestScope.createSyncOrchestrator( private fun TestScope.createSyncOrchestrator(
matrixClient: FakeMatrixClient = FakeMatrixClient(), matrixClient: FakeMatrixClient = FakeMatrixClient(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
+13 -16
View File
@@ -18,7 +18,6 @@ plugins {
alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.ksp) apply false alias(libs.plugins.ksp) apply false
alias(libs.plugins.dependencycheck) apply false alias(libs.plugins.dependencycheck) apply false
alias(libs.plugins.roborazzi) apply false
alias(libs.plugins.dependencyanalysis) alias(libs.plugins.dependencyanalysis)
alias(libs.plugins.detekt) alias(libs.plugins.detekt)
alias(libs.plugins.ktlint) alias(libs.plugins.ktlint)
@@ -154,6 +153,8 @@ allprojects {
val isScreenshotTest = project.gradle.startParameter.taskNames.any { it.contains("paparazzi", ignoreCase = true) } val isScreenshotTest = project.gradle.startParameter.taskNames.any { it.contains("paparazzi", ignoreCase = true) }
if (isScreenshotTest) { if (isScreenshotTest) {
// Paparazzi tests benefit from parallelisation, so we can use half the available cores to run them in parallel.
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
// Increase heap size for screenshot tests // Increase heap size for screenshot tests
maxHeapSize = "2g" maxHeapSize = "2g"
// Record all the languages? // Record all the languages?
@@ -165,9 +166,20 @@ allprojects {
exclude("translations/*.class") exclude("translations/*.class")
} }
} else { } else {
// Robolectric and Compose pay a 5 to 30 seconds bootstrap cost per test JVM (instrumenting
// android-all, then warming up the Compose runtime). That cost is paid once per JVM and then
// amortised over every test class the JVM runs, so splitting a module across several forks
// re-pays it for each fork instead of saving time. Keep a single fork per module and let
// Gradle parallelise by running many modules' test tasks concurrently instead.
maxParallelForks = 1
// Disable screenshot tests by default // Disable screenshot tests by default
exclude("ui/*.class") exclude("ui/*.class")
exclude("translations/*.class") exclude("translations/*.class")
// The Compound screenshot tests (:libraries:compound) live in a `screenshot` package, which
// the two patterns above do not match, so they would otherwise run on every plain unit test run.
// They are verified by the dedicated `verifyPaparazziDebug` task instead.
exclude("**/screenshot/**")
} }
} }
} }
@@ -214,21 +226,6 @@ subprojects {
tasks.findByName("recordPaparazziRelease")?.dependsOn(removeOldScreenshotsTask) tasks.findByName("recordPaparazziRelease")?.dependsOn(removeOldScreenshotsTask)
} }
// Make sure to delete old snapshot before recording new ones
subprojects {
val screenshotsDir = File("${project.projectDir}/screenshots")
val removeOldScreenshotsTask = tasks.register("removeOldScreenshots") {
onlyIf { screenshotsDir.exists() }
doFirst {
println("Delete previous screenshots located at $screenshotsDir\n")
screenshotsDir.deleteRecursively()
}
}
tasks.findByName("recordRoborazzi")?.dependsOn(removeOldScreenshotsTask)
tasks.findByName("recordRoborazziDebug")?.dependsOn(removeOldScreenshotsTask)
tasks.findByName("recordRoborazziRelease")?.dependsOn(removeOldScreenshotsTask)
}
subprojects { subprojects {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach { tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions { compilerOptions {
+1
View File
@@ -27,6 +27,7 @@ Click on this link and follow the instruction: [https://appdistribution.firebase
Nightly build can be built manually from your computer. You will need to retrieved some secrets from Passbolt and add them to your file `~/.gradle/gradle.properties`: Nightly build can be built manually from your computer. You will need to retrieved some secrets from Passbolt and add them to your file `~/.gradle/gradle.properties`:
``` ```
signing.element.nightly.storeFile=LOCATION_OF_THE_KEYSTORE_FILE
signing.element.nightly.storePassword=VALUE_FROM_PASSBOLT signing.element.nightly.storePassword=VALUE_FROM_PASSBOLT
signing.element.nightly.keyId=VALUE_FROM_PASSBOLT signing.element.nightly.keyId=VALUE_FROM_PASSBOLT
signing.element.nightly.keyPassword=VALUE_FROM_PASSBOLT signing.element.nightly.keyPassword=VALUE_FROM_PASSBOLT
@@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases
@@ -11,7 +11,7 @@
<string name="screen_create_room_private_option_title">"Soukromé"</string> <string name="screen_create_room_private_option_title">"Soukromé"</string>
<string name="screen_create_room_public_option_description">"Tuto místnost může najít kdokoli. <string name="screen_create_room_public_option_description">"Tuto místnost může najít kdokoli.
To můžete kdykoli změnit v nastavení místnosti."</string> To můžete kdykoli změnit v nastavení místnosti."</string>
<string name="screen_create_room_public_option_short_description">"Vstoupit může kdokoli."</string> <string name="screen_create_room_public_option_short_description">"Připojit se může kdokoli, včetně osob mimo vaši organizaci."</string>
<string name="screen_create_room_public_option_title">"Veřejné"</string> <string name="screen_create_room_public_option_title">"Veřejné"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Kdokoli může požádat o vstup do místnosti, ale správce nebo moderátor bude muset žádost přijmout."</string> <string name="screen_create_room_room_access_section_knocking_option_description">"Kdokoli může požádat o vstup do místnosti, ale správce nebo moderátor bude muset žádost přijmout."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Povolit žádost o vstup"</string> <string name="screen_create_room_room_access_section_knocking_option_title">"Povolit žádost o vstup"</string>
@@ -19,7 +19,7 @@ To můžete kdykoli změnit v nastavení místnosti."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Požádat o vstup"</string> <string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Požádat o vstup"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Vstoupit mohou pouze pozvaní lidé."</string> <string name="screen_create_room_room_access_section_private_option_description">"Vstoupit mohou pouze pozvaní lidé."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Soukromé"</string> <string name="screen_create_room_room_access_section_private_option_title">"Soukromé"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Vstoupit může kdokoli."</string> <string name="screen_create_room_room_access_section_public_option_description">"Připojit se může kdokoli, včetně osob mimo vaši organizaci."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Veřejné"</string> <string name="screen_create_room_room_access_section_public_option_title">"Veřejné"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Kdokoli může vstoupit do %1$s."</string> <string name="screen_create_room_room_access_section_restricted_option_description">"Kdokoli může vstoupit do %1$s."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Standard"</string> <string name="screen_create_room_room_access_section_restricted_option_title">"Standard"</string>
@@ -11,7 +11,7 @@
<string name="screen_create_room_private_option_title">"Privat"</string> <string name="screen_create_room_private_option_title">"Privat"</string>
<string name="screen_create_room_public_option_description">"Jeder kann diesen Chat finden. <string name="screen_create_room_public_option_description">"Jeder kann diesen Chat finden.
Du kannst dies jederzeit in den Einstellungen des Chats ändern."</string> Du kannst dies jederzeit in den Einstellungen des Chats ändern."</string>
<string name="screen_create_room_public_option_short_description">"Alle können beitreten."</string> <string name="screen_create_room_public_option_short_description">"Alle können beitreten, auch Personen außerhalb deiner Organisation."</string>
<string name="screen_create_room_public_option_title">"Öffentlich"</string> <string name="screen_create_room_public_option_title">"Öffentlich"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Jeder kann den Beitritt zum Chat erbitten, aber ein Admin oder Moderator muss die Anfrage akzeptieren."</string> <string name="screen_create_room_room_access_section_knocking_option_description">"Jeder kann den Beitritt zum Chat erbitten, aber ein Admin oder Moderator muss die Anfrage akzeptieren."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Anfrage zum Beitritt zulassen"</string> <string name="screen_create_room_room_access_section_knocking_option_title">"Anfrage zum Beitritt zulassen"</string>
@@ -19,7 +19,7 @@ Du kannst dies jederzeit in den Einstellungen des Chats ändern."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Beitritt anfragen"</string> <string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Beitritt anfragen"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Nur eingeladene Personen können beitreten."</string> <string name="screen_create_room_room_access_section_private_option_description">"Nur eingeladene Personen können beitreten."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Privat"</string> <string name="screen_create_room_room_access_section_private_option_title">"Privat"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Alle können beitreten."</string> <string name="screen_create_room_room_access_section_public_option_description">"Alle können beitreten, auch Personen außerhalb deiner Organisation."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Öffentlich"</string> <string name="screen_create_room_room_access_section_public_option_title">"Öffentlich"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Jeder in %1$s kann beitreten."</string> <string name="screen_create_room_room_access_section_restricted_option_description">"Jeder in %1$s kann beitreten."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Standard"</string> <string name="screen_create_room_room_access_section_restricted_option_title">"Standard"</string>
@@ -11,7 +11,7 @@
<string name="screen_create_room_private_option_title">"Ιδιωτικό"</string> <string name="screen_create_room_private_option_title">"Ιδιωτικό"</string>
<string name="screen_create_room_public_option_description">"Ο καθένας μπορεί να βρει αυτή την αίθουσα. <string name="screen_create_room_public_option_description">"Ο καθένας μπορεί να βρει αυτή την αίθουσα.
Αυτό μπορείτε να το αλλάξετε ανά πάσα στιγμή στις ρυθμίσεις της αίθουσας."</string> Αυτό μπορείτε να το αλλάξετε ανά πάσα στιγμή στις ρυθμίσεις της αίθουσας."</string>
<string name="screen_create_room_public_option_short_description">"Οποιοσδήποτε μπορεί να συμμετάσχει."</string> <string name="screen_create_room_public_option_short_description">"Οποιοσδήποτε μπορεί να συμμετάσχει, συμπεριλαμβανομένων ατόμων εκτός του οργανισμού σας."</string>
<string name="screen_create_room_public_option_title">"Δημόσιο"</string> <string name="screen_create_room_public_option_title">"Δημόσιο"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στην αίθουσα, αλλά ένας διαχειριστής ή ένας συντονιστής θα πρέπει να αποδεχτεί το αίτημα."</string> <string name="screen_create_room_room_access_section_knocking_option_description">"Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στην αίθουσα, αλλά ένας διαχειριστής ή ένας συντονιστής θα πρέπει να αποδεχτεί το αίτημα."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Επιτρέψτε την αίτηση συμμετοχής"</string> <string name="screen_create_room_room_access_section_knocking_option_title">"Επιτρέψτε την αίτηση συμμετοχής"</string>
@@ -19,7 +19,7 @@
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Αίτημα συμμετοχής"</string> <string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Αίτημα συμμετοχής"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Μόνο άτομα που έχουν προσκληθεί μπορούν να συμμετάσχουν."</string> <string name="screen_create_room_room_access_section_private_option_description">"Μόνο άτομα που έχουν προσκληθεί μπορούν να συμμετάσχουν."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Ιδιωτικό"</string> <string name="screen_create_room_room_access_section_private_option_title">"Ιδιωτικό"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Οποιοσδήποτε μπορεί να συμμετάσχει."</string> <string name="screen_create_room_room_access_section_public_option_description">"Οποιοσδήποτε μπορεί να συμμετάσχει, συμπεριλαμβανομένων και ατόμων εκτός του οργανισμού σας."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Δημόσιο"</string> <string name="screen_create_room_room_access_section_public_option_title">"Δημόσιο"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Οποιοσδήποτε στο %1$s μπορεί να συμμετάσχει."</string> <string name="screen_create_room_room_access_section_restricted_option_description">"Οποιοσδήποτε στο %1$s μπορεί να συμμετάσχει."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Πρότυπο"</string> <string name="screen_create_room_room_access_section_restricted_option_title">"Πρότυπο"</string>
@@ -11,7 +11,7 @@
<string name="screen_create_room_private_option_title">"Yksityinen"</string> <string name="screen_create_room_private_option_title">"Yksityinen"</string>
<string name="screen_create_room_public_option_description">"Kuka tahansa voi löytää tämän huoneen. <string name="screen_create_room_public_option_description">"Kuka tahansa voi löytää tämän huoneen.
Voit muuttaa tämän milloin tahansa huoneen asetuksista."</string> Voit muuttaa tämän milloin tahansa huoneen asetuksista."</string>
<string name="screen_create_room_public_option_short_description">"Kuka tahansa voi liittyä."</string> <string name="screen_create_room_public_option_short_description">"Kuka tahansa voi liittyä, myös organisaatiosi ulkopuoliset henkilöt."</string>
<string name="screen_create_room_public_option_title">"Julkinen"</string> <string name="screen_create_room_public_option_title">"Julkinen"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Kuka tahansa voi pyytää saada liittyä huoneeseen, mutta ylläpitäjän tai valvojan on hyväksyttävä pyyntö."</string> <string name="screen_create_room_room_access_section_knocking_option_description">"Kuka tahansa voi pyytää saada liittyä huoneeseen, mutta ylläpitäjän tai valvojan on hyväksyttävä pyyntö."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Salli liittymispyynnöt"</string> <string name="screen_create_room_room_access_section_knocking_option_title">"Salli liittymispyynnöt"</string>
@@ -19,7 +19,7 @@ Voit muuttaa tämän milloin tahansa huoneen asetuksista."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Pyydä liittymistä"</string> <string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Pyydä liittymistä"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Vain kutsutut henkilöt voivat liittyä."</string> <string name="screen_create_room_room_access_section_private_option_description">"Vain kutsutut henkilöt voivat liittyä."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Yksityinen"</string> <string name="screen_create_room_room_access_section_private_option_title">"Yksityinen"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Kuka tahansa voi liittyä."</string> <string name="screen_create_room_room_access_section_public_option_description">"Kuka tahansa voi liittyä, myös organisaatiosi ulkopuoliset henkilöt."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Julkinen"</string> <string name="screen_create_room_room_access_section_public_option_title">"Julkinen"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Kuka tahansa tilassa %1$s voi liittyä."</string> <string name="screen_create_room_room_access_section_restricted_option_description">"Kuka tahansa tilassa %1$s voi liittyä."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Vakio"</string> <string name="screen_create_room_room_access_section_restricted_option_title">"Vakio"</string>
@@ -11,7 +11,7 @@
<string name="screen_create_room_private_option_title">"Privatno"</string> <string name="screen_create_room_private_option_title">"Privatno"</string>
<string name="screen_create_room_public_option_description">"Svatko može pronaći ovu sobu. <string name="screen_create_room_public_option_description">"Svatko može pronaći ovu sobu.
To možete u svakom trenutku promijeniti u postavkama sobe."</string> To možete u svakom trenutku promijeniti u postavkama sobe."</string>
<string name="screen_create_room_public_option_short_description">"Svatko se može pridružiti."</string> <string name="screen_create_room_public_option_short_description">"Svatko se može pridružiti, uključujući i osobe izvan vaše organizacije."</string>
<string name="screen_create_room_public_option_title">"Javno"</string> <string name="screen_create_room_public_option_title">"Javno"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Svatko može zatražiti pridruživanje sobi, ali administrator ili moderator morat će prihvatiti zahtjev."</string> <string name="screen_create_room_room_access_section_knocking_option_description">"Svatko može zatražiti pridruživanje sobi, ali administrator ili moderator morat će prihvatiti zahtjev."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Dopusti traženje pridruživanja"</string> <string name="screen_create_room_room_access_section_knocking_option_title">"Dopusti traženje pridruživanja"</string>
@@ -19,7 +19,7 @@ To možete u svakom trenutku promijeniti u postavkama sobe."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Zatraži pridruživanje"</string> <string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Zatraži pridruživanje"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Samo pozvane osobe mogu pristupiti ovoj sobi. Sve su poruke sveobuhvatno šifrirane."</string> <string name="screen_create_room_room_access_section_private_option_description">"Samo pozvane osobe mogu pristupiti ovoj sobi. Sve su poruke sveobuhvatno šifrirane."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Privatno"</string> <string name="screen_create_room_room_access_section_private_option_title">"Privatno"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Svatko se može pridružiti."</string> <string name="screen_create_room_room_access_section_public_option_description">"Svatko se može pridružiti, uključujući i osobe izvan vaše organizacije."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Javno"</string> <string name="screen_create_room_room_access_section_public_option_title">"Javno"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Svatko u %1$s može se pridružiti."</string> <string name="screen_create_room_room_access_section_restricted_option_description">"Svatko u %1$s može se pridružiti."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Standard"</string> <string name="screen_create_room_room_access_section_restricted_option_title">"Standard"</string>
@@ -11,7 +11,7 @@
<string name="screen_create_room_private_option_title">"Privát"</string> <string name="screen_create_room_private_option_title">"Privát"</string>
<string name="screen_create_room_public_option_description">"Bárki megtalálhatja ezt a szobát. <string name="screen_create_room_public_option_description">"Bárki megtalálhatja ezt a szobát.
Ezt bármikor módosíthatja a szobabeállításokban."</string> Ezt bármikor módosíthatja a szobabeállításokban."</string>
<string name="screen_create_room_public_option_short_description">"Bárki csatlakozhat."</string> <string name="screen_create_room_public_option_short_description">"Bárki csatlakozhat, beleértve a szervezeten kívülieket is."</string>
<string name="screen_create_room_public_option_title">"Nyilvános"</string> <string name="screen_create_room_public_option_title">"Nyilvános"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Bárki kérheti, hogy csatlakozhasson a szobához, de egy adminisztrátornak vagy moderátornak el kell fogadnia a kérést."</string> <string name="screen_create_room_room_access_section_knocking_option_description">"Bárki kérheti, hogy csatlakozhasson a szobához, de egy adminisztrátornak vagy moderátornak el kell fogadnia a kérést."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Csatlakozás kérése"</string> <string name="screen_create_room_room_access_section_knocking_option_title">"Csatlakozás kérése"</string>
@@ -19,7 +19,7 @@ Ezt bármikor módosíthatja a szobabeállításokban."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Csatlakozás kérése"</string> <string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Csatlakozás kérése"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Csak a meghívottak léphetnek be."</string> <string name="screen_create_room_room_access_section_private_option_description">"Csak a meghívottak léphetnek be."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Privát"</string> <string name="screen_create_room_room_access_section_private_option_title">"Privát"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Bárki csatlakozhat."</string> <string name="screen_create_room_room_access_section_public_option_description">"Bárki csatlakozhat, beleértve a szervezeten kívülieket is."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Nyilvános"</string> <string name="screen_create_room_room_access_section_public_option_title">"Nyilvános"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Bárki csatlakozhat innen: %1$s."</string> <string name="screen_create_room_room_access_section_restricted_option_description">"Bárki csatlakozhat innen: %1$s."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Szokásos"</string> <string name="screen_create_room_room_access_section_restricted_option_title">"Szokásos"</string>
@@ -11,7 +11,7 @@
<string name="screen_create_room_private_option_title">"非公開"</string> <string name="screen_create_room_private_option_title">"非公開"</string>
<string name="screen_create_room_public_option_description">"ルームは全世界に公開されます。 <string name="screen_create_room_public_option_description">"ルームは全世界に公開されます。
ルーム設定でいつでも変更できます。"</string> ルーム設定でいつでも変更できます。"</string>
<string name="screen_create_room_public_option_short_description">"誰でも参加できます。"</string> <string name="screen_create_room_public_option_short_description">"あなたの組織外のユーザーを含め誰でも参加できます。"</string>
<string name="screen_create_room_public_option_title">"公開"</string> <string name="screen_create_room_public_option_title">"公開"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"誰でも参加できますが、管理者またはモデレーターの承認が必要です。"</string> <string name="screen_create_room_room_access_section_knocking_option_description">"誰でも参加できますが、管理者またはモデレーターの承認が必要です。"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"参加の要求を許可"</string> <string name="screen_create_room_room_access_section_knocking_option_title">"参加の要求を許可"</string>
@@ -19,7 +19,7 @@
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"参加を要求"</string> <string name="screen_create_room_room_access_section_knocking_restricted_option_title">"参加を要求"</string>
<string name="screen_create_room_room_access_section_private_option_description">"招待されたユーザーのみが参加できます。"</string> <string name="screen_create_room_room_access_section_private_option_description">"招待されたユーザーのみが参加できます。"</string>
<string name="screen_create_room_room_access_section_private_option_title">"非公開"</string> <string name="screen_create_room_room_access_section_private_option_title">"非公開"</string>
<string name="screen_create_room_room_access_section_public_option_description">"誰でも参加できます。"</string> <string name="screen_create_room_room_access_section_public_option_description">"あなたの組織外のユーザーを含め誰でも参加できます。"</string>
<string name="screen_create_room_room_access_section_public_option_title">"公開"</string> <string name="screen_create_room_room_access_section_public_option_title">"公開"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"%1$s にいる全員が参加することができます。"</string> <string name="screen_create_room_room_access_section_restricted_option_description">"%1$s にいる全員が参加することができます。"</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"スタンダード"</string> <string name="screen_create_room_room_access_section_restricted_option_title">"スタンダード"</string>
@@ -11,7 +11,7 @@
<string name="screen_create_room_private_option_title">"Приватний"</string> <string name="screen_create_room_private_option_title">"Приватний"</string>
<string name="screen_create_room_public_option_description">"Будь-хто може знайти цю кімнату. <string name="screen_create_room_public_option_description">"Будь-хто може знайти цю кімнату.
Ви можете змінити це в будь-який час у налаштуваннях кімнати."</string> Ви можете змінити це в будь-який час у налаштуваннях кімнати."</string>
<string name="screen_create_room_public_option_short_description">"Приєднатися може будь-хто."</string> <string name="screen_create_room_public_option_short_description">"Приєднатися може будь-хто, у тому числі й особи, які не належать до вашої організації."</string>
<string name="screen_create_room_public_option_title">"Публічний"</string> <string name="screen_create_room_public_option_title">"Публічний"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Будь-хто може подати запит на приєднання, але адміністратор або модератор повинен схвалити запит."</string> <string name="screen_create_room_room_access_section_knocking_option_description">"Будь-хто може подати запит на приєднання, але адміністратор або модератор повинен схвалити запит."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Дозволити запит на приєднання"</string> <string name="screen_create_room_room_access_section_knocking_option_title">"Дозволити запит на приєднання"</string>
@@ -19,7 +19,7 @@
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Запит на приєднання"</string> <string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Запит на приєднання"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Приєднатися можуть лише запрошені особи."</string> <string name="screen_create_room_room_access_section_private_option_description">"Приєднатися можуть лише запрошені особи."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Приватний"</string> <string name="screen_create_room_room_access_section_private_option_title">"Приватний"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Приєднатися може будь-хто."</string> <string name="screen_create_room_room_access_section_public_option_description">"Приєднатися може будь-хто, у тому числі й особи, які не належать до вашої організації."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Публічний"</string> <string name="screen_create_room_room_access_section_public_option_title">"Публічний"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Приєднатися може будь-хто з %1$s."</string> <string name="screen_create_room_room_access_section_restricted_option_description">"Приєднатися може будь-хто з %1$s."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Стандартний"</string> <string name="screen_create_room_room_access_section_restricted_option_title">"Стандартний"</string>
@@ -11,7 +11,7 @@
<string name="screen_create_room_private_option_title">"私人"</string> <string name="screen_create_room_private_option_title">"私人"</string>
<string name="screen_create_room_public_option_description">"任何人都可以找到此聊天室。 <string name="screen_create_room_public_option_description">"任何人都可以找到此聊天室。
您隨時都可以在聊天室設定中變更此設定。"</string> 您隨時都可以在聊天室設定中變更此設定。"</string>
<string name="screen_create_room_public_option_short_description">"任何人都可以加入。"</string> <string name="screen_create_room_public_option_short_description">"任何人都可以加入,包括貴組織以外的人士。"</string>
<string name="screen_create_room_public_option_title">"公開"</string> <string name="screen_create_room_public_option_title">"公開"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"任何人都可以要求加入,但管理員或版主必須接受該請求"</string> <string name="screen_create_room_room_access_section_knocking_option_description">"任何人都可以要求加入,但管理員或版主必須接受該請求"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"允許要求加入"</string> <string name="screen_create_room_room_access_section_knocking_option_title">"允許要求加入"</string>
@@ -19,7 +19,7 @@
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"要求加入"</string> <string name="screen_create_room_room_access_section_knocking_restricted_option_title">"要求加入"</string>
<string name="screen_create_room_room_access_section_private_option_description">"僅被邀請的人才可以加入。"</string> <string name="screen_create_room_room_access_section_private_option_description">"僅被邀請的人才可以加入。"</string>
<string name="screen_create_room_room_access_section_private_option_title">"私人"</string> <string name="screen_create_room_room_access_section_private_option_title">"私人"</string>
<string name="screen_create_room_room_access_section_public_option_description">"任何人都可以加入"</string> <string name="screen_create_room_room_access_section_public_option_description">"任何人都可以加入,包括貴組織以外的人士。"</string>
<string name="screen_create_room_room_access_section_public_option_title">"公開"</string> <string name="screen_create_room_room_access_section_public_option_title">"公開"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"在 %1$s 中的任何人都可以加入。"</string> <string name="screen_create_room_room_access_section_restricted_option_description">"在 %1$s 中的任何人都可以加入。"</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"標準"</string> <string name="screen_create_room_room_access_section_restricted_option_title">"標準"</string>
@@ -11,6 +11,7 @@ package io.element.android.features.enterprise.api
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -36,18 +37,21 @@ interface EnterpriseService {
suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String
/** /**
* Returns the list of homeservers the user is allowed to sign in to. * Returns the list of account provider 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<String> fun accountProviderAllowList(): List<AccountProvider>
/** /**
* Whether the user is allowed to sign in to a given homeserver, according to [homeserverAllowList]. * Whether the user is allowed to sign in to any account provider.
*
* @param homeserverUrl the server the user is trying to use.
*/ */
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean fun canConnectToAnyAccountProvider(): Boolean
/**
* Whether the user is allowed to sign in to a given homeserver, according to [accountProviderAllowList].
*
* @param accountProvider the account provider the user is trying to use.
*/
suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider): Boolean
/** /**
* Whether the given homeserver enforces the use of Element Pro or a derived app. * Whether the given homeserver enforces the use of Element Pro or a derived app.
@@ -97,14 +101,4 @@ interface EnterpriseService {
* @param sessionId the session whose channel is requested. * @param sessionId the session whose channel is requested.
*/ */
fun getNoisyNotificationChannelId(sessionId: SessionId): String? fun getNoisyNotificationChannelId(sessionId: SessionId): String?
companion object {
const val ANY_ACCOUNT_PROVIDER = "*"
}
}
fun EnterpriseService.canConnectToAnyHomeserver(): Boolean {
return homeserverAllowList().let {
it.isEmpty() || it.contains(EnterpriseService.ANY_ACCOUNT_PROVIDER)
}
} }
@@ -19,6 +19,7 @@ import io.element.android.libraries.core.extensions.mapCatchingExceptions
import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@@ -33,8 +34,9 @@ class DefaultEnterpriseService(
) : EnterpriseService { ) : EnterpriseService {
override suspend fun isEnterpriseUser(sessionId: SessionId) = false override suspend fun isEnterpriseUser(sessionId: SessionId) = false
override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url
override fun homeserverAllowList(): List<String> = emptyList() override fun accountProviderAllowList(): List<AccountProvider> = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true override fun canConnectToAnyAccountProvider(): Boolean = true
override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider) = true
override suspend fun isElementProEnforced(serverName: String): Boolean { override suspend fun isElementProEnforced(serverName: String): Boolean {
val temporaryMatrixClient = temporaryMatrixClientFactory.create(serverName).getOrElse { return false } val temporaryMatrixClient = temporaryMatrixClientFactory.create(serverName).getOrElse { return false }
return temporaryMatrixClient.use { client -> return temporaryMatrixClient.use { client ->
@@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClient import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClient
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory
import io.element.android.libraries.matrix.test.accountprovider.anAccountProviderGeneric
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -26,15 +27,21 @@ import org.junit.Test
class DefaultEnterpriseServiceTest { class DefaultEnterpriseServiceTest {
@Test @Test
fun homeserverWhitelist() { fun accountProviderAllowList() {
val defaultEnterpriseService = createDefaultEnterpriseService() val defaultEnterpriseService = createDefaultEnterpriseService()
assertThat(defaultEnterpriseService.homeserverAllowList()).isEmpty() assertThat(defaultEnterpriseService.accountProviderAllowList()).isEmpty()
} }
@Test @Test
fun `isAllowedToConnectToHomeserver is true for all homeserver urls`() = runTest { fun `canConnectToAnyAccountProvider is always true`() {
val defaultEnterpriseService = createDefaultEnterpriseService() val defaultEnterpriseService = createDefaultEnterpriseService()
assertThat(defaultEnterpriseService.isAllowedToConnectToHomeserver(A_HOMESERVER_URL)).isTrue() assertThat(defaultEnterpriseService.canConnectToAnyAccountProvider()).isTrue()
}
@Test
fun `isAllowedToConnectToAccountProvider is true for all account providers`() = runTest {
val defaultEnterpriseService = createDefaultEnterpriseService()
assertThat(defaultEnterpriseService.isAllowedToConnectToAccountProvider(anAccountProviderGeneric())).isTrue()
} }
@Test @Test
@@ -13,6 +13,7 @@ import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask import io.element.android.tests.testutils.simulateLongTask
@@ -22,8 +23,9 @@ import kotlinx.coroutines.flow.asStateFlow
class FakeEnterpriseService( class FakeEnterpriseService(
private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() }, private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() },
private val defaultHomeserverListResult: () -> List<String> = { emptyList() }, private val accountProviderAllowListResult: () -> List<AccountProvider> = { emptyList() },
private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() }, private val canConnectToAnyAccountProviderResult: () -> Boolean = { true },
private val isAllowedToConnectToAccountProviderResult: (AccountProvider) -> Boolean = { lambdaError() },
initialSemanticColors: SemanticColorsLightDark = SemanticColorsLightDark.default, initialSemanticColors: SemanticColorsLightDark = SemanticColorsLightDark.default,
initialBrandColor: Color? = null, initialBrandColor: Color? = null,
private val overrideBrandColorResult: (SessionId?, String?) -> Unit = { _, _ -> lambdaError() }, private val overrideBrandColorResult: (SessionId?, String?) -> Unit = { _, _ -> lambdaError() },
@@ -44,12 +46,16 @@ class FakeEnterpriseService(
tweakMasUrlResult(url, urlContentFetcher) tweakMasUrlResult(url, urlContentFetcher)
} }
override fun homeserverAllowList(): List<String> { override fun accountProviderAllowList(): List<AccountProvider> {
return defaultHomeserverListResult() return accountProviderAllowListResult()
} }
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean = simulateLongTask { override fun canConnectToAnyAccountProvider(): Boolean {
isAllowedToConnectToHomeserverResult(homeserverUrl) return canConnectToAnyAccountProviderResult()
}
override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider): Boolean = simulateLongTask {
isAllowedToConnectToAccountProviderResult(accountProvider)
} }
override suspend fun isElementProEnforced(serverName: String): Boolean = simulateLongTask { override suspend fun isElementProEnforced(serverName: String): Boolean = simulateLongTask {
@@ -32,11 +32,13 @@ import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.ui.common.nodes.EmptyState
import io.element.android.libraries.ui.common.nodes.emptyNode import io.element.android.libraries.ui.common.nodes.emptyNode
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlin.time.Duration.Companion.milliseconds
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@AssistedInject @AssistedInject
@@ -84,7 +86,10 @@ class FtueFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Placeholder -> { NavTarget.Placeholder -> {
emptyNode(buildContext) // Rendered while waiting for the initial state (see FtueStep.WaitingForInitialState).
// This can last for a while, or forever if the sync service never manages to run, so
// render a loading state rather than nothing at all.
emptyNode(buildContext, EmptyState(delayBeforeShowingContent = 500.milliseconds))
} }
is NavTarget.SessionVerification -> { is NavTarget.SessionVerification -> {
val callback = object : FtueSessionVerificationFlowNode.Callback { val callback = object : FtueSessionVerificationFlowNode.Callback {
+3 -2
View File
@@ -12,6 +12,7 @@ import extension.testCommonDependencies
plugins { plugins {
id("io.element.android-compose-library") id("io.element.android-compose-library")
id("kotlin-parcelize") id("kotlin-parcelize")
alias(libs.plugins.kotlin.serialization)
} }
android { android {
@@ -31,6 +32,8 @@ dependencies {
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.encryptedDb)
implementation(libs.serialization.json)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
@@ -55,8 +58,6 @@ dependencies {
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(projects.features.preferences.impl) implementation(projects.features.preferences.impl)
implementation(projects.features.reportroom.api) implementation(projects.features.reportroom.api)
implementation(projects.features.rolesandpermissions.api) implementation(projects.features.rolesandpermissions.api)
@@ -36,6 +36,7 @@ class CurrentUserWithNeighborsBuilder {
userId = UserId(it.userId), userId = UserId(it.userId),
displayName = it.userDisplayName, displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl, avatarUrl = it.userAvatarUrl,
avatarThumbnail = it.userAvatarData,
) )
} }
} }
@@ -6,8 +6,6 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalHazeMaterialsApi::class)
package io.element.android.features.home.impl package io.element.android.features.home.impl
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
@@ -40,11 +38,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.components.HomeTopBar import io.element.android.features.home.impl.components.HomeTopBar
@@ -190,7 +183,6 @@ private fun HomeScaffold(
} }
} }
val hazeState = rememberHazeState()
val roomsLazyListState = rememberLazyListState() val roomsLazyListState = rememberLazyListState()
val spacesLazyListState = rememberLazyListState() val spacesLazyListState = rememberLazyListState()
@@ -223,10 +215,6 @@ private fun HomeScaffold(
filtersState = roomListState.filtersState, filtersState = roomListState.filtersState,
spaceFiltersState = roomListState.spaceFiltersState, spaceFiltersState = roomListState.spaceFiltersState,
canReportBug = state.canReportBug, canReportBug = state.canReportBug,
modifier = Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick(),
)
) )
}, },
floatingActionButton = { floatingActionButton = {
@@ -297,7 +285,6 @@ private fun HomeScaffold(
modifier = Modifier modifier = Modifier
.padding(outerPadding) .padding(outerPadding)
.consumeWindowInsets(outerPadding) .consumeWindowInsets(outerPadding)
.hazeSource(state = hazeState)
) )
SpaceFiltersView(roomListState.spaceFiltersState) SpaceFiltersView(roomListState.spaceFiltersState)
} }
@@ -306,8 +293,7 @@ private fun HomeScaffold(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(outerPadding) .padding(outerPadding)
.consumeWindowInsets(outerPadding) .consumeWindowInsets(outerPadding),
.hazeSource(state = hazeState),
contentPadding = lazyColumnContentPadding + contentPadding, contentPadding = lazyColumnContentPadding + contentPadding,
state = state.homeSpacesState, state = state.homeSpacesState,
lazyListState = spacesLazyListState, lazyListState = spacesLazyListState,
@@ -345,7 +345,7 @@ private fun MessagePreviewAndIndicatorRow(
// Call and unread // Call and unread
Row( Row(
modifier = Modifier modifier = Modifier
.height(16.dp) .heightIn(min = 16.dp)
// Used to force this line to be read aloud earlier than the latest event when using Talkback // Used to force this line to be read aloud earlier than the latest event when using Talkback
.zIndex(-1f), .zIndex(-1f),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -358,21 +358,18 @@ private fun MessagePreviewAndIndicatorRow(
isAudio = room.activeCallIntent == CallIntent.AUDIO isAudio = room.activeCallIntent == CallIntent.AUDIO
) )
} }
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) { val isMuted = room.userDefinedNotificationMode == RoomNotificationMode.MUTE
if (isMuted) {
NotificationOffIndicatorAtom() NotificationOffIndicatorAtom()
} else if (room.numberOfUnreadMentions > 0) { } else if (room.numberOfUnreadMentions > 0) {
MentionIndicatorAtom() MentionIndicatorAtom()
} }
if (room.hasNewContent) { if (room.hasNewContent) {
val contentDescription = stringResource(CommonStrings.a11y_notifications_new_messages) val contentDescription = stringResource(CommonStrings.a11y_notifications_new_messages)
val count = if (showUnreadCount) { val count = when {
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) { showUnreadCount && !isMuted && room.numberOfUnreadNotifications > 0 -> room.numberOfUnreadNotifications
room.numberOfUnreadMessages !showUnreadCount -> 0
} else { else -> null
room.numberOfUnreadNotifications
}
} else {
null
} }
UnreadIndicatorAtom( UnreadIndicatorAtom(
color = tint, color = tint,
@@ -457,5 +454,6 @@ internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryPreviewP
isInviteSeen = data.name == "Bob", isInviteSeen = data.name == "Bob",
onClick = {}, onClick = {},
eventSink = {}, eventSink = {},
showUnreadCount = data.numberOfUnreadNotifications > 0,
) )
} }
@@ -82,6 +82,24 @@ open class RoomListRoomSummaryPreviewParam : PreviewParameterProvider<RoomListRo
numberOfUnreadMentions = 1, numberOfUnreadMentions = 1,
hasRoomCall = hasCall, hasRoomCall = hasCall,
), ),
aRoomListRoomSummary(
name = roomNotificationMode.name,
latestEvent = LatestEvent.Synced("New notifications" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0,
numberOfUnreadMentions = 0,
numberOfUnreadNotifications = 1,
hasRoomCall = hasCall,
),
aRoomListRoomSummary(
name = roomNotificationMode.name,
latestEvent = LatestEvent.Synced("New notifications" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0,
numberOfUnreadMentions = 0,
numberOfUnreadNotifications = 100,
hasRoomCall = hasCall,
),
) )
}.flatten() }.flatten()
}.flatten(), }.flatten(),
@@ -7,9 +7,27 @@
package io.element.android.features.home.impl.search package io.element.android.features.home.impl.search
import io.element.android.libraries.matrix.api.core.RoomId
sealed interface GlobalSearchEvent { sealed interface GlobalSearchEvent {
/** The user cleared the search query. */
object ClearQuery : GlobalSearchEvent object ClearQuery : GlobalSearchEvent
/** The search screen visibility changed (e.g. the user opened or closed the search screen). */
object ToggleSearchVisibility : GlobalSearchEvent object ToggleSearchVisibility : GlobalSearchEvent
/** The visible range for the displayed search results changed. */
data class UpdateVisibleRange(val range: IntRange) : GlobalSearchEvent data class UpdateVisibleRange(val range: IntRange) : GlobalSearchEvent
/** The user changed the search target (e.g. from "Rooms" to "Messages"). */
data class UpdateTarget(val target: GlobalSearchTarget) : GlobalSearchEvent data class UpdateTarget(val target: GlobalSearchTarget) : GlobalSearchEvent
/** The user triggered the keyboard search action (enter key): save the current query to the search history. */
object SaveQueryToHistory : GlobalSearchEvent
/** The user opened a room from the search results: save it to the search history. */
data class SaveRoomToHistory(val roomId: RoomId) : GlobalSearchEvent
/** The user selected a search result from the search history. */
data class SearchHistoryResultSelected(val resultItem: SearchHistoryResultItem) : GlobalSearchEvent
} }
@@ -18,6 +18,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.home.impl.search.history.SearchHistoryResult
import io.element.android.features.home.impl.search.history.SearchHistoryStore
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@@ -47,6 +49,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.isMediaContent 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.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.messages.toPlainText import io.element.android.libraries.matrix.ui.messages.toPlainText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -54,10 +57,8 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import kotlin.jvm.optionals.getOrElse
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@Inject @Inject
@@ -71,6 +72,7 @@ class GlobalSearchPresenter(
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val searchHistoryStore: SearchHistoryStore,
) : Presenter<GlobalSearchState> { ) : Presenter<GlobalSearchState> {
@Composable @Composable
override fun present(): GlobalSearchState { override fun present(): GlobalSearchState {
@@ -85,6 +87,21 @@ class GlobalSearchPresenter(
var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) } var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) }
val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) } val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) }
val searchHistory by produceState<AsyncData<ImmutableList<SearchHistoryResultItem>>>(initialValue = AsyncData.Uninitialized) {
searchHistoryStore.history.collectLatest { history ->
val mappedHistoryItems = history.mapNotNull { result ->
when (result) {
is SearchHistoryResult.Query -> SearchHistoryResultItem.Query(term = result.term)
is SearchHistoryResult.Room -> {
val roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@mapNotNull null
SearchHistoryResultItem.Room(roomId = result.roomId, roomInfo = roomInfo)
}
}
}
value = AsyncData.Success(mappedHistoryItems.toImmutableList())
}
}
LaunchedEffect(queryState.text) { LaunchedEffect(queryState.text) {
// Add a delay to avoid performing too many searches in a short period of time, which can be expensive // Add a delay to avoid performing too many searches in a short period of time, which can be expensive
delay(200.milliseconds) delay(200.milliseconds)
@@ -141,7 +158,7 @@ class GlobalSearchPresenter(
// result, which can be slow if we have a lot of results. The original order is kept by using `awaitAll()`. // 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 -> val mappedResults = results.map { result ->
async(coroutineDispatchers.computation) { async(coroutineDispatchers.computation) {
val roomInfo = matrixClient.getRoomInfoFlow(result.roomId).first().getOrElse { return@async null } val roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@async null
val formattedTimestamp = dateFormatter.format( val formattedTimestamp = dateFormatter.format(
timestamp = result.timestamp, timestamp = result.timestamp,
mode = DateFormatterMode.TimeOrDate, mode = DateFormatterMode.TimeOrDate,
@@ -180,7 +197,8 @@ class GlobalSearchPresenter(
fun handleEvent(event: GlobalSearchEvent) { fun handleEvent(event: GlobalSearchEvent) {
when (event) { when (event) {
GlobalSearchEvent.ClearQuery -> { GlobalSearchEvent.ClearQuery -> coroutineScope.launch {
searchResults = AsyncData.Uninitialized
queryState.clearText() queryState.clearText()
} }
GlobalSearchEvent.ToggleSearchVisibility -> { GlobalSearchEvent.ToggleSearchVisibility -> {
@@ -205,6 +223,30 @@ class GlobalSearchPresenter(
} }
} }
is GlobalSearchEvent.UpdateTarget -> currentTarget = event.target is GlobalSearchEvent.UpdateTarget -> currentTarget = event.target
GlobalSearchEvent.SaveQueryToHistory -> {
val term = queryState.text.toString()
if (term.isNotBlank()) {
coroutineScope.launch {
searchHistoryStore.add(SearchHistoryResult.Query(term))
}
}
}
is GlobalSearchEvent.SaveRoomToHistory -> coroutineScope.launch {
searchHistoryStore.add(SearchHistoryResult.Room(event.roomId))
}
is GlobalSearchEvent.SearchHistoryResultSelected -> {
when (event.resultItem) {
is SearchHistoryResultItem.Query -> coroutineScope.launch {
delay(300.milliseconds)
queryState.edit {
replace(0, length, event.resultItem.term)
}
searchResults = AsyncData.Loading()
currentTarget = GlobalSearchTarget.ROOMS
}
is SearchHistoryResultItem.Room -> Unit
}
}
} }
} }
@@ -214,6 +256,7 @@ class GlobalSearchPresenter(
queryState = queryState, queryState = queryState,
currentTarget = currentTarget, currentTarget = currentTarget,
results = searchResults, results = searchResults,
history = searchHistory,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -28,6 +28,7 @@ data class GlobalSearchState(
val queryState: TextFieldState, val queryState: TextFieldState,
val currentTarget: GlobalSearchTarget, val currentTarget: GlobalSearchTarget,
val results: AsyncData<GlobalSearchResults>, val results: AsyncData<GlobalSearchResults>,
val history: AsyncData<ImmutableList<SearchHistoryResultItem>>,
val eventSink: (GlobalSearchEvent) -> Unit val eventSink: (GlobalSearchEvent) -> Unit
) )
@@ -81,6 +82,20 @@ sealed interface MessageSearchResultItem {
} }
} }
sealed interface SearchHistoryResultItem {
val id: String
data class Query(val term: String) : SearchHistoryResultItem {
override val id: String = "query:$term"
}
data class Room(
val roomId: RoomId,
val roomInfo: RoomInfo,
) : SearchHistoryResultItem {
override val id: String = "room:${roomId.value}"
}
}
data class MediaSearchResultContent( data class MediaSearchResultContent(
val filename: String, val filename: String,
val extension: String?, val extension: String?,
@@ -36,14 +36,23 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.user.MatrixUser 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.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
private val tombstonedRoomId = RoomId("!tombstone:server.org")
private val roomId1 = RoomId("!roomId:server.org")
private val roomId2 = RoomId("!roomId2:server.org")
private val dmRoomId = RoomId("!dm:server.org")
private const val AN_AVATAR_URL = "https://example.com/avatar.png"
private val aCanonicalAlias = RoomAlias("#alias:server.org")
class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState> { class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState> {
override val values: Sequence<GlobalSearchState> override val values: Sequence<GlobalSearchState>
get() = sequenceOf( get() {
return sequenceOf(
// Not enabled, no preview // Not enabled, no preview
aGlobalSearchState(isEnabled = false), aGlobalSearchState(isEnabled = false),
aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")), aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")),
@@ -98,15 +107,68 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
queryState = TextFieldState("Query"), queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())), results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())),
), ),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState(),
results = AsyncData.Uninitialized,
history = AsyncData.Success(
persistentListOf(
SearchHistoryResultItem.Query("Query 1"),
SearchHistoryResultItem.Query("Query 2"),
SearchHistoryResultItem.Room(
roomId = roomId1,
roomInfo = aRoomInfo(
id = roomId1,
name = "A room with alias",
avatarUrl = AN_AVATAR_URL,
canonicalAlias = aCanonicalAlias,
) )
),
SearchHistoryResultItem.Room(
roomId = roomId2,
roomInfo = aRoomInfo(
id = roomId2,
name = "A room",
avatarUrl = AN_AVATAR_URL,
)
),
SearchHistoryResultItem.Room(
roomId = dmRoomId,
roomInfo = aRoomInfo(
id = dmRoomId,
name = "A DM",
isDm = true,
heroes = listOf(MatrixUser(UserId("@user:server.org"), "User", AN_AVATAR_URL)),
avatarUrl = AN_AVATAR_URL,
canonicalAlias = aCanonicalAlias,
)
),
SearchHistoryResultItem.Room(
roomId = tombstonedRoomId,
roomInfo = aRoomInfo(
id = tombstonedRoomId,
name = "A tombstoned room",
avatarUrl = AN_AVATAR_URL,
successorRoom = SuccessorRoom(
roomId = RoomId("!successorRoom:server.org"),
reason = null,
),
),
),
)
)
),
)
}
} }
fun aRoomInfo( fun aRoomInfo(
id: RoomId = RoomId("!roomId:server.org"), id: RoomId = roomId1,
name: String? = "A room", name: String? = "A room",
rawName: String? = "A room raw name", rawName: String? = "A room raw name",
topic: String? = "A room topic", topic: String? = "A room topic",
avatarUrl: String? = "https://example.com/avatar.png", avatarUrl: String? = AN_AVATAR_URL,
isPublic: Boolean = true, isPublic: Boolean = true,
isDirect: Boolean = false, isDirect: Boolean = false,
isEncrypted: Boolean = false, isEncrypted: Boolean = false,
@@ -217,7 +279,7 @@ fun aMessageContent(
) )
internal fun aMessageSearchResult( internal fun aMessageSearchResult(
roomId: RoomId = RoomId("!roomId:server.org"), roomId: RoomId = roomId1,
eventId: EventId = EventId("\$eventId:server.org"), eventId: EventId = EventId("\$eventId:server.org"),
senderId: UserId = UserId("@user:server.org"), senderId: UserId = UserId("@user:server.org"),
senderProfile: ProfileDetails = aProfileDetailsReady(), senderProfile: ProfileDetails = aProfileDetailsReady(),
@@ -238,6 +300,7 @@ internal fun aGlobalSearchState(
queryState: TextFieldState = TextFieldState(), queryState: TextFieldState = TextFieldState(),
currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS, currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS,
results: AsyncData<GlobalSearchResults> = AsyncData.Uninitialized, results: AsyncData<GlobalSearchResults> = AsyncData.Uninitialized,
history: AsyncData<ImmutableList<SearchHistoryResultItem>> = AsyncData.Uninitialized,
eventSink: (GlobalSearchEvent) -> Unit = {}, eventSink: (GlobalSearchEvent) -> Unit = {},
) = GlobalSearchState( ) = GlobalSearchState(
isEnabled = isEnabled, isEnabled = isEnabled,
@@ -245,5 +308,6 @@ internal fun aGlobalSearchState(
queryState = queryState, queryState = queryState,
currentTarget = currentTarget, currentTarget = currentTarget,
results = results, results = results,
history = history,
eventSink = eventSink eventSink = eventSink
) )
@@ -23,11 +23,15 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.KeyboardActionHandler
import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -44,6 +48,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -63,6 +68,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.roundToPx 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.FilledTextField
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon 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.IconButton
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
@@ -80,6 +86,7 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@Composable @Composable
@@ -103,7 +110,10 @@ fun GlobalSearchView(
onBackButtonClick = { onBackButtonClick = {
state.eventSink(GlobalSearchEvent.ToggleSearchVisibility) state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
}, },
onSelectSearchResult = onSelectSearchResult, onSelectSearchResult = { roomId, eventId ->
onSelectSearchResult(roomId, eventId)
state.eventSink(GlobalSearchEvent.SaveRoomToHistory(roomId))
},
) )
} }
} }
@@ -115,23 +125,65 @@ private fun GlobalSearchContent(
onBackButtonClick: () -> Unit, onBackButtonClick: () -> Unit,
onSelectSearchResult: (RoomId, EventId?) -> 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( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
navigationIcon = { BackButton(onClick = onBackButtonClick) }, navigationIcon = { BackButton(onClick = onBackButtonClick) },
title = { title = { SearchTopAppBarContent(state = state) }
)
},
contentWindowInsets = scaffoldScrollableContentInsets,
) { padding ->
Column(
modifier = Modifier.padding(padding),
) {
HorizontalDivider(color = ElementTheme.colors.borderInteractivePrimary)
SearchTargetSelector(state = state)
val lazyListState = rememberLazyListState()
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(GlobalSearchEvent.UpdateVisibleRange(visibleRange))
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = lazyColumnContentPadding,
state = lazyListState,
) {
val results = state.results.dataOrNull()
val historyResults = state.history.dataOrNull()?.toImmutableList() ?: persistentListOf()
when {
state.results.isUninitialized() -> if (state.queryState.text.isBlank() && historyResults.isNotEmpty()) {
searchHistory(historyResults) { result ->
when (result) {
is SearchHistoryResultItem.Query -> Unit
is SearchHistoryResultItem.Room -> onSelectSearchResult(result.roomInfo.id, null)
}
state.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(result))
}
} else {
startSearching()
}
state.results.isLoading() -> loadingResults()
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,
)
}
}
}
}
}
@Composable
private fun SearchTopAppBarContent(state: GlobalSearchState) {
// The stateSaver will keep the selection state when returning to this UI // The stateSaver will keep the selection state when returning to this UI
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val searchLabel = stringResource(CommonStrings.action_search) val searchLabel = stringResource(CommonStrings.action_search)
@@ -142,6 +194,11 @@ private fun GlobalSearchContent(
.semantics { contentDescription = searchLabel }, .semantics { contentDescription = searchLabel },
state = state.queryState, state = state.queryState,
lineLimits = TextFieldLineLimits.SingleLine, lineLimits = TextFieldLineLimits.SingleLine,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActionHandler { performDefault ->
state.eventSink(GlobalSearchEvent.SaveQueryToHistory)
performDefault()
},
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent,
@@ -153,10 +210,18 @@ private fun GlobalSearchContent(
), ),
trailingIcon = if (state.queryState.text.isNotEmpty()) { trailingIcon = if (state.queryState.text.isNotEmpty()) {
@Composable { @Composable {
IconButton(onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }) { IconButton(
modifier = Modifier.clip(CircleShape).size(20.dp),
onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) },
colors = IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.borderInteractivePrimary,
contentColor = ElementTheme.colors.iconOnSolidPrimary
)
) {
Icon( Icon(
modifier = Modifier.size(16.dp),
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.a11y_clear_search_field) contentDescription = stringResource(CommonStrings.a11y_clear_search_field),
) )
} }
} }
@@ -171,18 +236,16 @@ private fun GlobalSearchContent(
} }
focusRequester.saveFocusedChild() focusRequester.saveFocusedChild()
} }
} }
)
}, @Composable
contentWindowInsets = scaffoldScrollableContentInsets, private fun SearchTargetSelector(state: GlobalSearchState) {
) { padding -> if (state.queryState.text.isEmpty()) return
Column(
modifier = Modifier.padding(padding),
) {
SingleChoiceSegmentedButtonRow( SingleChoiceSegmentedButtonRow(
modifier = drawBottomLineModifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp) .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
) { ) {
SegmentedButton( SegmentedButton(
index = 0, index = 0,
@@ -200,34 +263,6 @@ private fun GlobalSearchContent(
text = stringResource(R.string.search_section_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() { private fun LazyListScope.startSearching() {
@@ -243,7 +278,98 @@ private fun LazyListScope.startSearching() {
} }
} }
private fun LazyListScope.loading() { private fun LazyListScope.searchHistory(
historyResults: ImmutableList<SearchHistoryResultItem>,
onSelectSearchHistoryResult: (SearchHistoryResultItem) -> Unit,
) {
item {
Text(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
text = stringResource(R.string.screen_search_recent_searches_section_title),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
}
items(
items = historyResults,
key = { it.id },
) { result ->
Row(
modifier = Modifier
.niceClickable(onClick = { onSelectSearchHistoryResult(result) })
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (result) {
is SearchHistoryResultItem.Query -> {
Icon(
modifier = Modifier.size(32.dp)
.clip(RoundedCornerShape(8.dp))
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(6.dp),
imageVector = CompoundIcons.History(),
contentDescription = null,
tint = ElementTheme.colors.iconPrimary,
)
Text(
text = result.term,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
)
}
is SearchHistoryResultItem.Room -> {
val heroAvatars = remember(result.roomInfo.heroes) {
result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.SearchRoomListItem) }.toImmutableList()
}
Avatar(
avatarData = result.roomInfo.getAvatarData(AvatarSize.SearchRoomListItem),
avatarType = AvatarType.Room(
heroes = heroAvatars,
isTombstoned = result.roomInfo.successorRoom != null,
),
)
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = result.roomInfo.name.orEmpty(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
if (result.roomInfo.isDm) {
val dmUserId = result.roomInfo.heroes.firstOrNull()?.userId
dmUserId?.let { userId ->
Text(
text = userId.value,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
} else if (result.roomInfo.canonicalAlias != null) {
Text(
text = result.roomInfo.canonicalAlias?.value!!,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
}
}
}
}
private fun LazyListScope.loadingResults() {
item { item {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
} }
@@ -0,0 +1,47 @@
/*
* 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.history
import android.content.Context
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.encrypteddb.crypto.EncryptedFile
import java.io.File
private const val SEARCH_HISTORY_FILE_NAME = "search_history"
/**
* Persists the search history in an encrypted file inside the current session file directory, using
* [EncryptedFile] (Tink StreamingAead + a master key stored in the Android Keystore).
*/
@ContributesBinding(SessionScope::class)
class DefaultSearchHistoryFileStore(
@ApplicationContext private val context: Context,
matrixClient: MatrixClient,
) : SearchHistoryFileStore {
private val file: File by lazy {
File(matrixClient.sessionPaths.fileDirectory, SEARCH_HISTORY_FILE_NAME)
}
override fun read(): ByteArray? {
if (!file.exists()) return null
return EncryptedFile(context, file).openFileInput().use { it.readBytes() }
}
override fun write(bytes: ByteArray) {
file.parentFile?.takeIf { !it.exists() }?.mkdirs()
// Opening a FileOutputStream truncates any previous content, so this overwrites the file.
EncryptedFile(context, file).openFileOutput().use { it.write(bytes) }
}
override fun delete(): Boolean {
return file.delete()
}
}
@@ -0,0 +1,96 @@
/*
* 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.history
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
@ContributesBinding(SessionScope::class)
class DefaultSearchHistoryStore(
private val fileStore: SearchHistoryFileStore,
private val dispatchers: CoroutineDispatchers,
private val jsonProvider: JsonProvider,
) : SearchHistoryStore {
private val json by lazy { jsonProvider() }
// Guards [fileStore] access and the lazy initialization of [cache].
private val mutex = Mutex()
private val cache = MutableStateFlow<List<SearchHistoryResult>>(emptyList())
private var isLoaded = false
override val history: Flow<List<SearchHistoryResult>> = flow {
ensureLoaded()
emitAll(cache)
}
override suspend fun add(result: SearchHistoryResult) = withContext(dispatchers.io) {
mutex.withLock {
ensureLoadedLocked()
val updated = buildList {
add(result)
// Deduplicate: an equal entry is moved to the front rather than being duplicated.
addAll(cache.value.filterNot { it == result })
}
.distinct()
.take(MAX_SEARCH_HISTORY_SIZE)
cache.value = updated
persist(updated)
}
}
override suspend fun clear() = withContext<Unit>(dispatchers.io) {
mutex.withLock {
isLoaded = true
cache.value = emptyList()
runCatchingExceptions { fileStore.delete() }
.onFailure { Timber.e(it, "Failed to delete search history") }
}
}
private suspend fun ensureLoaded() = withContext(dispatchers.io) {
mutex.withLock {
ensureLoadedLocked()
}
}
/**
* Must be called while holding [mutex].
*/
private fun ensureLoadedLocked() {
if (isLoaded) return
cache.value = readFromDisk()
isLoaded = true
}
private fun readFromDisk(): List<SearchHistoryResult> {
return runCatchingExceptions {
val bytes = fileStore.read() ?: return@runCatchingExceptions emptyList()
json.decodeFromString<List<SearchHistoryResult>>(bytes.decodeToString())
}.getOrElse {
Timber.e(it, "Failed to read search history, resetting it")
emptyList()
}
}
private fun persist(items: List<SearchHistoryResult>) {
runCatchingExceptions {
fileStore.write(json.encodeToString(items).encodeToByteArray())
}.onFailure { Timber.e(it, "Failed to persist search history") }
}
}
@@ -0,0 +1,34 @@
/*
* 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.history
/**
* Low level, raw byte read/write of the persisted search history.
*
* This exists as a seam so that [DefaultSearchHistoryStore] can be unit tested without relying on
* the Android Keystore. The production implementation ([DefaultSearchHistoryFileStore]) stores the
* bytes in an encrypted file.
*/
interface SearchHistoryFileStore {
/**
* Return the persisted bytes, or `null` if nothing has been persisted yet.
*/
fun read(): ByteArray?
/**
* Persist [bytes], replacing any previously persisted content.
*/
fun write(bytes: ByteArray)
/**
* Delete any persisted content.
*
* @return `true` if the persisted content was deleted, `false` if there was no persisted content to delete.
*/
fun delete(): Boolean
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.serialization.Serializable
/**
* A single entry in the global search history.
*
* The history stores either the term used to perform a search (when the keyboard search action is
* triggered) or the id of a room opened from the search results.
*/
@Serializable
sealed interface SearchHistoryResult {
@Serializable
data class Query(val term: String) : SearchHistoryResult
@Serializable
data class Room(val roomId: RoomId) : SearchHistoryResult
}
@@ -0,0 +1,42 @@
/*
* 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.history
import kotlinx.coroutines.flow.Flow
/**
* Stores the global search history for the current session.
*
* The history is persisted in an encrypted file and capped to [MAX_SEARCH_HISTORY_SIZE] entries.
* The most recent entry is always at the front of the list; when the cap is reached the oldest
* entries are dropped.
*/
interface SearchHistoryStore {
/**
* Emits the current search history, most recent entry first, and re-emits whenever it changes.
*/
val history: Flow<List<SearchHistoryResult>>
/**
* Add a new [result] at the front of the history.
*
* If an equal entry already exists it is moved to the front instead of being duplicated. When
* the history exceeds [MAX_SEARCH_HISTORY_SIZE] entries the oldest ones are dropped.
*/
suspend fun add(result: SearchHistoryResult)
/**
* Remove every entry from the history.
*/
suspend fun clear()
}
/**
* Maximum number of entries kept in the search history.
*/
const val MAX_SEARCH_HISTORY_SIZE = 25
@@ -14,7 +14,7 @@
<string name="confirm_recovery_key_banner_title">"Vaše úložiště klíčů není synchronizováno"</string> <string name="confirm_recovery_key_banner_title">"Vaše úložiště klíčů není synchronizováno"</string>
<string name="full_screen_intent_banner_message">"Abyste nikdy nezmeškali důležitý hovor, změňte nastavení tak, abyste povolili oznámení na celé obrazovce, když je telefon uzamčen."</string> <string name="full_screen_intent_banner_message">"Abyste nikdy nezmeškali důležitý hovor, změňte nastavení tak, abyste povolili oznámení na celé obrazovce, když je telefon uzamčen."</string>
<string name="full_screen_intent_banner_title">"Vylepšete si zážitek z volání"</string> <string name="full_screen_intent_banner_title">"Vylepšete si zážitek z volání"</string>
<string name="screen_home_tab_chats">"Všechny chaty"</string> <string name="screen_home_tab_chats">"Chaty"</string>
<string name="screen_home_tab_spaces">"Prostory"</string> <string name="screen_home_tab_spaces">"Prostory"</string>
<string name="screen_invites_decline_chat_message">"Opravdu chcete odmítnout pozvánku do %1$s?"</string> <string name="screen_invites_decline_chat_message">"Opravdu chcete odmítnout pozvánku do %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Odmítnout pozvání"</string> <string name="screen_invites_decline_chat_title">"Odmítnout pozvání"</string>
@@ -46,7 +46,7 @@ Prozatím můžete zrušit výběr filtrů, abyste viděli své další chaty"</
<string name="screen_roomlist_filter_unreads_empty_state_title">"Gratulujeme! <string name="screen_roomlist_filter_unreads_empty_state_title">"Gratulujeme!
Nemáte žádné nepřečtené zprávy!"</string> Nemáte žádné nepřečtené zprávy!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Žádost o vstup odeslána"</string> <string name="screen_roomlist_knock_event_sent_description">"Žádost o vstup odeslána"</string>
<string name="screen_roomlist_main_space_title">"Všechny chaty"</string> <string name="screen_roomlist_main_space_title">"Chaty"</string>
<string name="screen_roomlist_mark_as_read">"Označit jako přečtené"</string> <string name="screen_roomlist_mark_as_read">"Označit jako přečtené"</string>
<string name="screen_roomlist_mark_as_unread">"Označit jako nepřečtené"</string> <string name="screen_roomlist_mark_as_unread">"Označit jako nepřečtené"</string>
<string name="screen_roomlist_tombstoned_room_description">"Tato místnost byla aktualizována"</string> <string name="screen_roomlist_tombstoned_room_description">"Tato místnost byla aktualizována"</string>
@@ -36,6 +36,8 @@ Deaktiviere den entsprechenden Filter, um deine anderen Chats zu sehen"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Du hast keine ausstehenden Einladungen."</string> <string name="screen_roomlist_filter_invites_empty_state_title">"Du hast keine ausstehenden Einladungen."</string>
<string name="screen_roomlist_filter_low_priority">"Niedrige Priorität"</string> <string name="screen_roomlist_filter_low_priority">"Niedrige Priorität"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Du hast noch keine Chats mit niedriger Priorität"</string> <string name="screen_roomlist_filter_low_priority_empty_state_title">"Du hast noch keine Chats mit niedriger Priorität"</string>
<string name="screen_roomlist_filter_mention">"Erwähnungen"</string>
<string name="screen_roomlist_filter_mention_empty_state_title">"Du hast keine ungelesenen Erwähnungen"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Entferne Filter, um Chats zu sehen"</string> <string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Entferne Filter, um Chats zu sehen"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Für diese Auswahl hast du keinen Chat"</string> <string name="screen_roomlist_filter_mixed_empty_state_title">"Für diese Auswahl hast du keinen Chat"</string>
<string name="screen_roomlist_filter_people">"Personen"</string> <string name="screen_roomlist_filter_people">"Personen"</string>
@@ -36,6 +36,8 @@ Toistaiseksi voit poistaa suodattimien valinnan, jotta näet muut keskustelut."<
<string name="screen_roomlist_filter_invites_empty_state_title">"Sinulla ei ole yhtään odottavaa kutsua."</string> <string name="screen_roomlist_filter_invites_empty_state_title">"Sinulla ei ole yhtään odottavaa kutsua."</string>
<string name="screen_roomlist_filter_low_priority">"Matala prioriteetti"</string> <string name="screen_roomlist_filter_low_priority">"Matala prioriteetti"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Sinulla ei ole vielä yhtään matalan prioriteetin keskustelua"</string> <string name="screen_roomlist_filter_low_priority_empty_state_title">"Sinulla ei ole vielä yhtään matalan prioriteetin keskustelua"</string>
<string name="screen_roomlist_filter_mention">"Maininnat"</string>
<string name="screen_roomlist_filter_mention_empty_state_title">"Sinulla ei ole lukemattomia mainintoja"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Voit poistaa suodattimien valinnan nähdäksesi muut keskustelusi."</string> <string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Voit poistaa suodattimien valinnan nähdäksesi muut keskustelusi."</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Sinulla ei ole sopivia keskusteluja tähän valintaan"</string> <string name="screen_roomlist_filter_mixed_empty_state_title">"Sinulla ei ole sopivia keskusteluja tähän valintaan"</string>
<string name="screen_roomlist_filter_people">"Ihmiset"</string> <string name="screen_roomlist_filter_people">"Ihmiset"</string>
@@ -14,7 +14,7 @@
<string name="confirm_recovery_key_banner_title">"A kulcstároló nincs szinkronizálva"</string> <string name="confirm_recovery_key_banner_title">"A kulcstároló nincs szinkronizálva"</string>
<string name="full_screen_intent_banner_message">"Hogy sose maradjon le egyetlen fontos hívásról sem, a beállításokban engedélyezze a teljes képernyős értesítéseket, amikor a telefon zárolva van."</string> <string name="full_screen_intent_banner_message">"Hogy sose maradjon le egyetlen fontos hívásról sem, a beállításokban engedélyezze a teljes képernyős értesítéseket, amikor a telefon zárolva van."</string>
<string name="full_screen_intent_banner_title">"Fokozza a hívásélményét"</string> <string name="full_screen_intent_banner_title">"Fokozza a hívásélményét"</string>
<string name="screen_home_tab_chats">"Összes csevegés"</string> <string name="screen_home_tab_chats">"Csevegések"</string>
<string name="screen_home_tab_spaces">"Terek"</string> <string name="screen_home_tab_spaces">"Terek"</string>
<string name="screen_invites_decline_chat_message">"Biztos, hogy elutasítja a meghívást, hogy csatlakozzon ehhez: %1$s?"</string> <string name="screen_invites_decline_chat_message">"Biztos, hogy elutasítja a meghívást, hogy csatlakozzon ehhez: %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Meghívás elutasítása"</string> <string name="screen_invites_decline_chat_title">"Meghívás elutasítása"</string>
@@ -36,6 +36,8 @@ Egyelőre törölheti a szűrőket a többi csevegés megtekintéséhez."</strin
<string name="screen_roomlist_filter_invites_empty_state_title">"Nincsenek függőben lévő meghívásai."</string> <string name="screen_roomlist_filter_invites_empty_state_title">"Nincsenek függőben lévő meghívásai."</string>
<string name="screen_roomlist_filter_low_priority">"Alacsony prioritás"</string> <string name="screen_roomlist_filter_low_priority">"Alacsony prioritás"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Még nincsenek alacsony prioritású csevegései"</string> <string name="screen_roomlist_filter_low_priority_empty_state_title">"Még nincsenek alacsony prioritású csevegései"</string>
<string name="screen_roomlist_filter_mention">"Megemlítés"</string>
<string name="screen_roomlist_filter_mention_empty_state_title">"Nincs olvasatlan említése"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Kikapcsolhatja a szűrőket a többi csevegés megtekintéséhez"</string> <string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Kikapcsolhatja a szűrőket a többi csevegés megtekintéséhez"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Ehhez a kiválasztáshoz nem tartoznak csevegések"</string> <string name="screen_roomlist_filter_mixed_empty_state_title">"Ehhez a kiválasztáshoz nem tartoznak csevegések"</string>
<string name="screen_roomlist_filter_people">"Emberek"</string> <string name="screen_roomlist_filter_people">"Emberek"</string>
@@ -46,7 +48,7 @@ Egyelőre törölheti a szűrőket a többi csevegés megtekintéséhez."</strin
<string name="screen_roomlist_filter_unreads_empty_state_title">"Gratulálunk! <string name="screen_roomlist_filter_unreads_empty_state_title">"Gratulálunk!
Nincs olvasatlan üzenete!"</string> Nincs olvasatlan üzenete!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Csatlakozási kérés elküldve"</string> <string name="screen_roomlist_knock_event_sent_description">"Csatlakozási kérés elküldve"</string>
<string name="screen_roomlist_main_space_title">"Összes csevegés"</string> <string name="screen_roomlist_main_space_title">"Csevegések"</string>
<string name="screen_roomlist_mark_as_read">"Megjelölés olvasottként"</string> <string name="screen_roomlist_mark_as_read">"Megjelölés olvasottként"</string>
<string name="screen_roomlist_mark_as_unread">"Megjelölés olvasatlanként"</string> <string name="screen_roomlist_mark_as_unread">"Megjelölés olvasatlanként"</string>
<string name="screen_roomlist_tombstoned_room_description">"A szoba verzióját frissítették"</string> <string name="screen_roomlist_tombstoned_room_description">"A szoba verzióját frissítették"</string>
@@ -36,6 +36,8 @@
<string name="screen_roomlist_filter_invites_empty_state_title">"承認待ちの招待はありません"</string> <string name="screen_roomlist_filter_invites_empty_state_title">"承認待ちの招待はありません"</string>
<string name="screen_roomlist_filter_low_priority">"低い優先度"</string> <string name="screen_roomlist_filter_low_priority">"低い優先度"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"低い優先度のチャットはまだありません"</string> <string name="screen_roomlist_filter_low_priority_empty_state_title">"低い優先度のチャットはまだありません"</string>
<string name="screen_roomlist_filter_mention">"メンション"</string>
<string name="screen_roomlist_filter_mention_empty_state_title">"未読のメンションはありません"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"フィルターを解除して他のチャットを表示できます"</string> <string name="screen_roomlist_filter_mixed_empty_state_subtitle">"フィルターを解除して他のチャットを表示できます"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"この選択中にチャットがありません"</string> <string name="screen_roomlist_filter_mixed_empty_state_title">"この選択中にチャットがありません"</string>
<string name="screen_roomlist_filter_people">"ユーザー"</string> <string name="screen_roomlist_filter_people">"ユーザー"</string>
@@ -12,7 +12,7 @@
<string name="confirm_recovery_key_banner_title">"Din nyckellagring är inte synkroniserad"</string> <string name="confirm_recovery_key_banner_title">"Din nyckellagring är inte synkroniserad"</string>
<string name="full_screen_intent_banner_message">"För att säkerställa att du aldrig missar ett viktigt samtal, ändra dina inställningar för att tillåta helskärmsmeddelanden när telefonen är låst."</string> <string name="full_screen_intent_banner_message">"För att säkerställa att du aldrig missar ett viktigt samtal, ändra dina inställningar för att tillåta helskärmsmeddelanden när telefonen är låst."</string>
<string name="full_screen_intent_banner_title">"Förbättra din samtalsupplevelse"</string> <string name="full_screen_intent_banner_title">"Förbättra din samtalsupplevelse"</string>
<string name="screen_home_tab_chats">"Alla chattar"</string> <string name="screen_home_tab_chats">"Chattar"</string>
<string name="screen_home_tab_spaces">"Utrymmen"</string> <string name="screen_home_tab_spaces">"Utrymmen"</string>
<string name="screen_invites_decline_chat_message">"Är du säker på att du vill tacka nej till inbjudan att gå med%1$s?"</string> <string name="screen_invites_decline_chat_message">"Är du säker på att du vill tacka nej till inbjudan att gå med%1$s?"</string>
<string name="screen_invites_decline_chat_title">"Avböj inbjudan"</string> <string name="screen_invites_decline_chat_title">"Avböj inbjudan"</string>
@@ -44,7 +44,7 @@ För tillfället kan du avmarkera filter för att se dina andra chattar"</string
<string name="screen_roomlist_filter_unreads_empty_state_title">"Grattis! <string name="screen_roomlist_filter_unreads_empty_state_title">"Grattis!
Du har inga olästa meddelanden!"</string> Du har inga olästa meddelanden!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Begäran om att gå med skickad"</string> <string name="screen_roomlist_knock_event_sent_description">"Begäran om att gå med skickad"</string>
<string name="screen_roomlist_main_space_title">"Alla chattar"</string> <string name="screen_roomlist_main_space_title">"Chattar"</string>
<string name="screen_roomlist_mark_as_read">"Markera som läst"</string> <string name="screen_roomlist_mark_as_read">"Markera som läst"</string>
<string name="screen_roomlist_mark_as_unread">"Markera som oläst"</string> <string name="screen_roomlist_mark_as_unread">"Markera som oläst"</string>
<string name="screen_roomlist_tombstoned_room_description">"Det här rummet har uppgraderats"</string> <string name="screen_roomlist_tombstoned_room_description">"Det här rummet har uppgraderats"</string>
@@ -14,7 +14,7 @@
<string name="confirm_recovery_key_banner_title">"您的金鑰儲存空間並未同步"</string> <string name="confirm_recovery_key_banner_title">"您的金鑰儲存空間並未同步"</string>
<string name="full_screen_intent_banner_message">"為確保您永遠不會錯過重要通話,請變更設定以允許在手機鎖定時允許全螢幕通知。"</string> <string name="full_screen_intent_banner_message">"為確保您永遠不會錯過重要通話,請變更設定以允許在手機鎖定時允許全螢幕通知。"</string>
<string name="full_screen_intent_banner_title">"提升您的通話體驗"</string> <string name="full_screen_intent_banner_title">"提升您的通話體驗"</string>
<string name="screen_home_tab_chats">"所有聊天室"</string> <string name="screen_home_tab_chats">"聊天"</string>
<string name="screen_home_tab_spaces">"空間"</string> <string name="screen_home_tab_spaces">"空間"</string>
<string name="screen_invites_decline_chat_message">"您確定您想要拒絕加入 %1$s 的邀請嗎?"</string> <string name="screen_invites_decline_chat_message">"您確定您想要拒絕加入 %1$s 的邀請嗎?"</string>
<string name="screen_invites_decline_chat_title">"拒絕邀請"</string> <string name="screen_invites_decline_chat_title">"拒絕邀請"</string>
@@ -36,6 +36,8 @@
<string name="screen_roomlist_filter_invites_empty_state_title">"您沒有任何擱置中的邀請。"</string> <string name="screen_roomlist_filter_invites_empty_state_title">"您沒有任何擱置中的邀請。"</string>
<string name="screen_roomlist_filter_low_priority">"低優先度"</string> <string name="screen_roomlist_filter_low_priority">"低優先度"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"您尚無任何低優先程度聊天"</string> <string name="screen_roomlist_filter_low_priority_empty_state_title">"您尚無任何低優先程度聊天"</string>
<string name="screen_roomlist_filter_mention">"提及"</string>
<string name="screen_roomlist_filter_mention_empty_state_title">"您沒有任何未讀提及"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"您可以取消選取篩選條件以檢視其他聊天"</string> <string name="screen_roomlist_filter_mixed_empty_state_subtitle">"您可以取消選取篩選條件以檢視其他聊天"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"您並無此選擇的聊天"</string> <string name="screen_roomlist_filter_mixed_empty_state_title">"您並無此選擇的聊天"</string>
<string name="screen_roomlist_filter_people">"夥伴"</string> <string name="screen_roomlist_filter_people">"夥伴"</string>
@@ -46,7 +48,7 @@
<string name="screen_roomlist_filter_unreads_empty_state_title">"恭喜! <string name="screen_roomlist_filter_unreads_empty_state_title">"恭喜!
您沒有任何未讀的訊息!"</string> 您沒有任何未讀的訊息!"</string>
<string name="screen_roomlist_knock_event_sent_description">"已傳送加入請求"</string> <string name="screen_roomlist_knock_event_sent_description">"已傳送加入請求"</string>
<string name="screen_roomlist_main_space_title">"所有聊天室"</string> <string name="screen_roomlist_main_space_title">"聊天"</string>
<string name="screen_roomlist_mark_as_read">"標為已讀"</string> <string name="screen_roomlist_mark_as_read">"標為已讀"</string>
<string name="screen_roomlist_mark_as_unread">"標為未讀"</string> <string name="screen_roomlist_mark_as_unread">"標為未讀"</string>
<string name="screen_roomlist_tombstoned_room_description">"此聊天室已升級"</string> <string name="screen_roomlist_tombstoned_room_description">"此聊天室已升級"</string>
@@ -37,7 +37,7 @@
<string name="screen_roomlist_filter_low_priority">"低优先级"</string> <string name="screen_roomlist_filter_low_priority">"低优先级"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"你暂无任何低优先级聊天"</string> <string name="screen_roomlist_filter_low_priority_empty_state_title">"你暂无任何低优先级聊天"</string>
<string name="screen_roomlist_filter_mention">"提及"</string> <string name="screen_roomlist_filter_mention">"提及"</string>
<string name="screen_roomlist_filter_mention_empty_state_title">"你没有任何未读提及"</string> <string name="screen_roomlist_filter_mention_empty_state_title">"你暂无任何未读提及"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"你可以取消选择筛选器以查看其它对话"</string> <string name="screen_roomlist_filter_mixed_empty_state_subtitle">"你可以取消选择筛选器以查看其它对话"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"你暂无适用于此选项的聊天"</string> <string name="screen_roomlist_filter_mixed_empty_state_title">"你暂无适用于此选项的聊天"</string>
<string name="screen_roomlist_filter_people">"人员"</string> <string name="screen_roomlist_filter_people">"人员"</string>
@@ -46,7 +46,7 @@
<string name="screen_roomlist_filter_rooms_empty_state_title">"你尚未进入任何房间"</string> <string name="screen_roomlist_filter_rooms_empty_state_title">"你尚未进入任何房间"</string>
<string name="screen_roomlist_filter_unreads">"未读"</string> <string name="screen_roomlist_filter_unreads">"未读"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"恭喜! <string name="screen_roomlist_filter_unreads_empty_state_title">"恭喜!
没有任何未读消息!"</string> 你暂无任何未读消息!"</string>
<string name="screen_roomlist_knock_event_sent_description">"加入申请已发送"</string> <string name="screen_roomlist_knock_event_sent_description">"加入申请已发送"</string>
<string name="screen_roomlist_main_space_title">"聊天"</string> <string name="screen_roomlist_main_space_title">"聊天"</string>
<string name="screen_roomlist_mark_as_read">"设为已读"</string> <string name="screen_roomlist_mark_as_read">"设为已读"</string>
@@ -14,4 +14,5 @@
<string name="search_section_messages">Messages</string> <string name="search_section_messages">Messages</string>
<string name="search_no_results_title">No results</string> <string name="search_no_results_title">No results</string>
<string name="search_no_results_subtitle">There are no results for “%1$s”. Try a new search term.</string> <string name="search_no_results_subtitle">There are no results for “%1$s”. Try a new search term.</string>
<string name="screen_search_recent_searches_section_title">Recent searches</string>
</resources> </resources>
@@ -220,4 +220,30 @@ class CurrentUserWithNeighborsBuilderTest {
) )
) )
} }
@Test
fun `other accounts use the data from db, including the avatar thumbnail`() {
val sut = CurrentUserWithNeighborsBuilder()
val matrixUser = aMatrixUser(id = A_USER_ID.value)
val list = listOf(
aSessionData(
sessionId = A_USER_ID.value,
),
aSessionData(
sessionId = A_USER_ID_2.value,
userDisplayName = "Carol",
userAvatarUrl = "avatarUrl",
userAvatarData = "avatarData",
),
)
val result = sut.build(matrixUser, list)
assertThat(result[0]).isEqualTo(
MatrixUser(
userId = A_USER_ID_2,
displayName = "Carol",
avatarUrl = "avatarUrl",
avatarThumbnail = "avatarData",
)
)
}
} }
@@ -81,7 +81,7 @@ class HomePresenterTest {
val presenter = createHomePresenter( val presenter = createHomePresenter(
rageshakeFeatureAvailability = { flowOf(true) }, rageshakeFeatureAvailability = { flowOf(true) },
sessionStore = InMemorySessionStore( sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> }, updateUserProfileResult = { _, _, _, _ -> },
), ),
) )
presenter.test { presenter.test {
@@ -98,7 +98,7 @@ class HomePresenterTest {
val presenter = createHomePresenter( val presenter = createHomePresenter(
indicatorService = indicatorService, indicatorService = indicatorService,
sessionStore = InMemorySessionStore( sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> }, updateUserProfileResult = { _, _, _, _ -> },
), ),
) )
presenter.test { presenter.test {
@@ -120,7 +120,7 @@ class HomePresenterTest {
val presenter = createHomePresenter( val presenter = createHomePresenter(
client = matrixClient, client = matrixClient,
sessionStore = InMemorySessionStore( sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> }, updateUserProfileResult = { _, _, _, _ -> },
), ),
) )
presenter.test { presenter.test {
@@ -134,7 +134,7 @@ class HomePresenterTest {
fun `present - NavigationBar change`() = runTest { fun `present - NavigationBar change`() = runTest {
val presenter = createHomePresenter( val presenter = createHomePresenter(
sessionStore = InMemorySessionStore( sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> }, updateUserProfileResult = { _, _, _, _ -> },
), ),
) )
presenter.test { presenter.test {
@@ -91,7 +91,6 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
class RoomListPresenterTest { class RoomListPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@@ -11,6 +11,9 @@ package io.element.android.features.home.impl.search
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory
import io.element.android.features.home.impl.search.history.FakeSearchHistoryStore
import io.element.android.features.home.impl.search.history.SearchHistoryResult
import io.element.android.features.home.impl.search.history.SearchHistoryStore
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
@@ -28,6 +31,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListFilter.Joined
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter.NormalizedMatchRoomName import io.element.android.libraries.matrix.api.roomlist.RoomListFilter.NormalizedMatchRoomName
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.search.MessageSearchService import io.element.android.libraries.matrix.api.search.MessageSearchService
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser 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.room.aRoomSummary
@@ -41,12 +45,12 @@ import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import java.util.Optional
class GlobalSearchPresenterTest { class GlobalSearchPresenterTest {
@Test @Test
@@ -60,6 +64,8 @@ class GlobalSearchPresenterTest {
assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS) assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS)
assertThat(state.results).isEqualTo(AsyncData.Uninitialized) assertThat(state.results).isEqualTo(AsyncData.Uninitialized)
} }
cancelAndConsumeRemainingEvents()
} }
} }
@@ -85,6 +91,9 @@ class GlobalSearchPresenterTest {
fun `present - toggle search visibility`() = runTest { fun `present - toggle search visibility`() = runTest {
val presenter = createGlobalSearchPresenter() val presenter = createGlobalSearchPresenter()
presenter.test { presenter.test {
// Skip the initial state
skipItems(1)
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse() assertThat(state.isSearchActive).isFalse()
state.eventSink(GlobalSearchEvent.ToggleSearchVisibility) state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
@@ -103,6 +112,9 @@ class GlobalSearchPresenterTest {
fun `present - update target`() = runTest { fun `present - update target`() = runTest {
val presenter = createGlobalSearchPresenter() val presenter = createGlobalSearchPresenter()
presenter.test { presenter.test {
// Skip initial item
skipItems(1)
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS) assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS)
state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
@@ -194,7 +206,7 @@ class GlobalSearchPresenterTest {
fun `present - message search results are mapped when the message search emits`() = runTest { fun `present - message search results are mapped when the message search emits`() = runTest {
val messageSearch = FakeMessageSearch() val messageSearch = FakeMessageSearch()
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) } getRoomInfoLambda = { Result.success(aRoomInfo()) }
} }
val presenter = createGlobalSearchPresenter( val presenter = createGlobalSearchPresenter(
messageSearchService = FakeMessageSearchService(messageSearch), messageSearchService = FakeMessageSearchService(messageSearch),
@@ -221,7 +233,7 @@ class GlobalSearchPresenterTest {
fun `present - message search displays the uninitialized state after removing the query`() = runTest { fun `present - message search displays the uninitialized state after removing the query`() = runTest {
val messageSearch = FakeMessageSearch() val messageSearch = FakeMessageSearch()
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) } getRoomInfoLambda = { Result.success(aRoomInfo()) }
} }
val presenter = createGlobalSearchPresenter( val presenter = createGlobalSearchPresenter(
messageSearchService = FakeMessageSearchService(messageSearch), messageSearchService = FakeMessageSearchService(messageSearch),
@@ -256,7 +268,7 @@ class GlobalSearchPresenterTest {
fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest { fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest {
val messageSearch = FakeMessageSearch() val messageSearch = FakeMessageSearch()
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) } getRoomInfoLambda = { Result.success(aRoomInfo()) }
} }
val presenter = createGlobalSearchPresenter( val presenter = createGlobalSearchPresenter(
messageSearchService = FakeMessageSearchService(messageSearch), messageSearchService = FakeMessageSearchService(messageSearch),
@@ -279,6 +291,82 @@ class GlobalSearchPresenterTest {
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@Test
fun `present - SaveQueryToHistory stores the current query`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore()
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
initialState.queryState.edit { append("A query") }
testScheduler.advanceUntilIdle()
initialState.eventSink(GlobalSearchEvent.SaveQueryToHistory)
testScheduler.advanceUntilIdle()
assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("A query"))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SaveQueryToHistory ignores a blank query`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore()
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(GlobalSearchEvent.SaveQueryToHistory)
testScheduler.advanceUntilIdle()
assertThat(searchHistoryStore.history.first()).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SaveRoomToHistory stores the room`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore()
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(GlobalSearchEvent.SaveRoomToHistory(A_ROOM_ID))
testScheduler.advanceUntilIdle()
assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Room(A_ROOM_ID))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SearchHistoryResultSelected does nothing for a room result`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore()
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
skipItems(1)
initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Room(A_ROOM_ID, aRoomInfo())))
testScheduler.advanceUntilIdle()
ensureAllEventsConsumed()
}
}
@Test
fun `present - SearchHistoryResultSelected changes the search query when a previous search is selected`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore()
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
// Skip loading search results state
skipItems(1)
initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Query("Test")))
assertThat(awaitItem().queryState.text.toString()).isEqualTo("Test")
assertThat(awaitItem().results.isLoading()).isTrue()
}
}
} }
private fun TestScope.createGlobalSearchPresenter( private fun TestScope.createGlobalSearchPresenter(
@@ -290,6 +378,7 @@ private fun TestScope.createGlobalSearchPresenter(
fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(), fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(),
permalinkParser: PermalinkParser = FakePermalinkParser(), permalinkParser: PermalinkParser = FakePermalinkParser(),
matrixClient: MatrixClient = FakeMatrixClient(), matrixClient: MatrixClient = FakeMatrixClient(),
searchHistoryStore: SearchHistoryStore = FakeSearchHistoryStore(),
): GlobalSearchPresenter { ): GlobalSearchPresenter {
return GlobalSearchPresenter( return GlobalSearchPresenter(
roomListSearchDataSourceFactory = object : RoomListSearchDataSource.Factory { roomListSearchDataSourceFactory = object : RoomListSearchDataSource.Factory {
@@ -310,5 +399,6 @@ private fun TestScope.createGlobalSearchPresenter(
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
coroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers = testCoroutineDispatchers(),
matrixClient = matrixClient, matrixClient = matrixClient,
searchHistoryStore = searchHistoryStore,
) )
} }
@@ -0,0 +1,275 @@
/*
* 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(ExperimentalTestApi::class)
package io.element.android.features.home.impl.search
import androidx.activity.ComponentActivity
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.model.aRoomListRoomSummary
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.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParams
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
class GlobalSearchViewTest : RobolectricTest() {
@Test
fun `back button - emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
pressBack()
eventsRecorder.assertSingle(GlobalSearchEvent.ToggleSearchVisibility)
}
@Test
fun `back key - with search active toggles the search`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
pressBackKey()
// Advance time to let the event be processed, as the search toggle might have some delay (e.g. for the animation)
mainClock.advanceTimeBy(1)
eventsRecorder.assertSingle(GlobalSearchEvent.ToggleSearchVisibility)
}
@Test
fun `clear query button - emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState("Query"),
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
val clearSearch = activity!!.getString(CommonStrings.a11y_clear_search_field)
onNodeWithContentDescription(clearSearch).performClick()
eventsRecorder.assertSingle(GlobalSearchEvent.ClearQuery)
}
@Test
fun `search target selector - clicking on 'Chats' emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
clickOn(R.string.search_section_chats)
eventsRecorder.assertSingle(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS))
}
@Test
fun `search target selector - clicking on 'Messages' emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
clickOn(R.string.search_section_messages)
eventsRecorder.assertSingle(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
}
@Test
fun `clicking on a room result - invokes the callback and saves it to the history`() = runAndroidComposeUiTest {
val roomId = RoomId("!aRoom:server.org")
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
val onSelectSearchResult = EnsureCalledOnceWithTwoParams<RoomId, EventId?>(roomId, null)
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Success(
GlobalSearchResults.RoomListResults(
persistentListOf(
aRoomListRoomSummary(id = roomId.value, name = "A room result"),
)
)
),
eventSink = eventsRecorder,
),
onSelectSearchResult = onSelectSearchResult,
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithText("A room result").performClick()
onSelectSearchResult.assertSuccess()
eventsRecorder.assertSingle(GlobalSearchEvent.SaveRoomToHistory(roomId))
}
@Test
fun `clicking on a message result - invokes the callback and saves it to the history`() = runAndroidComposeUiTest {
val roomId = RoomId("!aMessageRoom:server.org")
val eventId = EventId("\$anEvent:server.org")
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
val onSelectSearchResult = EnsureCalledOnceWithTwoParams<RoomId, EventId?>(roomId, eventId)
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(
GlobalSearchResults.MessageSearchResults(
persistentListOf(
MessageSearchResultItem.Message(
messageSearchResult = aMessageSearchResult(roomId = roomId, eventId = eventId),
body = "A message body",
roomInfo = aRoomInfo(id = roomId, name = "A message room"),
formattedTimestamp = "12:00",
),
)
)
),
eventSink = eventsRecorder,
),
onSelectSearchResult = onSelectSearchResult,
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithText("A message room").performClick()
onSelectSearchResult.assertSuccess()
eventsRecorder.assertSingle(GlobalSearchEvent.SaveRoomToHistory(roomId))
}
@Test
fun `clicking on a history query result - emits the expected Event`() = runAndroidComposeUiTest {
val queryResult = SearchHistoryResultItem.Query("Recent term")
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState(),
results = AsyncData.Uninitialized,
history = AsyncData.Success(persistentListOf(queryResult)),
eventSink = eventsRecorder,
),
// A query history result must not trigger the search result callback
onSelectSearchResult = EnsureNeverCalledWithTwoParams(),
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithText("Recent term").performClick()
eventsRecorder.assertSingle(GlobalSearchEvent.SearchHistoryResultSelected(queryResult))
}
@Test
fun `clicking on a history room result - invokes the callback and emits the expected Events`() = runAndroidComposeUiTest {
val roomId = RoomId("!aHistoryRoom:server.org")
val roomResult = SearchHistoryResultItem.Room(
roomId = roomId,
roomInfo = aRoomInfo(id = roomId, name = "A history room"),
)
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
val onSelectSearchResult = EnsureCalledOnceWithTwoParams<RoomId, EventId?>(roomId, null)
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState(),
results = AsyncData.Uninitialized,
history = AsyncData.Success(persistentListOf(roomResult)),
eventSink = eventsRecorder,
),
onSelectSearchResult = onSelectSearchResult,
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithText("A history room").performClick()
onSelectSearchResult.assertSuccess()
eventsRecorder.assertList(
listOf(
GlobalSearchEvent.SaveRoomToHistory(roomId),
GlobalSearchEvent.SearchHistoryResultSelected(roomResult),
)
)
}
@Test
fun `with a query and uninitialized results - displays the start searching placeholder`() = runAndroidComposeUiTest {
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState("Query"),
results = AsyncData.Uninitialized,
),
)
val startSearching = activity!!.getString(R.string.start_searching_title)
onNodeWithText(startSearching).assertIsDisplayed()
}
@Test
fun `with empty results - displays the no results placeholder`() = runAndroidComposeUiTest {
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.RoomListResults(persistentListOf())),
),
)
val noResults = activity!!.getString(R.string.search_no_results_title)
onNodeWithText(noResults).assertIsDisplayed()
}
private fun AndroidComposeUiTest<ComponentActivity>.setGlobalSearchView(
state: GlobalSearchState,
onSelectSearchResult: (RoomId, EventId?) -> Unit = EnsureNeverCalledWithTwoParams(),
) {
setContent {
GlobalSearchView(
state = state,
onSelectSearchResult = onSelectSearchResult,
)
}
}
}
@@ -0,0 +1,121 @@
/*
* 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.history
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Test
class DefaultSearchHistoryStoreTest {
@Test
fun `history is empty when nothing has been persisted`() = runTest {
val store = createStore()
assertThat(store.history.first()).isEmpty()
}
@Test
fun `add prepends new entries so the most recent is first`() = runTest {
val store = createStore()
store.add(SearchHistoryResult.Query("first"))
store.add(SearchHistoryResult.Query("second"))
assertThat(store.history.first()).containsExactly(
SearchHistoryResult.Query("second"),
SearchHistoryResult.Query("first"),
).inOrder()
}
@Test
fun `add supports both queries and rooms`() = runTest {
val store = createStore()
store.add(SearchHistoryResult.Query("term"))
store.add(SearchHistoryResult.Room(A_ROOM_ID))
assertThat(store.history.first()).containsExactly(
SearchHistoryResult.Room(A_ROOM_ID),
SearchHistoryResult.Query("term"),
).inOrder()
}
@Test
fun `adding an existing entry moves it to the front without duplicating`() = runTest {
val store = createStore()
store.add(SearchHistoryResult.Query("a"))
store.add(SearchHistoryResult.Query("b"))
store.add(SearchHistoryResult.Query("a"))
assertThat(store.history.first()).containsExactly(
SearchHistoryResult.Query("a"),
SearchHistoryResult.Query("b"),
).inOrder()
}
@Test
fun `history is capped to the maximum size, dropping the oldest entries`() = runTest {
val store = createStore()
repeat(MAX_SEARCH_HISTORY_SIZE + 10) { index ->
store.add(SearchHistoryResult.Query("query$index"))
}
val history = store.history.first()
assertThat(history).hasSize(MAX_SEARCH_HISTORY_SIZE)
// Newest entry is first...
assertThat(history.first()).isEqualTo(SearchHistoryResult.Query("query${MAX_SEARCH_HISTORY_SIZE + 9}"))
// ...and the oldest ones have been dropped.
assertThat(history).doesNotContain(SearchHistoryResult.Query("query0"))
assertThat(history.last()).isEqualTo(SearchHistoryResult.Query("query10"))
}
@Test
fun `persisted history is deserialized back into SearchHistoryResult items`() = runTest {
val fileStore = FakeSearchHistoryFileStore()
createStore(fileStore).apply {
add(SearchHistoryResult.Query("persisted term"))
add(SearchHistoryResult.Room(A_ROOM_ID))
}
// A brand new store backed by the same file reads the same content back.
val reloaded = createStore(fileStore)
assertThat(reloaded.history.first()).containsExactly(
SearchHistoryResult.Room(A_ROOM_ID),
SearchHistoryResult.Query("persisted term"),
).inOrder()
}
@Test
fun `clear removes every entry and deletes the persisted content`() = runTest {
val fileStore = FakeSearchHistoryFileStore()
val store = createStore(fileStore)
store.add(SearchHistoryResult.Query("term"))
store.clear()
assertThat(store.history.first()).isEmpty()
assertThat(fileStore.content).isNull()
}
@Test
fun `corrupted content is ignored and results in an empty history`() = runTest {
val fileStore = FakeSearchHistoryFileStore(initialContent = "not valid json".toByteArray())
val store = createStore(fileStore)
assertThat(store.history.first()).isEmpty()
// The store recovers and can still add new entries.
store.add(SearchHistoryResult.Room(RoomId("!recovered:domain")))
assertThat(store.history.first()).containsExactly(SearchHistoryResult.Room(RoomId("!recovered:domain")))
}
private fun TestScope.createStore(
fileStore: SearchHistoryFileStore = FakeSearchHistoryFileStore(),
) = DefaultSearchHistoryStore(
fileStore = fileStore,
dispatchers = testCoroutineDispatchers(),
jsonProvider = { Json { ignoreUnknownKeys = true } }
)
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
/**
* In-memory [SearchHistoryFileStore] used to test [DefaultSearchHistoryStore] without the Android Keystore.
*/
class FakeSearchHistoryFileStore(
initialContent: ByteArray? = null,
) : SearchHistoryFileStore {
var content: ByteArray? = initialContent
private set
var writeCount = 0
private set
override fun read(): ByteArray? = content
override fun write(bytes: ByteArray) {
content = bytes
writeCount++
}
override fun delete(): Boolean {
content = null
return true
}
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeSearchHistoryStore : SearchHistoryStore {
private val state = MutableStateFlow<List<SearchHistoryResult>>(emptyList())
override val history: Flow<List<SearchHistoryResult>> = state
override suspend fun add(result: SearchHistoryResult) {
state.value = buildList {
add(result)
addAll(state.value.filterNot { it == result })
}.take(MAX_SEARCH_HISTORY_SIZE)
}
override suspend fun clear() {
state.value = emptyList()
}
}
@@ -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.home.impl.search.history
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import kotlinx.serialization.json.Json
import org.junit.Test
class SearchHistoryResultSerializationTest {
private val json = Json
@Test
fun `Query round-trips through serialization`() {
val original: SearchHistoryResult = SearchHistoryResult.Query("hello world")
val decoded = json.decodeFromString<SearchHistoryResult>(json.encodeToString(original))
assertThat(decoded).isEqualTo(original)
}
@Test
fun `Room round-trips through serialization`() {
val original: SearchHistoryResult = SearchHistoryResult.Room(A_ROOM_ID)
val decoded = json.decodeFromString<SearchHistoryResult>(json.encodeToString(original))
assertThat(decoded).isEqualTo(original)
}
@Test
fun `RoomId is serialized as its raw string value`() {
val encoded = json.encodeToString<SearchHistoryResult>(SearchHistoryResult.Room(A_ROOM_ID))
assertThat(encoded).contains(A_ROOM_ID.value)
}
@Test
fun `a mixed list round-trips preserving order`() {
val original = listOf(
SearchHistoryResult.Query("first"),
SearchHistoryResult.Room(A_ROOM_ID),
SearchHistoryResult.Query("second"),
SearchHistoryResult.Room(A_ROOM_ID_2),
)
val decoded = json.decodeFromString<List<SearchHistoryResult>>(json.encodeToString(original))
assertThat(decoded).containsExactlyElementsIn(original).inOrder()
}
}
@@ -59,7 +59,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@Suppress("LargeClass")
internal class DefaultInvitePeoplePresenterTest { internal class DefaultInvitePeoplePresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@@ -107,6 +107,7 @@ sealed interface LoadedDetails {
) : LoadedDetails ) : LoadedDetails
} }
@Immutable
sealed interface JoinAuthorisationStatus { sealed interface JoinAuthorisationStatus {
data object None : JoinAuthorisationStatus data object None : JoinAuthorisationStatus
data class IsInvited(val inviteData: InviteData, val inviteSender: InviteSender?) : JoinAuthorisationStatus data class IsInvited(val inviteData: InviteData, val inviteSender: InviteSender?) : JoinAuthorisationStatus
@@ -40,8 +40,8 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.invite.api.InviteData import io.element.android.features.invite.api.InviteData
import io.element.android.libraries.designsystem.atomic.atoms.CopiableTextAtom
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
@@ -514,7 +514,7 @@ private fun IncompleteContent(
title = { title = {
when (roomIdOrAlias) { when (roomIdOrAlias) {
is RoomIdOrAlias.Alias -> { is RoomIdOrAlias.Alias -> {
RoomPreviewAliasAtom(roomIdOrAlias.identifier) CopiableTextAtom(roomIdOrAlias.identifier)
} }
is RoomIdOrAlias.Id -> { is RoomIdOrAlias.Id -> {
PlaceholderAtom(width = 200.dp, height = 22.dp) PlaceholderAtom(width = 200.dp, height = 22.dp)
@@ -567,7 +567,7 @@ private fun DefaultLoadedContent(
}, },
subtitle = { subtitle = {
if (contentState.alias != null) { if (contentState.alias != null) {
RoomPreviewAliasAtom(contentState.alias.value) CopiableTextAtom(contentState.alias.value)
} }
if (contentState.details is LoadedDetails.Space) { if (contentState.details is LoadedDetails.Space) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -27,7 +27,7 @@
<string name="screen_join_room_loading_alert_title">"Náhled této místnosti jsme nemohli zobrazit"</string> <string name="screen_join_room_loading_alert_title">"Náhled této místnosti jsme nemohli zobrazit"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s zatím nepodporuje prostory. Prostory můžete používat na webu."</string> <string name="screen_join_room_space_not_supported_description">"%1$s zatím nepodporuje prostory. Prostory můžete používat na webu."</string>
<string name="screen_join_room_space_not_supported_title">"Prostory zatím nejsou podporovány"</string> <string name="screen_join_room_space_not_supported_title">"Prostory zatím nejsou podporovány"</string>
<string name="screen_join_room_subtitle_knock">"Klikněte na tlačítko níže a správce místnosti bude informován. Po schválení se budete moci připojit ke konverzaci."</string> <string name="screen_join_room_subtitle_knock">"Klepněte na tlačítko níže a správce místnosti bude informován. Po schválení se budete moci připojit ke konverzaci."</string>
<string name="screen_join_room_subtitle_no_preview">"Pro zobrazení historie zpráv musíte být členem této místnosti."</string> <string name="screen_join_room_subtitle_no_preview">"Pro zobrazení historie zpráv musíte být členem této místnosti."</string>
<string name="screen_join_room_title_knock">"Chcete se připojit k této místnosti?"</string> <string name="screen_join_room_title_knock">"Chcete se připojit k této místnosti?"</string>
<string name="screen_join_room_title_no_preview">"Náhled není k dispozici"</string> <string name="screen_join_room_title_no_preview">"Náhled není k dispozici"</string>
@@ -27,7 +27,7 @@
<string name="screen_join_room_loading_alert_title">"Emme voineet näyttää tämän huoneen esikatselua"</string> <string name="screen_join_room_loading_alert_title">"Emme voineet näyttää tämän huoneen esikatselua"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s ei tue vielä tiloja. Voit käyttää tiloja selainversiolla."</string> <string name="screen_join_room_space_not_supported_description">"%1$s ei tue vielä tiloja. Voit käyttää tiloja selainversiolla."</string>
<string name="screen_join_room_space_not_supported_title">"Tiloja ei vielä tueta"</string> <string name="screen_join_room_space_not_supported_title">"Tiloja ei vielä tueta"</string>
<string name="screen_join_room_subtitle_knock">"Paina alla olevaa nappia ja huoneen ylläpitäjä saa ilmoituksen. Voit liittyä keskusteluun kun pyyntösi on hyväksytty."</string> <string name="screen_join_room_subtitle_knock">"Paina alla olevaa nappia ja keskustelun ylläpitäjä saa ilmoituksen. Voit liittyä keskusteluun kun pyyntösi on hyväksytty."</string>
<string name="screen_join_room_subtitle_no_preview">"Sinun on oltava tämän huoneen jäsen, jotta voit nähdä viestihistorian."</string> <string name="screen_join_room_subtitle_no_preview">"Sinun on oltava tämän huoneen jäsen, jotta voit nähdä viestihistorian."</string>
<string name="screen_join_room_title_knock">"Haluatko liittyä tähän huoneeseen?"</string> <string name="screen_join_room_title_knock">"Haluatko liittyä tähän huoneeseen?"</string>
<string name="screen_join_room_title_no_preview">"Esikatselu ei ole saatavilla"</string> <string name="screen_join_room_title_no_preview">"Esikatselu ei ole saatavilla"</string>
@@ -27,7 +27,7 @@
<string name="screen_join_room_loading_alert_title">"我們無法顯示此聊天室的預覽"</string> <string name="screen_join_room_loading_alert_title">"我們無法顯示此聊天室的預覽"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s 尚未支援空間。您可以在網頁上存取空間。"</string> <string name="screen_join_room_space_not_supported_description">"%1$s 尚未支援空間。您可以在網頁上存取空間。"</string>
<string name="screen_join_room_space_not_supported_title">"尚未支援空間"</string> <string name="screen_join_room_space_not_supported_title">"尚未支援空間"</string>
<string name="screen_join_room_subtitle_knock">"點選下方按鈕將會通知聊天試管里員。一旦核准,您就可以加入對話。"</string> <string name="screen_join_room_subtitle_knock">"輕點下方的按鈕,系統將通知聊天室管理員。經核准後,您即可加入對話。"</string>
<string name="screen_join_room_subtitle_no_preview">"您必須是此聊天室的成員才能檢視訊息歷史紀錄。"</string> <string name="screen_join_room_subtitle_no_preview">"您必須是此聊天室的成員才能檢視訊息歷史紀錄。"</string>
<string name="screen_join_room_title_knock">"想要加入此聊天室?"</string> <string name="screen_join_room_title_knock">"想要加入此聊天室?"</string>
<string name="screen_join_room_title_no_preview">"無法使用預覽"</string> <string name="screen_join_room_title_no_preview">"無法使用預覽"</string>
@@ -70,7 +70,6 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.util.Optional import java.util.Optional
@Suppress("LargeClass")
class JoinRoomPresenterTest { class JoinRoomPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@@ -10,20 +10,12 @@ package io.element.android.features.location.impl.common
import android.Manifest import android.Manifest
import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.map.GestureOptions
import org.maplibre.compose.map.MapOptions
import org.maplibre.compose.map.RenderOptions
import org.maplibre.spatialk.geojson.Position import org.maplibre.spatialk.geojson.Position
/** /**
* Common configuration values for the map. * Common configuration values for the map.
*/ */
object MapDefaults { object MapDefaults {
val options = MapOptions(
renderOptions = RenderOptions.Standard,
gestureOptions = GestureOptions.Standard,
)
val defaultCameraPosition = CameraPosition( val defaultCameraPosition = CameraPosition(
target = Position(0.0, 0.0), target = Position(0.0, 0.0),
zoom = 0.0, zoom = 0.0,
@@ -24,13 +24,13 @@ import org.maplibre.compose.expressions.dsl.feature
import org.maplibre.compose.expressions.dsl.image import org.maplibre.compose.expressions.dsl.image
import org.maplibre.compose.expressions.dsl.not import org.maplibre.compose.expressions.dsl.not
import org.maplibre.compose.expressions.value.SymbolAnchor import org.maplibre.compose.expressions.value.SymbolAnchor
import org.maplibre.compose.interaction.ClickResult
import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.layers.CircleLayer
import org.maplibre.compose.layers.SymbolLayer import org.maplibre.compose.layers.SymbolLayer
import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.GeoJsonData
import org.maplibre.compose.sources.GeoJsonOptions import org.maplibre.compose.sources.GeoJsonOptions
import org.maplibre.compose.sources.GeoJsonSource import org.maplibre.compose.sources.GeoJsonSource
import org.maplibre.compose.sources.rememberGeoJsonSource import org.maplibre.compose.sources.rememberGeoJsonSource
import org.maplibre.compose.util.ClickResult
import org.maplibre.spatialk.geojson.Feature import org.maplibre.spatialk.geojson.Feature
import org.maplibre.spatialk.geojson.FeatureCollection import org.maplibre.spatialk.geojson.FeatureCollection
import org.maplibre.spatialk.geojson.Point import org.maplibre.spatialk.geojson.Point
@@ -7,6 +7,7 @@
package io.element.android.features.location.impl.common.ui package io.element.android.features.location.impl.common.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
@@ -23,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
@@ -41,27 +43,22 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max 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
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import org.maplibre.compose.camera.CameraState import org.maplibre.compose.interaction.MapInteractions
import org.maplibre.compose.camera.rememberCameraState import org.maplibre.compose.map.MapState
import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MapUiOptions
import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.map.RenderOptions
import org.maplibre.compose.overlay.ExpandingAttributionButton import org.maplibre.compose.overlay.ExpandingAttributionButton
import org.maplibre.compose.overlay.MapOverlay
import org.maplibre.compose.overlay.MaplibreLogo import org.maplibre.compose.overlay.MaplibreLogo
import org.maplibre.compose.style.BaseStyle
import org.maplibre.compose.util.MaplibreComposable
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
@@ -72,43 +69,45 @@ import kotlin.math.roundToInt
* - Updating camera position padding based on sheet height * - Updating camera position padding based on sheet height
* - Rendering the MaplibreMap with proper ornament positioning * - Rendering the MaplibreMap with proper ornament positioning
* *
* @param customMapTilerConfig Optional custom style URL for the map * @param mapState The map state (camera, style and layer content) for the map. `null` in
* inspection/preview mode, where the native map cannot initialize and a placeholder is shown.
* @param isReady Used to gate rendering until the map should be displayed
* @param modifier Modifier for the root layout * @param modifier Modifier for the root layout
* @param scaffoldState State for the bottom sheet scaffold * @param scaffoldState State for the bottom sheet scaffold
* @param cameraState The camera state for the map * @param renderOptions The options to configure the map rendering
* @param mapOptions The options to configure the map * @param interactions The gestures and camera interactions allowed on the map
* @param uiOptions The map UI options (gestures, scrolling, keys, first-frame behaviour)
* @param sheetPeekHeight The height of the sheet when collapsed * @param sheetPeekHeight The height of the sheet when collapsed
* @param sheetDragHandle Optional drag handle for the sheet * @param sheetDragHandle Optional drag handle for the sheet
* @param sheetSwipeEnabled Whether the sheet can be swiped * @param sheetSwipeEnabled Whether the sheet can be swiped
* @param topBar The top app bar content * @param topBar The top app bar content
* @param snackbarHost The snackbar host content * @param snackbarHost The snackbar host content
* @param sheetContent The content to display in the bottom sheet * @param sheetContent The content to display in the bottom sheet
* @param mapContent The content inside the MaplibreMap (layers, location pucks, etc.) * @param overlay Content to overlay on top of the map (FAB, pin icons, etc.)
* @param overlayContent Content to overlay on top of the map (FAB, pin icons, etc.)
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MapBottomSheetScaffold( fun MapBottomSheetScaffold(
customMapTilerConfig: AsyncData<MapTilerConfig?>, mapState: MapState?,
isReady: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState( scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberBottomSheetState( bottomSheetState = rememberBottomSheetState(
initialValue = SheetValue.PartiallyExpanded, initialValue = SheetValue.PartiallyExpanded,
) )
), ),
cameraState: CameraState = rememberCameraState(), renderOptions: RenderOptions = RenderOptions.Standard,
mapOptions: MapOptions = MapDefaults.options, interactions: MapInteractions = MapInteractions.Standard,
uiOptions: MapUiOptions = MapUiOptions.Standard,
sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight, sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight,
sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
sheetSwipeEnabled: Boolean = true, sheetSwipeEnabled: Boolean = true,
topBar: (@Composable () -> Unit)? = null, topBar: (@Composable () -> Unit)? = null,
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
sheetContent: @Composable ColumnScope.(PaddingValues) -> Unit = {}, sheetContent: @Composable ColumnScope.() -> Unit = {},
mapContent: @Composable @MaplibreComposable () -> Unit = {}, overlay: @Composable BoxScope.() -> Unit = {},
overlayContent: @Composable BoxScope.(sheetPadding: PaddingValues) -> Unit = {},
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) val windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
BoxWithConstraints(modifier = modifier.windowInsetsPadding(windowInsets)) { BoxWithConstraints(modifier = modifier.windowInsetsPadding(windowInsets)) {
val layoutHeightPx by rememberUpdatedState(constraints.maxHeight) val layoutHeightPx by rememberUpdatedState(constraints.maxHeight)
@@ -126,7 +125,7 @@ fun MapBottomSheetScaffold(
sheetContent = { sheetContent = {
val maxContentHeight = (layoutHeightPx * 0.5f).roundToInt().toDp() val maxContentHeight = (layoutHeightPx * 0.5f).roundToInt().toDp()
Column(modifier = Modifier.heightIn(max = maxContentHeight)) { Column(modifier = Modifier.heightIn(max = maxContentHeight)) {
sheetContent(sheetPadding) sheetContent()
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
} }
}, },
@@ -136,39 +135,57 @@ fun MapBottomSheetScaffold(
snackbarHost = snackbarHost, snackbarHost = snackbarHost,
topBar = topBar, topBar = topBar,
) { ) {
Box { Box(modifier = Modifier.fillMaxSize()) {
when (customMapTilerConfig) { when {
is AsyncData.Success -> { isReady && mapState != null -> {
MaplibreMap( MaplibreMap(
options = mapOptions, state = mapState,
cameraPadding = sheetPadding, cameraPadding = sheetPadding,
contentWindowInsets = WindowInsets(bottom = sheetPadding.calculateBottomPadding()), renderOptions = renderOptions,
baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapTilerConfig.data)), interactions = interactions,
overlay = MapOverlay { uiOptions = uiOptions,
val style = styleState overlay = {
Box(
modifier = Modifier
.fillMaxSize()
.padding(sheetPadding)
) {
Row( Row(
Modifier Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth(), .fillMaxWidth()
.padding(all = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
MaplibreLogo() MaplibreLogo()
ExpandingAttributionButton(cameraState = cameraState, styleState = style) ExpandingAttributionButton()
}
} }
}, },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
cameraState = cameraState,
content = mapContent,
) )
} }
mapState == null -> {
// Inspection/preview mode: the map cannot initialize, render a placeholder
// matching MaplibreMap's own inspection rendering.
Box(modifier = Modifier.fillMaxSize().background(Color.Gray))
}
else -> { else -> {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} }
} }
} }
overlayContent(sheetPadding) // App overlay (FABs, pins, live-location indicators). Layered above the map so it is
// present even in inspection/preview mode, where the map is a placeholder.
Box(
modifier = Modifier
.fillMaxSize()
.padding(sheetPadding)
) {
overlay()
}
} }
} }
} }
@@ -10,24 +10,23 @@ package io.element.android.features.location.impl.common.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import org.maplibre.compose.camera.CameraState import org.maplibre.compose.location.LocationMeasurement
import org.maplibre.compose.location.Location
import org.maplibre.compose.location.LocationPuck import org.maplibre.compose.location.LocationPuck
import org.maplibre.compose.location.LocationPuckColors import org.maplibre.compose.location.LocationPuckColors
import org.maplibre.compose.location.LocationPuckSizes import org.maplibre.compose.location.LocationPuckSizes
import org.maplibre.spatialk.units.Length
@Composable @Composable
fun UserLocationPuck( fun UserLocationPuck(
cameraState: CameraState, location: LocationMeasurement?,
location: Location?,
) { ) {
LocationPuck( LocationPuck(
idPrefix = "user-location", idPrefix = "user-location",
location = location, location = location,
cameraState = cameraState, // Hide the accuracy circle.
accuracyThreshold = Float.POSITIVE_INFINITY, accuracyThreshold = Length.PositiveInfinity,
showBearingAccuracy = false, // Hide the bearing (and bearing accuracy) indicator.
showBearing = false, bearing = null,
sizes = LocationPuckSizes( sizes = LocationPuckSizes(
dotRadius = 8.dp, dotRadius = 8.dp,
dotStrokeWidth = 2.dp, dotStrokeWidth = 2.dp,
@@ -39,7 +39,7 @@ class DefaultUserLocationStateFactory : UserLocationState.Factory {
.updates(locationRequest) .updates(locationRequest)
.mapNotNull { event -> .mapNotNull { event ->
when (event) { when (event) {
is LocationEvent.Fix -> event.location is LocationEvent.Update -> event.measurement
is LocationEvent.Unavailable -> null is LocationEvent.Unavailable -> null
} }
} }
@@ -8,19 +8,18 @@
package io.element.android.features.location.impl.common.userlocation package io.element.android.features.location.impl.common.userlocation
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import org.maplibre.compose.location.PositionWithAccuracy import org.maplibre.compose.location.LocationMeasurement
import org.maplibre.spatialk.geojson.Position import org.maplibre.spatialk.geojson.Position
import org.maplibre.spatialk.units.extensions.meters import org.maplibre.spatialk.units.extensions.meters
import kotlin.time.TimeSource import kotlin.time.Clock
import org.maplibre.compose.location.Location as MapLibreLocation
fun Location.asMapLibreLocation(): MapLibreLocation { fun Location.toLocationMeasurement(): LocationMeasurement {
return MapLibreLocation( return LocationMeasurement(
position = PositionWithAccuracy( position = Position(
value = Position(latitude = lat, longitude = lon), latitude = lat,
accuracy = accuracy?.toDouble()?.meters longitude = lon,
), ),
// Not relevant as not used horizontalAccuracy = accuracy?.toDouble()?.meters,
timestamp = TimeSource.Monotonic.markNow(), measuredAt = Clock.System.now(),
) )
} }
@@ -37,7 +37,7 @@ import org.maplibre.compose.location.LocationEvent
import org.maplibre.compose.location.LocationProvider import org.maplibre.compose.location.LocationProvider
import org.maplibre.compose.location.LocationRequest import org.maplibre.compose.location.LocationRequest
import org.maplibre.compose.location.LocationUnavailableReason import org.maplibre.compose.location.LocationUnavailableReason
import org.maplibre.compose.location.asMapLibreLocation import org.maplibre.compose.location.asMapLibreLocationUpdate
import org.maplibre.spatialk.units.extensions.inMeters import org.maplibre.spatialk.units.extensions.inMeters
import java.util.ServiceLoader import java.util.ServiceLoader
@@ -75,7 +75,7 @@ class PlatformLocationProvider(
val locationManager = context.getSystemService(LocationManager::class.java) val locationManager = context.getSystemService(LocationManager::class.java)
val listener = object : LocationListenerCompat { val listener = object : LocationListenerCompat {
override fun onLocationChanged(location: Location) { override fun onLocationChanged(location: Location) {
trySend(LocationEvent.Fix(location.asMapLibreLocation())) trySend(location.asMapLibreLocationUpdate())
} }
override fun onProviderDisabled(provider: String) { override fun onProviderDisabled(provider: String) {
@@ -100,7 +100,7 @@ class PlatformLocationProvider(
return return
} }
locationManager.getLastKnownLocation(provider)?.let { location -> locationManager.getLastKnownLocation(provider)?.let { location ->
trySend(LocationEvent.Fix(location.asMapLibreLocation())) trySend(location.asMapLibreLocationUpdate())
} }
val locationRequestCompat = LocationRequestCompat.Builder(request.minimumInterval.inWholeMilliseconds) val locationRequestCompat = LocationRequestCompat.Builder(request.minimumInterval.inWholeMilliseconds)
.setQuality(request.accuracy.toLocationRequestQuality()) .setQuality(request.accuracy.toLocationRequestQuality())

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