mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-07 01:19:17 +00:00
Merge branch 'develop' into feature/fga/timeline-multi-forward
This commit is contained in:
@@ -13,8 +13,8 @@
|
||||
"hostRules": [
|
||||
{
|
||||
"matchHost": "https://repo1.maven.org/maven2/",
|
||||
"concurrentRequestLimit": 10,
|
||||
"maxRequestsPerSecond": 10,
|
||||
"concurrentRequestLimit": 5,
|
||||
"maxRequestsPerSecond": 5,
|
||||
"timeout": 10000,
|
||||
}
|
||||
],
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
- name: Clone submodules
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: ⏬ Checkout with LFS
|
||||
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
name: Use JDK 21
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.ref }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
name: Use JDK 21
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
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' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
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' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
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' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
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' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
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' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: ☕️ Use JDK 21
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -57,12 +57,6 @@ echo "Deleting previous screenshots"
|
||||
echo "Record screenshots"
|
||||
./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"
|
||||
git config http.sslVerify false
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: ☕️ Use JDK 21
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
|
||||
|
||||
- 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
|
||||
run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
|
||||
@@ -100,7 +100,6 @@ jobs:
|
||||
name: tests-and-screenshot-tests-results
|
||||
path: |
|
||||
**/build/paparazzi/failures/
|
||||
**/build/roborazzi/failures/
|
||||
**/build/reports/tests/*UnitTest/
|
||||
|
||||
- name: 🚫 Modify summary on error
|
||||
@@ -114,7 +113,7 @@ jobs:
|
||||
|
||||
# https://github.com/codecov/codecov-action
|
||||
- name: ☂️ Upload coverage reports to codecov
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -7,6 +7,13 @@ appId: ${MAESTRO_APP_ID}
|
||||
- tapOn:
|
||||
id: "login-continue"
|
||||
## 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:
|
||||
maxRetries: 3
|
||||
commands:
|
||||
|
||||
@@ -2,6 +2,9 @@ appId: ${MAESTRO_APP_ID}
|
||||
---
|
||||
- tapOn:
|
||||
id: "home_screen-settings"
|
||||
- tapOn:
|
||||
id: "settings-user_profile"
|
||||
- assertVisible: "Your account"
|
||||
- tapOn: "Remove this device"
|
||||
- takeScreenshot: build/maestro/900-SignOutScreen
|
||||
- back
|
||||
|
||||
@@ -8,6 +8,12 @@ appId: ${MAESTRO_APP_ID}
|
||||
- tapOn: "Add description…"
|
||||
- inputText: "aRoomTopic"
|
||||
- tapOn: "Create"
|
||||
|
||||
# It can take ages to create room on matrix.org
|
||||
- extendedWaitUntil:
|
||||
timeout: 30000
|
||||
visible: "Invite people"
|
||||
|
||||
- takeScreenshot: build/maestro/320-createAndDeleteRoom
|
||||
- tapOn: "Search for someone"
|
||||
- inputText: ${MAESTRO_INVITEE1_MXID}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
appId: ${MAESTRO_APP_ID}
|
||||
---
|
||||
- runFlow: cleanUpRoomList.yaml
|
||||
- runFlow: searchRoomList.yaml
|
||||
#- runFlow: searchRoomList.yaml
|
||||
- takeScreenshot: build/maestro/300-RoomList
|
||||
- runFlow: timeline/timeline.yaml
|
||||
- runFlow: roomContextMenu.yaml
|
||||
|
||||
@@ -3,5 +3,5 @@ appId: ${MAESTRO_APP_ID}
|
||||
- takeScreenshot: build/maestro/520-Timeline
|
||||
- tapOn: "Add attachment"
|
||||
- tapOn: "Share location"
|
||||
- tapOn: "Share selected location"
|
||||
- tapOn: "Share my location"
|
||||
- takeScreenshot: build/maestro/521-Timeline
|
||||
|
||||
@@ -4,9 +4,12 @@ appId: ${MAESTRO_APP_ID}
|
||||
id: "home_screen-settings"
|
||||
- assertVisible: "Settings"
|
||||
- takeScreenshot: build/maestro/600-Settings
|
||||
|
||||
## Account settings
|
||||
- tapOn:
|
||||
text: "Analytics"
|
||||
- assertVisible: "Share analytics data"
|
||||
id: "settings-user_profile"
|
||||
- assertVisible: "Edit profile"
|
||||
- takeScreenshot: build/maestro/610-AccountEditProfile
|
||||
- back
|
||||
|
||||
- tapOn:
|
||||
@@ -14,16 +17,27 @@ appId: ${MAESTRO_APP_ID}
|
||||
- assertVisible: "Enable notifications on this device"
|
||||
- back
|
||||
|
||||
- tapOn:
|
||||
text: "Encryption"
|
||||
- assertVisible: "Key storage"
|
||||
- back
|
||||
|
||||
- tapOn:
|
||||
text: "Moderation & safety"
|
||||
- assertVisible: "Your appearance"
|
||||
- back
|
||||
|
||||
- tapOn:
|
||||
text: "Report a problem"
|
||||
- assertVisible: "Report a problem"
|
||||
- back
|
||||
|
||||
## App settings section
|
||||
- assertVisible: "Appearance"
|
||||
|
||||
- tapOn:
|
||||
text: "About"
|
||||
- assertVisible: "Copyright"
|
||||
- assertVisible: "Acceptable use policy"
|
||||
- assertVisible: "Privacy policy"
|
||||
text: "Media upload quality"
|
||||
- assertVisible: "Media upload quality"
|
||||
- back
|
||||
|
||||
- tapOn:
|
||||
@@ -32,14 +46,53 @@ appId: ${MAESTRO_APP_ID}
|
||||
- hideKeyboard
|
||||
- back
|
||||
|
||||
- scrollUntilVisible:
|
||||
direction: DOWN
|
||||
element:
|
||||
text: "Location sharing"
|
||||
- tapOn:
|
||||
text: "Advanced settings"
|
||||
- assertVisible: "View source"
|
||||
text: "Location sharing"
|
||||
- assertVisible: "Live location updates"
|
||||
- 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:
|
||||
text: "Developer options"
|
||||
- assertVisible: "Feature flags"
|
||||
- assertVisible: "View source"
|
||||
- back
|
||||
|
||||
- back
|
||||
|
||||
+63
@@ -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
|
||||
=============================
|
||||
|
||||
|
||||
+12
-7
@@ -21,6 +21,7 @@ import extension.allFeaturesImpl
|
||||
import extension.allLibrariesImpl
|
||||
import extension.allServicesImpl
|
||||
import extension.buildConfigFieldStr
|
||||
import extension.environmentOrGradleValue
|
||||
import extension.locales
|
||||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
@@ -88,13 +89,14 @@ android {
|
||||
storePassword = "android"
|
||||
}
|
||||
register("nightly") {
|
||||
keyAlias = System.getenv("ELEMENT_ANDROID_NIGHTLY_KEYID")
|
||||
?: project.property("signing.element.nightly.keyId") as? String?
|
||||
keyPassword = System.getenv("ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD")
|
||||
?: project.property("signing.element.nightly.keyPassword") as? String?
|
||||
storeFile = file("./signature/nightly.keystore")
|
||||
storePassword = System.getenv("ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD")
|
||||
?: project.property("signing.element.nightly.storePassword") as? String?
|
||||
keyAlias = environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_KEYID", "signing.element.nightly.keyId")
|
||||
keyPassword = environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD", "signing.element.nightly.keyPassword")
|
||||
storeFile = file(
|
||||
environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_STOREFILE", "signing.element.nightly.storeFile")
|
||||
?.ifBlank { null }
|
||||
?: "./signature/nightly.keystore"
|
||||
)
|
||||
storePassword = environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD", "signing.element.nightly.storePassword")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +342,9 @@ licensee {
|
||||
ignoreDependencies("com.github.matrix-org", "matrix-analytics-events")
|
||||
// Ignore dependency that are not third-party licenses to us.
|
||||
ignoreDependencies(groupId = "io.element.android")
|
||||
// The rustls-platform-verifier dependency does not provide a license file in their maven repo, but there are MIT and Apache licenses in
|
||||
// the source code repo: see https://github.com/rustls/rustls-platform-verifier
|
||||
ignoreDependencies(groupId = "org.rustls", artifactId = "rustls-platform-verifier")
|
||||
}
|
||||
|
||||
fun Project.configureLicensesTasks(reportingExtension: ReportingExtension) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import android.system.Os
|
||||
import androidx.startup.Initializer
|
||||
import io.element.android.features.rageshake.api.logs.createWriteToFilesConfiguration
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.tracing.TracingConfiguration
|
||||
import io.element.android.x.di.AppBindings
|
||||
@@ -41,9 +42,18 @@ class PlatformInitializer : Initializer<Unit> {
|
||||
sdkSentryDsn = appBindings.sentrySdkDsn()?.value?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
bugReporter.setCurrentTracingLogLevel(logLevel.name)
|
||||
|
||||
// Init the tracing service with the configuration so we have logging enabled as soon as possible
|
||||
platformService.init(tracingConfiguration)
|
||||
// Also set env variable for rust back trace
|
||||
Os.setenv("RUST_BACKTRACE", "1", true)
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -30,5 +30,384 @@
|
||||
<domain includeSubdomains="true">home</domain>
|
||||
<domain includeSubdomains="true">lan</domain>
|
||||
<domain includeSubdomains="true">localdomain</domain>
|
||||
|
||||
<!-- Domains below are imported from rustls-platform-verifier 0.2.0 by tools/dependencies/update_rustls_platform_verifier.py -->
|
||||
<!-- They are needed to fetch the certificate revocation lists over cleartext -->
|
||||
<domain includeSubdomains="true">abitabdv.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">abitabev.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">abitabov.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">alpiro.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">anticdv.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">antrustovsslg2r30ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">autontrustprodvtlsg2r31ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">bitcert.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">btdvtlsr35g2ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">c.cf-b.ssl.com</domain>
|
||||
<domain includeSubdomains="true">c.cf-i.ssl.com</domain>
|
||||
<domain includeSubdomains="true">c.pki.goog</domain>
|
||||
<domain includeSubdomains="true">ca1.agid.gov.it</domain>
|
||||
<domain includeSubdomains="true">cdn.d-trust-cloudcrl.net</domain>
|
||||
<domain includeSubdomains="true">cdp.disig.sk</domain>
|
||||
<domain includeSubdomains="true">cdp.elektronicznypodpis.pl</domain>
|
||||
<domain includeSubdomains="true">cdp.geotrust.com</domain>
|
||||
<domain includeSubdomains="true">cdp.rapidssl.com</domain>
|
||||
<domain includeSubdomains="true">cdp.thawte.com</domain>
|
||||
<domain includeSubdomains="true">cdp1.disig.sk</domain>
|
||||
<domain includeSubdomains="true">cdp1.pca.dfn.de</domain>
|
||||
<domain includeSubdomains="true">cdpa.digitalcertvalidation.com</domain>
|
||||
<domain includeSubdomains="true">cdpb.digitalcertvalidation.com</domain>
|
||||
<domain includeSubdomains="true">cdpc.digitalcertvalidation.com</domain>
|
||||
<domain includeSubdomains="true">cdpd.digitalcertvalidation.com</domain>
|
||||
<domain includeSubdomains="true">cdpe.digitalcertvalidation.com</domain>
|
||||
<domain includeSubdomains="true">cdpf.digitalcertvalidation.com</domain>
|
||||
<domain includeSubdomains="true">cdpg.digitalcertvalidation.com</domain>
|
||||
<domain includeSubdomains="true">cdph.digitalcertvalidation.com</domain>
|
||||
<domain includeSubdomains="true">cdpi.digitalcertvalidation.com</domain>
|
||||
<domain includeSubdomains="true">certcloud.crl.trust-provider.com</domain>
|
||||
<domain includeSubdomains="true">certdata.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">certera.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">certificates.godaddy.com</domain>
|
||||
<domain includeSubdomains="true">certum-crl.wt.trustasia.com</domain>
|
||||
<domain includeSubdomains="true">certum.crl.sheca.com</domain>
|
||||
<domain includeSubdomains="true">certumdvtlsg2e39ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">certumdvtlsg2r39ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">certumevtlsg2e39ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">certumevtlsg2r39ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">certumovtlsg2e39ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">certumovtlsg2r39ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">cfcadveccca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">cfcadvrsaca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">cfcaeveccca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">cfcaevrsaca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">cfcaoveccca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">cfcaovrsaca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">cnssldvtlsg2r36ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">cnsslovtlsg2r36ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">crl-01.r1.ica.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl-01.r1.ica.amznts.eu</domain>
|
||||
<domain includeSubdomains="true">crl-c.emsign.com</domain>
|
||||
<domain includeSubdomains="true">crl-pro.litessl.com</domain>
|
||||
<domain includeSubdomains="true">crl.anf.es</domain>
|
||||
<domain includeSubdomains="true">crl.apple.com</domain>
|
||||
<domain includeSubdomains="true">crl.businessid.telesec.de</domain>
|
||||
<domain includeSubdomains="true">crl.buypass.no</domain>
|
||||
<domain includeSubdomains="true">crl.catrust.oemssl.cn</domain>
|
||||
<domain includeSubdomains="true">crl.certigna.fr</domain>
|
||||
<domain includeSubdomains="true">crl.certsign.ro</domain>
|
||||
<domain includeSubdomains="true">crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">crl.cfca.com.cn</domain>
|
||||
<domain includeSubdomains="true">crl.cntrus.oemssl.cn</domain>
|
||||
<domain includeSubdomains="true">crl.cnwebtrust.oemssl.cn</domain>
|
||||
<domain includeSubdomains="true">crl.comodoca.com</domain>
|
||||
<domain includeSubdomains="true">crl.crlocsp.cn</domain>
|
||||
<domain includeSubdomains="true">crl.cybertrust.ne.jp</domain>
|
||||
<domain includeSubdomains="true">crl.d-trust.net</domain>
|
||||
<domain includeSubdomains="true">crl.dhimyotis.com</domain>
|
||||
<domain includeSubdomains="true">crl.digicert.cn</domain>
|
||||
<domain includeSubdomains="true">crl.digicert.eu</domain>
|
||||
<domain includeSubdomains="true">crl.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">crl.e2m01.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e2m01.eu.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e2m02.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e2m03.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e2m04.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e2s14.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e2s15.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e2s16.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e2s17.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e2s18.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e2s19.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e2s20.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e2s21.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e3m01.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e3m01.eu.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e3m02.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e3m03.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e3m04.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e3s22.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e3s23.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e3s24.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e3s25.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e3s26.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e3s27.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e3s28.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.e3s29.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.emsign.com</domain>
|
||||
<domain includeSubdomains="true">crl.ensuredca.com</domain>
|
||||
<domain includeSubdomains="true">crl.firmaprofesional.com</domain>
|
||||
<domain includeSubdomains="true">crl.gdca.com.cn</domain>
|
||||
<domain includeSubdomains="true">crl.global.sheca.com</domain>
|
||||
<domain includeSubdomains="true">crl.globalsign.com</domain>
|
||||
<domain includeSubdomains="true">crl.globalsign.net</domain>
|
||||
<domain includeSubdomains="true">crl.godaddy.com</domain>
|
||||
<domain includeSubdomains="true">crl.harica.gr</domain>
|
||||
<domain includeSubdomains="true">crl.izenpe.com</domain>
|
||||
<domain includeSubdomains="true">crl.litessl.com</domain>
|
||||
<domain includeSubdomains="true">crl.netsolssl.com</domain>
|
||||
<domain includeSubdomains="true">crl.oem.trustca.net</domain>
|
||||
<domain includeSubdomains="true">crl.pcsp.telesec.de</domain>
|
||||
<domain includeSubdomains="true">crl.pki.goog</domain>
|
||||
<domain includeSubdomains="true">crl.quovadisglobal.com</domain>
|
||||
<domain includeSubdomains="true">crl.r1001.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r2001.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r2m01.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r2m01.eu.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r2m02.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r2m03.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r2m04.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r2s18.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r2s19.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r2s20.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r2s21.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r2s22.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r2s23.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r2s24.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r2s25.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r3001.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r4001.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r4m01.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r4m02.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r4m03.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.r4m04.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.root-e1.certainly.com</domain>
|
||||
<domain includeSubdomains="true">crl.root-r1.certainly.com</domain>
|
||||
<domain includeSubdomains="true">crl.rootca1.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.rootca2.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.rootca3.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.rootca4.amazontrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.rztrust.oemssl.cn</domain>
|
||||
<domain includeSubdomains="true">crl.sbca.telesec.de</domain>
|
||||
<domain includeSubdomains="true">crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">crl.sectigochina.com</domain>
|
||||
<domain includeSubdomains="true">crl.serverid.telesec.de</domain>
|
||||
<domain includeSubdomains="true">crl.starfieldtech.com</domain>
|
||||
<domain includeSubdomains="true">crl.swisssign.ch</domain>
|
||||
<domain includeSubdomains="true">crl.trust-provider.cn</domain>
|
||||
<domain includeSubdomains="true">crl.trust-provider.com</domain>
|
||||
<domain includeSubdomains="true">crl.tuntrust.tn</domain>
|
||||
<domain includeSubdomains="true">crl.usertrust.com</domain>
|
||||
<domain includeSubdomains="true">crl.wt.trustasia.com</domain>
|
||||
<domain includeSubdomains="true">crl.xinchacha.oemssl.cn</domain>
|
||||
<domain includeSubdomains="true">crl.zw.oemssl.cn</domain>
|
||||
<domain includeSubdomains="true">crl05.actalis.it</domain>
|
||||
<domain includeSubdomains="true">crl06.actalis.it</domain>
|
||||
<domain includeSubdomains="true">crl09.actalis.it</domain>
|
||||
<domain includeSubdomains="true">crl1.ecert.gov.hk</domain>
|
||||
<domain includeSubdomains="true">crl13.actalis.it</domain>
|
||||
<domain includeSubdomains="true">crl14.actalis.it</domain>
|
||||
<domain includeSubdomains="true">crl15.actalis.it</domain>
|
||||
<domain includeSubdomains="true">crl2.microsoft.com</domain>
|
||||
<domain includeSubdomains="true">crl3.digicert.com</domain>
|
||||
<domain includeSubdomains="true">crl4.digicert.com</domain>
|
||||
<domain includeSubdomains="true">crls.certainly.com</domain>
|
||||
<domain includeSubdomains="true">crls.ssl.com</domain>
|
||||
<domain includeSubdomains="true">cyberfolks2.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">depo.kamusm.gov.tr</domain>
|
||||
<domain includeSubdomains="true">digitaltrust.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">dnencrypt.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">dvtlsca2025-crl.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">dvtlsca2026-crl.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">e5.c.lencr.org</domain>
|
||||
<domain includeSubdomains="true">e6.c.lencr.org</domain>
|
||||
<domain includeSubdomains="true">e7.c.lencr.org</domain>
|
||||
<domain includeSubdomains="true">e8.c.lencr.org</domain>
|
||||
<domain includeSubdomains="true">ec2ca2017-crl1.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">ec2ca2017-crl2.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">ec2ca2017-crl3.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">ec2sslca2017-crl1.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">ec2sslca2017-crl2.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">ec2sslca2017-crl3.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">ec3ca2017-crl1.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">ec3ca2017-crl2.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">ec3ca2017-crl3.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">ec3sslca2017-crl1.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">ec3sslca2017-crl2.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">ec3sslca2017-crl3.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eca.hinet.net</domain>
|
||||
<domain includeSubdomains="true">edvtlsca2023-crl.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">edvtlsca2025-crl.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">edvtlsca2026-crl.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">emudhra.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">eosslca2017-crl1.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eosslca2017-crl2.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eosslca2017-crl3.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eovtlsca2023-crl.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eovtlsca2025-crl.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eovtlsca2026-crl.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eqca2017-crl1.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eqca2017-crl2.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eqca2017-crl3.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eqcpca2017-crl1.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eqcpca2017-crl2.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eqcpca2017-crl3.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eqoca2017-crl1.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eqoca2017-crl2.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eqoca2017-crl3.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eqtlsca2018-crl1.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eqtlsca2018-crl2.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eqtlsca2018-crl3.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eqtlsca2023-crl.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eqtlsca2025-crl.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">eqtlsca2026-crl.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">esafer.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">eue2m1.crl.root.amznts.eu</domain>
|
||||
<domain includeSubdomains="true">eue3m1.crl.root.amznts.eu</domain>
|
||||
<domain includeSubdomains="true">eur2m1.crl.root.amznts.eu</domain>
|
||||
<domain includeSubdomains="true">eveccca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">evrsaca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">gdcadv.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">gdcaev.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">gdcaov.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">gdcatrustauthdvtlsg3r31ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">gdcatrustauthevtlsg3r31ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">gdcatrustauthovtlsg3r31ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">geant.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">genious.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">geossldvtlsr34g2ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">geosslevtlsr34g2ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">geosslovtlsr34g2ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">globaltrust.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">globessl.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">gogetssldv.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">gogetsslev.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">gogetsslov.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">homepldvtlsg2r35ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">homepldvtlsg2r36ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">homeplovtlsg2r35ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">homeplovtlsg2r36ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">httpcrl.trust.telia.com</domain>
|
||||
<domain includeSubdomains="true">ica.navercloudtrust.com</domain>
|
||||
<domain includeSubdomains="true">ica.navercorp.com</domain>
|
||||
<domain includeSubdomains="true">issauth.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">itrusdv2.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">itrusdvtlsr35g2ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">itrusov2.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">itrusovtlsr35g2ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">itso.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">joyssldvtlsg2r33ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">joysslovtlsg2r33ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">kingnettechdv.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">kingnettechev.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">kingnettechov.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">kr.crl.digicert.com</domain>
|
||||
<domain includeSubdomains="true">lh2.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">namecheap.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">nazwassldvtlsg2e29ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">nazwassldvtlsg2r29ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">netartcadv2.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">netartssldvtlsg2e31ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">nijimo.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">nyalabscadv.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">nyatworkdv.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">omitsecurity.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">onesign.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">osslca2016-crl1.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">osslca2016-crl2.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">osslca2016-crl3.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">ovtlsca2025-crl.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">ovtlsca2026-crl.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">pki-crl.atos.net</domain>
|
||||
<domain includeSubdomains="true">pki.telesec.de</domain>
|
||||
<domain includeSubdomains="true">pkipro.certsign.ro</domain>
|
||||
<domain includeSubdomains="true">positiwise.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">psw.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">public.wisekey.com</domain>
|
||||
<domain includeSubdomains="true">qiduodv.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">qtlsca2018-crl1.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">qtlsca2018-crl2.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">qtlsca2018-crl3.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">qtlsca2026-crl.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">r10.c.lencr.org</domain>
|
||||
<domain includeSubdomains="true">r11.c.lencr.org</domain>
|
||||
<domain includeSubdomains="true">r12.c.lencr.org</domain>
|
||||
<domain includeSubdomains="true">r13.c.lencr.org</domain>
|
||||
<domain includeSubdomains="true">rca.navercloudtrust.com</domain>
|
||||
<domain includeSubdomains="true">rca.navercorp.com</domain>
|
||||
<domain includeSubdomains="true">repo.pubcert.jprs.jp</domain>
|
||||
<domain includeSubdomains="true">repo1.secomtrust.net</domain>
|
||||
<domain includeSubdomains="true">repository.secomtrust.net</domain>
|
||||
<domain includeSubdomains="true">repository.tls.hinet.net</domain>
|
||||
<domain includeSubdomains="true">rootca.twca.com.tw</domain>
|
||||
<domain includeSubdomains="true">rootglobaldv.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">rootnetworksdv2.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">shenzhendv.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">shenzhenev.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">shenzhenov.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">shoperdvtlsg2r34ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">shuididv.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">shuidiev.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">shuidiov.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">soomadv.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">ssl2ca2016-crl1.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">ssl2ca2016-crl2.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">ssl2ca2016-crl3.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">sslca2014-crl1.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">sslca2014-crl2.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">sslca2014-crl3.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">sslcom.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">ssllimiteddv.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">sslserver.twca.com.tw</domain>
|
||||
<domain includeSubdomains="true">subca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">titrust.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">tlcdvtlsr34g2ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">tlcevtlsr34g2ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">tlcovtlsr34g2ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">tlsrootca2025-crl.e-szigno.hu</domain>
|
||||
<domain includeSubdomains="true">trustasia.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">trustasiadv.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">trustasiadvtlsr35g2ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">trustasiaev.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">trustasiaevtlse35g2ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">trustasiaevtlsr35g2ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">trustasiaov.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">trustasiaovtlse35g2ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">trustasiaovtlsr35g2ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">trustocean.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">tstlser20.crl.telesec.de</domain>
|
||||
<domain includeSubdomains="true">tstlsrr23.crl.telesec.de</domain>
|
||||
<domain includeSubdomains="true">unitedtrustov.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">validation.identrust.com</domain>
|
||||
<domain includeSubdomains="true">verokey.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">vtrusdvtlsg3r31ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">vtrusovtlsg3r31ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">webnic.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">whitessldvtlsg2e36ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">wosign.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">wotrus-dvca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">wotrus-evca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">wotrus-ovca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">wotrus.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">wtca-cafiles.itrus.com.cn</domain>
|
||||
<domain includeSubdomains="true">wtca-crl.itrus.com.cn</domain>
|
||||
<domain includeSubdomains="true">www.accv.es</domain>
|
||||
<domain includeSubdomains="true">www.cert.fnmt.es</domain>
|
||||
<domain includeSubdomains="true">www.d-trust.net</domain>
|
||||
<domain includeSubdomains="true">www.microsoft.com</domain>
|
||||
<domain includeSubdomains="true">x1.c.lencr.org</domain>
|
||||
<domain includeSubdomains="true">x2.c.lencr.org</domain>
|
||||
<domain includeSubdomains="true">xcctrustdvtlsg3r31ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">xcctrustevtlsg3r31ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">xcctrustovtlsg3r31ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">xinchacha2dv.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">xinchacha2ov.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">xinchachatrustdvtlsg2r34ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">xinchachatrustevtlsg2r34ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">xinchachatrustovtlsg2r34ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">xinnetdvtlsr34g2ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">xinnetevtlsr34g2ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">xinnetovtlsr34g2ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">ye.c.lencr.org</domain>
|
||||
<domain includeSubdomains="true">ye1.c.lencr.org</domain>
|
||||
<domain includeSubdomains="true">ye2.c.lencr.org</domain>
|
||||
<domain includeSubdomains="true">yektadv.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">yektaov.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">yr.c.lencr.org</domain>
|
||||
<domain includeSubdomains="true">yr1.c.lencr.org</domain>
|
||||
<domain includeSubdomains="true">yr2.c.lencr.org</domain>
|
||||
<domain includeSubdomains="true">zerossl.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">ziwit.crl.sectigo.com</domain>
|
||||
<domain includeSubdomains="true">zycatrustdvtlsg3r31ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">zycatrustevtlsg3r31ca.crl.certum.pl</domain>
|
||||
<domain includeSubdomains="true">zycatrustovtlsg3r31ca.crl.certum.pl</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Ref: https://developer.android.com/training/articles/security-config.html -->
|
||||
<!-- By default, do not allow clearText traffic -->
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates
|
||||
src="user"
|
||||
tools:ignore="AcceptsUserCertificates" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
|
||||
<!-- Allow clearText traffic on some specified host -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<!-- Localhost -->
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
<!-- Localhost for Android emulator -->
|
||||
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||
<!-- Onion services -->
|
||||
<domain includeSubdomains="true">onion</domain>
|
||||
|
||||
<!-- Domains that are used for LANs -->
|
||||
<!-- These are IANA recognized special use domain names, see https://www.iana.org/assignments/special-use-domain-names/special-use-domain-names.xhtml -->
|
||||
<domain includeSubdomains="true">home.arpa</domain>
|
||||
<domain includeSubdomains="true">local</domain> <!-- Note this has been reserved for use with mDNS -->
|
||||
<domain includeSubdomains="true">test</domain>
|
||||
<!-- These are observed in the wild either by convention or RFCs that have not been accepted, and are not currently TLDs -->
|
||||
<domain includeSubdomains="true">home</domain>
|
||||
<domain includeSubdomains="true">lan</domain>
|
||||
<domain includeSubdomains="true">localdomain</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -9,15 +9,8 @@
|
||||
package io.element.android.appconfig
|
||||
|
||||
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.
|
||||
*/
|
||||
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.preferences.api.store.AppPreferencesStore
|
||||
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.services.analytics.api.AnalyticsLongRunningTransaction
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
@@ -113,6 +114,7 @@ import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toKotlinDuration
|
||||
@@ -324,7 +326,9 @@ class LoggedInFlowNode(
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
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 -> {
|
||||
val callback = object : LoggedInNode.Callback {
|
||||
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.createNode
|
||||
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.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
@@ -410,7 +410,8 @@ class RootFlowNode(
|
||||
}
|
||||
|
||||
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?
|
||||
val sessions = sessionStore.getAllSessions()
|
||||
if (sessions.isNotEmpty()) {
|
||||
|
||||
@@ -28,10 +28,14 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
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.seconds
|
||||
|
||||
@@ -60,6 +64,12 @@ class SyncOrchestrator(
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -91,6 +101,17 @@ class SyncOrchestrator(
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun observeStates() = coroutineScope.launch {
|
||||
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(
|
||||
appForegroundStateService.isInForeground,
|
||||
appForegroundStateService.isInCall,
|
||||
@@ -117,14 +138,26 @@ class SyncOrchestrator(
|
||||
SyncStateAction.StopSync
|
||||
} else if (syncState == SyncState.Idle && isAppActive && isNetworkAvailable) {
|
||||
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 {
|
||||
SyncStateAction.NoOp
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.debounce { action ->
|
||||
when (action) {
|
||||
// Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often
|
||||
if (action == SyncStateAction.StopSync) 3.seconds else 0.seconds
|
||||
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 {
|
||||
Timber.tag(tag).d("has been stopped")
|
||||
@@ -134,6 +167,11 @@ class SyncOrchestrator(
|
||||
SyncStateAction.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 -> {
|
||||
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 {
|
||||
StartSync,
|
||||
RestartSync,
|
||||
StopSync,
|
||||
NoOp,
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="banner_migrate_to_native_sliding_sync_action">"Odhlásit se a upgradovat"</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s již nepodporuje starý protokol. Odhlaste se a znovu přihlaste, abyste mohli pokračovat v používání aplikace."</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Váš domovský server již nepodporuje starý protokol. Chcete-li pokračovat v používání aplikace, odhlaste se a znovu se přihlaste."</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Váš poskytovatel účtu již nepodporuje starý protokol. Chcete-li aplikaci nadále používat, odhlaste se a znovu se přihlaste."</string>
|
||||
</resources>
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="banner_migrate_to_native_sliding_sync_action">"Kirjaudu Ulos & 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_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>
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
<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_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>
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
<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_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>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
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.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||
@@ -414,6 +417,131 @@ class SyncOrchestratorTest {
|
||||
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(
|
||||
matrixClient: FakeMatrixClient = FakeMatrixClient(),
|
||||
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
|
||||
|
||||
+13
-16
@@ -18,7 +18,6 @@ plugins {
|
||||
alias(libs.plugins.compose.compiler) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
alias(libs.plugins.dependencycheck) apply false
|
||||
alias(libs.plugins.roborazzi) apply false
|
||||
alias(libs.plugins.dependencyanalysis)
|
||||
alias(libs.plugins.detekt)
|
||||
alias(libs.plugins.ktlint)
|
||||
@@ -154,6 +153,8 @@ allprojects {
|
||||
|
||||
val isScreenshotTest = project.gradle.startParameter.taskNames.any { it.contains("paparazzi", ignoreCase = true) }
|
||||
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
|
||||
maxHeapSize = "2g"
|
||||
// Record all the languages?
|
||||
@@ -165,9 +166,20 @@ allprojects {
|
||||
exclude("translations/*.class")
|
||||
}
|
||||
} 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
|
||||
exclude("ui/*.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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
|
||||
@@ -27,6 +27,7 @@ Click on this link and follow the instruction: [https://appdistribution.firebase
|
||||
Nightly build can be built manually from your computer. You will need to retrieved some secrets from Passbolt and add them to your file `~/.gradle/gradle.properties`:
|
||||
|
||||
```
|
||||
signing.element.nightly.storeFile=LOCATION_OF_THE_KEYSTORE_FILE
|
||||
signing.element.nightly.storePassword=VALUE_FROM_PASSBOLT
|
||||
signing.element.nightly.keyId=VALUE_FROM_PASSBOLT
|
||||
signing.element.nightly.keyPassword=VALUE_FROM_PASSBOLT
|
||||
|
||||
+1
-1
Submodule enterprise updated: f463003f68...6b422f4c8e
@@ -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_public_option_description">"Tuto místnost může najít kdokoli.
|
||||
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_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>
|
||||
@@ -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_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_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_restricted_option_description">"Kdokoli může vstoupit do %1$s."</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_public_option_description">"Jeder kann diesen Chat finden.
|
||||
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_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>
|
||||
@@ -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_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_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_restricted_option_description">"Jeder in %1$s kann beitreten."</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_public_option_description">"Ο καθένας μπορεί να βρει αυτή την αίθουσα.
|
||||
Αυτό μπορείτε να το αλλάξετε ανά πάσα στιγμή στις ρυθμίσεις της αίθουσας."</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_room_access_section_knocking_option_description">"Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στην αίθουσα, αλλά ένας διαχειριστής ή ένας συντονιστής θα πρέπει να αποδεχτεί το αίτημα."</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_private_option_description">"Μόνο άτομα που έχουν προσκληθεί μπορούν να συμμετάσχουν."</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_restricted_option_description">"Οποιοσδήποτε στο %1$s μπορεί να συμμετάσχει."</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_public_option_description">"Kuka tahansa voi löytää tämän huoneen.
|
||||
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_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>
|
||||
@@ -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_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_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_restricted_option_description">"Kuka tahansa tilassa %1$s voi liittyä."</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_public_option_description">"Svatko može pronaći ovu sobu.
|
||||
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_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>
|
||||
@@ -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_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_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_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>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<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.
|
||||
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_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>
|
||||
@@ -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_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_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_restricted_option_description">"Bárki csatlakozhat innen: %1$s."</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_public_option_description">"ルームは全世界に公開されます。
|
||||
ルーム設定でいつでも変更できます。"</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_room_access_section_knocking_option_description">"誰でも参加できますが、管理者またはモデレーターの承認が必要です。"</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_private_option_description">"招待されたユーザーのみが参加できます。"</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_restricted_option_description">"%1$s にいる全員が参加することができます。"</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_public_option_description">"Будь-хто може знайти цю кімнату.
|
||||
Ви можете змінити це в будь-який час у налаштуваннях кімнати."</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_room_access_section_knocking_option_description">"Будь-хто може подати запит на приєднання, але адміністратор або модератор повинен схвалити запит."</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_private_option_description">"Приєднатися можуть лише запрошені особи."</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_restricted_option_description">"Приєднатися може будь-хто з %1$s."</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_public_option_description">"任何人都可以找到此聊天室。
|
||||
您隨時都可以在聊天室設定中變更此設定。"</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_room_access_section_knocking_option_description">"任何人都可以要求加入,但管理員或版主必須接受該請求"</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_private_option_description">"僅被邀請的人才可以加入。"</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_restricted_option_description">"在 %1$s 中的任何人都可以加入。"</string>
|
||||
<string name="screen_create_room_room_access_section_restricted_option_title">"標準"</string>
|
||||
|
||||
+12
-18
@@ -11,6 +11,7 @@ package io.element.android.features.enterprise.api
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
||||
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
|
||||
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -36,18 +37,21 @@ interface EnterpriseService {
|
||||
suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String
|
||||
|
||||
/**
|
||||
* Returns the list of homeservers the user is allowed to sign in to.
|
||||
*
|
||||
* If the list is empty or contains the special value [ANY_ACCOUNT_PROVIDER], the user is allowed to sign in to any homeserver.
|
||||
* Returns the list of account provider the user is allowed to sign in to.
|
||||
*/
|
||||
fun homeserverAllowList(): List<String>
|
||||
fun accountProviderAllowList(): List<AccountProvider>
|
||||
|
||||
/**
|
||||
* Whether the user is allowed to sign in to a given homeserver, according to [homeserverAllowList].
|
||||
*
|
||||
* @param homeserverUrl the server the user is trying to use.
|
||||
* Whether the user is allowed to sign in to any account provider.
|
||||
*/
|
||||
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.
|
||||
@@ -97,14 +101,4 @@ interface EnterpriseService {
|
||||
* @param sessionId the session whose channel is requested.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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.matrix.api.ClientUrlContentFetcher
|
||||
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 kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
@@ -33,8 +34,9 @@ class DefaultEnterpriseService(
|
||||
) : EnterpriseService {
|
||||
override suspend fun isEnterpriseUser(sessionId: SessionId) = false
|
||||
override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url
|
||||
override fun homeserverAllowList(): List<String> = emptyList()
|
||||
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
|
||||
override fun accountProviderAllowList(): List<AccountProvider> = emptyList()
|
||||
override fun canConnectToAnyAccountProvider(): Boolean = true
|
||||
override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider) = true
|
||||
override suspend fun isElementProEnforced(serverName: String): Boolean {
|
||||
val temporaryMatrixClient = temporaryMatrixClientFactory.create(serverName).getOrElse { return false }
|
||||
return temporaryMatrixClient.use { client ->
|
||||
|
||||
+11
-4
@@ -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.FakeTemporaryMatrixClient
|
||||
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.value
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -26,15 +27,21 @@ import org.junit.Test
|
||||
|
||||
class DefaultEnterpriseServiceTest {
|
||||
@Test
|
||||
fun homeserverWhitelist() {
|
||||
fun accountProviderAllowList() {
|
||||
val defaultEnterpriseService = createDefaultEnterpriseService()
|
||||
assertThat(defaultEnterpriseService.homeserverAllowList()).isEmpty()
|
||||
assertThat(defaultEnterpriseService.accountProviderAllowList()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isAllowedToConnectToHomeserver is true for all homeserver urls`() = runTest {
|
||||
fun `canConnectToAnyAccountProvider is always true`() {
|
||||
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
|
||||
|
||||
+12
-6
@@ -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.EnterpriseService
|
||||
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.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
@@ -22,8 +23,9 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class FakeEnterpriseService(
|
||||
private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() },
|
||||
private val defaultHomeserverListResult: () -> List<String> = { emptyList() },
|
||||
private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() },
|
||||
private val accountProviderAllowListResult: () -> List<AccountProvider> = { emptyList() },
|
||||
private val canConnectToAnyAccountProviderResult: () -> Boolean = { true },
|
||||
private val isAllowedToConnectToAccountProviderResult: (AccountProvider) -> Boolean = { lambdaError() },
|
||||
initialSemanticColors: SemanticColorsLightDark = SemanticColorsLightDark.default,
|
||||
initialBrandColor: Color? = null,
|
||||
private val overrideBrandColorResult: (SessionId?, String?) -> Unit = { _, _ -> lambdaError() },
|
||||
@@ -44,12 +46,16 @@ class FakeEnterpriseService(
|
||||
tweakMasUrlResult(url, urlContentFetcher)
|
||||
}
|
||||
|
||||
override fun homeserverAllowList(): List<String> {
|
||||
return defaultHomeserverListResult()
|
||||
override fun accountProviderAllowList(): List<AccountProvider> {
|
||||
return accountProviderAllowListResult()
|
||||
}
|
||||
|
||||
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean = simulateLongTask {
|
||||
isAllowedToConnectToHomeserverResult(homeserverUrl)
|
||||
override fun canConnectToAnyAccountProvider(): Boolean {
|
||||
return canConnectToAnyAccountProviderResult()
|
||||
}
|
||||
|
||||
override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider): Boolean = simulateLongTask {
|
||||
isAllowedToConnectToAccountProviderResult(accountProvider)
|
||||
}
|
||||
|
||||
override suspend fun isElementProEnforced(serverName: String): Boolean = simulateLongTask {
|
||||
|
||||
+6
-1
@@ -32,11 +32,13 @@ import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.ui.common.nodes.EmptyState
|
||||
import io.element.android.libraries.ui.common.nodes.emptyNode
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
@AssistedInject
|
||||
@@ -84,7 +86,10 @@ class FtueFlowNode(
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
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 -> {
|
||||
val callback = object : FtueSessionVerificationFlowNode.Callback {
|
||||
|
||||
@@ -12,6 +12,7 @@ import extension.testCommonDependencies
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
id("kotlin-parcelize")
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -31,6 +32,8 @@ dependencies {
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.encryptedDb)
|
||||
implementation(libs.serialization.json)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
@@ -55,8 +58,6 @@ dependencies {
|
||||
implementation(projects.features.rageshake.api)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.haze)
|
||||
implementation(libs.haze.materials)
|
||||
implementation(projects.features.preferences.impl)
|
||||
implementation(projects.features.reportroom.api)
|
||||
implementation(projects.features.rolesandpermissions.api)
|
||||
|
||||
+1
@@ -36,6 +36,7 @@ class CurrentUserWithNeighborsBuilder {
|
||||
userId = UserId(it.userId),
|
||||
displayName = it.userDisplayName,
|
||||
avatarUrl = it.userAvatarUrl,
|
||||
avatarThumbnail = it.userAvatarData,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-15
@@ -6,8 +6,6 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalHazeMaterialsApi::class)
|
||||
|
||||
package io.element.android.features.home.impl
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
@@ -40,11 +38,6 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.home.impl.components.HomeTopBar
|
||||
@@ -190,7 +183,6 @@ private fun HomeScaffold(
|
||||
}
|
||||
}
|
||||
|
||||
val hazeState = rememberHazeState()
|
||||
val roomsLazyListState = rememberLazyListState()
|
||||
val spacesLazyListState = rememberLazyListState()
|
||||
|
||||
@@ -223,10 +215,6 @@ private fun HomeScaffold(
|
||||
filtersState = roomListState.filtersState,
|
||||
spaceFiltersState = roomListState.spaceFiltersState,
|
||||
canReportBug = state.canReportBug,
|
||||
modifier = Modifier.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.thick(),
|
||||
)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
@@ -297,7 +285,6 @@ private fun HomeScaffold(
|
||||
modifier = Modifier
|
||||
.padding(outerPadding)
|
||||
.consumeWindowInsets(outerPadding)
|
||||
.hazeSource(state = hazeState)
|
||||
)
|
||||
SpaceFiltersView(roomListState.spaceFiltersState)
|
||||
}
|
||||
@@ -306,8 +293,7 @@ private fun HomeScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(outerPadding)
|
||||
.consumeWindowInsets(outerPadding)
|
||||
.hazeSource(state = hazeState),
|
||||
.consumeWindowInsets(outerPadding),
|
||||
contentPadding = lazyColumnContentPadding + contentPadding,
|
||||
state = state.homeSpacesState,
|
||||
lazyListState = spacesLazyListState,
|
||||
|
||||
+8
-10
@@ -345,7 +345,7 @@ private fun MessagePreviewAndIndicatorRow(
|
||||
// Call and unread
|
||||
Row(
|
||||
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
|
||||
.zIndex(-1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -358,21 +358,18 @@ private fun MessagePreviewAndIndicatorRow(
|
||||
isAudio = room.activeCallIntent == CallIntent.AUDIO
|
||||
)
|
||||
}
|
||||
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
|
||||
val isMuted = room.userDefinedNotificationMode == RoomNotificationMode.MUTE
|
||||
if (isMuted) {
|
||||
NotificationOffIndicatorAtom()
|
||||
} else if (room.numberOfUnreadMentions > 0) {
|
||||
MentionIndicatorAtom()
|
||||
}
|
||||
if (room.hasNewContent) {
|
||||
val contentDescription = stringResource(CommonStrings.a11y_notifications_new_messages)
|
||||
val count = if (showUnreadCount) {
|
||||
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
|
||||
room.numberOfUnreadMessages
|
||||
} else {
|
||||
room.numberOfUnreadNotifications
|
||||
}
|
||||
} else {
|
||||
null
|
||||
val count = when {
|
||||
showUnreadCount && !isMuted && room.numberOfUnreadNotifications > 0 -> room.numberOfUnreadNotifications
|
||||
!showUnreadCount -> 0
|
||||
else -> null
|
||||
}
|
||||
UnreadIndicatorAtom(
|
||||
color = tint,
|
||||
@@ -457,5 +454,6 @@ internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryPreviewP
|
||||
isInviteSeen = data.name == "Bob",
|
||||
onClick = {},
|
||||
eventSink = {},
|
||||
showUnreadCount = data.numberOfUnreadNotifications > 0,
|
||||
)
|
||||
}
|
||||
|
||||
+18
@@ -82,6 +82,24 @@ open class RoomListRoomSummaryPreviewParam : PreviewParameterProvider<RoomListRo
|
||||
numberOfUnreadMentions = 1,
|
||||
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(),
|
||||
|
||||
+18
@@ -7,9 +7,27 @@
|
||||
|
||||
package io.element.android.features.home.impl.search
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
sealed interface GlobalSearchEvent {
|
||||
/** The user cleared the search query. */
|
||||
object ClearQuery : GlobalSearchEvent
|
||||
|
||||
/** The search screen visibility changed (e.g. the user opened or closed the search screen). */
|
||||
object ToggleSearchVisibility : GlobalSearchEvent
|
||||
|
||||
/** The visible range for the displayed search results changed. */
|
||||
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
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
+47
-4
@@ -18,6 +18,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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.architecture.AsyncData
|
||||
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.ui.components.AttachmentThumbnailType
|
||||
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.async
|
||||
@@ -54,10 +57,8 @@ import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlin.jvm.optionals.getOrElse
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Inject
|
||||
@@ -71,6 +72,7 @@ class GlobalSearchPresenter(
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val searchHistoryStore: SearchHistoryStore,
|
||||
) : Presenter<GlobalSearchState> {
|
||||
@Composable
|
||||
override fun present(): GlobalSearchState {
|
||||
@@ -85,6 +87,21 @@ class GlobalSearchPresenter(
|
||||
var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) }
|
||||
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) {
|
||||
// Add a delay to avoid performing too many searches in a short period of time, which can be expensive
|
||||
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()`.
|
||||
val mappedResults = results.map { result ->
|
||||
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(
|
||||
timestamp = result.timestamp,
|
||||
mode = DateFormatterMode.TimeOrDate,
|
||||
@@ -180,7 +197,8 @@ class GlobalSearchPresenter(
|
||||
|
||||
fun handleEvent(event: GlobalSearchEvent) {
|
||||
when (event) {
|
||||
GlobalSearchEvent.ClearQuery -> {
|
||||
GlobalSearchEvent.ClearQuery -> coroutineScope.launch {
|
||||
searchResults = AsyncData.Uninitialized
|
||||
queryState.clearText()
|
||||
}
|
||||
GlobalSearchEvent.ToggleSearchVisibility -> {
|
||||
@@ -205,6 +223,30 @@ class GlobalSearchPresenter(
|
||||
}
|
||||
}
|
||||
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,
|
||||
currentTarget = currentTarget,
|
||||
results = searchResults,
|
||||
history = searchHistory,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
+15
@@ -28,6 +28,7 @@ data class GlobalSearchState(
|
||||
val queryState: TextFieldState,
|
||||
val currentTarget: GlobalSearchTarget,
|
||||
val results: AsyncData<GlobalSearchResults>,
|
||||
val history: AsyncData<ImmutableList<SearchHistoryResultItem>>,
|
||||
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(
|
||||
val filename: String,
|
||||
val extension: String?,
|
||||
|
||||
+68
-4
@@ -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.ui.components.AttachmentThumbnailType
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
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> {
|
||||
override val values: Sequence<GlobalSearchState>
|
||||
get() = sequenceOf(
|
||||
get() {
|
||||
return sequenceOf(
|
||||
// Not enabled, no preview
|
||||
aGlobalSearchState(isEnabled = false),
|
||||
aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")),
|
||||
@@ -98,15 +107,68 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
|
||||
queryState = TextFieldState("Query"),
|
||||
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(
|
||||
id: RoomId = RoomId("!roomId:server.org"),
|
||||
id: RoomId = roomId1,
|
||||
name: String? = "A room",
|
||||
rawName: String? = "A room raw name",
|
||||
topic: String? = "A room topic",
|
||||
avatarUrl: String? = "https://example.com/avatar.png",
|
||||
avatarUrl: String? = AN_AVATAR_URL,
|
||||
isPublic: Boolean = true,
|
||||
isDirect: Boolean = false,
|
||||
isEncrypted: Boolean = false,
|
||||
@@ -217,7 +279,7 @@ fun aMessageContent(
|
||||
)
|
||||
|
||||
internal fun aMessageSearchResult(
|
||||
roomId: RoomId = RoomId("!roomId:server.org"),
|
||||
roomId: RoomId = roomId1,
|
||||
eventId: EventId = EventId("\$eventId:server.org"),
|
||||
senderId: UserId = UserId("@user:server.org"),
|
||||
senderProfile: ProfileDetails = aProfileDetailsReady(),
|
||||
@@ -238,6 +300,7 @@ internal fun aGlobalSearchState(
|
||||
queryState: TextFieldState = TextFieldState(),
|
||||
currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS,
|
||||
results: AsyncData<GlobalSearchResults> = AsyncData.Uninitialized,
|
||||
history: AsyncData<ImmutableList<SearchHistoryResultItem>> = AsyncData.Uninitialized,
|
||||
eventSink: (GlobalSearchEvent) -> Unit = {},
|
||||
) = GlobalSearchState(
|
||||
isEnabled = isEnabled,
|
||||
@@ -245,5 +308,6 @@ internal fun aGlobalSearchState(
|
||||
queryState = queryState,
|
||||
currentTarget = currentTarget,
|
||||
results = results,
|
||||
history = history,
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
||||
+182
-56
@@ -23,11 +23,15 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.material3.MaterialTheme
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
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.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
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.text.roundToPx
|
||||
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.IconButton
|
||||
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.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Composable
|
||||
@@ -103,7 +110,10 @@ fun GlobalSearchView(
|
||||
onBackButtonClick = {
|
||||
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,
|
||||
onSelectSearchResult: (RoomId, EventId?) -> Unit,
|
||||
) {
|
||||
val borderColor = MaterialTheme.colorScheme.tertiary
|
||||
val strokeWidth = 1.dp
|
||||
|
||||
val drawBottomLineModifier = Modifier.drawBehind {
|
||||
drawLine(
|
||||
color = borderColor,
|
||||
start = Offset(0f, size.height),
|
||||
end = Offset(size.width, size.height),
|
||||
strokeWidth = strokeWidth.value
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = { BackButton(onClick = onBackButtonClick) },
|
||||
title = {
|
||||
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
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val searchLabel = stringResource(CommonStrings.action_search)
|
||||
@@ -142,6 +194,11 @@ private fun GlobalSearchContent(
|
||||
.semantics { contentDescription = searchLabel },
|
||||
state = state.queryState,
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActionHandler { performDefault ->
|
||||
state.eventSink(GlobalSearchEvent.SaveQueryToHistory)
|
||||
performDefault()
|
||||
},
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
@@ -153,10 +210,18 @@ private fun GlobalSearchContent(
|
||||
),
|
||||
trailingIcon = if (state.queryState.text.isNotEmpty()) {
|
||||
@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(
|
||||
modifier = Modifier.size(16.dp),
|
||||
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()
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
contentWindowInsets = scaffoldScrollableContentInsets,
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(padding),
|
||||
) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchTargetSelector(state: GlobalSearchState) {
|
||||
if (state.queryState.text.isEmpty()) return
|
||||
|
||||
SingleChoiceSegmentedButtonRow(
|
||||
modifier = drawBottomLineModifier
|
||||
modifier = Modifier
|
||||
.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(
|
||||
index = 0,
|
||||
@@ -200,34 +263,6 @@ private fun GlobalSearchContent(
|
||||
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() {
|
||||
@@ -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 {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
+47
@@ -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()
|
||||
}
|
||||
}
|
||||
+96
@@ -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") }
|
||||
}
|
||||
}
|
||||
+34
@@ -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
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
+42
@@ -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="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="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_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>
|
||||
@@ -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!
|
||||
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_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_unread">"Označit jako nepřečtené"</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_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_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_title">"Für diese Auswahl hast du keinen Chat"</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_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_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_title">"Sinulla ei ole sopivia keskusteluja tähän valintaan"</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="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="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_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>
|
||||
@@ -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_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_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_title">"Ehhez a kiválasztáshoz nem tartoznak csevegések"</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!
|
||||
Nincs olvasatlan üzenete!"</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_unread">"Megjelölés olvasatlanként"</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_low_priority">"低い優先度"</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_title">"この選択中にチャットがありません"</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="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="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_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>
|
||||
@@ -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!
|
||||
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_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_unread">"Markera som oläst"</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="full_screen_intent_banner_message">"為確保您永遠不會錯過重要通話,請變更設定以允許在手機鎖定時允許全螢幕通知。"</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_invites_decline_chat_message">"您確定您想要拒絕加入 %1$s 的邀請嗎?"</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_low_priority">"低優先度"</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_title">"您並無此選擇的聊天"</string>
|
||||
<string name="screen_roomlist_filter_people">"夥伴"</string>
|
||||
@@ -46,7 +48,7 @@
|
||||
<string name="screen_roomlist_filter_unreads_empty_state_title">"恭喜!
|
||||
您沒有任何未讀的訊息!"</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_unread">"標為未讀"</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_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_mention_empty_state_title">"你暂无任何未读提及"</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_people">"人员"</string>
|
||||
@@ -46,7 +46,7 @@
|
||||
<string name="screen_roomlist_filter_rooms_empty_state_title">"你尚未进入任何房间"</string>
|
||||
<string name="screen_roomlist_filter_unreads">"未读"</string>
|
||||
<string name="screen_roomlist_filter_unreads_empty_state_title">"恭喜!
|
||||
没有任何未读消息!"</string>
|
||||
你暂无任何未读消息!"</string>
|
||||
<string name="screen_roomlist_knock_event_sent_description">"加入申请已发送"</string>
|
||||
<string name="screen_roomlist_main_space_title">"聊天"</string>
|
||||
<string name="screen_roomlist_mark_as_read">"设为已读"</string>
|
||||
|
||||
@@ -14,4 +14,5 @@
|
||||
<string name="search_section_messages">Messages</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="screen_search_recent_searches_section_title">Recent searches</string>
|
||||
</resources>
|
||||
|
||||
+26
@@ -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",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -81,7 +81,7 @@ class HomePresenterTest {
|
||||
val presenter = createHomePresenter(
|
||||
rageshakeFeatureAvailability = { flowOf(true) },
|
||||
sessionStore = InMemorySessionStore(
|
||||
updateUserProfileResult = { _, _, _ -> },
|
||||
updateUserProfileResult = { _, _, _, _ -> },
|
||||
),
|
||||
)
|
||||
presenter.test {
|
||||
@@ -98,7 +98,7 @@ class HomePresenterTest {
|
||||
val presenter = createHomePresenter(
|
||||
indicatorService = indicatorService,
|
||||
sessionStore = InMemorySessionStore(
|
||||
updateUserProfileResult = { _, _, _ -> },
|
||||
updateUserProfileResult = { _, _, _, _ -> },
|
||||
),
|
||||
)
|
||||
presenter.test {
|
||||
@@ -120,7 +120,7 @@ class HomePresenterTest {
|
||||
val presenter = createHomePresenter(
|
||||
client = matrixClient,
|
||||
sessionStore = InMemorySessionStore(
|
||||
updateUserProfileResult = { _, _, _ -> },
|
||||
updateUserProfileResult = { _, _, _, _ -> },
|
||||
),
|
||||
)
|
||||
presenter.test {
|
||||
@@ -134,7 +134,7 @@ class HomePresenterTest {
|
||||
fun `present - NavigationBar change`() = runTest {
|
||||
val presenter = createHomePresenter(
|
||||
sessionStore = InMemorySessionStore(
|
||||
updateUserProfileResult = { _, _, _ -> },
|
||||
updateUserProfileResult = { _, _, _, _ -> },
|
||||
),
|
||||
)
|
||||
presenter.test {
|
||||
|
||||
-1
@@ -91,7 +91,6 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Suppress("LargeClass")
|
||||
class RoomListPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
+95
-5
@@ -11,6 +11,9 @@ package io.element.android.features.home.impl.search
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory
|
||||
import io.element.android.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.FileSizeFormatter
|
||||
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.RoomListService
|
||||
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.permalink.FakePermalinkParser
|
||||
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.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.util.Optional
|
||||
|
||||
class GlobalSearchPresenterTest {
|
||||
@Test
|
||||
@@ -60,6 +64,8 @@ class GlobalSearchPresenterTest {
|
||||
assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS)
|
||||
assertThat(state.results).isEqualTo(AsyncData.Uninitialized)
|
||||
}
|
||||
|
||||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +91,9 @@ class GlobalSearchPresenterTest {
|
||||
fun `present - toggle search visibility`() = runTest {
|
||||
val presenter = createGlobalSearchPresenter()
|
||||
presenter.test {
|
||||
// Skip the initial state
|
||||
skipItems(1)
|
||||
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.isSearchActive).isFalse()
|
||||
state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
|
||||
@@ -103,6 +112,9 @@ class GlobalSearchPresenterTest {
|
||||
fun `present - update target`() = runTest {
|
||||
val presenter = createGlobalSearchPresenter()
|
||||
presenter.test {
|
||||
// Skip initial item
|
||||
skipItems(1)
|
||||
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS)
|
||||
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 {
|
||||
val messageSearch = FakeMessageSearch()
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
|
||||
getRoomInfoLambda = { Result.success(aRoomInfo()) }
|
||||
}
|
||||
val presenter = createGlobalSearchPresenter(
|
||||
messageSearchService = FakeMessageSearchService(messageSearch),
|
||||
@@ -221,7 +233,7 @@ class GlobalSearchPresenterTest {
|
||||
fun `present - message search displays the uninitialized state after removing the query`() = runTest {
|
||||
val messageSearch = FakeMessageSearch()
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
|
||||
getRoomInfoLambda = { Result.success(aRoomInfo()) }
|
||||
}
|
||||
val presenter = createGlobalSearchPresenter(
|
||||
messageSearchService = FakeMessageSearchService(messageSearch),
|
||||
@@ -256,7 +268,7 @@ class GlobalSearchPresenterTest {
|
||||
fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest {
|
||||
val messageSearch = FakeMessageSearch()
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
|
||||
getRoomInfoLambda = { Result.success(aRoomInfo()) }
|
||||
}
|
||||
val presenter = createGlobalSearchPresenter(
|
||||
messageSearchService = FakeMessageSearchService(messageSearch),
|
||||
@@ -279,6 +291,82 @@ class GlobalSearchPresenterTest {
|
||||
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(
|
||||
@@ -290,6 +378,7 @@ private fun TestScope.createGlobalSearchPresenter(
|
||||
fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(),
|
||||
permalinkParser: PermalinkParser = FakePermalinkParser(),
|
||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||
searchHistoryStore: SearchHistoryStore = FakeSearchHistoryStore(),
|
||||
): GlobalSearchPresenter {
|
||||
return GlobalSearchPresenter(
|
||||
roomListSearchDataSourceFactory = object : RoomListSearchDataSource.Factory {
|
||||
@@ -310,5 +399,6 @@ private fun TestScope.createGlobalSearchPresenter(
|
||||
permalinkParser = permalinkParser,
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
matrixClient = matrixClient,
|
||||
searchHistoryStore = searchHistoryStore,
|
||||
)
|
||||
}
|
||||
|
||||
+275
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -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 } }
|
||||
)
|
||||
}
|
||||
+33
@@ -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
|
||||
}
|
||||
}
|
||||
+28
@@ -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()
|
||||
}
|
||||
}
|
||||
+50
@@ -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()
|
||||
}
|
||||
}
|
||||
-1
@@ -59,7 +59,6 @@ import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@Suppress("LargeClass")
|
||||
internal class DefaultInvitePeoplePresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
+1
@@ -107,6 +107,7 @@ sealed interface LoadedDetails {
|
||||
) : LoadedDetails
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed interface JoinAuthorisationStatus {
|
||||
data object None : JoinAuthorisationStatus
|
||||
data class IsInvited(val inviteData: InviteData, val inviteSender: InviteSender?) : JoinAuthorisationStatus
|
||||
|
||||
+3
-3
@@ -40,8 +40,8 @@ import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.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.RoomPreviewAliasAtom
|
||||
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.molecules.ButtonRowMolecule
|
||||
@@ -514,7 +514,7 @@ private fun IncompleteContent(
|
||||
title = {
|
||||
when (roomIdOrAlias) {
|
||||
is RoomIdOrAlias.Alias -> {
|
||||
RoomPreviewAliasAtom(roomIdOrAlias.identifier)
|
||||
CopiableTextAtom(roomIdOrAlias.identifier)
|
||||
}
|
||||
is RoomIdOrAlias.Id -> {
|
||||
PlaceholderAtom(width = 200.dp, height = 22.dp)
|
||||
@@ -567,7 +567,7 @@ private fun DefaultLoadedContent(
|
||||
},
|
||||
subtitle = {
|
||||
if (contentState.alias != null) {
|
||||
RoomPreviewAliasAtom(contentState.alias.value)
|
||||
CopiableTextAtom(contentState.alias.value)
|
||||
}
|
||||
if (contentState.details is LoadedDetails.Space) {
|
||||
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_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_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_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>
|
||||
|
||||
@@ -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_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_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_title_knock">"Haluatko liittyä tähän huoneeseen?"</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_space_not_supported_description">"%1$s 尚未支援空間。您可以在網頁上存取空間。"</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_title_knock">"想要加入此聊天室?"</string>
|
||||
<string name="screen_join_room_title_no_preview">"無法使用預覽"</string>
|
||||
|
||||
-1
@@ -70,7 +70,6 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.Optional
|
||||
|
||||
@Suppress("LargeClass")
|
||||
class JoinRoomPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
-8
@@ -10,20 +10,12 @@ package io.element.android.features.location.impl.common
|
||||
|
||||
import android.Manifest
|
||||
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
|
||||
|
||||
/**
|
||||
* Common configuration values for the map.
|
||||
*/
|
||||
object MapDefaults {
|
||||
val options = MapOptions(
|
||||
renderOptions = RenderOptions.Standard,
|
||||
gestureOptions = GestureOptions.Standard,
|
||||
)
|
||||
|
||||
val defaultCameraPosition = CameraPosition(
|
||||
target = Position(0.0, 0.0),
|
||||
zoom = 0.0,
|
||||
|
||||
+1
-1
@@ -24,13 +24,13 @@ import org.maplibre.compose.expressions.dsl.feature
|
||||
import org.maplibre.compose.expressions.dsl.image
|
||||
import org.maplibre.compose.expressions.dsl.not
|
||||
import org.maplibre.compose.expressions.value.SymbolAnchor
|
||||
import org.maplibre.compose.interaction.ClickResult
|
||||
import org.maplibre.compose.layers.CircleLayer
|
||||
import org.maplibre.compose.layers.SymbolLayer
|
||||
import org.maplibre.compose.sources.GeoJsonData
|
||||
import org.maplibre.compose.sources.GeoJsonOptions
|
||||
import org.maplibre.compose.sources.GeoJsonSource
|
||||
import org.maplibre.compose.sources.rememberGeoJsonSource
|
||||
import org.maplibre.compose.util.ClickResult
|
||||
import org.maplibre.spatialk.geojson.Feature
|
||||
import org.maplibre.spatialk.geojson.FeatureCollection
|
||||
import org.maplibre.spatialk.geojson.Point
|
||||
|
||||
+53
-36
@@ -7,6 +7,7 @@
|
||||
|
||||
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.Box
|
||||
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.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
@@ -41,27 +43,22 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.max
|
||||
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
|
||||
import io.element.android.features.location.api.internal.rememberTileStyleUrl
|
||||
import io.element.android.features.location.impl.common.MapDefaults
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
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.CircularProgressIndicator
|
||||
import org.maplibre.compose.camera.CameraState
|
||||
import org.maplibre.compose.camera.rememberCameraState
|
||||
import org.maplibre.compose.map.MapOptions
|
||||
import org.maplibre.compose.interaction.MapInteractions
|
||||
import org.maplibre.compose.map.MapState
|
||||
import org.maplibre.compose.map.MapUiOptions
|
||||
import org.maplibre.compose.map.MaplibreMap
|
||||
import org.maplibre.compose.map.RenderOptions
|
||||
import org.maplibre.compose.overlay.ExpandingAttributionButton
|
||||
import org.maplibre.compose.overlay.MapOverlay
|
||||
import org.maplibre.compose.overlay.MaplibreLogo
|
||||
import org.maplibre.compose.style.BaseStyle
|
||||
import org.maplibre.compose.util.MaplibreComposable
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -72,43 +69,45 @@ import kotlin.math.roundToInt
|
||||
* - Updating camera position padding based on sheet height
|
||||
* - 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 scaffoldState State for the bottom sheet scaffold
|
||||
* @param cameraState The camera state for the map
|
||||
* @param mapOptions The options to configure the map
|
||||
* @param renderOptions The options to configure the map rendering
|
||||
* @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 sheetDragHandle Optional drag handle for the sheet
|
||||
* @param sheetSwipeEnabled Whether the sheet can be swiped
|
||||
* @param topBar The top app bar content
|
||||
* @param snackbarHost The snackbar host content
|
||||
* @param sheetContent The content to display in the bottom sheet
|
||||
* @param mapContent The content inside the MaplibreMap (layers, location pucks, etc.)
|
||||
* @param overlayContent Content to overlay on top of the map (FAB, pin icons, etc.)
|
||||
* @param overlay Content to overlay on top of the map (FAB, pin icons, etc.)
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MapBottomSheetScaffold(
|
||||
customMapTilerConfig: AsyncData<MapTilerConfig?>,
|
||||
mapState: MapState?,
|
||||
isReady: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(
|
||||
bottomSheetState = rememberBottomSheetState(
|
||||
initialValue = SheetValue.PartiallyExpanded,
|
||||
)
|
||||
),
|
||||
cameraState: CameraState = rememberCameraState(),
|
||||
mapOptions: MapOptions = MapDefaults.options,
|
||||
renderOptions: RenderOptions = RenderOptions.Standard,
|
||||
interactions: MapInteractions = MapInteractions.Standard,
|
||||
uiOptions: MapUiOptions = MapUiOptions.Standard,
|
||||
sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight,
|
||||
sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
|
||||
sheetSwipeEnabled: Boolean = true,
|
||||
topBar: (@Composable () -> Unit)? = null,
|
||||
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
|
||||
sheetContent: @Composable ColumnScope.(PaddingValues) -> Unit = {},
|
||||
mapContent: @Composable @MaplibreComposable () -> Unit = {},
|
||||
overlayContent: @Composable BoxScope.(sheetPadding: PaddingValues) -> Unit = {},
|
||||
sheetContent: @Composable ColumnScope.() -> Unit = {},
|
||||
overlay: @Composable BoxScope.() -> Unit = {},
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
|
||||
val windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
|
||||
BoxWithConstraints(modifier = modifier.windowInsetsPadding(windowInsets)) {
|
||||
val layoutHeightPx by rememberUpdatedState(constraints.maxHeight)
|
||||
@@ -126,7 +125,7 @@ fun MapBottomSheetScaffold(
|
||||
sheetContent = {
|
||||
val maxContentHeight = (layoutHeightPx * 0.5f).roundToInt().toDp()
|
||||
Column(modifier = Modifier.heightIn(max = maxContentHeight)) {
|
||||
sheetContent(sheetPadding)
|
||||
sheetContent()
|
||||
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
||||
}
|
||||
},
|
||||
@@ -136,39 +135,57 @@ fun MapBottomSheetScaffold(
|
||||
snackbarHost = snackbarHost,
|
||||
topBar = topBar,
|
||||
) {
|
||||
Box {
|
||||
when (customMapTilerConfig) {
|
||||
is AsyncData.Success -> {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when {
|
||||
isReady && mapState != null -> {
|
||||
MaplibreMap(
|
||||
options = mapOptions,
|
||||
state = mapState,
|
||||
cameraPadding = sheetPadding,
|
||||
contentWindowInsets = WindowInsets(bottom = sheetPadding.calculateBottomPadding()),
|
||||
baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapTilerConfig.data)),
|
||||
overlay = MapOverlay {
|
||||
val style = styleState
|
||||
renderOptions = renderOptions,
|
||||
interactions = interactions,
|
||||
uiOptions = uiOptions,
|
||||
overlay = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(sheetPadding)
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth(),
|
||||
.fillMaxWidth()
|
||||
.padding(all = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MaplibreLogo()
|
||||
ExpandingAttributionButton(cameraState = cameraState, styleState = style)
|
||||
ExpandingAttributionButton()
|
||||
}
|
||||
}
|
||||
},
|
||||
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 -> {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-8
@@ -10,24 +10,23 @@ package io.element.android.features.location.impl.common.ui
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import org.maplibre.compose.camera.CameraState
|
||||
import org.maplibre.compose.location.Location
|
||||
import org.maplibre.compose.location.LocationMeasurement
|
||||
import org.maplibre.compose.location.LocationPuck
|
||||
import org.maplibre.compose.location.LocationPuckColors
|
||||
import org.maplibre.compose.location.LocationPuckSizes
|
||||
import org.maplibre.spatialk.units.Length
|
||||
|
||||
@Composable
|
||||
fun UserLocationPuck(
|
||||
cameraState: CameraState,
|
||||
location: Location?,
|
||||
location: LocationMeasurement?,
|
||||
) {
|
||||
LocationPuck(
|
||||
idPrefix = "user-location",
|
||||
location = location,
|
||||
cameraState = cameraState,
|
||||
accuracyThreshold = Float.POSITIVE_INFINITY,
|
||||
showBearingAccuracy = false,
|
||||
showBearing = false,
|
||||
// Hide the accuracy circle.
|
||||
accuracyThreshold = Length.PositiveInfinity,
|
||||
// Hide the bearing (and bearing accuracy) indicator.
|
||||
bearing = null,
|
||||
sizes = LocationPuckSizes(
|
||||
dotRadius = 8.dp,
|
||||
dotStrokeWidth = 2.dp,
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ class DefaultUserLocationStateFactory : UserLocationState.Factory {
|
||||
.updates(locationRequest)
|
||||
.mapNotNull { event ->
|
||||
when (event) {
|
||||
is LocationEvent.Fix -> event.location
|
||||
is LocationEvent.Update -> event.measurement
|
||||
is LocationEvent.Unavailable -> null
|
||||
}
|
||||
}
|
||||
|
||||
+9
-10
@@ -8,19 +8,18 @@
|
||||
package io.element.android.features.location.impl.common.userlocation
|
||||
|
||||
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.units.extensions.meters
|
||||
import kotlin.time.TimeSource
|
||||
import org.maplibre.compose.location.Location as MapLibreLocation
|
||||
import kotlin.time.Clock
|
||||
|
||||
fun Location.asMapLibreLocation(): MapLibreLocation {
|
||||
return MapLibreLocation(
|
||||
position = PositionWithAccuracy(
|
||||
value = Position(latitude = lat, longitude = lon),
|
||||
accuracy = accuracy?.toDouble()?.meters
|
||||
fun Location.toLocationMeasurement(): LocationMeasurement {
|
||||
return LocationMeasurement(
|
||||
position = Position(
|
||||
latitude = lat,
|
||||
longitude = lon,
|
||||
),
|
||||
// Not relevant as not used
|
||||
timestamp = TimeSource.Monotonic.markNow(),
|
||||
horizontalAccuracy = accuracy?.toDouble()?.meters,
|
||||
measuredAt = Clock.System.now(),
|
||||
)
|
||||
}
|
||||
|
||||
+3
-3
@@ -37,7 +37,7 @@ import org.maplibre.compose.location.LocationEvent
|
||||
import org.maplibre.compose.location.LocationProvider
|
||||
import org.maplibre.compose.location.LocationRequest
|
||||
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 java.util.ServiceLoader
|
||||
|
||||
@@ -75,7 +75,7 @@ class PlatformLocationProvider(
|
||||
val locationManager = context.getSystemService(LocationManager::class.java)
|
||||
val listener = object : LocationListenerCompat {
|
||||
override fun onLocationChanged(location: Location) {
|
||||
trySend(LocationEvent.Fix(location.asMapLibreLocation()))
|
||||
trySend(location.asMapLibreLocationUpdate())
|
||||
}
|
||||
|
||||
override fun onProviderDisabled(provider: String) {
|
||||
@@ -100,7 +100,7 @@ class PlatformLocationProvider(
|
||||
return
|
||||
}
|
||||
locationManager.getLastKnownLocation(provider)?.let { location ->
|
||||
trySend(LocationEvent.Fix(location.asMapLibreLocation()))
|
||||
trySend(location.asMapLibreLocationUpdate())
|
||||
}
|
||||
val locationRequestCompat = LocationRequestCompat.Builder(request.minimumInterval.inWholeMilliseconds)
|
||||
.setQuality(request.accuracy.toLocationRequestQuality())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user