Merge branch 'develop' into renovate/maplibre.compose

This commit is contained in:
ganfra
2026-09-28 17:42:16 +02:00
1034 changed files with 13225 additions and 5341 deletions
+2 -2
View File
@@ -13,8 +13,8 @@
"hostRules": [
{
"matchHost": "https://repo1.maven.org/maven2/",
"concurrentRequestLimit": 10,
"maxRequestsPerSecond": 10,
"concurrentRequestLimit": 5,
"maxRequestsPerSecond": 5,
"timeout": 10000,
}
],
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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'
+2 -2
View File
@@ -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'
+6 -6
View File
@@ -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'
+1 -1
View File
@@ -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'
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+3 -7
View File
@@ -63,7 +63,7 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
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,10 +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
- name: ⚙️ Run Compound screenshot tests
run: ./gradlew :libraries:compound:verifyRoborazziDebug $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
@@ -103,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
@@ -117,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
View File
@@ -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:
+3
View File
@@ -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 -1
View File
@@ -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
+61 -8
View File
@@ -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
View File
@@ -1,3 +1,66 @@
Changes in Element X v26.09.3
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.09.3 -->
## What's Changed
### ✨ Features
* Integrate the new timeline renderer behind a feature flag by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7722
* Add headers (`h1-h6`) tag support for the new timeline renderer by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7754
### 🙌 Improvements
* Add smooth animation to voice messages play progress by @bxdxnn in https://github.com/element-hq/element-x-android/pull/7698
* Add linkification to the new timeline renderer by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7731
### 🐛 Bugfixes
* Make global search use a `Joined` filter for room results by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7695
* Fix flaky test in `TimelinePresenterTest` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7702
* Close the threaded timelines created to create a poll, share a location or send media by @bmarty in https://github.com/element-hq/element-x-android/pull/7705
* Start sync manually after completing the auth flow by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7734
* Fix forwarding an event not working in threads by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7743
* Fix blank screen at launch when the homeserver expires the sliding sync session by @bmarty in https://github.com/element-hq/element-x-android/pull/7692
* Fix unread notification count in room list by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7749
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7699
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7736
### 🧱 Build
* Reduce parallel JVMs spawned for tests by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7701
* Replace git flow with regular git commands in the release script V2 by @bmarty in https://github.com/element-hq/element-x-android/pull/7707
* Remove Roborazzi, use Paparazzi for Compound by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7735
* Fix regex for invalid placeholders by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7738
* Add a script to install and launch an APK given a PR URL by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7752
### 🚧 In development 🚧
* New timeline event renderer: add new modules by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7721
* Add recent search results to global search by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7742
### Dependency upgrades
* Update dependency io.sentry:sentry-android to v8.55.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7685
* Update coil to v3.6.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7693
* Update kotlinpoet to v2.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7709
* Update plugin sonarqube to v7.5.0.8588 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7671
* Update roborazzi to v1.74.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7712
* Update dependency io.element.android:element-call-embedded to v0.26.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7715
* Use patched Konsist version (forked, 0.18.0) by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7719
* Update dependency net.zetetic:sqlcipher-android to v4.19.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7716
* Update metro to v1.4.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7711
* Update actions/setup-java action to v6.0.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7728
* Update dependency androidx.compose:compose-bom to v2026.09.00 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7730
* Update dependency androidx.sqlite:sqlite-ktx to v2.7.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7729
* Update dependencyAnalysis to v3.19.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7755
* Update media3 to v1.11.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7756
* Upgrade SDK bindings to `v26.09.26` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7780
### Others
* Let the user give a reason when removing a message by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7538
* Iterate on client creation by @bmarty in https://github.com/element-hq/element-x-android/pull/7591
* Iteration on Acount provider by @bmarty in https://github.com/element-hq/element-x-android/pull/7703
* Do not report detekt LargeClass issues on test classes by @bmarty in https://github.com/element-hq/element-x-android/pull/7713
* Let the user give an optional reason when removing a message by @bmarty in https://github.com/element-hq/element-x-android/pull/7679
* Make NotificationResultProcessor.emit wait until the batch is processed by @Johennes in https://github.com/element-hq/element-x-android/pull/7640
* Settings iteration: move user status and update multi-account section by @bmarty in https://github.com/element-hq/element-x-android/pull/7741
* Update remove message confirmation dialog. by @bmarty in https://github.com/element-hq/element-x-android/pull/7757
* Add a script to end all the sessions of a Matrix account using MAS by @bmarty in https://github.com/element-hq/element-x-android/pull/7762
* Settings iteration: split account settings and application settings. by @bmarty in https://github.com/element-hq/element-x-android/pull/7750
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.09.2...v26.09.3
Changes in Element X v26.09.2
=============================
+9 -7
View File
@@ -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")
}
}
@@ -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() {
@@ -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 ->
// 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
when (action) {
// Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often
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 &amp; Päivitä"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s ei enää tue vanhaa protokollaa. Kirjaudu ulos ja takaisin sisään jatkaaksesi sovelluksen käyttöä."</string>
<string name="banner_migrate_to_native_sliding_sync_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(),
+4 -23
View File
@@ -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)
@@ -153,7 +152,6 @@ allprojects {
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
val isScreenshotTest = project.gradle.startParameter.taskNames.any { it.contains("paparazzi", ignoreCase = true) }
val isRoborazziTest = project.gradle.startParameter.taskNames.any { it.contains("roborazzi", 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)
@@ -178,12 +176,10 @@ allprojects {
// Disable screenshot tests by default
exclude("ui/*.class")
exclude("translations/*.class")
if (isRoborazziTest.not()) {
// Roborazzi screenshot tests (:libraries:compound) live in a `screenshot` package, which
// the two patterns above do not match, so they used to run on every plain unit test run.
// They are verified by the dedicated `verifyRoborazziDebug` task instead.
exclude("**/screenshot/**")
}
// 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/**")
}
}
}
@@ -230,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 {
+1
View File
@@ -27,6 +27,7 @@ Click on this link and follow the instruction: [https://appdistribution.firebase
Nightly build can be built manually from your computer. You will need to retrieved some secrets from Passbolt and add them to your file `~/.gradle/gradle.properties`:
```
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
@@ -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>
@@ -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 {
+3 -2
View File
@@ -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)
@@ -36,6 +36,7 @@ class CurrentUserWithNeighborsBuilder {
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
avatarThumbnail = it.userAvatarData,
)
}
}
@@ -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,
@@ -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,
)
}
@@ -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(),
@@ -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
}
@@ -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,
)
}
@@ -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?,
@@ -36,77 +36,139 @@ 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(
// Not enabled, no preview
aGlobalSearchState(isEnabled = false),
aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Loading(),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Success(
GlobalSearchResults.RoomListResults(
results = aRoomListRoomSummaryList().mapIndexed { index, summary ->
summary.copy(name = "Room with Query #${index + 1}")
}.toPersistentList()
get() {
return sequenceOf(
// Not enabled, no preview
aGlobalSearchState(isEnabled = false),
aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Loading(),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Success(
GlobalSearchResults.RoomListResults(
results = aRoomListRoomSummaryList().mapIndexed { index, summary ->
summary.copy(name = "Room with Query #${index + 1}")
}.toPersistentList()
)
),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf(
MessageSearchResultItem.Message(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")),
body = "A message with Query",
roomInfo = aRoomInfo(),
formattedTimestamp = "12:00",
),
MessageSearchResultItem.Media(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")),
mediaContent = MediaSearchResultContent(
filename = "file.png",
extension = "PNG",
caption = "A caption containing Query",
formattedSize = "1 MB",
thumbnailSource = MediaSource("https://example.com/thumbnail.png"),
thumbnailType = AttachmentThumbnailType.Image,
blurhash = null,
),
roomInfo = aRoomInfo(),
formattedTimestamp = "12:00",
),
))),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())),
),
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,
),
),
),
)
)
),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf(
MessageSearchResultItem.Message(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")),
body = "A message with Query",
roomInfo = aRoomInfo(),
formattedTimestamp = "12:00",
),
MessageSearchResultItem.Media(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")),
mediaContent = MediaSearchResultContent(
filename = "file.png",
extension = "PNG",
caption = "A caption containing Query",
formattedSize = "1 MB",
thumbnailSource = MediaSource("https://example.com/thumbnail.png"),
thumbnailType = AttachmentThumbnailType.Image,
blurhash = null,
),
roomInfo = aRoomInfo(),
formattedTimestamp = "12:00",
),
))),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())),
),
)
)
}
}
fun aRoomInfo(
id: RoomId = RoomId("!roomId:server.org"),
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
)
@@ -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,63 +125,11 @@ 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 = {
// The stateSaver will keep the selection state when returning to this UI
val focusRequester = remember { FocusRequester() }
val searchLabel = stringResource(CommonStrings.action_search)
FilledTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.semantics { contentDescription = searchLabel },
state = state.queryState,
lineLimits = TextFieldLineLimits.SingleLine,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
),
trailingIcon = if (state.queryState.text.isNotEmpty()) {
@Composable {
IconButton(onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.a11y_clear_search_field)
)
}
}
} else {
null
},
)
LaunchedEffect(Unit) {
if (!focusRequester.restoreFocusedChild()) {
focusRequester.requestFocus()
}
focusRequester.saveFocusedChild()
}
}
title = { SearchTopAppBarContent(state = state) }
)
},
contentWindowInsets = scaffoldScrollableContentInsets,
@@ -179,27 +137,9 @@ private fun GlobalSearchContent(
Column(
modifier = Modifier.padding(padding),
) {
SingleChoiceSegmentedButtonRow(
modifier = drawBottomLineModifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
) {
SegmentedButton(
index = 0,
count = 2,
selected = state.currentTarget == GlobalSearchTarget.ROOMS,
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS)) },
text = stringResource(R.string.search_section_chats),
)
HorizontalDivider(color = ElementTheme.colors.borderInteractivePrimary)
SegmentedButton(
index = 1,
count = 2,
selected = state.currentTarget == GlobalSearchTarget.MESSAGES,
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) },
text = stringResource(R.string.search_section_messages),
)
}
SearchTargetSelector(state = state)
val lazyListState = rememberLazyListState()
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
@@ -212,9 +152,21 @@ private fun GlobalSearchContent(
state = lazyListState,
) {
val results = state.results.dataOrNull()
val historyResults = state.history.dataOrNull()?.toImmutableList() ?: persistentListOf()
when {
state.results.isUninitialized() -> startSearching()
state.results.isLoading() -> loading()
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,
@@ -230,6 +182,89 @@ private fun GlobalSearchContent(
}
}
@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)
FilledTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.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,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
),
trailingIcon = if (state.queryState.text.isNotEmpty()) {
@Composable {
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),
)
}
}
} else {
null
},
)
LaunchedEffect(Unit) {
if (!focusRequester.restoreFocusedChild()) {
focusRequester.requestFocus()
}
focusRequester.saveFocusedChild()
}
}
@Composable
private fun SearchTargetSelector(state: GlobalSearchState) {
if (state.queryState.text.isEmpty()) return
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
) {
SegmentedButton(
index = 0,
count = 2,
selected = state.currentTarget == GlobalSearchTarget.ROOMS,
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS)) },
text = stringResource(R.string.search_section_chats),
)
SegmentedButton(
index = 1,
count = 2,
selected = state.currentTarget == GlobalSearchTarget.MESSAGES,
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) },
text = stringResource(R.string.search_section_messages),
)
}
}
private fun LazyListScope.startSearching() {
item {
IconTitleSubtitleMolecule(
@@ -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())
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
import android.content.Context
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.encrypteddb.crypto.EncryptedFile
import java.io.File
private const val SEARCH_HISTORY_FILE_NAME = "search_history"
/**
* Persists the search history in an encrypted file inside the current session file directory, using
* [EncryptedFile] (Tink StreamingAead + a master key stored in the Android Keystore).
*/
@ContributesBinding(SessionScope::class)
class DefaultSearchHistoryFileStore(
@ApplicationContext private val context: Context,
matrixClient: MatrixClient,
) : SearchHistoryFileStore {
private val file: File by lazy {
File(matrixClient.sessionPaths.fileDirectory, SEARCH_HISTORY_FILE_NAME)
}
override fun read(): ByteArray? {
if (!file.exists()) return null
return EncryptedFile(context, file).openFileInput().use { it.readBytes() }
}
override fun write(bytes: ByteArray) {
file.parentFile?.takeIf { !it.exists() }?.mkdirs()
// Opening a FileOutputStream truncates any previous content, so this overwrites the file.
EncryptedFile(context, file).openFileOutput().use { it.write(bytes) }
}
override fun delete(): Boolean {
return file.delete()
}
}
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
@ContributesBinding(SessionScope::class)
class DefaultSearchHistoryStore(
private val fileStore: SearchHistoryFileStore,
private val dispatchers: CoroutineDispatchers,
private val jsonProvider: JsonProvider,
) : SearchHistoryStore {
private val json by lazy { jsonProvider() }
// Guards [fileStore] access and the lazy initialization of [cache].
private val mutex = Mutex()
private val cache = MutableStateFlow<List<SearchHistoryResult>>(emptyList())
private var isLoaded = false
override val history: Flow<List<SearchHistoryResult>> = flow {
ensureLoaded()
emitAll(cache)
}
override suspend fun add(result: SearchHistoryResult) = withContext(dispatchers.io) {
mutex.withLock {
ensureLoadedLocked()
val updated = buildList {
add(result)
// Deduplicate: an equal entry is moved to the front rather than being duplicated.
addAll(cache.value.filterNot { it == result })
}
.distinct()
.take(MAX_SEARCH_HISTORY_SIZE)
cache.value = updated
persist(updated)
}
}
override suspend fun clear() = withContext<Unit>(dispatchers.io) {
mutex.withLock {
isLoaded = true
cache.value = emptyList()
runCatchingExceptions { fileStore.delete() }
.onFailure { Timber.e(it, "Failed to delete search history") }
}
}
private suspend fun ensureLoaded() = withContext(dispatchers.io) {
mutex.withLock {
ensureLoadedLocked()
}
}
/**
* Must be called while holding [mutex].
*/
private fun ensureLoadedLocked() {
if (isLoaded) return
cache.value = readFromDisk()
isLoaded = true
}
private fun readFromDisk(): List<SearchHistoryResult> {
return runCatchingExceptions {
val bytes = fileStore.read() ?: return@runCatchingExceptions emptyList()
json.decodeFromString<List<SearchHistoryResult>>(bytes.decodeToString())
}.getOrElse {
Timber.e(it, "Failed to read search history, resetting it")
emptyList()
}
}
private fun persist(items: List<SearchHistoryResult>) {
runCatchingExceptions {
fileStore.write(json.encodeToString(items).encodeToByteArray())
}.onFailure { Timber.e(it, "Failed to persist search history") }
}
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
/**
* Low level, raw byte read/write of the persisted search history.
*
* This exists as a seam so that [DefaultSearchHistoryStore] can be unit tested without relying on
* the Android Keystore. The production implementation ([DefaultSearchHistoryFileStore]) stores the
* bytes in an encrypted file.
*/
interface SearchHistoryFileStore {
/**
* Return the persisted bytes, or `null` if nothing has been persisted yet.
*/
fun read(): ByteArray?
/**
* Persist [bytes], replacing any previously persisted content.
*/
fun write(bytes: ByteArray)
/**
* Delete any persisted content.
*
* @return `true` if the persisted content was deleted, `false` if there was no persisted content to delete.
*/
fun delete(): Boolean
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.serialization.Serializable
/**
* A single entry in the global search history.
*
* The history stores either the term used to perform a search (when the keyboard search action is
* triggered) or the id of a room opened from the search results.
*/
@Serializable
sealed interface SearchHistoryResult {
@Serializable
data class Query(val term: String) : SearchHistoryResult
@Serializable
data class Room(val roomId: RoomId) : SearchHistoryResult
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
import kotlinx.coroutines.flow.Flow
/**
* Stores the global search history for the current session.
*
* The history is persisted in an encrypted file and capped to [MAX_SEARCH_HISTORY_SIZE] entries.
* The most recent entry is always at the front of the list; when the cap is reached the oldest
* entries are dropped.
*/
interface SearchHistoryStore {
/**
* Emits the current search history, most recent entry first, and re-emits whenever it changes.
*/
val history: Flow<List<SearchHistoryResult>>
/**
* Add a new [result] at the front of the history.
*
* If an equal entry already exists it is moved to the front instead of being duplicated. When
* the history exceeds [MAX_SEARCH_HISTORY_SIZE] entries the oldest ones are dropped.
*/
suspend fun add(result: SearchHistoryResult)
/**
* Remove every entry from the history.
*/
suspend fun clear()
}
/**
* Maximum number of entries kept in the search history.
*/
const val MAX_SEARCH_HISTORY_SIZE = 25
@@ -14,7 +14,7 @@
<string name="confirm_recovery_key_banner_title">"Vaše úložiště klíčů není synchronizováno"</string>
<string name="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>
@@ -220,4 +220,30 @@ class CurrentUserWithNeighborsBuilderTest {
)
)
}
@Test
fun `other accounts use the data from db, including the avatar thumbnail`() {
val sut = CurrentUserWithNeighborsBuilder()
val matrixUser = aMatrixUser(id = A_USER_ID.value)
val list = listOf(
aSessionData(
sessionId = A_USER_ID.value,
),
aSessionData(
sessionId = A_USER_ID_2.value,
userDisplayName = "Carol",
userAvatarUrl = "avatarUrl",
userAvatarData = "avatarData",
),
)
val result = sut.build(matrixUser, list)
assertThat(result[0]).isEqualTo(
MatrixUser(
userId = A_USER_ID_2,
displayName = "Carol",
avatarUrl = "avatarUrl",
avatarThumbnail = "avatarData",
)
)
}
}
@@ -81,7 +81,7 @@ class HomePresenterTest {
val presenter = createHomePresenter(
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 {
@@ -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,
)
}
@@ -0,0 +1,275 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.home.impl.search
import androidx.activity.ComponentActivity
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.model.aRoomListRoomSummary
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParams
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
class GlobalSearchViewTest : RobolectricTest() {
@Test
fun `back button - emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
pressBack()
eventsRecorder.assertSingle(GlobalSearchEvent.ToggleSearchVisibility)
}
@Test
fun `back key - with search active toggles the search`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
pressBackKey()
// Advance time to let the event be processed, as the search toggle might have some delay (e.g. for the animation)
mainClock.advanceTimeBy(1)
eventsRecorder.assertSingle(GlobalSearchEvent.ToggleSearchVisibility)
}
@Test
fun `clear query button - emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState("Query"),
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
val clearSearch = activity!!.getString(CommonStrings.a11y_clear_search_field)
onNodeWithContentDescription(clearSearch).performClick()
eventsRecorder.assertSingle(GlobalSearchEvent.ClearQuery)
}
@Test
fun `search target selector - clicking on 'Chats' emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
clickOn(R.string.search_section_chats)
eventsRecorder.assertSingle(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS))
}
@Test
fun `search target selector - clicking on 'Messages' emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
clickOn(R.string.search_section_messages)
eventsRecorder.assertSingle(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
}
@Test
fun `clicking on a room result - invokes the callback and saves it to the history`() = runAndroidComposeUiTest {
val roomId = RoomId("!aRoom:server.org")
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
val onSelectSearchResult = EnsureCalledOnceWithTwoParams<RoomId, EventId?>(roomId, null)
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Success(
GlobalSearchResults.RoomListResults(
persistentListOf(
aRoomListRoomSummary(id = roomId.value, name = "A room result"),
)
)
),
eventSink = eventsRecorder,
),
onSelectSearchResult = onSelectSearchResult,
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithText("A room result").performClick()
onSelectSearchResult.assertSuccess()
eventsRecorder.assertSingle(GlobalSearchEvent.SaveRoomToHistory(roomId))
}
@Test
fun `clicking on a message result - invokes the callback and saves it to the history`() = runAndroidComposeUiTest {
val roomId = RoomId("!aMessageRoom:server.org")
val eventId = EventId("\$anEvent:server.org")
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
val onSelectSearchResult = EnsureCalledOnceWithTwoParams<RoomId, EventId?>(roomId, eventId)
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(
GlobalSearchResults.MessageSearchResults(
persistentListOf(
MessageSearchResultItem.Message(
messageSearchResult = aMessageSearchResult(roomId = roomId, eventId = eventId),
body = "A message body",
roomInfo = aRoomInfo(id = roomId, name = "A message room"),
formattedTimestamp = "12:00",
),
)
)
),
eventSink = eventsRecorder,
),
onSelectSearchResult = onSelectSearchResult,
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithText("A message room").performClick()
onSelectSearchResult.assertSuccess()
eventsRecorder.assertSingle(GlobalSearchEvent.SaveRoomToHistory(roomId))
}
@Test
fun `clicking on a history query result - emits the expected Event`() = runAndroidComposeUiTest {
val queryResult = SearchHistoryResultItem.Query("Recent term")
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState(),
results = AsyncData.Uninitialized,
history = AsyncData.Success(persistentListOf(queryResult)),
eventSink = eventsRecorder,
),
// A query history result must not trigger the search result callback
onSelectSearchResult = EnsureNeverCalledWithTwoParams(),
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithText("Recent term").performClick()
eventsRecorder.assertSingle(GlobalSearchEvent.SearchHistoryResultSelected(queryResult))
}
@Test
fun `clicking on a history room result - invokes the callback and emits the expected Events`() = runAndroidComposeUiTest {
val roomId = RoomId("!aHistoryRoom:server.org")
val roomResult = SearchHistoryResultItem.Room(
roomId = roomId,
roomInfo = aRoomInfo(id = roomId, name = "A history room"),
)
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
val onSelectSearchResult = EnsureCalledOnceWithTwoParams<RoomId, EventId?>(roomId, null)
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState(),
results = AsyncData.Uninitialized,
history = AsyncData.Success(persistentListOf(roomResult)),
eventSink = eventsRecorder,
),
onSelectSearchResult = onSelectSearchResult,
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithText("A history room").performClick()
onSelectSearchResult.assertSuccess()
eventsRecorder.assertList(
listOf(
GlobalSearchEvent.SaveRoomToHistory(roomId),
GlobalSearchEvent.SearchHistoryResultSelected(roomResult),
)
)
}
@Test
fun `with a query and uninitialized results - displays the start searching placeholder`() = runAndroidComposeUiTest {
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState("Query"),
results = AsyncData.Uninitialized,
),
)
val startSearching = activity!!.getString(R.string.start_searching_title)
onNodeWithText(startSearching).assertIsDisplayed()
}
@Test
fun `with empty results - displays the no results placeholder`() = runAndroidComposeUiTest {
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.RoomListResults(persistentListOf())),
),
)
val noResults = activity!!.getString(R.string.search_no_results_title)
onNodeWithText(noResults).assertIsDisplayed()
}
private fun AndroidComposeUiTest<ComponentActivity>.setGlobalSearchView(
state: GlobalSearchState,
onSelectSearchResult: (RoomId, EventId?) -> Unit = EnsureNeverCalledWithTwoParams(),
) {
setContent {
GlobalSearchView(
state = state,
onSelectSearchResult = onSelectSearchResult,
)
}
}
}
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Test
class DefaultSearchHistoryStoreTest {
@Test
fun `history is empty when nothing has been persisted`() = runTest {
val store = createStore()
assertThat(store.history.first()).isEmpty()
}
@Test
fun `add prepends new entries so the most recent is first`() = runTest {
val store = createStore()
store.add(SearchHistoryResult.Query("first"))
store.add(SearchHistoryResult.Query("second"))
assertThat(store.history.first()).containsExactly(
SearchHistoryResult.Query("second"),
SearchHistoryResult.Query("first"),
).inOrder()
}
@Test
fun `add supports both queries and rooms`() = runTest {
val store = createStore()
store.add(SearchHistoryResult.Query("term"))
store.add(SearchHistoryResult.Room(A_ROOM_ID))
assertThat(store.history.first()).containsExactly(
SearchHistoryResult.Room(A_ROOM_ID),
SearchHistoryResult.Query("term"),
).inOrder()
}
@Test
fun `adding an existing entry moves it to the front without duplicating`() = runTest {
val store = createStore()
store.add(SearchHistoryResult.Query("a"))
store.add(SearchHistoryResult.Query("b"))
store.add(SearchHistoryResult.Query("a"))
assertThat(store.history.first()).containsExactly(
SearchHistoryResult.Query("a"),
SearchHistoryResult.Query("b"),
).inOrder()
}
@Test
fun `history is capped to the maximum size, dropping the oldest entries`() = runTest {
val store = createStore()
repeat(MAX_SEARCH_HISTORY_SIZE + 10) { index ->
store.add(SearchHistoryResult.Query("query$index"))
}
val history = store.history.first()
assertThat(history).hasSize(MAX_SEARCH_HISTORY_SIZE)
// Newest entry is first...
assertThat(history.first()).isEqualTo(SearchHistoryResult.Query("query${MAX_SEARCH_HISTORY_SIZE + 9}"))
// ...and the oldest ones have been dropped.
assertThat(history).doesNotContain(SearchHistoryResult.Query("query0"))
assertThat(history.last()).isEqualTo(SearchHistoryResult.Query("query10"))
}
@Test
fun `persisted history is deserialized back into SearchHistoryResult items`() = runTest {
val fileStore = FakeSearchHistoryFileStore()
createStore(fileStore).apply {
add(SearchHistoryResult.Query("persisted term"))
add(SearchHistoryResult.Room(A_ROOM_ID))
}
// A brand new store backed by the same file reads the same content back.
val reloaded = createStore(fileStore)
assertThat(reloaded.history.first()).containsExactly(
SearchHistoryResult.Room(A_ROOM_ID),
SearchHistoryResult.Query("persisted term"),
).inOrder()
}
@Test
fun `clear removes every entry and deletes the persisted content`() = runTest {
val fileStore = FakeSearchHistoryFileStore()
val store = createStore(fileStore)
store.add(SearchHistoryResult.Query("term"))
store.clear()
assertThat(store.history.first()).isEmpty()
assertThat(fileStore.content).isNull()
}
@Test
fun `corrupted content is ignored and results in an empty history`() = runTest {
val fileStore = FakeSearchHistoryFileStore(initialContent = "not valid json".toByteArray())
val store = createStore(fileStore)
assertThat(store.history.first()).isEmpty()
// The store recovers and can still add new entries.
store.add(SearchHistoryResult.Room(RoomId("!recovered:domain")))
assertThat(store.history.first()).containsExactly(SearchHistoryResult.Room(RoomId("!recovered:domain")))
}
private fun TestScope.createStore(
fileStore: SearchHistoryFileStore = FakeSearchHistoryFileStore(),
) = DefaultSearchHistoryStore(
fileStore = fileStore,
dispatchers = testCoroutineDispatchers(),
jsonProvider = { Json { ignoreUnknownKeys = true } }
)
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
/**
* In-memory [SearchHistoryFileStore] used to test [DefaultSearchHistoryStore] without the Android Keystore.
*/
class FakeSearchHistoryFileStore(
initialContent: ByteArray? = null,
) : SearchHistoryFileStore {
var content: ByteArray? = initialContent
private set
var writeCount = 0
private set
override fun read(): ByteArray? = content
override fun write(bytes: ByteArray) {
content = bytes
writeCount++
}
override fun delete(): Boolean {
content = null
return true
}
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeSearchHistoryStore : SearchHistoryStore {
private val state = MutableStateFlow<List<SearchHistoryResult>>(emptyList())
override val history: Flow<List<SearchHistoryResult>> = state
override suspend fun add(result: SearchHistoryResult) {
state.value = buildList {
add(result)
addAll(state.value.filterNot { it == result })
}.take(MAX_SEARCH_HISTORY_SIZE)
}
override suspend fun clear() {
state.value = emptyList()
}
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import kotlinx.serialization.json.Json
import org.junit.Test
class SearchHistoryResultSerializationTest {
private val json = Json
@Test
fun `Query round-trips through serialization`() {
val original: SearchHistoryResult = SearchHistoryResult.Query("hello world")
val decoded = json.decodeFromString<SearchHistoryResult>(json.encodeToString(original))
assertThat(decoded).isEqualTo(original)
}
@Test
fun `Room round-trips through serialization`() {
val original: SearchHistoryResult = SearchHistoryResult.Room(A_ROOM_ID)
val decoded = json.decodeFromString<SearchHistoryResult>(json.encodeToString(original))
assertThat(decoded).isEqualTo(original)
}
@Test
fun `RoomId is serialized as its raw string value`() {
val encoded = json.encodeToString<SearchHistoryResult>(SearchHistoryResult.Room(A_ROOM_ID))
assertThat(encoded).contains(A_ROOM_ID.value)
}
@Test
fun `a mixed list round-trips preserving order`() {
val original = listOf(
SearchHistoryResult.Query("first"),
SearchHistoryResult.Room(A_ROOM_ID),
SearchHistoryResult.Query("second"),
SearchHistoryResult.Room(A_ROOM_ID_2),
)
val decoded = json.decodeFromString<List<SearchHistoryResult>>(json.encodeToString(original))
assertThat(decoded).containsExactlyElementsIn(original).inOrder()
}
}
@@ -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
@@ -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>
@@ -217,8 +217,8 @@ class ShareLocationPresenter(
val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply
val inReplyToEventId = replyMode?.eventId
val geoUri = event.location.toGeoUri()
getTimeline().flatMap {
it.sendLocation(
withTimeline { timeline ->
timeline.sendLocation(
body = generateBody(geoUri),
geoUri = geoUri,
description = null,
@@ -237,10 +237,22 @@ class ShareLocationPresenter(
)
}
private suspend fun getTimeline(): Result<Timeline> {
/**
* Invokes [block] with the [Timeline] matching [timelineMode].
*
* For a thread, a dedicated timeline is created and closed once [block] returns, since such a
* timeline owns SDK resources. For the other modes, the live timeline is used, and is not
* closed here since it is owned by its room.
*/
private suspend fun <T> withTimeline(block: suspend (Timeline) -> Result<T>): Result<T> {
return when (timelineMode) {
is Timeline.Mode.Thread -> room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId))
else -> Result.success(room.liveTimeline)
is Timeline.Mode.Thread -> {
room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId))
.flatMap { threadedTimeline ->
threadedTimeline.use { block(it) }
}
}
else -> block(room.liveTimeline)
}
}
}
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Το ιστορικό ζωντανής τοποθεσίας σας θα αποθηκευτεί στην αίθουσα και θα είναι ορατό στα μέλη μετά το τέλος της συνεδρίας."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Επιλέξτε για πόσο χρονικό διάστημα θα κοινοποιείτε την τρέχουσα τοποθεσία σας."</string>
<string name="screen_share_location_live_location_missing_permissions">"Δεν έχετε δικαιώματα για να μοιραστείτε την τρέχουσα τοποθεσία σας σε αυτήν την αίθουσα"</string>
</resources>
@@ -708,6 +708,45 @@ class ShareLocationPresenterTest {
}
}
@Test
fun `ShareStaticLocation in a thread closes the created timeline`() = runTest {
val threadedTimeline = FakeTimeline(mode = Timeline.Mode.Thread(A_THREAD_ID)).apply {
sendLocationLambda = { _, _, _, _, _, _ -> Result.success(Unit) }
}
val liveTimeline = FakeTimeline()
val joinedRoom = FakeJoinedRoom(
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(threadedTimeline) },
)
val shareLocationPresenter = createShareLocationPresenter(
joinedRoom = joinedRoom,
timelineMode = Timeline.Mode.Thread(A_THREAD_ID),
)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
shareLocationPresenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(
ShareLocationEvent.ShareStaticLocation(
location = Location(lat = 3.0, lon = 4.0, accuracy = 5.0f),
isPinned = false,
)
)
advanceUntilIdle()
assertThat(threadedTimeline.closeCounter).isEqualTo(1)
assertThat(liveTimeline.closeCounter).isEqualTo(0)
cancelAndIgnoreRemainingEvents()
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1)
return awaitItem()
@@ -16,7 +16,7 @@
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
<string name="screen_change_server_error_element_pro_required_message">"Na %1$s je vyžadována aplikace Element Pro. Stáhněte si ji prosím z obchodu."</string>
<string name="screen_change_server_error_element_pro_required_title">"Vyžadován Element Pro"</string>
<string name="screen_change_server_error_invalid_homeserver">"Nepodařilo se nám připojit k tomuto domovskému serveru. Zkontrolujte prosím, zda jste správně zadali adresu URL domovského serveru. Pokud je adresa URL správná, obraťte se na správce domovského serveru, který vám poskytne další pomoc."</string>
<string name="screen_change_server_error_invalid_homeserver">"Nepodařilo se nám spojit s tímto poskytovatelem účtu. Zkontrolujte prosím, zda jste správně zadali URL adresu poskytovatele účtu. Pokud je URL adresa správná, obraťte se na administrátora poskytovatele účtu, který vám poskytne další pomoc."</string>
<string name="screen_change_server_error_invalid_well_known">"Server není k dispozici kvůli problému se souborem well-known:
%1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Vybraný poskytovatel účtu nepodporuje klouzavou synchronizaci. Pro použití %1$s je nutná aktualizace serveru."</string>
@@ -32,7 +32,7 @@
<string name="screen_login_error_invalid_credentials">"Nesprávné uživatelské jméno nebo heslo"</string>
<string name="screen_login_error_invalid_user_id">"Toto není platný identifikátor uživatele. Očekávaný formát: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_refresh_tokens">"Tento server je nakonfigurován tak, aby používal obnovovací tokeny. Ty nejsou podporovány při použití přihlašovacích údajů založených na hesle."</string>
<string name="screen_login_error_unsupported_authentication">"Vybraný domovský server nepodporuje přihlášení pomocí hesla nebo OAuth. Kontaktujte prosím svého správce nebo vyberte jiný domovský server."</string>
<string name="screen_login_error_unsupported_authentication">"Vybraný poskytovatel účtu nepodporuje přihlášení pomocí hesla ani OAuth. Kontaktujte prosím svého poskytovatele účtu nebo zvolte jiného."</string>
<string name="screen_login_form_header">"Zadejte své údaje"</string>
<string name="screen_login_subtitle">"Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci."</string>
<string name="screen_login_title">"Vítejte zpět!"</string>
@@ -16,7 +16,7 @@
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
<string name="screen_change_server_error_element_pro_required_message">"Element Pro -sovellus on pakollinen %1$s -palvelimella. Lataa se sovelluskaupasta."</string>
<string name="screen_change_server_error_element_pro_required_title">"Element Pro vaaditaan"</string>
<string name="screen_change_server_error_invalid_homeserver">"Kotipalvelimeen ei saatu yhteyttä. Varmista, että olet syöttänyt osoitteen oikein. Jos osoite on oikein, ota yhteyttä palvelimesi ylläpitäjään."</string>
<string name="screen_change_server_error_invalid_homeserver">"Palveluntarjoajaan ei saatu yhteyttä. Varmista, että olet syöttänyt osoitteen oikein. Jos osoite on oikein, ota yhteyttä palveluntarjoajasi ylläpitäjään."</string>
<string name="screen_change_server_error_invalid_well_known">"Palvelin ei ole saatavilla .well-known tiedostossa olevan ongelman vuoksi:
%1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Valitsemasi palveluntarjoaja ei tue sliding syncia. Palvelimen päivitys tarvitaan %1$s -sovelluksen käyttämiseen."</string>
@@ -40,7 +40,7 @@
<string name="screen_login_error_invalid_credentials">"Väärä käyttäjänimi ja/tai salasana"</string>
<string name="screen_login_error_invalid_user_id">"Tämä ei ole kelvollinen käyttäjätunnus. Odotettu muoto: \'@käyttäjä:kotipalvelin.fi\'"</string>
<string name="screen_login_error_refresh_tokens">"Tämä palvelin on määritetty käyttämään refresh tokeneja. Näitä ei tueta salasanapohjaisen kirjautumisen kanssa."</string>
<string name="screen_login_error_unsupported_authentication">"Valitsemasi kotipalvelin ei tue salasana- tai OAuth-kirjautumista. Ota yhteyttä palvelimesi ylläpitäjään tai valitse toinen kotipalvelin."</string>
<string name="screen_login_error_unsupported_authentication">"Valitsemasi palveluntarjoaja ei tue salasana- tai OAuth-kirjautumista. Ota yhteyttä palveluntarjoajaasi tai valitse toinen."</string>
<string name="screen_login_form_header">"Anna tietosi"</string>
<string name="screen_login_subtitle">"Matrix on avoin verkko turvallista, hajautettua viestintää varten."</string>
<string name="screen_login_title">"Tervetuloa takaisin!"</string>
@@ -89,7 +89,7 @@ Yritä kirjautua sisään manuaalisesti tai skannaa QR-koodi toisella laitteella
<string name="screen_qr_code_login_error_sliding_sync_not_supported_title">"%1$s -sovellusta ei tueta"</string>
<string name="screen_qr_code_login_initial_state_button_title">"Valmis skannaamaan"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Avaa %1$s tietokoneella"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Napsauta avatariasi"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Klikkaa avatariasi"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Valitse %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Yhdistä uusi laite”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Skannaa QR-koodi tällä laitteella"</string>
@@ -25,8 +25,16 @@
<string name="screen_change_server_error_unauthorized_homeserver_title">"A(z) %1$s fiókszolgáltató nem engedélyezett."</string>
<string name="screen_change_server_form_header">"Matrix-kiszolgáló webcíme"</string>
<string name="screen_change_server_form_notice">"Adjon meg egy domaincímet."</string>
<string name="screen_change_server_navigation_title_login">"Bejelentkezés"</string>
<string name="screen_change_server_navigation_title_register">"Fiók létrehozása"</string>
<string name="screen_change_server_subtitle">"Mi a kiszolgálója címe?"</string>
<string name="screen_change_server_textfield_footer_login">"Ha fiókját a vállalata vagy egy közösség biztosítja, akkor módosítsa a fiók szolgáltatóját (például: companyname.com)."</string>
<string name="screen_change_server_textfield_footer_register">"Hozzon létre ingyenes fiókot a matrix.org oldalon. Ha más szolgáltatónál szeretne fiókot létrehozni, akkor módosítsa a nevet ennek megfelelően (például: communityname.org)."</string>
<string name="screen_change_server_textfield_header">"Fiókszolgáltató"</string>
<string name="screen_change_server_textfield_placeholder">"example.com"</string>
<string name="screen_change_server_title">"Válassza ki a kiszolgálóját"</string>
<string name="screen_change_server_title_login">"Adja meg a fiókszolgáltatóját"</string>
<string name="screen_change_server_title_register">"Válasszon fiókszolgáltatót"</string>
<string name="screen_create_account_title">"Fiók létrehozása"</string>
<string name="screen_login_error_deactivated_account">"Ez a fiók törölve lett."</string>
<string name="screen_login_error_invalid_credentials">"Helytelen felhasználónév vagy jelszó"</string>
@@ -16,7 +16,7 @@
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
<string name="screen_change_server_error_element_pro_required_message">"%1$s では Element Pro を使用する必要があります。アプリストアよりダウンロードしてください。"</string>
<string name="screen_change_server_error_element_pro_required_title">"Element Pro が必要です"</string>
<string name="screen_change_server_error_invalid_homeserver">"このホームサーバーに接続できませんでした。正しいURLを入力したことを確認し、問題が継続する場合は、ホームサーバーの管理者に問い合わせてください。"</string>
<string name="screen_change_server_error_invalid_homeserver">"このアカウント提供元に接続できませんでした。正しいURLを入力したことを確認し、問題が継続する場合は、アカウント提供元の管理者に問い合わせてください。"</string>
<string name="screen_change_server_error_invalid_well_known">".well-knownファイルに問題があるためサーバーを使用できません: %1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"このアカウント提供元は、スライド同期に対応していません。%1$s を使用するにはサーバーのアップグレードが必要です。"</string>
<string name="screen_change_server_error_unauthorized_homeserver">"%2$s は %1$s からの接続を許可していません。"</string>
@@ -39,7 +39,7 @@
<string name="screen_login_error_invalid_credentials">"ユーザー名またはパスワードが違います"</string>
<string name="screen_login_error_invalid_user_id">"無効なユーザーIDです。正しい形式は \"@ユーザー:ホームサーバー\" です。"</string>
<string name="screen_login_error_refresh_tokens">"このサーバーはリフレッシュトークンを使用します。パスワードを使用したログインとは併用できません。"</string>
<string name="screen_login_error_unsupported_authentication">"指定したホームサーバはパスワードまたはOAuthによるログインに対応していません。管理者に問い合わせるか、異なるホームサーバーを使用してください。"</string>
<string name="screen_login_error_unsupported_authentication">"指定したアカウント提供元はパスワードまたはOAuthによるログインに対応していません。管理者に問い合わせるか、異なるアカウント提供元を使用してください。"</string>
<string name="screen_login_form_header">"詳細を入力"</string>
<string name="screen_login_subtitle">"Matrix は安全で分散型のオープンなネットワークです。"</string>
<string name="screen_login_title">"お待ちしておりました。"</string>
@@ -16,7 +16,7 @@
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
<string name="screen_change_server_error_element_pro_required_message">"Element Pro-appen krävs på %1$s. Ladda ner den från butiken."</string>
<string name="screen_change_server_error_element_pro_required_title">"Element Pro krävs"</string>
<string name="screen_change_server_error_invalid_homeserver">"Vi kunde inte nå den här hemservern. Kontrollera att du har angett hemserverns URL korrekt. Om URL:en är korrekt kontaktar du administratören för hemservern för ytterligare hjälp."</string>
<string name="screen_change_server_error_invalid_homeserver">"Vi kunde inte nå den här kontoleverantören. Kontrollera att du har angett kontoleverantörens URL korrekt. Om URL:en är korrekt kontaktar du administratören för kontoleverantören för ytterligare hjälp."</string>
<string name="screen_change_server_error_invalid_well_known">"Sliding Sync är inte tillgängligt på grund av ett problem i .well-known-filen:
%1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Den valda kontoleverantören stöder inte sliding sync. En uppgradering till servern behövs för att använda %1$s."</string>
@@ -32,7 +32,7 @@
<string name="screen_login_error_invalid_credentials">"Felaktigt användarnamn och/eller lösenord"</string>
<string name="screen_login_error_invalid_user_id">"Detta är inte en giltig användaridentifierare. Förväntat format: \'@användare:hemserver.org\'"</string>
<string name="screen_login_error_refresh_tokens">"Den här servern är konfigurerad för att använda uppdateringstokens. Dessa stöds inte när du använder lösenordsbaserad inloggning."</string>
<string name="screen_login_error_unsupported_authentication">"Den valda hemservern stöder inte lösenord eller OAuth-inloggning. Kontakta administratören eller välj en annan hemserver."</string>
<string name="screen_login_error_unsupported_authentication">"Den valda kontoleverantören stöder inte lösenords- eller OAuth-inloggning. Kontakta kontoleverantören eller välj en annan."</string>
<string name="screen_login_form_header">"Ange dina uppgifter"</string>
<string name="screen_login_subtitle">"Matrix är ett öppet nätverk för säker, decentraliserad kommunikation."</string>
<string name="screen_login_title">"Välkommen tillbaka!"</string>
@@ -16,7 +16,7 @@
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
<string name="screen_change_server_error_element_pro_required_message">"%1$s 需要 Element Pro 應用程式。請從商店下載。"</string>
<string name="screen_change_server_error_element_pro_required_title">"需要 Element Pro"</string>
<string name="screen_change_server_error_invalid_homeserver">"我們無法連線至此家伺服器。請檢查您是否已正確輸入家伺服器 URL。若 URL 正確,請聯絡您家伺服器的管理員以取得進一步協助。"</string>
<string name="screen_change_server_error_invalid_homeserver">"我們無法連線至此帳號提供者。請確認您輸入的帳號提供者網址是否正確。如果網址正確,請聯絡您的帳號提供者管理員以取得進一步協助。"</string>
<string name="screen_change_server_error_invalid_well_known">"因為 well-known 檔案的問題,伺服器不可用:
%1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"選定的帳號提供者不支援 sliding sync。必須升級伺服器才能使用 %1$s。"</string>
@@ -40,7 +40,7 @@
<string name="screen_login_error_invalid_credentials">"不正確的使用者名稱或密碼"</string>
<string name="screen_login_error_invalid_user_id">"此非有效的使用者識別字串。預期的格式:‘@user:homeserver.org’"</string>
<string name="screen_login_error_refresh_tokens">"此伺服器已設定為使用重新整理權杖。使用以密碼為基礎的登入方式時,不支援這些功能。"</string>
<string name="screen_login_error_unsupported_authentication">"選定的家伺服器不支援密碼或 OAuth 登入。請聯絡您的管理員或選擇其他家伺服器。"</string>
<string name="screen_login_error_unsupported_authentication">"您選擇的帳號提供者不支援密碼或 OAuth 登入。請聯絡您的帳號提供者,或選擇其他帳號提供者。"</string>
<string name="screen_login_form_header">"輸入您的詳細資料"</string>
<string name="screen_login_subtitle">"Matrix 是一個開放網路,為了安全且去中心化的通訊而生。"</string>
<string name="screen_login_title">"歡迎回來!"</string>
@@ -3,7 +3,7 @@
<string name="screen_account_provider_change">"更改账户提供者"</string>
<string name="screen_account_provider_form_hint">"主服务器地址"</string>
<string name="screen_account_provider_form_notice">"输入搜索词或域名地址。"</string>
<string name="screen_account_provider_form_subtitle">"搜索公司、社区或私人服务器。"</string>
<string name="screen_account_provider_form_subtitle">"搜索公司、社区或私有服务器。"</string>
<string name="screen_account_provider_form_title">"查找账户提供者"</string>
<string name="screen_account_provider_signin_subtitle">"这是你的对话将存在的地方,就像你使用邮件提供者来存储电子邮件那样。"</string>
<string name="screen_account_provider_signin_title">"你即将登录到 %s"</string>
@@ -108,7 +108,7 @@
<string name="screen_qr_code_login_verify_code_subtitle">"你的账户提供者可能会要求你提供以下代码以验证登录。"</string>
<string name="screen_qr_code_login_verify_code_title">"你的验证码"</string>
<string name="screen_server_confirmation_change_server">"更改账户提供者"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"专为 Element 员工提供的私人服务器。"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"专为 Element 员工提供的私有服务器。"</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix 是一个用于安全、去中心化通信的开放网络。"</string>
<string name="screen_server_confirmation_message_register">"这是你的对话将存在的地方,就像你使用邮件提供者来存储电子邮件那样。"</string>
<string name="screen_server_confirmation_title_login">"即将登录 %1$s"</string>
@@ -39,5 +39,6 @@ fun aVoiceMessagePreviewState() = VoiceMessageState.Preview(
showCursor = false,
playbackProgress = 0f,
time = 10.seconds,
duration = 10.seconds,
waveform = WaveFormSamples.realisticWaveForm,
)
+3
View File
@@ -45,6 +45,7 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.eventformatter.api)
implementation(projects.libraries.htmlrenderer.api)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaviewer.api)
implementation(projects.libraries.featureflag.api)
@@ -59,6 +60,7 @@ dependencies {
implementation(projects.libraries.voicerecorder.api)
implementation(projects.libraries.mediaplayer.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.testtags)
implementation(projects.libraries.wellknown.api)
@@ -110,4 +112,5 @@ dependencies {
testImplementation(projects.libraries.emoji.impl)
testImplementation(projects.libraries.emoji.test)
testImplementation(projects.libraries.slashcommands.test)
testImplementation(projects.libraries.htmlrenderer.test)
}
@@ -18,6 +18,8 @@ sealed interface MessagesEvent {
data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvent
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvent
data class OnUserClicked(val user: MatrixUser) : MessagesEvent
data class ConfirmRedact(val reason: String?) : MessagesEvent
data object CancelRedact : MessagesEvent
data object StopLiveLocationShare : MessagesEvent
data object ShowLiveLocationShare : MessagesEvent
data object MarkAsFullyReadAndExit : MessagesEvent
@@ -87,6 +87,7 @@ import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
@@ -104,6 +105,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlin.time.Duration.Companion.milliseconds
@@ -180,7 +182,8 @@ class MessagesFlowNode(
@Parcelize
data class ForwardEvent(
val eventId: EventId,
val fromPinnedEvents: Boolean,
@IgnoredOnParcel
val timelineProvider: TimelineProvider? = null,
) : NavTarget
@Parcelize
@@ -303,8 +306,8 @@ class MessagesFlowNode(
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
}
override fun forwardEvent(eventId: EventId) {
backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents = false))
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) {
backstack.push(NavTarget.ForwardEvent(eventId, timelineProvider))
}
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
@@ -459,11 +462,8 @@ class MessagesFlowNode(
createNode<EventDebugInfoNode>(buildContext, listOf(inputs))
}
is NavTarget.ForwardEvent -> {
val timelineProvider = if (navTarget.fromPinnedEvents) {
pinnedEventsTimelineProvider
} else {
timelineController
}
// If no timeline provider is received, assume the live timeline should be used
val timelineProvider = navTarget.timelineProvider ?: timelineController
val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider)
val callback = object : ForwardEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) {
@@ -550,8 +550,8 @@ class MessagesFlowNode(
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
}
override fun handleForwardEventClick(eventId: EventId) {
backstack.push(NavTarget.ForwardEvent(eventId = eventId, fromPinnedEvents = true))
override fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) {
backstack.push(NavTarget.ForwardEvent(eventId = eventId, timelineProvider = timelineProvider))
}
override fun navigateToThread(threadRootId: ThreadId) {
@@ -617,8 +617,8 @@ class MessagesFlowNode(
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
}
override fun handleForwardEventClick(eventId: EventId) {
backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents = false))
override fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) {
backstack.push(NavTarget.ForwardEvent(eventId, timelineProvider))
}
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
@@ -13,12 +13,13 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import kotlinx.collections.immutable.ImmutableList
interface MessagesNavigator {
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun forwardEvent(eventId: EventId)
fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider)
fun navigateToReportMessage(eventId: EventId, senderId: UserId)
fun navigateToEditPoll(eventId: EventId)
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
@@ -68,6 +68,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
@@ -137,7 +138,7 @@ class MessagesNode(
fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData)
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun forwardEvent(eventId: EventId)
fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider)
fun navigateToReportMessage(eventId: EventId, senderId: UserId)
fun navigateToSendLocation()
fun navigateToCreatePoll()
@@ -220,8 +221,8 @@ class MessagesNode(
callback.navigateToEventDebugInfo(eventId, debugInfo)
}
override fun forwardEvent(eventId: EventId) {
callback.forwardEvent(eventId)
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) {
callback.forwardEvent(eventId, timelineProvider)
}
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
@@ -58,6 +58,7 @@ import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@@ -84,6 +85,7 @@ import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibilit
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.dmUserStatus
import io.element.android.libraries.matrix.ui.model.getAvatarData
@@ -176,7 +178,7 @@ class MessagesPresenter(
.collectLatest { value = it.toImmutableList() }
}
val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomThreadList).collectAsState(initial = false)
val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(initial = false)
val isCurrentlySharingLiveLocationInRoom by remember { liveLocationShareManager.isCurrentlySharing(room.roomId) }.collectAsState()
val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
@@ -241,6 +243,8 @@ class MessagesPresenter(
onPauseOrDispose {}
}
val redactEventAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
fun handleEvent(event: MessagesEvent) {
when (event) {
is MessagesEvent.HandleAction -> {
@@ -251,8 +255,21 @@ class MessagesPresenter(
enableTextFormatting = composerState.showTextFormatting,
timelineState = timelineState,
timelineProtectionState = timelineProtectionState,
redactEventAction = redactEventAction,
)
}
is MessagesEvent.ConfirmRedact -> {
val confirming = redactEventAction.value as? MessagesState.ConfirmingRedaction
redactEventAction.value = AsyncAction.Uninitialized
if (confirming != null) {
localCoroutineScope.launch {
redact(confirming.eventId.toEventOrTransactionId(), event.reason?.takeIf { it.isNotBlank() })
}
}
}
MessagesEvent.CancelRedact -> {
redactEventAction.value = AsyncAction.Uninitialized
}
is MessagesEvent.ToggleReaction -> {
localCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId)
}
@@ -331,6 +348,7 @@ class MessagesPresenter(
hasUnreadThreads = false,
),
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom && timelineState.timelineMode !is Timeline.Mode.Thread,
redactEventAction = redactEventAction.value,
eventSink = ::handleEvent,
)
}
@@ -369,12 +387,13 @@ class MessagesPresenter(
timelineProtectionState: TimelineProtectionState,
enableTextFormatting: Boolean,
timelineState: TimelineState,
redactEventAction: MutableState<AsyncAction<Unit>>,
) = launch {
when (action) {
TimelineItemAction.CopyText -> handleCopyContents(targetEvent)
TimelineItemAction.CopyCaption -> handleCopyCaption(targetEvent)
TimelineItemAction.CopyLink -> handleCopyLink(targetEvent)
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Redact -> handleActionRedact(targetEvent, redactEventAction)
TimelineItemAction.Edit,
TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
@@ -496,9 +515,19 @@ class MessagesPresenter(
)
}
private suspend fun handleActionRedact(event: TimelineItem.Event) {
private suspend fun handleActionRedact(event: TimelineItem.Event, redactEventAction: MutableState<AsyncAction<Unit>>) {
val eventId = event.eventId
if (eventId == null) {
// The message was never sent, so there is nobody to give a reason to.
redact(event.eventOrTransactionId, reason = null)
} else {
redactEventAction.value = MessagesState.ConfirmingRedaction(eventId)
}
}
private suspend fun redact(eventOrTransactionId: EventOrTransactionId, reason: String?) {
timelineController.invokeOnCurrentTimeline {
redactEvent(eventOrTransactionId = event.eventOrTransactionId, reason = null)
redactEvent(eventOrTransactionId = eventOrTransactionId, reason = reason)
.onFailure { Timber.e(it) }
}
}
@@ -586,7 +615,7 @@ class MessagesPresenter(
private fun handleForwardAction(event: TimelineItem.Event) {
if (event.eventId == null) return
navigator.forwardEvent(event.eventId)
navigator.forwardEvent(eventId = event.eventId, timelineProvider = timelineController)
}
private fun handleReportAction(event: TimelineItem.Event) {
@@ -21,9 +21,11 @@ import io.element.android.features.messages.impl.timeline.components.receipt.bot
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
@@ -61,6 +63,7 @@ data class MessagesState(
val successorRoom: SuccessorRoom?,
val threads: Threads,
val showLiveLocationShareBanner: Boolean,
val redactEventAction: AsyncAction<Unit>,
val eventSink: (MessagesEvent) -> Unit
) {
val isTombstoned = successorRoom != null
@@ -69,6 +72,11 @@ data class MessagesState(
val hasThreads: Boolean,
val hasUnreadThreads: Boolean,
)
/** The user asked to remove [eventId] and has to confirm, optionally giving a reason. */
data class ConfirmingRedaction(
val eventId: EventId,
) : AsyncAction.Confirming
}
/** Type of "shared history" icon to show in the top bar. */
@@ -41,10 +41,12 @@ import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent
import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
@@ -92,6 +94,7 @@ open class MessagesStatePreviewParam : PreviewParameterProvider<MessagesState> {
composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()),
identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange()))
),
aMessagesState(redactEventAction = MessagesState.ConfirmingRedaction(EventId("\$anEventId"))),
)
}
@@ -131,6 +134,7 @@ fun aMessagesState(
isCurrentlySharingLiveLocationInRoom: Boolean = false,
dmUserStatus: DisplayedStatus? = null,
eventSink: (MessagesEvent) -> Unit = {},
redactEventAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
) = MessagesState(
roomId = RoomId("!id:domain"),
roomName = roomName,
@@ -161,6 +165,7 @@ fun aMessagesState(
threads = threads,
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom,
dmUserStatus = dmUserStatus,
redactEventAction = redactEventAction,
eventSink = eventSink,
)
@@ -104,6 +104,7 @@ import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertM
import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayout
import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayoutState
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.TextFieldDialog
import io.element.android.libraries.designsystem.components.rememberExpandableBottomSheetLayoutState
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -455,6 +456,19 @@ fun MessagesView(
state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog)
},
)
if (state.redactEventAction is MessagesState.ConfirmingRedaction) {
TextFieldDialog(
title = stringResource(R.string.screen_room_confirm_removal_title),
placeholder = stringResource(R.string.screen_room_confirm_removal_reason_placeholder),
supportingText = stringResource(R.string.screen_room_confirm_removal_reason_supporting_text),
value = null,
submitText = stringResource(CommonStrings.action_remove),
destructiveSubmit = true,
onSubmit = { reason -> state.eventSink(MessagesEvent.ConfirmRedact(reason)) },
onDismissRequest = { state.eventSink(MessagesEvent.CancelRedact) },
)
}
}
@Composable

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