Merge branch 'release/26.04.0'

This commit is contained in:
Jorge Martín 2026-04-01 13:42:06 +02:00
commit d790aeb179
699 changed files with 6597 additions and 4961 deletions

View file

@ -52,10 +52,13 @@ Uncomment this markdown table below and edit the last line `|||`:
<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. --> <!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->
- This PR was made with the help of AI:
- [ ] Yes. In this case, please request a review by Copilot.
- [ ] No.
- [ ] Changes have been tested on an Android device or Android emulator with API 24 - [ ] Changes have been tested on an Android device or Android emulator with API 24
- [ ] UI change has been tested on both light and dark themes - [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility - [ ] Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [ ] Pull request is based on the develop branch - [ ] Pull request is based on the develop branch
- [ ] Pull request title will be used in the release note, it clearly define what will change for the user - [ ] Pull request title will be used in the release note, it clearly defines what will change for the user
- [ ] Pull request includes screenshots or videos if containing UI changes - [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] You've made a self review of your PR - [ ] You've made a self review of your PR

View file

@ -29,6 +29,7 @@ jobs:
repo: context.repo.repo, repo: context.repo.repo,
body: `Thank you for your contribution! Here are a few things to check in the PR to ensure it's reviewed as quickly as possible: body: `Thank you for your contribution! Here are a few things to check in the PR to ensure it's reviewed as quickly as possible:
- If your pull request adds a feature or modifies the UI, this should have an equivalent pull request in the [Element X iOS repo](https://github.com/element-hq/element-x-ios) unless it only affects an Android-only behaviour or is behind a disabled feature flag, since we need parity in both clients to consider a feature done. It will also need to be approved by our product and design teams before being merged, so it's usually a good idea to discuss the changes in a Github issue first and then start working on them once the approach has been validated.
- Your branch should be based on \`origin/develop\`, at least when it was created. - Your branch should be based on \`origin/develop\`, at least when it was created.
- The title of the PR will be used for release notes, so it needs to describe the change visible to the user. - The title of the PR will be used for release notes, so it needs to describe the change visible to the user.
- The test pass locally running \`./gradlew test\`. - The test pass locally running \`./gradlew test\`.

View file

@ -12,9 +12,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Skip in forks # Skip in forks
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
permissions:
contents: write
steps: steps:
- name: ⏬ Checkout with LFS - name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@f46300cd8952454b9f0a21a3d133d4bd5684cfc2 # v1.2.3 uses: nschloe/action-cached-lfs-checkout@1c185ad576953eab13e35ffe1bffef437d97e9d2 # v1.2.4
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with: with:

View file

@ -34,7 +34,7 @@ jobs:
swap-storage: false swap-storage: false
- name: ⏬ Checkout with LFS - name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@f46300cd8952454b9f0a21a3d133d4bd5684cfc2 # v1.2.3 uses: nschloe/action-cached-lfs-checkout@1c185ad576953eab13e35ffe1bffef437d97e9d2 # v1.2.4
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0

View file

@ -43,13 +43,13 @@ jobs:
labels: Record-Screenshots labels: Record-Screenshots
- name: ⏬ Checkout with LFS (PR) - name: ⏬ Checkout with LFS (PR)
if: github.event.label.name == 'Record-Screenshots' if: github.event.label.name == 'Record-Screenshots'
uses: nschloe/action-cached-lfs-checkout@f46300cd8952454b9f0a21a3d133d4bd5684cfc2 # v1.2.3 uses: nschloe/action-cached-lfs-checkout@1c185ad576953eab13e35ffe1bffef437d97e9d2 # v1.2.4
with: with:
persist-credentials: false persist-credentials: false
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
- name: ⏬ Checkout with LFS (Branch) - name: ⏬ Checkout with LFS (Branch)
if: github.event_name == 'workflow_dispatch' if: github.event_name == 'workflow_dispatch'
uses: nschloe/action-cached-lfs-checkout@f46300cd8952454b9f0a21a3d133d4bd5684cfc2 # v1.2.3 uses: nschloe/action-cached-lfs-checkout@1c185ad576953eab13e35ffe1bffef437d97e9d2 # v1.2.4
with: with:
persist-credentials: false persist-credentials: false
- name: ☕️ Use JDK 21 - name: ☕️ Use JDK 21

View file

@ -19,6 +19,9 @@ adb install -r $1
echo "Starting the screen recording..." echo "Starting the screen recording..."
adb push .github/workflows/scripts/maestro/local-recording.sh /data/local/tmp/ adb push .github/workflows/scripts/maestro/local-recording.sh /data/local/tmp/
adb shell "chmod +x /data/local/tmp/local-recording.sh" adb shell "chmod +x /data/local/tmp/local-recording.sh"
mkdir -p ~/.maestro/tests
# Start logcat in the background and save the output to a file, use `org.matrix.rust.sdk` tag since the SDK handles the logging
adb logcat 'org.matrix.rust.sdk:D *:S' > ~/.maestro/tests/logcat.txt &
adb shell "/data/local/tmp/local-recording.sh & echo \$! > /data/local/tmp/screenrecord_pid.txt" & adb shell "/data/local/tmp/local-recording.sh & echo \$! > /data/local/tmp/screenrecord_pid.txt" &
set +e set +e
~/.maestro/bin/maestro test .maestro/allTests.yaml ~/.maestro/bin/maestro test .maestro/allTests.yaml

View file

@ -49,7 +49,7 @@ jobs:
sudo swapon /mnt/swapfile sudo swapon /mnt/swapfile
sudo swapon --show sudo swapon --show
- name: ⏬ Checkout with LFS - name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@f46300cd8952454b9f0a21a3d133d4bd5684cfc2 # v1.2.3 uses: nschloe/action-cached-lfs-checkout@1c185ad576953eab13e35ffe1bffef437d97e9d2 # v1.2.4
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881

View file

@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Validate name: Validate
steps: steps:
- uses: nschloe/action-cached-lfs-checkout@f46300cd8952454b9f0a21a3d133d4bd5684cfc2 # v1.2.3 - uses: nschloe/action-cached-lfs-checkout@1c185ad576953eab13e35ffe1bffef437d97e9d2 # v1.2.4
- run: | - run: |
./tools/git/validate_lfs.sh ./tools/git/validate_lfs.sh

2
.idea/kotlinc.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="2.3.10" /> <option name="version" value="2.3.20" />
</component> </component>
</project> </project>

View file

@ -2,14 +2,14 @@ appId: ${MAESTRO_APP_ID}
--- ---
- tapOn: - tapOn:
id: "home_screen-settings" id: "home_screen-settings"
- tapOn: "Sign out" - tapOn: "Remove this device"
- takeScreenshot: build/maestro/900-SignOutScreen - takeScreenshot: build/maestro/900-SignOutScreen
- back - back
- tapOn: "Sign out" - tapOn: "Remove this device"
# Ensure cancel cancels # Ensure cancel cancels
- tapOn: - tapOn:
id: "dialog-negative" id: "dialog-negative"
- tapOn: "Sign out" - tapOn: "Remove this device"
- tapOn: - tapOn:
id: "dialog-positive" id: "dialog-positive"
- runFlow: ../assertions/assertInitDisplayed.yaml - runFlow: ../assertions/assertInitDisplayed.yaml

View file

@ -1,5 +1,5 @@
appId: ${MAESTRO_APP_ID} appId: ${MAESTRO_APP_ID}
--- ---
- extendedWaitUntil: - extendedWaitUntil:
visible: "Confirm your identity" visible: "Confirm your digital identity"
timeout: 60000 timeout: 60000

View file

@ -1,7 +1,7 @@
appId: ${MAESTRO_APP_ID} appId: ${MAESTRO_APP_ID}
--- ---
# Purpose: Test the creation and deletion of a DM room. # Purpose: Test the creation and deletion of a DM room.
- tapOn: "Create a new conversation or room" - tapOn: "Create room"
- tapOn: "Search for someone" - tapOn: "Search for someone"
- inputText: ${MAESTRO_INVITEE1_MXID} - inputText: ${MAESTRO_INVITEE1_MXID}
- tapOn: - tapOn:

View file

@ -1,7 +1,7 @@
appId: ${MAESTRO_APP_ID} appId: ${MAESTRO_APP_ID}
--- ---
# Purpose: Test the creation and deletion of a room # Purpose: Test the creation and deletion of a room
- tapOn: "Create a new conversation or room" - tapOn: "Create room"
- tapOn: "New room" - tapOn: "New room"
- tapOn: "Add name…" - tapOn: "Add name…"
- inputText: "aRoomName" - inputText: "aRoomName"

View file

@ -2,6 +2,6 @@ appId: ${MAESTRO_APP_ID}
--- ---
- takeScreenshot: build/maestro/520-Timeline - takeScreenshot: build/maestro/520-Timeline
- tapOn: "Add attachment" - tapOn: "Add attachment"
- tapOn: "Location" - tapOn: "Share location"
- tapOn: "Share my location" - tapOn: "Share selected location"
- takeScreenshot: build/maestro/521-Timeline - takeScreenshot: build/maestro/521-Timeline

118
AGENTS.md Normal file
View file

@ -0,0 +1,118 @@
# AGENTS.md — Element X Android
> **Repo:** `element-hq/element-x-android` — Android Matrix client (Compose UI + `matrix-rust-sdk`).
---
## Strong Conventions
PRs must meet these rules.
### Code Style
- Style enforced by **Editor config** (`.editorconfig`).
- Set "Hard wrap at" to 160 chars in Android Studio.
### PII & Logging
- We use **Timber** for logging. Never use `android.util.Log`.
- **Never log secrets, passwords, keys, or user content** (e.g. message bodies).
- Matrix IDs (User IDs, Room IDs, Event IDs) are safe to log.
### Strings & Localisation
- Default localisation: `en` (en-GB strings), shared with Element X iOS via [Localazy](https://localazy.com/p/element).
- **Never edit `localazy.xml`** — it is auto-generated and overwritten.
- New English strings go in **`temporary.xml`**. The core team imports these to Localazy.
- **Key naming**:
- Cross-screen verbs: `action_` (e.g., `action_copy`).
- Common nouns/other: `common_` (e.g., `common_error`).
- Accessibility: `a11y_`.
- Screen-specific: `screen_<name>_<key>` (e.g., `screen_onboarding_welcome_title`).
- Errors: `error_` prefix.
- Platform-specific: `_ios` or `_android` suffix.
- Placeholders: Use numbered form `%1$s`, `%2$d`.
### Previews
- Create previews for **all main states** of a Composable.
- Use `@PreviewsDayNight` for consistency.
- Use `PreviewParameterProvider` (e.g., `FooStateProvider`) to provide states.
- Wrap previews in `ElementPreview { ... }`.
---
## Pull Request Guidelines
- Use sentence-style commit/PR messages (no conventional commits).
- Apply exactly **one** `PR-` label for changelog categorization.
- PR title = changelog entry — make it descriptive; no "Fixes #…" prefixes.
- Include screenshots or screen recordings for any UI changes.
- Keep PRs focused; split changes over 1000 lines.
---
## Project Structure
### Build System
Common Gradle tasks:
- Build: `./gradlew assembleDebug`
- Unit Tests: `./gradlew test`
- Lint: `./gradlew lint`
- Format: `./gradlew ktlintFormat`
- Update Docs TOC: `./gradlew generateDocsToc`
### Gradle Modules
Features follow a 3-module structure:
- `features/foo/api`: Public interfaces and data classes.
- `features/foo/impl`: Internal implementation, Presenter, and View.
- `features/foo/test`: Test fakes and utilities.
---
## Architecture: Appyx + Molecule
We use [Appyx](https://bumble-tech.github.io/appyx/) for navigation and [Molecule](https://github.com/cashapp/molecule) for Presenters.
### Files Per Screen (`Foo`)
| File | Purpose |
| :--- | :--- |
| `FooNode.kt` | Appyx Node: Handles navigation and wires the Presenter to the View. |
| `FooPresenter.kt` | A `@Composable` function that produces `FooState` from `FooEvent`s. |
| `FooView.kt` | Stateless Composable rendering the UI from `FooState`. |
| `FooState.kt` | Data class representing the immutable UI state. |
| `FooEvent.kt` | Sealed interface for UI actions sent to the Presenter. |
| `FooStateProvider.kt` | Provides sample states for Previews and Screenshot tests. |
| `FooPresenterTest.kt` | Unit tests for the Presenter logic using Turbine. |
---
## Dependency Injection (Metro)
- We use [Metro](https://zacsweers.github.io/metro/) for DI.
- Inject via constructor parameters using `@Inject`.
- Use `@AssistedInject` and `@AssistedFactory` for components requiring runtime arguments (like Navigators or IDs).
- Use `@ContributesBinding(AppScope::class)` for singleton-like services.
- Use `@ContributesNode(RoomScope::class)` for Appyx Nodes.
---
## Compound Design System
Always prefer Compound components and tokens from `libraries/compound/` module.
- **Colours**: `ElementTheme.colors.textPrimary`, `ElementTheme.colors.bgCanvasDefault`.
- **Typography**: `ElementTheme.typography.fontBodyMdRegular`.
- **Icons**: Use `CompoundIcons.IconName()` (e.g., `CompoundIcons.UserProfileSolid()`).
---
## The Rust SDK Layer
We wrap the `matrix-rust-sdk` to isolate the UI from the underlying SDK.
- Naming: SDK `Room``JoinedRoom` or `RoomInfo`.
- Type Mapping: Map Rust SDK types to Kotlin data classes in the `api` module to avoid leaking `MatrixRustSDK` into the UI.
- Always follow Kotlin naming conventions (e.g., `userId` instead of `userID`).

View file

@ -1,3 +1,71 @@
Changes in Element X v26.03.4
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.03.4 -->
## What's Changed
### ✨ Features
* Add a foreground service with a wakelock for fetching push notifications by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6321
### 🙌 Improvements
* Iterate on send button colors by @bmarty in https://github.com/element-hq/element-x-android/pull/6314
### 🐛 Bugfixes
* Fix key storage if it's broken by @andybalaam in https://github.com/element-hq/element-x-android/pull/6290
* Improve error displayed when .well-known file is malformed by @bmarty in https://github.com/element-hq/element-x-android/pull/6370
* Fix crash when starting a DM by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6419
* Fix media seeking flicker by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6434
* Fix `TransactionTooLargeExceptions` caused by Appyx by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6410
* Fix wakelock not stopping early when notifications are disabled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6424
* Fix long messages not being clickable by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6356
* Fix: "Reset identity" flow leaves backup disabled #5075 by @andybalaam in https://github.com/element-hq/element-x-android/pull/6420
* Restore custom user certificate provider by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6451
### 🗣 Translations
* Sync Strings - iterate on wording about crypto identity by @ElementBot in https://github.com/element-hq/element-x-android/pull/6352
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6435
### 🧱 Build
* Limit number of created PR to upgrade Posthog dependency by @bmarty in https://github.com/element-hq/element-x-android/pull/6318
* Renovate: add a cooldown of 7 days for dependencies that we do not manage by @bmarty in https://github.com/element-hq/element-x-android/pull/6323
* Improve Kover setup by using only convention plugins by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6213
* Fix permissions issue. by @bmarty in https://github.com/element-hq/element-x-android/pull/6355
* Fix permissions issue. by @bmarty in https://github.com/element-hq/element-x-android/pull/6366
### 📄 Documentation
* Add warning about new features to pull request template by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6425
### Dependency upgrades
* fix(deps): update dependency com.posthog:posthog-android to v3.36.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6311
* fix(deps): update dependency com.posthog:posthog-android to v3.36.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6316
* chore(deps): update reactivecircus/android-emulator-runner action to v2.36.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6320
* fix(deps): update dependency com.posthog:posthog-android to v3.37.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6317
* chore(deps): update actions/download-artifact action to v8.0.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6324
* fix(deps): update dependency com.github.matrix-org:matrix-analytics-events to v0.33.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6313
* chore(deps): update plugin ktlint to v14.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6332
* fix(deps): update dependency androidx.compose:compose-bom to v2026.03.00 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6329
* fix(deps): update datastore to v1.2.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6326
* chore(deps): update webfactory/ssh-agent action to v0.10.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6325
* fix(deps): update activity to v1.13.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6327
* fix(deps): update dependency io.sentry:sentry-android to v8.35.0 and enable ANR profiling by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6331
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v26.03.19 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6411
* chore(deps): update reactivecircus/android-emulator-runner action to v2.37.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6430
* fix(deps): update media3 to v1.9.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6445
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v26.03.23 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6444
* fix(deps): update dependency androidx.compose.material3:material3 to v1.5.0-alpha15 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6306
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v26.03.24 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6455
### Others
* fix(deps): update sqldelight to v2.3.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6343
* Remove matrix.to intent filter from the AndroidManifest. by @bmarty in https://github.com/element-hq/element-x-android/pull/6345
* Update wording of button "Enter recovery key" to "Use recovery key" by @bmarty in https://github.com/element-hq/element-x-android/pull/6357
* Fix room member not tappable in a Thread by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6416
* Fix keyboard not auto-opening when editing a message by @kalix127 in https://github.com/element-hq/element-x-android/pull/6412
* Design iteration on file attachment in the timeline by @bmarty in https://github.com/element-hq/element-x-android/pull/6322
* fix(deps): update dependency org.maplibre.gl:android-sdk to v13.0.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6428
* Iterate on microphone icon by @bmarty in https://github.com/element-hq/element-x-android/pull/6452
* Increase icon size of audio and files in the timeline by @bmarty in https://github.com/element-hq/element-x-android/pull/6453
* Fix voice recording being interrupted by notifications sounds by @kalix127 in https://github.com/element-hq/element-x-android/pull/6438
## New Contributors
* @bxdxnn made their first contribution in https://github.com/element-hq/element-x-android/pull/6416
* @kalix127 made their first contribution in https://github.com/element-hq/element-x-android/pull/6412
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.03.3...v26.03.4
Changes in Element X v26.03.3 Changes in Element X v26.03.3
============================= =============================

1
CLAUDE.md Normal file
View file

@ -0,0 +1 @@
@AGENTS.md

View file

@ -307,6 +307,7 @@ licensee {
allow("BSD-2-Clause") allow("BSD-2-Clause")
allow("BSD-3-Clause") allow("BSD-3-Clause")
allow("EPL-1.0") allow("EPL-1.0")
allowUrl("https://opensource.org/license/bsd-3-clause")
allowUrl("https://opensource.org/licenses/MIT") allowUrl("https://opensource.org/licenses/MIT")
allowUrl("https://developer.android.com/studio/terms.html") allowUrl("https://developer.android.com/studio/terms.html")
allowUrl("https://www.zetetic.net/sqlcipher/license/") allowUrl("https://www.zetetic.net/sqlcipher/license/")

View file

@ -0,0 +1,2 @@
Main changes in this version: bug fixes for crashes from the SDK and notifications and UI improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -2,8 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"Kan ikke bekræfte?"</string> <string name="screen_identity_confirmation_cannot_confirm">"Kan ikke bekræfte?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Opret en ny gendannelsesnøgle"</string> <string name="screen_identity_confirmation_create_new_recovery_key">"Opret en ny gendannelsesnøgle"</string>
<string name="screen_identity_confirmation_subtitle">"Verificér denne enhed for at konfigurere sikre meddelelser."</string> <string name="screen_identity_confirmation_subtitle">"Vælg, hvordan du vil verificere dig for at konfigurere sikre beskeder."</string>
<string name="screen_identity_confirmation_title">"Bekræft din identitet"</string> <string name="screen_identity_confirmation_title">"Bekræft din digitale identitet"</string>
<string name="screen_identity_confirmation_use_another_device">"Brug en anden enhed"</string> <string name="screen_identity_confirmation_use_another_device">"Brug en anden enhed"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Brug gendannelsesnøgle"</string> <string name="screen_identity_confirmation_use_recovery_key">"Brug gendannelsesnøgle"</string>
<string name="screen_identity_confirmed_subtitle">"Nu kan du læse eller sende beskeder sikkert, og enhver du samtaler med kan også stole på denne enhed."</string> <string name="screen_identity_confirmed_subtitle">"Nu kan du læse eller sende beskeder sikkert, og enhver du samtaler med kan også stole på denne enhed."</string>

View file

@ -5,9 +5,9 @@
<string name="banner_battery_optimization_title_android">"Modtager du ikke notifikationer?"</string> <string name="banner_battery_optimization_title_android">"Modtager du ikke notifikationer?"</string>
<string name="banner_new_sound_message">"Dit notifikationsping er blevet opdateret tydeligere, hurtigere og mindre forstyrrende."</string> <string name="banner_new_sound_message">"Dit notifikationsping er blevet opdateret tydeligere, hurtigere og mindre forstyrrende."</string>
<string name="banner_new_sound_title">"Vi har opdateret dine lyde"</string> <string name="banner_new_sound_title">"Vi har opdateret dine lyde"</string>
<string name="banner_set_up_recovery_content">"Gendan din kryptografiske identitet og meddelelseshistorik med en gendannelsesnøgle, hvis du har mistet alle dine eksisterende enheder."</string> <string name="banner_set_up_recovery_content">"Dine chats sikkerhedskopieres automatisk med end-to-end-kryptering. For at kunne gendanne denne sikkerhedskopi og bevare din digitale identitet, hvis du mister adgang til alle dine enheder, får du brug for din gendannelsesnøgle."</string>
<string name="banner_set_up_recovery_submit">"Opsæt gendannelse"</string> <string name="banner_set_up_recovery_submit">"Hent gendannelsesnøgle"</string>
<string name="banner_set_up_recovery_title">"Konfigurer gendannelse for at beskytte din konto"</string> <string name="banner_set_up_recovery_title">"Sikkerhedskopier dine samtaler"</string>
<string name="confirm_recovery_key_banner_message">"Bekræft din gendannelsesnøgle for at bevare adgangen til nøglelager og meddelelseshistorik."</string> <string name="confirm_recovery_key_banner_message">"Bekræft din gendannelsesnøgle for at bevare adgangen til nøglelager og meddelelseshistorik."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Indtast din gendannelsesnøgle"</string> <string name="confirm_recovery_key_banner_primary_button_title">"Indtast din gendannelsesnøgle"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Har du glemt din gendannelsesnøgle?"</string> <string name="confirm_recovery_key_banner_secondary_button_title">"Har du glemt din gendannelsesnøgle?"</string>

View file

@ -19,7 +19,7 @@ private const val GEO_URI_REGEX = """geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitu
data class Location( data class Location(
val lat: Double, val lat: Double,
val lon: Double, val lon: Double,
val accuracy: Float, val accuracy: Float? = null,
) : Parcelable { ) : Parcelable {
companion object { companion object {
fun fromGeoUri(geoUri: String): Location? { fun fromGeoUri(geoUri: String): Location? {
@ -27,12 +27,15 @@ data class Location(
return Location( return Location(
lat = result.groups["latitude"]?.value?.toDoubleOrNull() ?: return null, lat = result.groups["latitude"]?.value?.toDoubleOrNull() ?: return null,
lon = result.groups["longitude"]?.value?.toDoubleOrNull() ?: return null, lon = result.groups["longitude"]?.value?.toDoubleOrNull() ?: return null,
accuracy = result.groups["uncertainty"]?.value?.toFloatOrNull() ?: 0f, accuracy = result.groups["uncertainty"]?.value?.toFloatOrNull(),
) )
} }
} }
fun toGeoUri(): String { fun toGeoUri(): String = buildString {
return "geo:$lat,$lon;u=$accuracy" append("geo:$lat,$lon")
if (accuracy != null) {
append(";u=$accuracy")
}
} }
} }

View file

@ -14,11 +14,11 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
/** /**
* The "Send location" screen. * The "Share location" screen.
* *
* Allows a user to share a location message within a room. * Allows a user to share a location message within a room.
*/ */
interface SendLocationEntryPoint : FeatureEntryPoint { interface ShareLocationEntryPoint : FeatureEntryPoint {
fun createNode( fun createNode(
parentNode: Node, parentNode: Node,
buildContext: BuildContext, buildContext: BuildContext,

View file

@ -15,8 +15,7 @@ import io.element.android.libraries.architecture.NodeInputs
interface ShowLocationEntryPoint : FeatureEntryPoint { interface ShowLocationEntryPoint : FeatureEntryPoint {
data class Inputs( data class Inputs(
val location: Location, val mode: ShowLocationMode,
val description: String?,
) : NodeInputs ) : NodeInputs
fun createNode( fun createNode(

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.api
import android.os.Parcelable
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
import kotlinx.parcelize.Parcelize
sealed interface ShowLocationMode : Parcelable {
@Parcelize
data class Static(
val location: Location,
val senderName: String,
val senderId: UserId,
val senderAvatarUrl: String?,
val timestamp: Long,
val assetType: AssetType?,
) : ShowLocationMode
@Parcelize
data object Live : ShowLocationMode
}

View file

@ -19,7 +19,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@ -32,10 +31,10 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.api.internal.StaticMapPlaceholder import io.element.android.features.location.api.internal.StaticMapPlaceholder
import io.element.android.features.location.api.internal.StaticMapUrlBuilder import io.element.android.features.location.api.internal.StaticMapUrlBuilder
import io.element.android.features.location.api.internal.centerBottomEdge import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.libraries.designsystem.components.LocationPin
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.utils.CommonDrawables
/** /**
* Shows a static map image downloaded via a third party service's static maps API. * Shows a static map image downloaded via a third party service's static maps API.
@ -45,6 +44,7 @@ fun StaticMapView(
lat: Double, lat: Double,
lon: Double, lon: Double,
zoom: Double, zoom: Double,
pinVariant: PinVariant,
contentDescription: String?, contentDescription: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
darkMode: Boolean = !ElementTheme.isLightTheme, darkMode: Boolean = !ElementTheme.isLightTheme,
@ -95,12 +95,7 @@ fun StaticMapView(
// We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case. // We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case.
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
) )
Icon( LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this))
resourceId = CommonDrawables.pin,
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.centerBottomEdge(this),
)
} else { } else {
StaticMapPlaceholder( StaticMapPlaceholder(
showProgress = collectedState.value.isLoading(), showProgress = collectedState.value.isLoading(),
@ -127,6 +122,7 @@ internal fun StaticMapViewPreview() = ElementPreview {
lon = 0.0, lon = 0.0,
zoom = 0.0, zoom = 0.0,
contentDescription = null, contentDescription = null,
pinVariant = PinVariant.PinnedLocation,
modifier = Modifier.size(400.dp), modifier = Modifier.size(400.dp),
) )
} }

View file

@ -58,7 +58,7 @@ internal class MapTilerStaticMapUrlBuilder(
// image smaller than the available space in pixels. // image smaller than the available space in pixels.
// The resulting image will have to be scaled to fit the available space in order // The resulting image will have to be scaled to fit the available space in order
// to keep the perceived content size constant at the expense of sharpness. // to keep the perceived content size constant at the expense of sharpness.
return "$baseUrl/$mapId/static/$lon,$lat,$finalZoom/${finalWidth}x${finalHeight}$scale.webp?key=$apiKey&attribution=bottomleft" return "$baseUrl/$mapId/static/$lon,$lat,$finalZoom/${finalWidth}x${finalHeight}$scale.webp?key=$apiKey&attribution=topright"
} }
override fun isServiceAvailable() = apiKey.isNotEmpty() override fun isServiceAvailable() = apiKey.isNotEmpty()

View file

@ -33,13 +33,13 @@ internal class LocationKtTest {
assertThat(Location.fromGeoUri("geo:1.234,5.678")).isEqualTo(Location( assertThat(Location.fromGeoUri("geo:1.234,5.678")).isEqualTo(Location(
lat = 1.234, lat = 1.234,
lon = 5.678, lon = 5.678,
accuracy = 0f, accuracy = null,
)) ))
assertThat(Location.fromGeoUri("geo:1,5")).isEqualTo(Location( assertThat(Location.fromGeoUri("geo:1,5")).isEqualTo(Location(
lat = 1.0, lat = 1.0,
lon = 5.0, lon = 5.0,
accuracy = 0f, accuracy = null,
)) ))
assertThat(Location.fromGeoUri("geo:1.234,5.678;u=3000")).isEqualTo(Location( assertThat(Location.fromGeoUri("geo:1.234,5.678;u=3000")).isEqualTo(Location(
@ -68,7 +68,13 @@ internal class LocationKtTest {
} }
@Test @Test
fun `encode geoUri - returns geoUri from a Location`() { fun `encode geoUri - returns geoUri from a Location without accuracy`() {
assertThat(Location(1.0, 2.0, null).toGeoUri())
.isEqualTo("geo:1.0,2.0")
}
@Test
fun `encode geoUri - returns geoUri from a Location with accuracy`() {
assertThat(Location(1.0, 2.0, 3.0f).toGeoUri()) assertThat(Location(1.0, 2.0, 3.0f).toGeoUri())
.isEqualTo("geo:1.0,2.0;u=3.0") .isEqualTo("geo:1.0,2.0;u=3.0")
} }

View file

@ -47,7 +47,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 600, height = 600,
density = 1f, density = 1f,
) )
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=topright")
} }
@Test @Test
@ -62,7 +62,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 900, height = 900,
density = 1.5f, density = 1.5f,
) )
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=topright")
} }
@Test @Test
@ -77,7 +77,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 1200, height = 1200,
density = 2f, density = 2f,
) )
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=topright")
} }
@Test @Test
@ -92,7 +92,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 1800, height = 1800,
density = 3f, density = 3f,
) )
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=topright")
} }
@Test @Test
@ -107,7 +107,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 2048, height = 2048,
density = 1f, density = 1f,
) )
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/2048x1024.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/2048x1024.webp?key=anApiKey&attribution=topright")
assertThat( assertThat(
builder.build( builder.build(
@ -119,7 +119,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 4096, height = 4096,
density = 1f, density = 1f,
) )
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x2048.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x2048.webp?key=anApiKey&attribution=topright")
assertThat( assertThat(
builder.build( builder.build(
@ -131,7 +131,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 2048, height = 2048,
density = 2f, density = 2f,
) )
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x512@2x.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x512@2x.webp?key=anApiKey&attribution=topright")
assertThat( assertThat(
builder.build( builder.build(
@ -143,7 +143,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 4096, height = 4096,
density = 2f, density = 2f,
) )
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/512x1024@2x.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/512x1024@2x.webp?key=anApiKey&attribution=topright")
assertThat( assertThat(
builder.build( builder.build(
@ -155,7 +155,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = Int.MAX_VALUE, height = Int.MAX_VALUE,
density = 2f, density = 2f,
) )
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x1024@2x.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x1024@2x.webp?key=anApiKey&attribution=topright")
} }
@Test @Test
@ -170,7 +170,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 0, height = 0,
density = 1f, density = 1f,
) )
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=topright")
assertThat( assertThat(
builder.build( builder.build(
@ -182,7 +182,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 0, height = 0,
density = 2f, density = 2f,
) )
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0@2x.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0@2x.webp?key=anApiKey&attribution=topright")
assertThat( assertThat(
builder.build( builder.build(
@ -194,6 +194,6 @@ class MapTilerStaticMapUrlBuilderTest {
height = Int.MIN_VALUE, height = Int.MIN_VALUE,
density = 1f, density = 1f,
) )
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=topright")
} }
} }

View file

@ -27,7 +27,8 @@ setupDependencyInjection()
dependencies { dependencies {
api(projects.features.location.api) api(projects.features.location.api)
implementation(projects.features.messages.api) implementation(projects.features.messages.api)
implementation(projects.libraries.maplibreCompose) implementation(libs.maplibre.compose)
implementation(libs.coil)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.di) implementation(projects.libraries.di)
@ -35,14 +36,18 @@ dependencies {
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.androidutils)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(libs.accompanist.permission) implementation(libs.accompanist.permission)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.dateformatter.api)
testCommonDependencies(libs, true) testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.libraries.testtags) testImplementation(projects.libraries.testtags)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test) testImplementation(projects.features.messages.test)
testImplementation(projects.libraries.featureflag.test)
} }

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.common
import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
sealed interface LocationConstraintsCheck {
data object Success : LocationConstraintsCheck
data object PermissionRationale : LocationConstraintsCheck
data object PermissionDenied : LocationConstraintsCheck
data object LocationServiceDisabled : LocationConstraintsCheck
}
fun checkLocationConstraints(
permissionsState: PermissionsState,
locationActions: LocationActions,
): LocationConstraintsCheck {
return when {
permissionsState.isAnyGranted -> {
if (locationActions.isLocationEnabled()) {
LocationConstraintsCheck.Success
} else {
LocationConstraintsCheck.LocationServiceDisabled
}
}
permissionsState.shouldShowRationale -> LocationConstraintsCheck.PermissionRationale
else -> LocationConstraintsCheck.PermissionDenied
}
}
fun LocationConstraintsCheck.toDialogState(): LocationConstraintsDialogState {
return when (this) {
LocationConstraintsCheck.Success -> LocationConstraintsDialogState.None
LocationConstraintsCheck.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale
LocationConstraintsCheck.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied
LocationConstraintsCheck.LocationServiceDisabled -> LocationConstraintsDialogState.LocationServiceDisabled
}
}

View file

@ -9,57 +9,35 @@
package io.element.android.features.location.impl.common package io.element.android.features.location.impl.common
import android.Manifest import android.Manifest
import android.view.Gravity import androidx.compose.ui.Alignment
import androidx.compose.runtime.Composable import org.maplibre.compose.camera.CameraPosition
import androidx.compose.runtime.ReadOnlyComposable import org.maplibre.compose.map.GestureOptions
import androidx.compose.ui.graphics.Color import org.maplibre.compose.map.MapOptions
import io.element.android.compound.theme.ElementTheme import org.maplibre.compose.map.OrnamentOptions
import io.element.android.libraries.maplibre.compose.MapLocationSettings import org.maplibre.compose.map.RenderOptions
import io.element.android.libraries.maplibre.compose.MapSymbolManagerSettings import org.maplibre.spatialk.geojson.Position
import io.element.android.libraries.maplibre.compose.MapUiSettings
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
/** /**
* Common configuration values for the map. * Common configuration values for the map.
*/ */
object MapDefaults { object MapDefaults {
val uiSettings: MapUiSettings val options = MapOptions(
@Composable renderOptions = RenderOptions.Standard,
@ReadOnlyComposable gestureOptions = GestureOptions.Standard,
get() = MapUiSettings( ornamentOptions = OrnamentOptions(
compassEnabled = false, isLogoEnabled = true,
rotationGesturesEnabled = false, logoAlignment = Alignment.BottomStart,
scrollGesturesEnabled = true, isAttributionEnabled = true,
tiltGesturesEnabled = false, attributionAlignment = Alignment.BottomEnd,
zoomGesturesEnabled = true, isCompassEnabled = false,
logoGravity = Gravity.TOP, isScaleBarEnabled = false,
attributionGravity = Gravity.TOP,
attributionTintColor = ElementTheme.colors.iconPrimary
) )
)
val symbolManagerSettings: MapSymbolManagerSettings val defaultCameraPosition = CameraPosition(
get() = MapSymbolManagerSettings( target = Position(0.0, 0.0),
iconAllowOverlap = true zoom = 0.0,
) )
val locationSettings: MapLocationSettings
get() = MapLocationSettings(
locationEnabled = false,
backgroundTintColor = Color.White,
foregroundTintColor = Color.Black,
backgroundStaleTintColor = Color.White,
foregroundStaleTintColor = Color.Black,
accuracyColor = Color.Black,
pulseEnabled = true,
pulseColor = Color.Black,
)
val centerCameraPosition = CameraPosition.Builder()
.target(LatLng(49.843, 9.902056))
.zoom(2.7)
.build()
const val DEFAULT_ZOOM = 15.0 const val DEFAULT_ZOOM = 15.0
val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)

View file

@ -1,29 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.common
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun PermissionDeniedDialog(
onContinue: () -> Unit,
onDismiss: () -> Unit,
appName: String,
) {
ConfirmationDialog(
content = stringResource(CommonStrings.error_missing_location_auth_android, appName),
onSubmitClick = onContinue,
onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue),
cancelText = stringResource(CommonStrings.action_cancel),
)
}

View file

@ -1,29 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.common
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun PermissionRationaleDialog(
onContinue: () -> Unit,
onDismiss: () -> Unit,
appName: String,
) {
ConfirmationDialog(
content = stringResource(CommonStrings.error_missing_location_rationale_android, appName),
onSubmitClick = onContinue,
onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue),
cancelText = stringResource(CommonStrings.action_cancel),
)
}

View file

@ -10,8 +10,11 @@ package io.element.android.features.location.impl.common.actions
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.location.LocationManager
import android.net.Uri import android.net.Uri
import android.provider.Settings
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.core.location.LocationManagerCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
@ -40,9 +43,26 @@ class AndroidLocationActions(
} }
} }
override fun openSettings() { override fun openAppSettings() {
context.openAppSettingsPage() context.openAppSettingsPage()
} }
override fun isLocationEnabled(): Boolean {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
return LocationManagerCompat.isLocationEnabled(locationManager)
}
override fun openLocationSettings() {
runCatchingExceptions {
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}.onSuccess {
Timber.v("Open location settings succeed")
}.onFailure {
Timber.e(it, "Open location settings failed")
}
}
} }
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap // Ref: https://developer.android.com/guide/components/intents-common#ViewMap

View file

@ -12,5 +12,7 @@ import io.element.android.features.location.api.Location
interface LocationActions { interface LocationActions {
fun share(location: Location, label: String?) fun share(location: Location, label: String?)
fun openSettings() fun openAppSettings()
fun isLocationEnabled(): Boolean
fun openLocationSettings()
} }

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.common.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LocationConstraintsDialog(
state: LocationConstraintsDialogState,
appName: String,
onRequestPermissions: () -> Unit,
onOpenAppSettings: () -> Unit,
onOpenLocationSettings: () -> Unit,
onDismiss: () -> Unit,
) {
when (state) {
LocationConstraintsDialogState.None -> Unit
LocationConstraintsDialogState.PermissionRationale -> ConfirmationDialog(
content = stringResource(CommonStrings.error_missing_location_rationale_android, appName),
onSubmitClick = onRequestPermissions,
onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue),
)
LocationConstraintsDialogState.PermissionDenied -> ConfirmationDialog(
content = stringResource(CommonStrings.error_missing_location_auth_android, appName),
onSubmitClick = onOpenAppSettings,
onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue),
)
LocationConstraintsDialogState.LocationServiceDisabled -> ConfirmationDialog(
content = stringResource(CommonStrings.error_location_service_disabled_android),
onSubmitClick = onOpenLocationSettings,
onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue),
)
}
}
@Immutable
sealed interface LocationConstraintsDialogState {
data object None : LocationConstraintsDialogState
data object PermissionRationale : LocationConstraintsDialogState
data object PermissionDenied : LocationConstraintsDialogState
data object LocationServiceDisabled : LocationConstraintsDialogState
}

View file

@ -9,7 +9,7 @@
package io.element.android.features.location.impl.common.ui package io.element.android.features.location.impl.common.ui
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -30,13 +30,11 @@ internal fun LocationFloatingActionButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
FloatingActionButton( FloatingActionButton(
shape = FloatingActionButtonDefaults.smallShape, shape = CircleShape,
containerColor = ElementTheme.colors.bgCanvasDefault, containerColor = ElementTheme.colors.bgCanvasDefault,
contentColor = ElementTheme.colors.iconPrimary, contentColor = ElementTheme.colors.iconPrimary,
onClick = onClick, onClick = onClick,
modifier = modifier modifier = modifier.size(48.dp),
// Note: design is 40dp, but min is 48 for accessibility.
.size(48.dp),
) { ) {
val iconImage = if (isMapCenteredOnUser) { val iconImage = if (isMapCenteredOnUser) {
CompoundIcons.LocationNavigatorCentred() CompoundIcons.LocationNavigatorCentred()

View file

@ -0,0 +1,172 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.common.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.api.Location
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.rememberLocationPinBitmap
import kotlinx.serialization.json.JsonPrimitive
import org.maplibre.compose.expressions.dsl.and
import org.maplibre.compose.expressions.dsl.asString
import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.expressions.dsl.eq
import org.maplibre.compose.expressions.dsl.feature
import org.maplibre.compose.expressions.dsl.image
import org.maplibre.compose.expressions.dsl.not
import org.maplibre.compose.expressions.value.SymbolAnchor
import org.maplibre.compose.layers.CircleLayer
import org.maplibre.compose.layers.SymbolLayer
import org.maplibre.compose.sources.GeoJsonData
import org.maplibre.compose.sources.GeoJsonOptions
import org.maplibre.compose.sources.GeoJsonSource
import org.maplibre.compose.sources.rememberGeoJsonSource
import org.maplibre.compose.util.ClickResult
import org.maplibre.spatialk.geojson.Feature
import org.maplibre.spatialk.geojson.FeatureCollection
import org.maplibre.spatialk.geojson.Point
import org.maplibre.spatialk.geojson.Position
import org.maplibre.spatialk.geojson.toJson
private const val LOCATION_MARKER_ID = "LOCATION_MARKER_ID"
/**
* Data class representing a marker on the map.
*
* @param id Unique identifier for the marker
* @param location The geographic location of the marker
* @param variant The visual variant of the pin (user location, pinned, stale)
*/
data class LocationMarkerData(
val id: String,
val location: Location,
val variant: PinVariant,
)
/**
* A composable that renders location markers on a MapLibre map with clustering support.
*
* Uses GeoJSON source with clustering enabled to group nearby markers.
* Individual markers are rendered using Canvas-based pin rendering with Coil for avatar loading.
* Clusters are rendered as circles with point counts.
*
* Must be used within a MaplibreMap content block.
*
* @param markers List of markers to display on the map
* @param onMarkerClick Callback when a marker is clicked
* @param onClusterClick Callback when a cluster is clicked, provides cluster center position
*/
@Composable
fun LocationPinMarkers(
markers: List<LocationMarkerData>,
onMarkerClick: ((LocationMarkerData) -> Unit)? = null,
onClusterClick: ((Position) -> Unit)? = null,
) {
if (markers.isEmpty()) return
val clusterColor = ElementTheme.colors.bgAccentRest
val clusterStrokeColor = ElementTheme.colors.iconOnSolidPrimary
val clusterTextColor = ElementTheme.colors.textOnSolidPrimary
val clusterTextStyle = ElementTheme.typography.fontBodyMdMedium
// Convert markers to GeoJSON
val geoJsonString = remember(markers) {
val features = markers.map { marker ->
Feature(
id = JsonPrimitive(marker.id),
geometry = Point(Position(marker.location.lon, marker.location.lat)),
properties = mapOf(
LOCATION_MARKER_ID to JsonPrimitive(marker.id),
)
)
}
FeatureCollection(features).toJson()
}
// Create GeoJSON source with clustering
val markersSource = rememberGeoJsonSource(
data = GeoJsonData.JsonString(geoJsonString),
options = GeoJsonOptions(
cluster = true,
clusterMinPoints = 3,
clusterRadius = 30
),
)
// Cluster circle layer
CircleLayer(
id = "cluster-circles",
source = markersSource,
filter = feature.has("point_count"),
color = const(clusterColor),
radius = const(24.dp),
strokeWidth = const(1.dp),
strokeColor = const(clusterStrokeColor),
onClick = { features ->
features.firstOrNull()?.let { feat ->
val point = feat.geometry as? Point
if (point != null && onClusterClick != null) {
onClusterClick(point.coordinates)
ClickResult.Consume
} else {
ClickResult.Pass
}
} ?: ClickResult.Pass
},
)
// Cluster count text layer
SymbolLayer(
id = "cluster-count",
source = markersSource,
filter = feature.has("point_count"),
textField = feature["point_count_abbreviated"].asString(),
textColor = const(clusterTextColor),
textSize = const(clusterTextStyle.fontSize),
textFont = const(listOfNotNull(clusterTextStyle.fontFamily?.toString())),
textLetterSpacing = const(clusterTextStyle.letterSpacing),
)
// Individual marker layers - one per marker for unique avatars
markers.forEach { marker ->
LocationPinMarkerLayer(
marker = marker,
source = markersSource,
onMarkerClick = onMarkerClick,
)
}
}
@Composable
private fun LocationPinMarkerLayer(
marker: LocationMarkerData,
source: GeoJsonSource,
onMarkerClick: ((LocationMarkerData) -> Unit)?,
) {
val imageBitmap = rememberLocationPinBitmap(marker.variant)
if (imageBitmap != null) {
SymbolLayer(
id = "pin-marker-${marker.id}",
source = source,
filter = !feature.has("point_count") and (feature[LOCATION_MARKER_ID].asString() eq const(marker.id)),
iconImage = image(imageBitmap),
iconAnchor = const(SymbolAnchor.Bottom),
iconAllowOverlap = const(true),
onClick = { features ->
if (features.isNotEmpty() && onMarkerClick != null) {
onMarkerClick(marker)
ClickResult.Consume
} else {
ClickResult.Pass
}
},
)
}
}

View file

@ -0,0 +1,151 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.common.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.show.LocationShareItem
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LocationShareRow(
item: LocationShareItem,
onShareClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
avatarData = item.avatarData,
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = item.displayName,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (item.isLive) {
Icon(
imageVector = CompoundIcons.LocationPinSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconAccentPrimary,
modifier = Modifier.size(16.dp),
)
} else {
val icon = if (item.assetType == AssetType.PIN) {
CompoundIcons.LocationNavigator()
} else {
CompoundIcons.LocationNavigatorCentred()
}
Icon(
imageVector = icon,
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
modifier = Modifier.size(16.dp),
)
}
Text(
text = item.formattedTimestamp,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
IconButton(onClick = onShareClick) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = stringResource(CommonStrings.action_share),
tint = ElementTheme.colors.iconPrimary,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun LocationShareRowPreview() = ElementPreview {
Column {
LocationShareRow(
item = LocationShareItem(
userId = UserId("@alice:matrix.org"),
displayName = "Alice",
avatarData = AvatarData(
id = "@alice:matrix.org",
name = "Alice",
url = null,
size = AvatarSize.UserListItem,
),
formattedTimestamp = "Shared 1 min ago",
isLive = true,
assetType = AssetType.SENDER,
location = Location(0.0, 0.0)
),
onShareClick = {},
)
LocationShareRow(
item = LocationShareItem(
userId = UserId("@bob:matrix.org"),
displayName = "Bob",
avatarData = AvatarData(
id = "@bob:matrix.org",
name = "Bob",
url = null,
size = AvatarSize.UserListItem,
),
isLive = false,
assetType = AssetType.PIN,
formattedTimestamp = "Shared 5 hours ago",
location = Location(0.0, 0.0)
),
onShareClick = {},
)
}
}

View file

@ -0,0 +1,138 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.common.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.BottomSheetScaffoldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.map.MapOptions
import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.style.BaseStyle
import org.maplibre.compose.util.MaplibreComposable
import kotlin.math.roundToInt
/**
* A reusable scaffold component for map views with a bottom sheet.
*
* Handles the layout complexity of:
* - Calculating the visible sheet height dynamically
* - Updating camera position padding based on sheet height
* - Rendering the MaplibreMap with proper ornament positioning
*
* @param modifier Modifier for the root layout
* @param scaffoldState State for the bottom sheet scaffold
* @param cameraState The camera state for the map
* @param mapOptions The options to configure the map
* @param sheetPeekHeight The height of the sheet when collapsed
* @param sheetDragHandle Optional drag handle for the sheet
* @param sheetSwipeEnabled Whether the sheet can be swiped
* @param topBar The top app bar content
* @param snackbarHost The snackbar host content
* @param sheetContent The content to display in the bottom sheet
* @param mapContent The content inside the MaplibreMap (layers, location pucks, etc.)
* @param overlayContent Content to overlay on top of the map (FAB, pin icons, etc.)
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapBottomSheetScaffold(
modifier: Modifier = Modifier,
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.PartiallyExpanded)
),
cameraState: CameraState = rememberCameraState(),
mapOptions: MapOptions = MapDefaults.options,
sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight,
sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
sheetSwipeEnabled: Boolean = true,
topBar: (@Composable () -> Unit)? = null,
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
sheetContent: @Composable ColumnScope.(PaddingValues) -> Unit = {},
mapContent: @Composable @MaplibreComposable () -> Unit = {},
overlayContent: @Composable BoxScope.(sheetPadding: PaddingValues) -> Unit = {},
) {
val density = LocalDensity.current
val windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
BoxWithConstraints(modifier = modifier.windowInsetsPadding(windowInsets)) {
val layoutHeightPx by rememberUpdatedState(constraints.maxHeight)
val sheetPadding by remember {
derivedStateOf {
val sheetOffset = tryOrNull { scaffoldState.bottomSheetState.requireOffset() } ?: 0f
val sheetVisibleHeightPx = layoutHeightPx - sheetOffset
val bottomPadding = with(density) { max(sheetVisibleHeightPx.roundToInt().toDp(), 0.dp) }
PaddingValues(bottom = bottomPadding)
}
}
// Update camera position when sheet padding changes
LaunchedEffect(sheetPadding) {
cameraState.position = cameraState.position.copy(padding = sheetPadding)
}
BottomSheetScaffold(
modifier = Modifier,
sheetPeekHeight = sheetPeekHeight,
sheetContent = {
sheetContent(sheetPadding)
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
},
scaffoldState = scaffoldState,
sheetDragHandle = sheetDragHandle,
sheetSwipeEnabled = sheetSwipeEnabled,
snackbarHost = snackbarHost,
topBar = topBar,
) {
val ornamentOptions = mapOptions.ornamentOptions.copy(padding = sheetPadding)
val mapOptions = mapOptions.copy(ornamentOptions = ornamentOptions)
Box {
MaplibreMap(
options = mapOptions,
baseStyle = BaseStyle.Uri(rememberTileStyleUrl()),
modifier = Modifier.fillMaxSize(),
cameraState = cameraState,
content = mapContent,
)
overlayContent(sheetPadding)
}
}
}
}

View file

@ -0,0 +1,81 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.common.ui
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.impl.common.MapDefaults
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.location.DesiredAccuracy
import org.maplibre.compose.location.LocationPuck
import org.maplibre.compose.location.LocationPuckColors
import org.maplibre.compose.location.LocationPuckSizes
import org.maplibre.compose.location.LocationTrackingEffect
import org.maplibre.compose.location.UserLocationState
import org.maplibre.compose.location.rememberAndroidLocationProvider
import org.maplibre.compose.location.rememberNullLocationProvider
import org.maplibre.compose.location.rememberUserLocationState
import kotlin.time.Duration.Companion.minutes
@Composable
fun UserLocationPuck(
cameraState: CameraState,
locationState: UserLocationState,
trackUserLocation: Boolean,
) {
LocationTrackingEffect(
locationState = locationState,
enabled = trackUserLocation,
) {
val finalPosition = cameraState.position.copy(
target = currentLocation.position,
bearing = currentLocation.bearing ?: cameraState.position.bearing,
zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM)
)
cameraState.animateTo(finalPosition)
}
val location = locationState.location
if (location != null) {
LocationPuck(
idPrefix = "user-location",
locationState = locationState,
cameraState = cameraState,
accuracyThreshold = Float.POSITIVE_INFINITY,
showBearingAccuracy = false,
showBearing = false,
sizes = LocationPuckSizes(
dotRadius = 8.dp,
dotStrokeWidth = 2.dp,
),
colors = LocationPuckColors(
dotFillColorCurrentLocation = ElementTheme.colors.iconAccentPrimary,
dotFillColorOldLocation = ElementTheme.colors.iconAccentTertiary,
dotStrokeColor = ElementTheme.colors.bgCanvasDefault,
)
)
}
}
@SuppressLint("MissingPermission")
@Composable
fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState {
val isPreview = LocalInspectionMode.current
val locationProvider = if (isPreview || !hasLocationPermission) {
rememberNullLocationProvider()
} else {
rememberAndroidLocationProvider(
updateInterval = 1.minutes,
desiredAccuracy = DesiredAccuracy.Balanced,
minDistanceMeters = 50f,
)
}
return rememberUserLocationState(locationProvider)
}

View file

@ -1,30 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.send
import io.element.android.features.location.api.Location
sealed interface SendLocationEvents {
data class SendLocation(
val cameraPosition: CameraPosition,
val location: Location?,
) : SendLocationEvents {
data class CameraPosition(
val lat: Double,
val lon: Double,
val zoom: Double,
)
}
data object SwitchToMyLocationMode : SendLocationEvents
data object SwitchToPinLocationMode : SendLocationEvents
data object DismissDialog : SendLocationEvents
data object RequestPermissions : SendLocationEvents
data object OpenAppSettings : SendLocationEvents
}

View file

@ -1,175 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.send
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.launch
@AssistedInject
class SendLocationPresenter(
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val room: JoinedRoom,
@Assisted private val timelineMode: Timeline.Mode,
private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContext,
private val locationActions: LocationActions,
private val buildMeta: BuildMeta,
) : Presenter<SendLocationState> {
@AssistedFactory
fun interface Factory {
fun create(timelineMode: Timeline.Mode): SendLocationPresenter
}
private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
@Composable
override fun present(): SendLocationState {
val permissionsState: PermissionsState = permissionsPresenter.present()
var mode: SendLocationState.Mode by remember {
mutableStateOf(
if (permissionsState.isAnyGranted) {
SendLocationState.Mode.SenderLocation
} else {
SendLocationState.Mode.PinLocation
}
)
}
val appName by remember { derivedStateOf { buildMeta.applicationName } }
var permissionDialog: SendLocationState.Dialog by remember {
mutableStateOf(SendLocationState.Dialog.None)
}
val scope = rememberCoroutineScope()
LaunchedEffect(permissionsState.permissions) {
if (permissionsState.isAnyGranted) {
mode = SendLocationState.Mode.SenderLocation
permissionDialog = SendLocationState.Dialog.None
}
}
fun handleEvent(event: SendLocationEvents) {
when (event) {
is SendLocationEvents.SendLocation -> scope.launch {
sendLocation(event, mode)
}
SendLocationEvents.SwitchToMyLocationMode -> when {
permissionsState.isAnyGranted -> mode = SendLocationState.Mode.SenderLocation
permissionsState.shouldShowRationale -> permissionDialog = SendLocationState.Dialog.PermissionRationale
else -> permissionDialog = SendLocationState.Dialog.PermissionDenied
}
SendLocationEvents.SwitchToPinLocationMode -> mode = SendLocationState.Mode.PinLocation
SendLocationEvents.DismissDialog -> permissionDialog = SendLocationState.Dialog.None
SendLocationEvents.OpenAppSettings -> {
locationActions.openSettings()
permissionDialog = SendLocationState.Dialog.None
}
SendLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
}
}
return SendLocationState(
permissionDialog = permissionDialog,
mode = mode,
hasLocationPermission = permissionsState.isAnyGranted,
appName = appName,
eventSink = ::handleEvent,
)
}
private suspend fun sendLocation(
event: SendLocationEvents.SendLocation,
mode: SendLocationState.Mode,
) {
val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply
val inReplyToEventId = replyMode?.eventId
when (mode) {
SendLocationState.Mode.PinLocation -> {
val geoUri = event.cameraPosition.toGeoUri()
getTimeline().flatMap {
it.sendLocation(
body = generateBody(geoUri),
geoUri = geoUri,
description = null,
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
assetType = AssetType.PIN,
inReplyToEventId = inReplyToEventId,
)
}
analyticsService.capture(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
isReply = messageComposerContext.composerMode.isReply,
messageType = Composer.MessageType.LocationPin,
)
)
}
SendLocationState.Mode.SenderLocation -> {
val geoUri = event.toGeoUri()
getTimeline().flatMap {
it.sendLocation(
body = generateBody(geoUri),
geoUri = geoUri,
description = null,
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
assetType = AssetType.SENDER,
inReplyToEventId = inReplyToEventId,
)
}
analyticsService.capture(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
isReply = messageComposerContext.composerMode.isReply,
messageType = Composer.MessageType.LocationUser,
)
)
}
}
}
private suspend fun getTimeline(): Result<Timeline> {
return when (timelineMode) {
is Timeline.Mode.Thread -> room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId))
else -> Result.success(room.liveTimeline)
}
}
}
private fun SendLocationEvents.SendLocation.toGeoUri(): String = location?.toGeoUri() ?: cameraPosition.toGeoUri()
private fun SendLocationEvents.SendLocation.CameraPosition.toGeoUri(): String = "geo:$lat,$lon"
private fun generateBody(uri: String): String = "Location was shared at $uri"

View file

@ -1,28 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.send
data class SendLocationState(
val permissionDialog: Dialog,
val mode: Mode,
val hasLocationPermission: Boolean,
val appName: String,
val eventSink: (SendLocationEvents) -> Unit,
) {
sealed interface Mode {
data object SenderLocation : Mode
data object PinLocation : Mode
}
sealed interface Dialog {
data object None : Dialog
data object PermissionRationale : Dialog
data object PermissionDenied : Dialog
}
}

View file

@ -1,58 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.send
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
private const val APP_NAME = "ApplicationName"
class SendLocationStateProvider : PreviewParameterProvider<SendLocationState> {
override val values: Sequence<SendLocationState>
get() = sequenceOf(
aSendLocationState(
permissionDialog = SendLocationState.Dialog.None,
mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = false,
),
aSendLocationState(
permissionDialog = SendLocationState.Dialog.PermissionDenied,
mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = false,
),
aSendLocationState(
permissionDialog = SendLocationState.Dialog.PermissionRationale,
mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = false,
),
aSendLocationState(
permissionDialog = SendLocationState.Dialog.None,
mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = true,
),
aSendLocationState(
permissionDialog = SendLocationState.Dialog.None,
mode = SendLocationState.Mode.SenderLocation,
hasLocationPermission = true,
),
)
}
private fun aSendLocationState(
permissionDialog: SendLocationState.Dialog,
mode: SendLocationState.Mode,
hasLocationPermission: Boolean,
): SendLocationState {
return SendLocationState(
permissionDialog = permissionDialog,
mode = mode,
hasLocationPermission = hasLocationPermission,
appName = APP_NAME,
eventSink = {}
)
}

View file

@ -1,212 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.send
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.R
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.PermissionDeniedDialog
import io.element.android.features.location.impl.common.PermissionRationaleDialog
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.maplibre.compose.CameraMode
import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason
import io.element.android.libraries.maplibre.compose.MapLibreMap
import io.element.android.libraries.maplibre.compose.rememberCameraPositionState
import io.element.android.libraries.ui.strings.CommonStrings
import org.maplibre.android.camera.CameraPosition
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SendLocationView(
state: SendLocationState,
navigateUp: () -> Unit,
modifier: Modifier = Modifier,
) {
LaunchedEffect(Unit) {
state.eventSink(SendLocationEvents.RequestPermissions)
}
when (state.permissionDialog) {
SendLocationState.Dialog.None -> Unit
SendLocationState.Dialog.PermissionDenied -> PermissionDeniedDialog(
onContinue = { state.eventSink(SendLocationEvents.OpenAppSettings) },
onDismiss = { state.eventSink(SendLocationEvents.DismissDialog) },
appName = state.appName,
)
SendLocationState.Dialog.PermissionRationale -> PermissionRationaleDialog(
onContinue = { state.eventSink(SendLocationEvents.RequestPermissions) },
onDismiss = { state.eventSink(SendLocationEvents.DismissDialog) },
appName = state.appName,
)
}
val cameraPositionState = rememberCameraPositionState {
position = MapDefaults.centerCameraPosition
}
LaunchedEffect(state.mode) {
when (state.mode) {
SendLocationState.Mode.PinLocation -> {
cameraPositionState.cameraMode = CameraMode.NONE
}
SendLocationState.Mode.SenderLocation -> {
cameraPositionState.position = CameraPosition.Builder()
.zoom(MapDefaults.DEFAULT_ZOOM)
.build()
cameraPositionState.cameraMode = CameraMode.TRACKING
}
}
}
LaunchedEffect(cameraPositionState.isMoving) {
if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) {
state.eventSink(SendLocationEvents.SwitchToPinLocationMode)
}
}
// BottomSheetScaffold doesn't manage the system insets for sheetContent and the FAB, so we need to do it manually.
val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
BottomSheetScaffold(
sheetContent = {
Spacer(modifier = Modifier.height(16.dp))
ListItem(
headlineContent = {
Text(
stringResource(
when (state.mode) {
SendLocationState.Mode.PinLocation -> CommonStrings.screen_share_this_location_action
SendLocationState.Mode.SenderLocation -> CommonStrings.screen_share_my_location_action
}
)
)
},
modifier = Modifier.clickable(
// target is null when the map hasn't loaded (or api key is wrong) so we disable the button
enabled = cameraPositionState.position.target != null
) {
state.eventSink(
SendLocationEvents.SendLocation(
cameraPosition = SendLocationEvents.SendLocation.CameraPosition(
lat = cameraPositionState.position.target!!.latitude,
lon = cameraPositionState.position.target!!.longitude,
zoom = cameraPositionState.position.zoom,
),
location = cameraPositionState.location?.let {
Location(
lat = it.latitude,
lon = it.longitude,
accuracy = it.accuracy,
)
}
)
)
navigateUp()
},
leadingContent = {
Icon(
resourceId = R.drawable.pin_small,
contentDescription = null,
tint = Color.Unspecified,
)
},
)
Spacer(modifier = Modifier.height(16.dp + navBarPadding))
},
modifier = modifier,
scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded),
),
sheetDragHandle = {},
sheetSwipeEnabled = false,
topBar = {
TopAppBar(
titleStr = stringResource(CommonStrings.screen_share_location_title),
navigationIcon = {
BackButton(onClick = navigateUp)
},
)
},
) {
Box(
modifier = Modifier
.padding(it)
.consumeWindowInsets(it),
contentAlignment = Alignment.Center
) {
MapLibreMap(
styleUri = rememberTileStyleUrl(),
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
uiSettings = MapDefaults.uiSettings,
symbolManagerSettings = MapDefaults.symbolManagerSettings,
locationSettings = MapDefaults.locationSettings.copy(
locationEnabled = state.hasLocationPermission,
),
)
Icon(
resourceId = CommonDrawables.pin,
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.centerBottomEdge(this),
)
LocationFloatingActionButton(
isMapCenteredOnUser = state.mode == SendLocationState.Mode.SenderLocation,
onClick = { state.eventSink(SendLocationEvents.SwitchToMyLocationMode) },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 18.dp, bottom = 72.dp + navBarPadding),
)
}
}
}
@PreviewsDayNight
@Composable
internal fun SendLocationViewPreview(
@PreviewParameter(SendLocationStateProvider::class) state: SendLocationState
) = ElementPreview {
SendLocationView(
state = state,
navigateUp = {},
)
}

View file

@ -6,26 +6,26 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.location.impl.send package io.element.android.features.location.impl.share
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.location.api.SendLocationEntryPoint import io.element.android.features.location.api.ShareLocationEntryPoint
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultSendLocationEntryPoint : SendLocationEntryPoint { class DefaultShareLocationEntryPoint : ShareLocationEntryPoint {
override fun createNode( override fun createNode(
parentNode: Node, parentNode: Node,
buildContext: BuildContext, buildContext: BuildContext,
timelineMode: Timeline.Mode, timelineMode: Timeline.Mode,
): Node { ): Node {
return parentNode.createNode<SendLocationNode>( return parentNode.createNode<ShareLocationNode>(
buildContext = buildContext, buildContext = buildContext,
plugins = listOf(SendLocationNode.Inputs(timelineMode)) plugins = listOf(ShareLocationNode.Inputs(timelineMode))
) )
} }
} }

View file

@ -0,0 +1,15 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.share
import kotlin.time.Duration
data class LiveLocationDuration(
val duration: Duration,
val formatted: String
)

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.share
import io.element.android.features.location.api.Location
import kotlin.time.Duration
sealed interface ShareLocationEvent {
data class ShareStaticLocation(
val location: Location,
val isPinned: Boolean,
) : ShareLocationEvent
data object ShowLiveLocationDurationPicker : ShareLocationEvent
data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent
data object StartTrackingUserLocation : ShareLocationEvent
data object StopTrackingUserLocation : ShareLocationEvent
data object DismissDialog : ShareLocationEvent
data object RequestPermissions : ShareLocationEvent
data object OpenAppSettings : ShareLocationEvent
data object OpenLocationSettings : ShareLocationEvent
}

View file

@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.location.impl.send package io.element.android.features.location.impl.share
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -26,10 +26,10 @@ import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@AssistedInject @AssistedInject
class SendLocationNode( class ShareLocationNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: SendLocationPresenter.Factory, presenterFactory: ShareLocationPresenter.Factory,
analyticsService: AnalyticsService, analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class Inputs( data class Inputs(
@ -48,7 +48,7 @@ class SendLocationNode(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
SendLocationView( ShareLocationView(
state = presenter.present(), state = presenter.present(),
modifier = modifier, modifier = modifier,
navigateUp = ::navigateUp, navigateUp = ::navigateUp,

View file

@ -0,0 +1,177 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.location.impl.common.LocationConstraintsCheck
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.location.impl.common.checkLocationConstraints
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.share.ShareLocationState.Dialog.Constraints
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.dateformatter.api.DurationFormatter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
private val LIVE_LOCATION_DURATIONS = listOf(15.minutes, 1.hours, 8.hours)
@AssistedInject
class ShareLocationPresenter(
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val room: JoinedRoom,
@Assisted private val timelineMode: Timeline.Mode,
private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContext,
private val locationActions: LocationActions,
private val buildMeta: BuildMeta,
private val featureFlagService: FeatureFlagService,
private val client: MatrixClient,
private val durationFormatter: DurationFormatter,
) : Presenter<ShareLocationState> {
@AssistedFactory
fun interface Factory {
fun create(timelineMode: Timeline.Mode): ShareLocationPresenter
}
private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
@Composable
override fun present(): ShareLocationState {
val permissionsState: PermissionsState = permissionsPresenter.present()
var trackUserPosition: Boolean by remember { mutableStateOf(permissionsState.isAnyGranted && locationActions.isLocationEnabled()) }
val isLiveLocationSharingEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing)
}.collectAsState(false)
val appName by remember { derivedStateOf { buildMeta.applicationName } }
var dialogState: ShareLocationState.Dialog by remember {
mutableStateOf(ShareLocationState.Dialog.None)
}
val currentUser by client.userProfile.collectAsState()
val scope = rememberCoroutineScope()
fun checkLocationConstraints() {
val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
dialogState = Constraints(locationConstraints.toDialogState())
trackUserPosition = locationConstraints is LocationConstraintsCheck.Success
}
LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() }
fun handleEvent(event: ShareLocationEvent) {
when (event) {
is ShareLocationEvent.ShareStaticLocation -> scope.launch {
shareStaticLocation(event)
}
ShareLocationEvent.StartTrackingUserLocation -> checkLocationConstraints()
ShareLocationEvent.StopTrackingUserLocation -> trackUserPosition = false
ShareLocationEvent.DismissDialog -> dialogState = ShareLocationState.Dialog.None
ShareLocationEvent.OpenAppSettings -> {
locationActions.openAppSettings()
dialogState = ShareLocationState.Dialog.None
}
ShareLocationEvent.OpenLocationSettings -> {
locationActions.openLocationSettings()
dialogState = ShareLocationState.Dialog.None
}
ShareLocationEvent.ShowLiveLocationDurationPicker -> {
val constraintsResult = checkLocationConstraints(permissionsState, locationActions)
dialogState = if (constraintsResult is LocationConstraintsCheck.Success) {
val durations = LIVE_LOCATION_DURATIONS.map {
LiveLocationDuration(duration = it, formatted = durationFormatter.format(it))
}
ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
} else {
Constraints(constraintsResult.toDialogState())
}
}
is ShareLocationEvent.StartLiveLocationShare -> scope.launch {
dialogState = ShareLocationState.Dialog.None
// room.startLiveLocationShare(event.duration.inWholeMilliseconds)
}
ShareLocationEvent.RequestPermissions -> {
dialogState = ShareLocationState.Dialog.None
permissionsState.eventSink(PermissionsEvents.RequestPermissions)
}
}
}
return ShareLocationState(
currentUser = currentUser,
dialogState = dialogState,
trackUserLocation = trackUserPosition,
hasLocationPermission = permissionsState.isAnyGranted,
canShareLiveLocation = isLiveLocationSharingEnabled,
appName = appName,
eventSink = ::handleEvent,
)
}
private suspend fun shareStaticLocation(event: ShareLocationEvent.ShareStaticLocation) {
val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply
val inReplyToEventId = replyMode?.eventId
val geoUri = event.location.toGeoUri()
getTimeline().flatMap {
it.sendLocation(
body = generateBody(geoUri),
geoUri = geoUri,
description = null,
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
assetType = if (event.isPinned) AssetType.PIN else AssetType.SENDER,
inReplyToEventId = inReplyToEventId,
)
}
analyticsService.capture(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
isReply = messageComposerContext.composerMode.isReply,
messageType = if (event.isPinned) Composer.MessageType.LocationPin else Composer.MessageType.LocationUser
)
)
}
private suspend fun getTimeline(): Result<Timeline> {
return when (timelineMode) {
is Timeline.Mode.Thread -> room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId))
else -> Result.success(room.liveTimeline)
}
}
}
private fun generateBody(uri: String): String = "Location was shared at $uri"

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.share
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class ShareLocationState(
val currentUser: MatrixUser,
val dialogState: Dialog,
val trackUserLocation: Boolean,
val hasLocationPermission: Boolean,
val appName: String,
val canShareLiveLocation: Boolean,
val eventSink: (ShareLocationEvent) -> Unit,
) {
sealed interface Dialog {
data object None : Dialog
data class Constraints(val state: LocationConstraintsDialogState) : Dialog
data class LiveLocationDurations(val durations: ImmutableList<LiveLocationDuration>) : Dialog
}
}

View file

@ -0,0 +1,87 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.share
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.persistentListOf
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
private const val APP_NAME = "ApplicationName"
class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState> {
override val values: Sequence<ShareLocationState>
get() = sequenceOf(
aShareLocationState(
dialogState = ShareLocationState.Dialog.None,
trackUserPosition = false,
hasLocationPermission = false,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied),
trackUserPosition = false,
hasLocationPermission = false,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale),
trackUserPosition = false,
hasLocationPermission = false,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
trackUserPosition = false,
hasLocationPermission = true,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.None,
trackUserPosition = false,
hasLocationPermission = true,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.None,
trackUserPosition = true,
hasLocationPermission = true,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.LiveLocationDurations(
persistentListOf(
LiveLocationDuration(15.minutes, "15 minutes"),
LiveLocationDuration(1.hours, "1 hour"),
LiveLocationDuration(8.hours, "8 hours"),
)
),
trackUserPosition = true,
hasLocationPermission = true,
canShareLiveLocation = true,
),
)
}
fun aShareLocationState(
currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")),
dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None,
trackUserPosition: Boolean = false,
hasLocationPermission: Boolean = false,
canShareLiveLocation: Boolean = false,
appName: String = APP_NAME,
eventSink: (ShareLocationEvent) -> Unit = {},
): ShareLocationState {
return ShareLocationState(
currentUser = currentUser,
dialogState = dialogState,
trackUserLocation = trackUserPosition,
hasLocationPermission = hasLocationPermission,
canShareLiveLocation = canShareLiveLocation,
appName = appName,
eventSink = eventSink
)
}

View file

@ -0,0 +1,292 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
package io.element.android.features.location.impl.share
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.features.location.impl.R
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
import io.element.android.features.location.impl.common.ui.UserLocationPuck
import io.element.android.features.location.impl.common.ui.rememberUserLocationState
import io.element.android.libraries.androidutils.system.toast
import io.element.android.libraries.designsystem.components.LocationPin
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.list.RadioButtonListItem
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import org.maplibre.compose.camera.CameraMoveReason
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.location.UserLocationState
import kotlin.time.Duration
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShareLocationView(
state: ShareLocationState,
navigateUp: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
when (val dialogState = state.dialogState) {
ShareLocationState.Dialog.None -> Unit
is ShareLocationState.Dialog.Constraints -> LocationConstraintsDialog(
state = dialogState.state,
appName = state.appName,
onRequestPermissions = { state.eventSink(ShareLocationEvent.RequestPermissions) },
onOpenAppSettings = { state.eventSink(ShareLocationEvent.OpenAppSettings) },
onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) },
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
)
is ShareLocationState.Dialog.LiveLocationDurations -> LiveLocationDurationDialog(
durations = dialogState.durations,
onSelectDuration = { duration ->
state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration))
context.toast("Not implemented yet!")
navigateUp()
},
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
)
}
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded)
)
val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition)
val userLocationState = rememberUserLocationState(state.hasLocationPermission)
LaunchedEffect(cameraState.isCameraMoving) {
if (cameraState.moveReason == CameraMoveReason.GESTURE) {
state.eventSink(ShareLocationEvent.StopTrackingUserLocation)
}
}
MapBottomSheetScaffold(
cameraState = cameraState,
modifier = modifier,
scaffoldState = scaffoldState,
sheetDragHandle = null,
sheetSwipeEnabled = false,
topBar = {
TopAppBar(
titleStr = stringResource(CommonStrings.screen_share_location_title),
navigationIcon = {
BackButton(onClick = navigateUp)
},
)
},
sheetContent = {
BottomSheetContent(
cameraState = cameraState,
state = state,
userLocationState = userLocationState,
navigateUp = navigateUp
)
},
mapContent = {
UserLocationPuck(
cameraState = cameraState,
locationState = userLocationState,
trackUserLocation = state.trackUserLocation
)
},
overlayContent = { sheetPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(sheetPadding)
) {
val variant = if (state.trackUserLocation) {
PinVariant.UserLocation(isLive = false, avatarData = state.currentUser.getAvatarData(AvatarSize.LocationPin))
} else {
PinVariant.PinnedLocation
}
LocationPin(
variant = variant,
modifier = Modifier.centerBottomEdge(this),
)
}
LocationFloatingActionButton(
isMapCenteredOnUser = state.trackUserLocation,
onClick = { state.eventSink(ShareLocationEvent.StartTrackingUserLocation) },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(all = 16.dp),
)
}
)
}
@Composable
private fun BottomSheetContent(
cameraState: CameraState,
state: ShareLocationState,
userLocationState: UserLocationState,
navigateUp: () -> Unit,
) {
Spacer(Modifier.height(20.dp))
val userLocation = userLocationState.location
if (state.trackUserLocation && userLocation != null) {
ShareCurrentLocationItem {
state.eventSink(
ShareLocationEvent.ShareStaticLocation(
location = Location(
lat = userLocation.position.latitude,
lon = userLocation.position.longitude
),
isPinned = false
)
)
navigateUp()
}
} else {
SharePinLocationItem(
onClick = {
val positionTarget = cameraState.position.target
state.eventSink(
ShareLocationEvent.ShareStaticLocation(
location = Location(lat = positionTarget.latitude, lon = positionTarget.longitude),
isPinned = true
)
)
navigateUp()
}
)
}
if (state.canShareLiveLocation) {
ShareLiveLocationItem {
state.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
}
}
}
@Composable
private fun ShareCurrentLocationItem(
onClick: () -> Unit,
) {
ListItem(
headlineContent = {
Text(stringResource(CommonStrings.screen_share_my_location_action))
},
onClick = onClick,
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.LocationNavigatorCentred())
)
)
}
@Composable
private fun SharePinLocationItem(
onClick: () -> Unit,
) {
ListItem(
headlineContent = {
Text(stringResource(CommonStrings.screen_share_this_location_action))
},
onClick = onClick,
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.LocationNavigator())
)
)
}
@Composable
private fun ShareLiveLocationItem(
onClick: () -> Unit,
) {
ListItem(
headlineContent = {
Text(stringResource(CommonStrings.action_share_live_location))
},
onClick = onClick,
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.LocationPinSolid()),
tintColor = ElementTheme.colors.iconAccentPrimary,
)
)
}
@Composable
private fun LiveLocationDurationDialog(
durations: ImmutableList<LiveLocationDuration>,
onSelectDuration: (Duration) -> Unit,
onDismiss: () -> Unit,
) {
var selectedIndex by remember { mutableIntStateOf(0) }
ListDialog(
title = stringResource(R.string.screen_share_location_live_location_duration_picker_title),
submitText = stringResource(CommonStrings.action_continue),
onSubmit = { onSelectDuration(durations[selectedIndex].duration) },
onDismissRequest = onDismiss,
applyPaddingToContents = false,
verticalArrangement = Arrangement.Top
) {
itemsIndexed(durations) { index, duration ->
RadioButtonListItem(
headline = duration.formatted,
selected = index == selectedIndex,
onSelect = { selectedIndex = index },
compactLayout = true,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
@PreviewsDayNight
@Composable
internal fun ShareLocationViewPreview(
@PreviewParameter(ShareLocationStateProvider::class) state: ShareLocationState
) = ElementPreview {
ShareLocationView(
state = state,
navigateUp = {},
)
}

View file

@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.show
import io.element.android.features.location.api.Location
sealed interface ShowLocationEvent {
data class Share(val location: Location) : ShowLocationEvent
data class TrackMyLocation(val enabled: Boolean) : ShowLocationEvent
data object DismissDialog : ShowLocationEvent
data object RequestPermissions : ShowLocationEvent
data object OpenAppSettings : ShowLocationEvent
data object OpenLocationSettings : ShowLocationEvent
}

View file

@ -1,17 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.show
sealed interface ShowLocationEvents {
data object Share : ShowLocationEvents
data class TrackMyLocation(val enabled: Boolean) : ShowLocationEvents
data object DismissDialog : ShowLocationEvents
data object RequestPermissions : ShowLocationEvents
data object OpenAppSettings : ShowLocationEvents
}

View file

@ -40,7 +40,7 @@ class ShowLocationNode(
} }
private val inputs: ShowLocationEntryPoint.Inputs = inputs() private val inputs: ShowLocationEntryPoint.Inputs = inputs()
private val presenter = presenterFactory.create(inputs.location, inputs.description) private val presenter = presenterFactory.create(inputs.mode)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {

View file

@ -18,26 +18,38 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.features.location.api.Location import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.common.LocationConstraintsCheck
import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.actions.LocationActions import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.location.impl.common.checkLocationConstraints
import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.collections.immutable.persistentListOf
@AssistedInject @AssistedInject
class ShowLocationPresenter( class ShowLocationPresenter(
@Assisted private val location: Location, @Assisted private val mode: ShowLocationMode,
@Assisted private val description: String?,
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
private val locationActions: LocationActions, private val locationActions: LocationActions,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val dateFormatter: DateFormatter,
private val stringProvider: StringProvider,
) : Presenter<ShowLocationState> { ) : Presenter<ShowLocationState> {
@AssistedFactory @AssistedFactory
fun interface Factory { fun interface Factory {
fun create(location: Location, description: String?): ShowLocationPresenter fun create(mode: ShowLocationMode): ShowLocationPresenter
} }
private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions) private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
@ -47,43 +59,75 @@ class ShowLocationPresenter(
val permissionsState: PermissionsState = permissionsPresenter.present() val permissionsState: PermissionsState = permissionsPresenter.present()
var isTrackMyLocation by remember { mutableStateOf(false) } var isTrackMyLocation by remember { mutableStateOf(false) }
val appName by remember { derivedStateOf { buildMeta.applicationName } } val appName by remember { derivedStateOf { buildMeta.applicationName } }
var permissionDialog: ShowLocationState.Dialog by remember { var dialogState: LocationConstraintsDialogState by remember {
mutableStateOf(ShowLocationState.Dialog.None) mutableStateOf(LocationConstraintsDialogState.None)
} }
LaunchedEffect(permissionsState.permissions) { LaunchedEffect(permissionsState.permissions) {
if (permissionsState.isAnyGranted) { if (permissionsState.isAnyGranted) {
permissionDialog = ShowLocationState.Dialog.None dialogState = LocationConstraintsDialogState.None
} }
} }
fun handleEvent(event: ShowLocationEvents) { fun handleEvent(event: ShowLocationEvent) {
when (event) { when (event) {
ShowLocationEvents.Share -> locationActions.share(location, description) is ShowLocationEvent.Share -> {
is ShowLocationEvents.TrackMyLocation -> { locationActions.share(event.location, null)
}
is ShowLocationEvent.TrackMyLocation -> {
if (event.enabled) { if (event.enabled) {
when { val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
permissionsState.isAnyGranted -> isTrackMyLocation = true isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success
permissionsState.shouldShowRationale -> permissionDialog = ShowLocationState.Dialog.PermissionRationale dialogState = locationConstraints.toDialogState()
else -> permissionDialog = ShowLocationState.Dialog.PermissionDenied
}
} else { } else {
isTrackMyLocation = false isTrackMyLocation = false
} }
} }
ShowLocationEvents.DismissDialog -> permissionDialog = ShowLocationState.Dialog.None ShowLocationEvent.DismissDialog -> dialogState = LocationConstraintsDialogState.None
ShowLocationEvents.OpenAppSettings -> { ShowLocationEvent.OpenAppSettings -> {
locationActions.openSettings() locationActions.openAppSettings()
permissionDialog = ShowLocationState.Dialog.None dialogState = LocationConstraintsDialogState.None
} }
ShowLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) ShowLocationEvent.OpenLocationSettings -> {
locationActions.openLocationSettings()
dialogState = LocationConstraintsDialogState.None
}
ShowLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
}
}
val locationShares = remember {
when (mode) {
is ShowLocationMode.Static -> {
val relativeTime = dateFormatter.format(timestamp = mode.timestamp, mode = DateFormatterMode.Full, useRelative = true)
val formattedTimestamp = stringProvider.getString(
CommonStrings.screen_static_location_sheet_timestamp_description,
relativeTime
)
persistentListOf(
LocationShareItem(
userId = mode.senderId,
displayName = mode.senderName,
avatarData = AvatarData(
id = mode.senderId.value,
name = mode.senderName,
url = mode.senderAvatarUrl,
size = AvatarSize.UserListItem,
),
formattedTimestamp = formattedTimestamp,
location = mode.location,
isLive = false,
assetType = mode.assetType,
)
)
}
ShowLocationMode.Live -> persistentListOf()
} }
} }
return ShowLocationState( return ShowLocationState(
permissionDialog = permissionDialog, dialogState = dialogState,
location = location, locationShares = locationShares,
description = description,
hasLocationPermission = permissionsState.isAnyGranted, hasLocationPermission = permissionsState.isAnyGranted,
isTrackMyLocation = isTrackMyLocation, isTrackMyLocation = isTrackMyLocation,
appName = appName, appName = appName,

View file

@ -9,19 +9,47 @@
package io.element.android.features.location.impl.show package io.element.android.features.location.impl.show
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.ui.LocationMarkerData
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
import kotlinx.collections.immutable.ImmutableList
data class ShowLocationState( data class ShowLocationState(
val permissionDialog: Dialog, val dialogState: LocationConstraintsDialogState,
val location: Location, val locationShares: ImmutableList<LocationShareItem>,
val description: String?,
val hasLocationPermission: Boolean, val hasLocationPermission: Boolean,
val isTrackMyLocation: Boolean, val isTrackMyLocation: Boolean,
val appName: String, val appName: String,
val eventSink: (ShowLocationEvents) -> Unit, val eventSink: (ShowLocationEvent) -> Unit,
) { ) {
sealed interface Dialog { val isSheetDraggable = locationShares.any { item -> item.isLive }
data object None : Dialog }
data object PermissionRationale : Dialog
data object PermissionDenied : Dialog data class LocationShareItem(
} val userId: UserId,
val displayName: String,
val avatarData: AvatarData,
val formattedTimestamp: String,
val location: Location,
val isLive: Boolean,
val assetType: AssetType?,
)
fun LocationShareItem.toMarkerData(): LocationMarkerData {
val pinVariant = if (assetType == AssetType.PIN) {
PinVariant.PinnedLocation
} else {
PinVariant.UserLocation(
avatarData = avatarData,
isLive = isLive,
)
}
return LocationMarkerData(
id = userId.value,
location = location,
variant = pinVariant,
)
} }

View file

@ -10,18 +10,26 @@ package io.element.android.features.location.impl.show
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
private const val APP_NAME = "ApplicationName" import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
import kotlinx.collections.immutable.toImmutableList
class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> { class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
override val values: Sequence<ShowLocationState> override val values: Sequence<ShowLocationState>
get() = sequenceOf( get() = sequenceOf(
aShowLocationState(), aShowLocationState(),
aShowLocationState( aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionDenied, constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
), ),
aShowLocationState( aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionRationale, constraintsDialogState = LocationConstraintsDialogState.PermissionRationale,
),
aShowLocationState(
constraintsDialogState = LocationConstraintsDialogState.LocationServiceDisabled,
hasLocationPermission = true,
), ),
aShowLocationState( aShowLocationState(
hasLocationPermission = true, hasLocationPermission = true,
@ -30,33 +38,48 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
hasLocationPermission = true, hasLocationPermission = true,
isTrackMyLocation = true, isTrackMyLocation = true,
), ),
aShowLocationState(
description = "My favourite place!",
),
aShowLocationState(
description = "For some reason I decided to to write a small essay that wraps at just two lines!",
),
aShowLocationState(
description = "For some reason I decided to write a small essay in the location description. " +
"It is so long that it will wrap onto more than two lines!",
),
) )
} }
private const val APP_NAME = "ApplicationName"
fun aShowLocationState( fun aShowLocationState(
permissionDialog: ShowLocationState.Dialog = ShowLocationState.Dialog.None, constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None,
location: Location = Location(1.23, 2.34, 4f), locationShares: List<LocationShareItem> = listOf(aLocationShareItem()),
description: String? = null,
hasLocationPermission: Boolean = false, hasLocationPermission: Boolean = false,
isTrackMyLocation: Boolean = false, isTrackMyLocation: Boolean = false,
appName: String = APP_NAME, appName: String = APP_NAME,
eventSink: (ShowLocationEvents) -> Unit = {}, eventSink: (ShowLocationEvent) -> Unit = {},
) = ShowLocationState( ): ShowLocationState {
permissionDialog = permissionDialog, return ShowLocationState(
dialogState = constraintsDialogState,
locationShares = locationShares.toImmutableList(),
hasLocationPermission = hasLocationPermission,
isTrackMyLocation = isTrackMyLocation,
appName = appName,
eventSink = eventSink,
)
}
fun aLocationShareItem(
userId: UserId = UserId("@alice:matrix.org"),
displayName: String = "Alice",
avatarData: AvatarData = AvatarData(
id = userId.value,
name = displayName,
url = null,
size = AvatarSize.UserListItem,
),
formattedTimestamp: String = "Shared 1 min ago",
location: Location = Location(1.23, 2.34, 4f),
isLive: Boolean = false,
assetType: AssetType? = null,
) = LocationShareItem(
userId = userId,
displayName = displayName,
avatarData = avatarData,
formattedTimestamp = formattedTimestamp,
location = location, location = location,
description = description, isLive = isLive,
hasLocationPermission = hasLocationPermission, assetType = assetType,
isTrackMyLocation = isTrackMyLocation,
appName = appName,
eventSink = eventSink,
) )

View file

@ -6,49 +6,48 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
package io.element.android.features.location.impl.show package io.element.android.features.location.impl.show
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.TypographyTokens
import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.PermissionDeniedDialog import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog
import io.element.android.features.location.impl.common.PermissionRationaleDialog
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
import io.element.android.features.location.impl.common.ui.LocationPinMarkers
import io.element.android.features.location.impl.common.ui.LocationShareRow
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
import io.element.android.features.location.impl.common.ui.UserLocationPuck
import io.element.android.features.location.impl.common.ui.rememberUserLocationState
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.maplibre.compose.CameraMode
import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason
import io.element.android.libraries.maplibre.compose.IconAnchor
import io.element.android.libraries.maplibre.compose.MapLibreMap
import io.element.android.libraries.maplibre.compose.Symbol
import io.element.android.libraries.maplibre.compose.rememberCameraPositionState
import io.element.android.libraries.maplibre.compose.rememberSymbolState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.launch
import org.maplibre.android.camera.CameraPosition import org.maplibre.compose.camera.CameraMoveReason
import org.maplibre.android.geometry.LatLng import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.spatialk.geojson.Position
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -57,46 +56,53 @@ fun ShowLocationView(
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when (state.permissionDialog) { LocationConstraintsDialog(
ShowLocationState.Dialog.None -> Unit state = state.dialogState,
ShowLocationState.Dialog.PermissionDenied -> PermissionDeniedDialog( appName = state.appName,
onContinue = { state.eventSink(ShowLocationEvents.OpenAppSettings) }, onRequestPermissions = { state.eventSink(ShowLocationEvent.RequestPermissions) },
onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) }, onOpenAppSettings = { state.eventSink(ShowLocationEvent.OpenAppSettings) },
appName = state.appName, onOpenLocationSettings = { state.eventSink(ShowLocationEvent.OpenLocationSettings) },
) onDismiss = { state.eventSink(ShowLocationEvent.DismissDialog) },
ShowLocationState.Dialog.PermissionRationale -> PermissionRationaleDialog( )
onContinue = { state.eventSink(ShowLocationEvents.RequestPermissions) },
onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) },
appName = state.appName,
)
}
val cameraPositionState = rememberCameraPositionState { val initialPosition = remember {
position = CameraPosition.Builder() if (state.locationShares.isEmpty()) {
.target(LatLng(state.location.lat, state.location.lon)) MapDefaults.defaultCameraPosition
.zoom(MapDefaults.DEFAULT_ZOOM) } else {
.build() val firstLocation = state.locationShares.first().location
CameraPosition(
target = Position(latitude = firstLocation.lat, longitude = firstLocation.lon),
zoom = MapDefaults.DEFAULT_ZOOM
)
}
} }
val cameraState = rememberCameraState(firstPosition = initialPosition)
LaunchedEffect(state.isTrackMyLocation) { val userLocationState = rememberUserLocationState(state.hasLocationPermission)
when (state.isTrackMyLocation) { LaunchedEffect(cameraState.isCameraMoving) {
false -> cameraPositionState.cameraMode = CameraMode.NONE if (cameraState.moveReason == CameraMoveReason.GESTURE) {
true -> { state.eventSink(ShowLocationEvent.TrackMyLocation(false))
cameraPositionState.position = CameraPosition.Builder()
.zoom(MapDefaults.DEFAULT_ZOOM)
.build()
cameraPositionState.cameraMode = CameraMode.TRACKING
}
} }
} }
LaunchedEffect(cameraPositionState.isMoving) { val scaffoldState = rememberBottomSheetScaffoldState(
if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) { bottomSheetState = rememberStandardBottomSheetState(
state.eventSink(ShowLocationEvents.TrackMyLocation(false)) initialValue =
} if (state.isSheetDraggable) {
} SheetValue.PartiallyExpanded
} else {
Scaffold( SheetValue.Expanded
}
)
)
MapBottomSheetScaffold(
sheetDragHandle = if (state.isSheetDraggable) {
{ BottomSheetDefaults.DragHandle() }
} else {
null
},
sheetSwipeEnabled = state.isSheetDraggable,
scaffoldState = scaffoldState,
cameraState = cameraState,
modifier = modifier, modifier = modifier,
topBar = { topBar = {
TopAppBar( TopAppBar(
@ -106,65 +112,56 @@ fun ShowLocationView(
onClick = onBackClick, onClick = onBackClick,
) )
}, },
actions = {
IconButton(
onClick = { state.eventSink(ShowLocationEvents.Share) }
) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = stringResource(CommonStrings.action_share),
)
}
}
) )
}, },
floatingActionButton = { sheetContent = { sheetPaddings ->
val coroutineScope = rememberCoroutineScope()
Spacer(Modifier.height(20.dp))
Text(
text = stringResource(CommonStrings.screen_static_location_sheet_title),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
state.locationShares.forEach { locationShare ->
LocationShareRow(
item = locationShare,
onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) },
modifier = Modifier.clickable {
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
val position = CameraPosition(
padding = sheetPaddings,
target = Position(locationShare.location.lon, locationShare.location.lat),
zoom = MapDefaults.DEFAULT_ZOOM
)
coroutineScope.launch {
cameraState.animateTo(finalPosition = position)
}
}
)
}
},
mapContent = {
UserLocationPuck(
cameraState = cameraState,
locationState = userLocationState,
trackUserLocation = state.isTrackMyLocation
)
val markers = remember(state.locationShares) {
state.locationShares.map { it.toMarkerData() }
}
LocationPinMarkers(markers)
},
overlayContent = {
LocationFloatingActionButton( LocationFloatingActionButton(
isMapCenteredOnUser = state.isTrackMyLocation, isMapCenteredOnUser = state.isTrackMyLocation,
onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) }, onClick = { state.eventSink(ShowLocationEvent.TrackMyLocation(true)) },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(all = 16.dp),
) )
},
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.fillMaxSize(),
) {
state.description?.let {
Text(
text = it,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = TypographyTokens.fontBodyMdRegular,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
)
}
MapLibreMap(
styleUri = rememberTileStyleUrl(),
modifier = Modifier.fillMaxSize(),
images = mapOf(PIN_ID to CommonDrawables.pin).toImmutableMap(),
cameraPositionState = cameraPositionState,
uiSettings = MapDefaults.uiSettings,
symbolManagerSettings = MapDefaults.symbolManagerSettings,
locationSettings = MapDefaults.locationSettings.copy(
locationEnabled = state.hasLocationPermission,
),
) {
Symbol(
iconId = PIN_ID,
state = rememberSymbolState(
position = LatLng(state.location.lat, state.location.lon)
),
iconAnchor = IconAnchor.BOTTOM,
)
}
} }
} )
} }
@PreviewsDayNight @PreviewsDayNight
@ -175,5 +172,3 @@ internal fun ShowLocationViewPreview(@PreviewParameter(ShowLocationStateProvider
onBackClick = {}, onBackClick = {},
) )
} }
private const val PIN_ID = "pin"

View file

@ -1,19 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="28dp"
android:viewportWidth="26"
android:viewportHeight="28">
<path
android:pathData="M12.962,28L9.819,24.889L16.105,24.889L12.962,28Z"
android:fillColor="#EBEEF2"/>
<path
android:pathData="M12.963,12.963m-12.963,0a12.963,12.963 0,1 1,25.926 0a12.963,12.963 0,1 1,-25.926 0"
android:fillColor="#EBEEF2"/>
<group>
<clip-path
android:pathData="M6.74,6.74h12.444v12.444h-12.444z"/>
<path
android:pathData="M12.962,6.74C10.554,6.74 8.606,8.741 8.606,11.215C8.606,13.88 11.357,17.555 12.489,18.955C12.738,19.262 13.192,19.262 13.441,18.955C14.567,17.555 17.318,13.88 17.318,11.215C17.318,8.741 15.37,6.74 12.962,6.74ZM12.962,12.813C12.103,12.813 11.406,12.097 11.406,11.215C11.406,10.333 12.103,9.617 12.962,9.617C13.821,9.617 14.518,10.333 14.518,11.215C14.518,12.097 13.821,12.813 12.962,12.813Z"
android:fillColor="#101317"/>
</group>
</vector>

View file

@ -1,19 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="28dp"
android:viewportWidth="26"
android:viewportHeight="28">
<path
android:pathData="M12.962,28L9.819,24.889L16.105,24.889L12.962,28Z"
android:fillColor="#1B1D22"/>
<path
android:pathData="M12.963,12.963m-12.963,0a12.963,12.963 0,1 1,25.926 0a12.963,12.963 0,1 1,-25.926 0"
android:fillColor="#1B1D22"/>
<group>
<clip-path
android:pathData="M6.74,6.741h12.444v12.444h-12.444z"/>
<path
android:pathData="M12.962,6.741C10.554,6.741 8.606,8.741 8.606,11.215C8.606,13.88 11.357,17.555 12.489,18.955C12.738,19.262 13.192,19.262 13.441,18.955C14.567,17.555 17.318,13.88 17.318,11.215C17.318,8.741 15.37,6.741 12.962,6.741ZM12.962,12.813C12.103,12.813 11.406,12.097 11.406,11.215C11.406,10.333 12.103,9.617 12.962,9.617C13.821,9.617 14.518,10.333 14.518,11.215C14.518,12.097 13.821,12.813 12.962,12.813Z"
android:fillColor="#ffffff"/>
</group>
</vector>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_duration_picker_title">"Vælg, hvor længe du vil dele din aktuelle position."</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_duration_picker_title">"Valitse, kuinka kauan haluat jakaa reaaliaikaisen sijaintisi."</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_duration_picker_title">"Choisissez la durée pendant laquelle vous partagerez votre position en direct."</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_duration_picker_title">"Choose how long to share your live location."</string>
</resources>

View file

@ -0,0 +1,78 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.common
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.PermissionsState
import org.junit.Test
class LocationConstraintsCheckTest {
@Test
fun `checkLocationConstraints returns Success when permissions granted and location enabled`() {
val permissionsState = aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
val result = checkLocationConstraints(permissionsState, locationActions)
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
}
@Test
fun `checkLocationConstraints returns Success when some permissions granted and location enabled`() {
val permissionsState = aPermissionsState(
permissions = PermissionsState.Permissions.SomeGranted,
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
val result = checkLocationConstraints(permissionsState, locationActions)
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
}
@Test
fun `checkLocationConstraints returns LocationServiceDisabled when permissions granted but location disabled`() {
val permissionsState = aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
)
val locationActions = FakeLocationActions(isLocationEnabled = false)
val result = checkLocationConstraints(permissionsState, locationActions)
assertThat(result).isEqualTo(LocationConstraintsCheck.LocationServiceDisabled)
}
@Test
fun `checkLocationConstraints returns PermissionRationale when permissions denied with rationale`() {
val permissionsState = aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
val result = checkLocationConstraints(permissionsState, locationActions)
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionRationale)
}
@Test
fun `checkLocationConstraints returns PermissionDenied when permissions denied without rationale`() {
val permissionsState = aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
val result = checkLocationConstraints(permissionsState, locationActions)
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionDenied)
}
}

View file

@ -10,7 +10,9 @@ package io.element.android.features.location.impl.common.actions
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
class FakeLocationActions : LocationActions { class FakeLocationActions(
private var isLocationEnabled: Boolean = true,
) : LocationActions {
var sharedLocation: Location? = null var sharedLocation: Location? = null
private set private set
@ -20,12 +22,27 @@ class FakeLocationActions : LocationActions {
var openSettingsInvocationsCount = 0 var openSettingsInvocationsCount = 0
private set private set
var openLocationSettingsInvocationsCount = 0
private set
override fun share(location: Location, label: String?) { override fun share(location: Location, label: String?) {
sharedLocation = location sharedLocation = location
sharedLabel = label sharedLabel = label
} }
override fun openSettings() { override fun openAppSettings() {
openSettingsInvocationsCount++ openSettingsInvocationsCount++
} }
override fun isLocationEnabled(): Boolean {
return isLocationEnabled
}
override fun openLocationSettings() {
openLocationSettingsInvocationsCount++
}
fun givenLocationEnabled(enabled: Boolean) {
isLocationEnabled = enabled
}
} }

View file

@ -1,496 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.send
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class SendLocationPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private val fakePermissionsPresenter = FakePermissionsPresenter()
private val fakeAnalyticsService = FakeAnalyticsService()
private val fakeMessageComposerContext = FakeMessageComposerContext()
private val fakeLocationActions = FakeLocationActions()
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
private fun createSendLocationPresenter(
joinedRoom: JoinedRoom = FakeJoinedRoom(),
): SendLocationPresenter = SendLocationPresenter(
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permissions: List<String>): PermissionsPresenter = fakePermissionsPresenter
},
room = joinedRoom,
timelineMode = Timeline.Mode.Live,
analyticsService = fakeAnalyticsService,
messageComposerContext = fakeMessageComposerContext,
locationActions = fakeLocationActions,
buildMeta = fakeBuildMeta,
)
@Test
fun `initial state with permissions granted`() = runTest {
val sendLocationPresenter = createSendLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.SenderLocation)
assertThat(initialState.hasLocationPermission).isTrue()
// Swipe the map to switch mode
initialState.eventSink(SendLocationEvents.SwitchToPinLocationMode)
val myLocationState = awaitItem()
assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
assertThat(myLocationState.hasLocationPermission).isTrue()
}
}
@Test
fun `initial state with permissions partially granted`() = runTest {
val sendLocationPresenter = createSendLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.SomeGranted,
shouldShowRationale = false,
)
)
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.SenderLocation)
assertThat(initialState.hasLocationPermission).isTrue()
// Swipe the map to switch mode
initialState.eventSink(SendLocationEvents.SwitchToPinLocationMode)
val myLocationState = awaitItem()
assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
assertThat(myLocationState.hasLocationPermission).isTrue()
}
}
@Test
fun `initial state with permissions denied`() = runTest {
val sendLocationPresenter = createSendLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
assertThat(initialState.hasLocationPermission).isFalse()
// Click on the button to switch mode
initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
val myLocationState = awaitItem()
assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionDenied)
assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
assertThat(myLocationState.hasLocationPermission).isFalse()
}
}
@Test
fun `initial state with permissions denied once`() = runTest {
val sendLocationPresenter = createSendLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
)
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
assertThat(initialState.hasLocationPermission).isFalse()
// Click on the button to switch mode
initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
val myLocationState = awaitItem()
assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale)
assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
assertThat(myLocationState.hasLocationPermission).isFalse()
}
}
@Test
fun `rationale dialog dismiss`() = runTest {
val sendLocationPresenter = createSendLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
)
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
// Skip initial state
val initialState = awaitItem()
// Click on the button to switch mode
initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
val myLocationState = awaitItem()
assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale)
assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
assertThat(myLocationState.hasLocationPermission).isFalse()
// Dismiss the dialog
myLocationState.eventSink(SendLocationEvents.DismissDialog)
val dialogDismissedState = awaitItem()
assertThat(dialogDismissedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
assertThat(dialogDismissedState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
assertThat(dialogDismissedState.hasLocationPermission).isFalse()
}
}
@Test
fun `rationale dialog continue`() = runTest {
val sendLocationPresenter = createSendLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
)
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
// Skip initial state
val initialState = awaitItem()
// Click on the button to switch mode
initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
val myLocationState = awaitItem()
assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale)
assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
assertThat(myLocationState.hasLocationPermission).isFalse()
// Continue the dialog sends permission request to the permissions presenter
myLocationState.eventSink(SendLocationEvents.RequestPermissions)
assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions)
}
}
@Test
fun `permission denied dialog dismiss`() = runTest {
val sendLocationPresenter = createSendLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
// Skip initial state
val initialState = awaitItem()
// Click on the button to switch mode
initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
val myLocationState = awaitItem()
assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionDenied)
assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
assertThat(myLocationState.hasLocationPermission).isFalse()
// Dismiss the dialog
myLocationState.eventSink(SendLocationEvents.DismissDialog)
val dialogDismissedState = awaitItem()
assertThat(dialogDismissedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
assertThat(dialogDismissedState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
assertThat(dialogDismissedState.hasLocationPermission).isFalse()
}
}
@Test
fun `share sender location`() = runTest {
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, EventId?, Result<Unit>> { _, _, _, _, _, _ ->
Result.success(Unit)
}
val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendLocationLambda = sendLocationResult
},
)
val sendLocationPresenter = createSendLocationPresenter(joinedRoom)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
// Skip initial state
val initialState = awaitItem()
// Send location
initialState.eventSink(
SendLocationEvents.SendLocation(
cameraPosition = SendLocationEvents.SendLocation.CameraPosition(
lat = 0.0,
lon = 1.0,
zoom = 2.0,
),
location = Location(
lat = 3.0,
lon = 4.0,
accuracy = 5.0f,
)
)
)
delay(1) // Wait for the coroutine to finish
sendLocationResult.assertions().isCalledOnce()
.with(
value("Location was shared at geo:3.0,4.0;u=5.0"),
value("geo:3.0,4.0;u=5.0"),
value(null),
value(15),
value(AssetType.SENDER),
value(null),
)
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
Composer(
inThread = false,
isEditing = false,
isReply = false,
messageType = Composer.MessageType.LocationUser,
)
)
}
}
@Test
fun `share pin location`() = runTest {
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, EventId?, Result<Unit>> { _, _, _, _, _, _ ->
Result.success(Unit)
}
val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendLocationLambda = sendLocationResult
},
)
val sendLocationPresenter = createSendLocationPresenter(joinedRoom)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
// Skip initial state
val initialState = awaitItem()
// Send location
initialState.eventSink(
SendLocationEvents.SendLocation(
cameraPosition = SendLocationEvents.SendLocation.CameraPosition(
lat = 0.0,
lon = 1.0,
zoom = 2.0,
),
location = Location(
lat = 3.0,
lon = 4.0,
accuracy = 5.0f,
)
)
)
delay(1) // Wait for the coroutine to finish
sendLocationResult.assertions().isCalledOnce()
.with(
value("Location was shared at geo:0.0,1.0"),
value("geo:0.0,1.0"),
value(null),
value(15),
value(AssetType.PIN),
value(null),
)
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
Composer(
inThread = false,
isEditing = false,
isReply = false,
messageType = Composer.MessageType.LocationPin,
)
)
}
}
@Test
fun `composer context passes through analytics`() = runTest {
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, EventId?, Result<Unit>> { _, _, _, _, _, _ ->
Result.success(Unit)
}
val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendLocationLambda = sendLocationResult
},
)
val sendLocationPresenter = createSendLocationPresenter(joinedRoom)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
fakeMessageComposerContext.apply {
composerMode = MessageComposerMode.Edit(
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
content = ""
)
}
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
// Skip initial state
val initialState = awaitItem()
// Send location
initialState.eventSink(
SendLocationEvents.SendLocation(
cameraPosition = SendLocationEvents.SendLocation.CameraPosition(
lat = 0.0,
lon = 1.0,
zoom = 2.0,
),
location = null
)
)
delay(1) // Wait for the coroutine to finish
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
Composer(
inThread = false,
isEditing = true,
isReply = false,
messageType = Composer.MessageType.LocationPin,
)
)
}
}
@Test
fun `open settings activity`() = runTest {
val sendLocationPresenter = createSendLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
fakeMessageComposerContext.apply {
composerMode = MessageComposerMode.Edit(
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
content = ""
)
}
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
// Skip initial state
val initialState = awaitItem()
initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
val dialogShownState = awaitItem()
// Open settings
dialogShownState.eventSink(SendLocationEvents.OpenAppSettings)
val settingsOpenedState = awaitItem()
assertThat(settingsOpenedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1)
}
}
@Test
fun `application name is in state`() = runTest {
val sendLocationPresenter = createSendLocationPresenter()
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.appName).isEqualTo("app name")
}
}
}

View file

@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.location.impl.send package io.element.android.features.location.impl.share
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
@ -14,7 +14,10 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
@ -22,19 +25,19 @@ import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
class DefaultSendLocationEntryPointTest { class DefaultShareLocationEntryPointTest {
@get:Rule @get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule() val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test @Test
fun `test node builder`() { fun `test node builder`() {
val entryPoint = DefaultSendLocationEntryPoint() val entryPoint = DefaultShareLocationEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins -> val parentNode = TestParentNode.create { buildContext, plugins ->
SendLocationNode( ShareLocationNode(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
presenterFactory = { timelineMode: Timeline.Mode -> presenterFactory = { timelineMode: Timeline.Mode ->
SendLocationPresenter( ShareLocationPresenter(
permissionsPresenterFactory = { FakePermissionsPresenter() }, permissionsPresenterFactory = { FakePermissionsPresenter() },
room = FakeJoinedRoom(), room = FakeJoinedRoom(),
timelineMode = timelineMode, timelineMode = timelineMode,
@ -42,6 +45,9 @@ class DefaultSendLocationEntryPointTest {
messageComposerContext = FakeMessageComposerContext(), messageComposerContext = FakeMessageComposerContext(),
locationActions = FakeLocationActions(), locationActions = FakeLocationActions(),
buildMeta = aBuildMeta(), buildMeta = aBuildMeta(),
featureFlagService = FakeFeatureFlagService(),
client = FakeMatrixClient(),
durationFormatter = FakeDurationFormatter(),
) )
}, },
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
@ -53,7 +59,7 @@ class DefaultSendLocationEntryPointTest {
buildContext = BuildContext.root(null), buildContext = BuildContext.root(null),
timelineMode = timelineMode, timelineMode = timelineMode,
) )
assertThat(result).isInstanceOf(SendLocationNode::class.java) assertThat(result).isInstanceOf(ShareLocationNode::class.java)
assertThat(result.plugins).contains(SendLocationNode.Inputs(timelineMode)) assertThat(result.plugins).contains(ShareLocationNode.Inputs(timelineMode))
} }
} }

View file

@ -0,0 +1,447 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.share
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ShareLocationPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private val fakePermissionsPresenter = FakePermissionsPresenter()
private val fakeAnalyticsService = FakeAnalyticsService()
private val fakeMessageComposerContext = FakeMessageComposerContext()
private val fakeLocationActions = FakeLocationActions()
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
private val fakeFeatureFlagService = FakeFeatureFlagService()
private val fakeMatrixClient = FakeMatrixClient(sessionId = A_USER_ID)
private val durationFormatter = FakeDurationFormatter()
private fun createShareLocationPresenter(
joinedRoom: JoinedRoom = FakeJoinedRoom(),
locationActions: FakeLocationActions = fakeLocationActions,
): ShareLocationPresenter = ShareLocationPresenter(
permissionsPresenterFactory = { fakePermissionsPresenter },
room = joinedRoom,
timelineMode = Timeline.Mode.Live,
analyticsService = fakeAnalyticsService,
messageComposerContext = fakeMessageComposerContext,
locationActions = locationActions,
buildMeta = fakeBuildMeta,
featureFlagService = fakeFeatureFlagService,
client = fakeMatrixClient,
durationFormatter = durationFormatter,
)
@Test
fun `initial state with permissions granted and location enabled`() = runTest {
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
val shareLocationPresenter = createShareLocationPresenter()
shareLocationPresenter.test {
skipItems(1)
val state = awaitItem()
assertThat(state.trackUserLocation).isTrue()
assertThat(state.hasLocationPermission).isTrue()
assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
}
}
@Test
fun `initial state with permissions partially granted and location enabled`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.SomeGranted,
shouldShowRationale = false,
)
)
moleculeFlow(RecompositionMode.Immediate) {
shareLocationPresenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.trackUserLocation).isTrue()
assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
}
}
@Test
fun `initial state with permissions denied`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
moleculeFlow(RecompositionMode.Immediate) {
shareLocationPresenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.trackUserLocation).isFalse()
assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied)
)
}
}
@Test
fun `initial state with permissions denied with rationale`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.trackUserLocation).isFalse()
assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale)
)
}
}
@Test
fun `initial state with location services disabled`() = runTest {
val locationActions = FakeLocationActions(isLocationEnabled = false)
val shareLocationPresenter = createShareLocationPresenter(locationActions = locationActions)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.trackUserLocation).isFalse()
assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled)
)
}
}
@Test
fun `StopTrackingUserLocation event sets trackUserLocation to false`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.trackUserLocation).isTrue()
initialState.eventSink(ShareLocationEvent.StopTrackingUserLocation)
val stoppedState = awaitItem()
assertThat(stoppedState.trackUserLocation).isFalse()
}
}
@Test
fun `DismissDialog event clears dialog state`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale)
)
initialState.eventSink(ShareLocationEvent.DismissDialog)
val dismissedState = awaitItem()
assertThat(dismissedState.dialogState).isEqualTo(ShareLocationState.Dialog.None)
}
}
@Test
fun `RequestPermissions event triggers permission request`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
)
shareLocationPresenter.test {
val initialState = awaitItem()
initialState.eventSink(ShareLocationEvent.RequestPermissions)
// Wait for dialog to be dismissed
awaitItem()
assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `OpenAppSettings event opens settings and clears dialog`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(ShareLocationEvent.OpenAppSettings)
val settingsOpenedState = awaitItem()
assertThat(settingsOpenedState.dialogState).isEqualTo(ShareLocationState.Dialog.None)
assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1)
}
}
@Test
fun `OpenLocationSettings event opens location settings and clears dialog`() = runTest {
val locationActions = FakeLocationActions(isLocationEnabled = false)
val shareLocationPresenter = createShareLocationPresenter(locationActions = locationActions)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled)
)
initialState.eventSink(ShareLocationEvent.OpenLocationSettings)
val settingsOpenedState = awaitItem()
assertThat(settingsOpenedState.dialogState).isEqualTo(ShareLocationState.Dialog.None)
assertThat(locationActions.openLocationSettingsInvocationsCount).isEqualTo(1)
}
}
@Test
fun `ShowLiveLocationDurationPicker shows duration dialog when constraints pass`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
val durationDialogState = awaitItem()
assertThat(durationDialogState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `ShowLiveLocationDurationPicker shows constraint dialog when permissions denied`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
// Dismiss initial dialog
initialState.eventSink(ShareLocationEvent.DismissDialog)
val dismissedState = awaitItem()
dismissedState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
val constraintDialogState = awaitItem()
assertThat(constraintDialogState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied)
)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `ShareStaticLocation sends user location`() = runTest {
val sendLocationResult = lambdaRecorder { _: String, _: String, _: String?, _: Int?, _: AssetType?, _: EventId? ->
Result.success(Unit)
}
val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendLocationLambda = sendLocationResult
},
)
val shareLocationPresenter = createShareLocationPresenter(joinedRoom)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(
ShareLocationEvent.ShareStaticLocation(
location = Location(lat = 3.0, lon = 4.0, accuracy = 5.0f),
isPinned = false,
)
)
advanceUntilIdle()
sendLocationResult.assertions().isCalledOnce()
.with(
value("Location was shared at geo:3.0,4.0;u=5.0"),
value("geo:3.0,4.0;u=5.0"),
value(null),
value(15),
value(AssetType.SENDER),
value(null),
)
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
Composer(
inThread = false,
isEditing = false,
isReply = false,
messageType = Composer.MessageType.LocationUser,
)
)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `ShareStaticLocation sends pinned location`() = runTest {
val sendLocationResult = lambdaRecorder { _: String, _: String, _: String?, _: Int?, _: AssetType?, _: EventId? ->
Result.success(Unit)
}
val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendLocationLambda = sendLocationResult
},
)
val shareLocationPresenter = createShareLocationPresenter(joinedRoom)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
shareLocationPresenter.test {
val initialState = awaitItem()
initialState.eventSink(
ShareLocationEvent.ShareStaticLocation(
location = Location(lat = 1.0, lon = 2.0, accuracy = 3.0f),
isPinned = true,
)
)
advanceUntilIdle()
sendLocationResult.assertions().isCalledOnce()
.with(
value("Location was shared at geo:1.0,2.0;u=3.0"),
value("geo:1.0,2.0;u=3.0"),
value(null),
value(15),
value(AssetType.PIN),
value(null),
)
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
Composer(
inThread = false,
isEditing = false,
isReply = false,
messageType = Composer.MessageType.LocationPin,
)
)
cancelAndIgnoreRemainingEvents()
}
}
}

View file

@ -0,0 +1,162 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.share
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ShareLocationViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `test back action`() {
val eventsRecorder = EventsRecorder<ShareLocationEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setShareLocationView(
state = aShareLocationState(
eventSink = eventsRecorder
),
navigateUp = callback,
)
rule.pressBack()
}
}
@Test
fun `test fab click`() {
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView(
aShareLocationState(
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick()
eventsRecorder.assertSingle(ShareLocationEvent.StartTrackingUserLocation)
}
@Test
fun `when permission denied is displayed user can open the settings`() {
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied),
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShareLocationEvent.OpenAppSettings)
}
@Test
fun `when permission denied is displayed user can close the dialog`() {
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied),
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
}
@Test
fun `when permission rationale is displayed user can request permissions`() {
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale),
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShareLocationEvent.RequestPermissions)
}
@Test
fun `when permission rationale is displayed user can close the dialog`() {
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale),
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
}
@Test
fun `when location service disabled is displayed user can open location settings`() {
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
hasLocationPermission = true,
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShareLocationEvent.OpenLocationSettings)
}
@Test
fun `when location service disabled is displayed user can close the dialog`() {
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
hasLocationPermission = true,
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setShareLocationView(
state: ShareLocationState,
navigateUp: () -> Unit = EnsureNeverCalled(),
) {
setContent {
// Simulate a LocalInspectionMode for MapLibreMap
CompositionLocalProvider(LocalInspectionMode provides true) {
ShareLocationView(
state = state,
navigateUp = navigateUp,
)
}
}
}

View file

@ -13,10 +13,14 @@ import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationEntryPoint import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.node.TestParentNode import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -32,21 +36,28 @@ class DefaultShowLocationEntryPointTest {
ShowLocationNode( ShowLocationNode(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
presenterFactory = { location: Location, description: String? -> presenterFactory = object : ShowLocationPresenter.Factory {
ShowLocationPresenter( override fun create(mode: ShowLocationMode) = ShowLocationPresenter(
mode = mode,
permissionsPresenterFactory = { FakePermissionsPresenter() }, permissionsPresenterFactory = { FakePermissionsPresenter() },
locationActions = FakeLocationActions(), locationActions = FakeLocationActions(),
buildMeta = aBuildMeta(), buildMeta = aBuildMeta(),
location = location, dateFormatter = FakeDateFormatter(),
description = description, stringProvider = FakeStringProvider()
) )
}, },
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
) )
} }
val inputs = ShowLocationEntryPoint.Inputs( val inputs = ShowLocationEntryPoint.Inputs(
location = Location(37.4219983, -122.084, 10f), mode = ShowLocationMode.Static(
description = "My location", location = Location(37.4219983, -122.084, 10f),
senderName = "Alice",
senderId = UserId("@alice:matrix.org"),
senderAvatarUrl = null,
timestamp = System.currentTimeMillis(),
assetType = null,
),
) )
val result = entryPoint.createNode( val result = entryPoint.createNode(
parentNode = parentNode, parentNode = parentNode,

View file

@ -13,14 +13,19 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.aPermissionsState import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -33,15 +38,26 @@ class ShowLocationPresenterTest {
private val fakePermissionsPresenter = FakePermissionsPresenter() private val fakePermissionsPresenter = FakePermissionsPresenter()
private val fakeLocationActions = FakeLocationActions() private val fakeLocationActions = FakeLocationActions()
private val fakeBuildMeta = aBuildMeta(applicationName = "app name") private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
private val fakeDateFormatter = FakeDateFormatter()
private val location = Location(1.23, 4.56, 7.8f) private val location = Location(1.23, 4.56, 7.8f)
private val presenter = ShowLocationPresenter(
permissionsPresenterFactory = object : PermissionsPresenter.Factory { private fun createShowLocationPresenter(
override fun create(permissions: List<String>): PermissionsPresenter = fakePermissionsPresenter mode: ShowLocationMode = ShowLocationMode.Static(
}, location = location,
locationActions = fakeLocationActions, senderName = "Alice",
senderId = UserId("@alice:matrix.org"),
senderAvatarUrl = null,
timestamp = System.currentTimeMillis(),
assetType = null,
),
locationActions: FakeLocationActions = fakeLocationActions,
) = ShowLocationPresenter(
mode = mode,
permissionsPresenterFactory = { fakePermissionsPresenter },
locationActions = locationActions,
buildMeta = fakeBuildMeta, buildMeta = fakeBuildMeta,
location = location, dateFormatter = fakeDateFormatter,
description = A_DESCRIPTION, stringProvider = FakeStringProvider()
) )
@Test @Test
@ -53,12 +69,9 @@ class ShowLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
presenter.present() presenter.test {
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.location).isEqualTo(location)
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
assertThat(initialState.hasLocationPermission).isFalse() assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.isTrackMyLocation).isFalse() assertThat(initialState.isTrackMyLocation).isFalse()
} }
@ -73,12 +86,9 @@ class ShowLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
presenter.present() presenter.test {
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.location).isEqualTo(location)
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
assertThat(initialState.hasLocationPermission).isFalse() assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.isTrackMyLocation).isFalse() assertThat(initialState.isTrackMyLocation).isFalse()
} }
@ -88,12 +98,9 @@ class ShowLocationPresenterTest {
fun `emits initial state with location permission`() = runTest { fun `emits initial state with location permission`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
presenter.present() presenter.test {
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.location).isEqualTo(location)
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.isTrackMyLocation).isFalse() assertThat(initialState.isTrackMyLocation).isFalse()
} }
@ -103,12 +110,9 @@ class ShowLocationPresenterTest {
fun `emits initial state with partial location permission`() = runTest { fun `emits initial state with partial location permission`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted)) fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted))
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
presenter.present() presenter.test {
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.location).isEqualTo(location)
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.isTrackMyLocation).isFalse() assertThat(initialState.isTrackMyLocation).isFalse()
} }
@ -116,14 +120,12 @@ class ShowLocationPresenterTest {
@Test @Test
fun `uses action to share location`() = runTest { fun `uses action to share location`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
presenter.present() presenter.test {
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ShowLocationEvents.Share) initialState.eventSink(ShowLocationEvent.Share(location))
assertThat(fakeLocationActions.sharedLocation).isEqualTo(location) assertThat(fakeLocationActions.sharedLocation).isEqualTo(location)
assertThat(fakeLocationActions.sharedLabel).isEqualTo(A_DESCRIPTION)
} }
} }
@ -131,14 +133,13 @@ class ShowLocationPresenterTest {
fun `centers on user location`() = runTest { fun `centers on user location`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
presenter.present() presenter.test {
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.isTrackMyLocation).isFalse() assertThat(initialState.isTrackMyLocation).isFalse()
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
val trackMyLocationState = awaitItem() val trackMyLocationState = awaitItem()
delay(1) delay(1)
@ -147,9 +148,9 @@ class ShowLocationPresenterTest {
assertThat(trackMyLocationState.isTrackMyLocation).isTrue() assertThat(trackMyLocationState.isTrackMyLocation).isTrue()
// Swipe the map to switch mode // Swipe the map to switch mode
initialState.eventSink(ShowLocationEvents.TrackMyLocation(false)) initialState.eventSink(ShowLocationEvent.TrackMyLocation(false))
val trackLocationDisabledState = awaitItem() val trackLocationDisabledState = awaitItem()
assertThat(trackLocationDisabledState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) assertThat(trackLocationDisabledState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(trackLocationDisabledState.isTrackMyLocation).isFalse() assertThat(trackLocationDisabledState.isTrackMyLocation).isFalse()
assertThat(trackLocationDisabledState.hasLocationPermission).isTrue() assertThat(trackLocationDisabledState.hasLocationPermission).isTrue()
} }
@ -164,23 +165,22 @@ class ShowLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
presenter.present() presenter.test {
}.test {
// Skip initial state // Skip initial state
val initialState = awaitItem() val initialState = awaitItem()
// Click on the button to switch mode // Click on the button to switch mode
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
val trackLocationState = awaitItem() val trackLocationState = awaitItem()
assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionRationale) assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale)
assertThat(trackLocationState.isTrackMyLocation).isFalse() assertThat(trackLocationState.isTrackMyLocation).isFalse()
assertThat(trackLocationState.hasLocationPermission).isFalse() assertThat(trackLocationState.hasLocationPermission).isFalse()
// Dismiss the dialog // Dismiss the dialog
initialState.eventSink(ShowLocationEvents.DismissDialog) initialState.eventSink(ShowLocationEvent.DismissDialog)
val dialogDismissedState = awaitItem() val dialogDismissedState = awaitItem()
assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(dialogDismissedState.isTrackMyLocation).isFalse() assertThat(dialogDismissedState.isTrackMyLocation).isFalse()
assertThat(dialogDismissedState.hasLocationPermission).isFalse() assertThat(dialogDismissedState.hasLocationPermission).isFalse()
} }
@ -194,22 +194,20 @@ class ShowLocationPresenterTest {
shouldShowRationale = true, shouldShowRationale = true,
) )
) )
val presenter = createShowLocationPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
// Skip initial state // Skip initial state
val initialState = awaitItem() val initialState = awaitItem()
// Click on the button to switch mode // Click on the button to switch mode
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
val trackLocationState = awaitItem() val trackLocationState = awaitItem()
assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionRationale) assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale)
assertThat(trackLocationState.isTrackMyLocation).isFalse() assertThat(trackLocationState.isTrackMyLocation).isFalse()
assertThat(trackLocationState.hasLocationPermission).isFalse() assertThat(trackLocationState.hasLocationPermission).isFalse()
// Continue the dialog sends permission request to the permissions presenter // Continue the dialog sends permission request to the permissions presenter
trackLocationState.eventSink(ShowLocationEvents.RequestPermissions) trackLocationState.eventSink(ShowLocationEvent.RequestPermissions)
assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions)
} }
} }
@ -223,23 +221,22 @@ class ShowLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
presenter.present() presenter.test {
}.test {
// Skip initial state // Skip initial state
val initialState = awaitItem() val initialState = awaitItem()
// Click on the button to switch mode // Click on the button to switch mode
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
val trackLocationState = awaitItem() val trackLocationState = awaitItem()
assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionDenied) assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionDenied)
assertThat(trackLocationState.isTrackMyLocation).isFalse() assertThat(trackLocationState.isTrackMyLocation).isFalse()
assertThat(trackLocationState.hasLocationPermission).isFalse() assertThat(trackLocationState.hasLocationPermission).isFalse()
// Dismiss the dialog // Dismiss the dialog
initialState.eventSink(ShowLocationEvents.DismissDialog) initialState.eventSink(ShowLocationEvent.DismissDialog)
val dialogDismissedState = awaitItem() val dialogDismissedState = awaitItem()
assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(dialogDismissedState.isTrackMyLocation).isFalse() assertThat(dialogDismissedState.isTrackMyLocation).isFalse()
assertThat(dialogDismissedState.hasLocationPermission).isFalse() assertThat(dialogDismissedState.hasLocationPermission).isFalse()
} }
@ -254,20 +251,19 @@ class ShowLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionMode.Immediate) { val presenter = createShowLocationPresenter()
presenter.present() presenter.test {
}.test {
// Skip initial state // Skip initial state
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
val dialogShownState = awaitItem() val dialogShownState = awaitItem()
// Open settings // Open settings
dialogShownState.eventSink(ShowLocationEvents.OpenAppSettings) dialogShownState.eventSink(ShowLocationEvent.OpenAppSettings)
val settingsOpenedState = awaitItem() val settingsOpenedState = awaitItem()
assertThat(settingsOpenedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) assertThat(settingsOpenedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1) assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1)
} }
} }
@ -275,14 +271,51 @@ class ShowLocationPresenterTest {
@Test @Test
fun `application name is in state`() = runTest { fun `application name is in state`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() createShowLocationPresenter().present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.appName).isEqualTo("app name") assertThat(initialState.appName).isEqualTo("app name")
} }
} }
companion object { @Test
private const val A_DESCRIPTION = "My happy place" fun `location service disabled shows dialog`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
fakeLocationActions.givenLocationEnabled(false)
val presenter = createShowLocationPresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isTrue()
// Try to track location when location services are disabled
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
val dialogShownState = awaitItem()
assertThat(dialogShownState.dialogState).isEqualTo(LocationConstraintsDialogState.LocationServiceDisabled)
assertThat(dialogShownState.isTrackMyLocation).isFalse()
}
}
@Test
fun `open location settings from dialog`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
fakeLocationActions.givenLocationEnabled(false)
val presenter = createShowLocationPresenter()
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
val dialogShownState = awaitItem()
assertThat(dialogShownState.dialogState).isEqualTo(LocationConstraintsDialogState.LocationServiceDisabled)
// Open location settings
dialogShownState.eventSink(ShowLocationEvent.OpenLocationSettings)
val settingsOpenedState = awaitItem()
assertThat(settingsOpenedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(fakeLocationActions.openLocationSettingsInvocationsCount).isEqualTo(1)
}
} }
} }

View file

@ -17,6 +17,8 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
@ -35,7 +37,7 @@ class ShowLocationViewTest {
@Test @Test
fun `test back action`() { fun `test back action`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<ShowLocationEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setShowLocationView( rule.setShowLocationView(
state = aShowLocationState( state = aShowLocationState(
@ -49,7 +51,7 @@ class ShowLocationViewTest {
@Test @Test
fun `test share action`() { fun `test share action`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>() val eventsRecorder = EventsRecorder<ShowLocationEvent>()
rule.setShowLocationView( rule.setShowLocationView(
aShowLocationState( aShowLocationState(
eventSink = eventsRecorder eventSink = eventsRecorder
@ -58,12 +60,13 @@ class ShowLocationViewTest {
) )
val shareContentDescription = rule.activity.getString(CommonStrings.action_share) val shareContentDescription = rule.activity.getString(CommonStrings.action_share)
rule.onNodeWithContentDescription(shareContentDescription).performClick() rule.onNodeWithContentDescription(shareContentDescription).performClick()
eventsRecorder.assertSingle(ShowLocationEvents.Share) // The default aStaticLocationMode uses Location(1.23, 2.34, 4f)
eventsRecorder.assertSingle(ShowLocationEvent.Share(Location(1.23, 2.34, 4f)))
} }
@Test @Test
fun `test fab click`() { fun `test fab click`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>() val eventsRecorder = EventsRecorder<ShowLocationEvent>()
rule.setShowLocationView( rule.setShowLocationView(
aShowLocationState( aShowLocationState(
eventSink = eventsRecorder eventSink = eventsRecorder
@ -71,63 +74,63 @@ class ShowLocationViewTest {
onBackClick = EnsureNeverCalled(), onBackClick = EnsureNeverCalled(),
) )
rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick() rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick()
eventsRecorder.assertSingle(ShowLocationEvents.TrackMyLocation(true)) eventsRecorder.assertSingle(ShowLocationEvent.TrackMyLocation(true))
} }
@Test @Test
fun `when permission denied is displayed user can open the settings`() { fun `when permission denied is displayed user can open the settings`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>() val eventsRecorder = EventsRecorder<ShowLocationEvent>()
rule.setShowLocationView( rule.setShowLocationView(
aShowLocationState( aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionDenied, constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = EnsureNeverCalled(), onBackClick = EnsureNeverCalled(),
) )
rule.clickOn(CommonStrings.action_continue) rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShowLocationEvents.OpenAppSettings) eventsRecorder.assertSingle(ShowLocationEvent.OpenAppSettings)
} }
@Test @Test
fun `when permission denied is displayed user can close the dialog`() { fun `when permission denied is displayed user can close the dialog`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>() val eventsRecorder = EventsRecorder<ShowLocationEvent>()
rule.setShowLocationView( rule.setShowLocationView(
aShowLocationState( aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionDenied, constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = EnsureNeverCalled(), onBackClick = EnsureNeverCalled(),
) )
rule.clickOn(CommonStrings.action_cancel) rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog) eventsRecorder.assertSingle(ShowLocationEvent.DismissDialog)
} }
@Test @Test
fun `when permission rationale is displayed user can request permissions`() { fun `when permission rationale is displayed user can request permissions`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>() val eventsRecorder = EventsRecorder<ShowLocationEvent>()
rule.setShowLocationView( rule.setShowLocationView(
aShowLocationState( aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionRationale, constraintsDialogState = LocationConstraintsDialogState.PermissionRationale,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = EnsureNeverCalled(), onBackClick = EnsureNeverCalled(),
) )
rule.clickOn(CommonStrings.action_continue) rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShowLocationEvents.RequestPermissions) eventsRecorder.assertSingle(ShowLocationEvent.RequestPermissions)
} }
@Test @Test
fun `when permission rationale is displayed user can close the dialog`() { fun `when permission rationale is displayed user can close the dialog`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>() val eventsRecorder = EventsRecorder<ShowLocationEvent>()
rule.setShowLocationView( rule.setShowLocationView(
aShowLocationState( aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionRationale, constraintsDialogState = LocationConstraintsDialogState.PermissionRationale,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = EnsureNeverCalled(), onBackClick = EnsureNeverCalled(),
) )
rule.clickOn(CommonStrings.action_cancel) rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog) eventsRecorder.assertSingle(ShowLocationEvent.DismissDialog)
} }
} }

View file

@ -10,11 +10,11 @@ package io.element.android.features.location.test
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import io.element.android.features.location.api.SendLocationEntryPoint import io.element.android.features.location.api.ShareLocationEntryPoint
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
class FakeSendLocationEntryPoint : SendLocationEntryPoint { class FakeShareLocationEntryPoint : ShareLocationEntryPoint {
override fun createNode( override fun createNode(
parentNode: Node, parentNode: Node,
buildContext: BuildContext, buildContext: BuildContext,

View file

@ -23,7 +23,7 @@ Vælg noget mindeværdigt. Hvis du glemmer denne pinkode, bliver du logget ud af
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Indtast venligst den samme PIN-kode to gange"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Indtast venligst den samme PIN-kode to gange"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN-koderne stemmer ikke overens"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN-koderne stemmer ikke overens"</string>
<string name="screen_app_lock_signout_alert_message">"Du vil være nødt til at logge ind igen og oprette en ny PIN-kode for at fortsætte."</string> <string name="screen_app_lock_signout_alert_message">"Du vil være nødt til at logge ind igen og oprette en ny PIN-kode for at fortsætte."</string>
<string name="screen_app_lock_signout_alert_title">"Du bliver logget ud"</string> <string name="screen_app_lock_signout_alert_title">"Denne enhed bliver fjernet"</string>
<plurals name="screen_app_lock_subtitle"> <plurals name="screen_app_lock_subtitle">
<item quantity="one">"Du har %1$d forsøg på at låse op"</item> <item quantity="one">"Du har %1$d forsøg på at låse op"</item>
<item quantity="other">"Du har %1$d forsøg på at låse op"</item> <item quantity="other">"Du har %1$d forsøg på at låse op"</item>
@ -34,5 +34,5 @@ Vælg noget mindeværdigt. Hvis du glemmer denne pinkode, bliver du logget ud af
</plurals> </plurals>
<string name="screen_app_lock_use_biometric_android">"Brug biometri"</string> <string name="screen_app_lock_use_biometric_android">"Brug biometri"</string>
<string name="screen_app_lock_use_pin_android">"Brug PIN-kode"</string> <string name="screen_app_lock_use_pin_android">"Brug PIN-kode"</string>
<string name="screen_signout_in_progress_dialog_content">"Logger ud…"</string> <string name="screen_signout_in_progress_dialog_content">"Fjerner enhed…"</string>
</resources> </resources>

View file

@ -1,17 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Er du sikker på, at du vil logge ud?"</string> <string name="screen_signout_confirmation_dialog_content">"Er du sikker på, at ønsker at fjerne denne enhed?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Log ud"</string> <string name="screen_signout_confirmation_dialog_submit">"Fjern denne enhed"</string>
<string name="screen_signout_confirmation_dialog_title">"Log ud"</string> <string name="screen_signout_confirmation_dialog_title">"Fjern denne enhed"</string>
<string name="screen_signout_in_progress_dialog_content">"Logger ud…"</string> <string name="screen_signout_in_progress_dialog_content">"Fjerner enhed…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Du er ved at logge ud af din sidste session. Hvis du logger ud nu, mister du adgangen til dine krypterede meddelelser."</string> <string name="screen_signout_key_backup_disabled_subtitle">"Dette er din eneste enhed. Hvis du fjerner den, skal du bruge en gendannelsesnøgle for at bekræfte din digitale identitet og gendanne dine krypterede chats, næste gang du logger ind."</string>
<string name="screen_signout_key_backup_disabled_title">"Du har slået sikkerhedskopiering fra"</string> <string name="screen_signout_key_backup_disabled_title">"Du er ved at miste adgangen til dine krypterede chats"</string>
<string name="screen_signout_key_backup_offline_subtitle">"Dine nøgler blev stadig sikkerhedskopieret, da du gik offline. Opret forbindelse igen, så dine nøgler kan sikkerhedskopieres, før du logger ud."</string> <string name="screen_signout_key_backup_offline_subtitle">"Dine nøgler blev stadig sikkerhedskopieret, da du gik offline. Opret forbindelse igen, så dine nøgler kan sikkerhedskopieres, før du fjerner denne enhed."</string>
<string name="screen_signout_key_backup_offline_title">"Dine nøgler bliver stadig sikkerhedskopieret"</string> <string name="screen_signout_key_backup_offline_title">"Dine nøgler bliver stadig sikkerhedskopieret"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Vent på, at dette er fuldført, før du logger ud."</string> <string name="screen_signout_key_backup_ongoing_subtitle">"Vent venligst, indtil dette er færdigt, før du fjerner denne enhed."</string>
<string name="screen_signout_key_backup_ongoing_title">"Dine nøgler bliver stadig sikkerhedskopieret"</string> <string name="screen_signout_key_backup_ongoing_title">"Dine nøgler bliver stadig sikkerhedskopieret"</string>
<string name="screen_signout_preference_item">"Log ud"</string> <string name="screen_signout_preference_item">"Fjern denne enhed"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Du er ved at logge ud af din sidste session. Hvis du logger af nu, mister du adgangen til dine krypterede meddelelser."</string> <string name="screen_signout_recovery_disabled_subtitle">"Dette er din eneste enhed. Hvis du fjerner den, skal du bruge en gendannelsesnøgle for at bekræfte din digitale identitet og gendanne dine krypterede chats, næste gang du logger ind."</string>
<string name="screen_signout_recovery_disabled_title">"Gendannelse er ikke konfigureret"</string> <string name="screen_signout_recovery_disabled_title">"Du er ved at miste adgangen til dine krypterede chats"</string>
<string name="screen_signout_save_recovery_key_subtitle">"Du er ved at logge ud af din sidste session. Hvis du logger af nu, kan du miste adgangen til dine krypterede meddelelser."</string> <string name="screen_signout_save_recovery_key_subtitle">"Dette er din eneste enhed. Hvis du fjerner den, skal du bruge en gendannelsesnøgle for at bekræfte din digitale identitet og gendanne dine krypterede chats, næste gang du logger ind."</string>
<string name="screen_signout_save_recovery_key_title">"Sørg for, at du har adgang til din gendannelsesnøgle, før du fjerner denne enhed."</string>
</resources> </resources>

View file

@ -28,10 +28,10 @@ import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.LocationService import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.SendLocationEntryPoint import io.element.android.features.location.api.ShareLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
@ -102,7 +102,7 @@ class MessagesFlowNode(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val roomListService: RoomListService, private val roomListService: RoomListService,
private val sessionId: SessionId, private val sessionId: SessionId,
private val sendLocationEntryPoint: SendLocationEntryPoint, private val shareLocationEntryPoint: ShareLocationEntryPoint,
private val showLocationEntryPoint: ShowLocationEntryPoint, private val showLocationEntryPoint: ShowLocationEntryPoint,
private val createPollEntryPoint: CreatePollEntryPoint, private val createPollEntryPoint: CreatePollEntryPoint,
private val elementCallEntryPoint: ElementCallEntryPoint, private val elementCallEntryPoint: ElementCallEntryPoint,
@ -148,7 +148,7 @@ class MessagesFlowNode(
data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment, val inReplyToEventId: EventId?) : NavTarget data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment, val inReplyToEventId: EventId?) : NavTarget
@Parcelize @Parcelize
data class LocationViewer(val location: Location, val description: String?) : NavTarget data class LocationViewer(val mode: ShowLocationMode) : NavTarget
@Parcelize @Parcelize
data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget
@ -336,7 +336,7 @@ class MessagesFlowNode(
createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs)) createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs))
} }
is NavTarget.LocationViewer -> { is NavTarget.LocationViewer -> {
val inputs = ShowLocationEntryPoint.Inputs(navTarget.location, navTarget.description) val inputs = ShowLocationEntryPoint.Inputs(navTarget.mode)
showLocationEntryPoint.createNode( showLocationEntryPoint.createNode(
parentNode = this, parentNode = this,
buildContext = buildContext, buildContext = buildContext,
@ -374,7 +374,7 @@ class MessagesFlowNode(
createNode<ReportMessageNode>(buildContext, listOf(inputs)) createNode<ReportMessageNode>(buildContext, listOf(inputs))
} }
is NavTarget.SendLocation -> { is NavTarget.SendLocation -> {
sendLocationEntryPoint.createNode( shareLocationEntryPoint.createNode(
parentNode = this, parentNode = this,
buildContext = buildContext, buildContext = buildContext,
timelineMode = navTarget.timelineMode, timelineMode = navTarget.timelineMode,
@ -558,9 +558,16 @@ class MessagesFlowNode(
) )
} }
is TimelineItemLocationContent -> { is TimelineItemLocationContent -> {
NavTarget.LocationViewer( val mode = ShowLocationMode.Static(
location = event.content.location, location = event.content.location,
description = event.content.description, senderName = event.safeSenderName,
senderId = event.senderId,
senderAvatarUrl = event.senderAvatar.url,
timestamp = event.sentTimeMillis,
assetType = event.content.assetType,
)
NavTarget.LocationViewer(
mode = mode
).takeIf { locationService.isServiceAvailable() } ).takeIf { locationService.isServiceAvailable() }
} }
else -> null else -> null

View file

@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
@ -464,6 +465,9 @@ private fun MessagesViewContent(
val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberScrollBehavior( val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberScrollBehavior(
pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0, pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0,
) )
val density = LocalDensity.current
var pinnedBannerHeightDp by remember { mutableStateOf(0.dp) }
TimelineView( TimelineView(
state = state.timelineState, state = state.timelineState,
timelineProtectionState = state.timelineProtectionState, timelineProtectionState = state.timelineProtectionState,
@ -479,11 +483,13 @@ private fun MessagesViewContent(
forceJumpToBottomVisibility = forceJumpToBottomVisibility, forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onJoinCallClick = onJoinCallClick, onJoinCallClick = onJoinCallClick,
nestedScrollConnection = scrollBehavior.nestedScrollConnection, nestedScrollConnection = scrollBehavior.nestedScrollConnection,
floatingDateTopOffset = pinnedBannerHeightDp,
) )
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) { if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
AnimatedVisibility( AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible, visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
enter = expandVertically(), enter = expandVertically(),
exit = shrinkVertically(), exit = shrinkVertically(),
) { ) {

View file

@ -149,6 +149,9 @@ class TimelinePresenter(
val displayThreadSummaries by produceState(false) { val displayThreadSummaries by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads) value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
} }
val displayFloatingDateBadge by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.FloatingDateBadge)
}
fun handleEvent(event: TimelineEvent) { fun handleEvent(event: TimelineEvent) {
when (event) { when (event) {
@ -315,6 +318,7 @@ class TimelinePresenter(
messageShieldDialogData = messageShieldDialogData.value, messageShieldDialogData = messageShieldDialogData.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
displayFloatingDateBadge = displayFloatingDateBadge,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }

View file

@ -34,6 +34,7 @@ data class TimelineState(
val messageShieldDialogData: MessageShieldData?, val messageShieldDialogData: MessageShieldData?,
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState, val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
val displayThreadSummaries: Boolean, val displayThreadSummaries: Boolean,
val displayFloatingDateBadge: Boolean,
val eventSink: (TimelineEvent) -> Unit, val eventSink: (TimelineEvent) -> Unit,
) { ) {
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event

View file

@ -39,7 +39,7 @@ import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugIn
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -56,6 +56,7 @@ fun aTimelineState(
messageShield: MessageShield? = null, messageShield: MessageShield? = null,
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(), resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
displayThreadSummaries: Boolean = false, displayThreadSummaries: Boolean = false,
displayFloatingDateBadge: Boolean = false,
eventSink: (TimelineEvent) -> Unit = {}, eventSink: (TimelineEvent) -> Unit = {},
): TimelineState { ): TimelineState {
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
@ -75,6 +76,7 @@ fun aTimelineState(
messageShieldDialogData = messageShield?.let { MessageShieldData(it) }, messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
displayFloatingDateBadge = displayFloatingDateBadge,
eventSink = eventSink, eventSink = eventSink,
) )
} }
@ -166,7 +168,7 @@ internal fun aTimelineItemEvent(
isMine = isMine, isMine = isMine,
isEditable = isEditable, isEditable = isEditable,
canBeRepliedTo = canBeRepliedTo, canBeRepliedTo = canBeRepliedTo,
senderProfile = aProfileTimelineDetailsReady( senderProfile = aProfileDetailsReady(
displayName = senderDisplayName, displayName = senderDisplayName,
displayNameAmbiguous = displayNameAmbiguous, displayNameAmbiguous = displayNameAmbiguous,
), ),

View file

@ -47,10 +47,12 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView
import io.element.android.features.messages.impl.timeline.components.FloatingDateBadgeOverlay
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
import io.element.android.features.messages.impl.timeline.components.toText import io.element.android.features.messages.impl.timeline.components.toText
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
@ -105,6 +107,7 @@ fun TimelineView(
lazyListState: LazyListState = rememberLazyListState(), lazyListState: LazyListState = rememberLazyListState(),
forceJumpToBottomVisibility: Boolean = false, forceJumpToBottomVisibility: Boolean = false,
nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(), nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
floatingDateTopOffset: Dp = 0.dp,
) { ) {
fun clearFocusRequestState() { fun clearFocusRequestState() {
state.eventSink(TimelineEvent.ClearFocusRequestState) state.eventSink(TimelineEvent.ClearFocusRequestState)
@ -210,6 +213,15 @@ fun TimelineView(
onJumpToLive = ::onJumpToLive, onJumpToLive = ::onJumpToLive,
onFocusEventRender = ::onFocusEventRender, onFocusEventRender = ::onFocusEventRender,
) )
if (state.displayFloatingDateBadge && useReverseLayout) {
FloatingDateBadgeOverlay(
lazyListState = lazyListState,
timelineItems = state.timelineItems,
isLive = state.isLive,
topOffset = floatingDateTopOffset,
)
}
} }
} }

View file

@ -0,0 +1,144 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.floatingDateBadgeBackground
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlin.time.Duration.Companion.milliseconds
@Composable
internal fun BoxScope.FloatingDateBadgeOverlay(
lazyListState: LazyListState,
timelineItems: ImmutableList<TimelineItem>,
isLive: Boolean,
topOffset: Dp = 0.dp,
) {
// This needs to be a state to trigger a `derivedState` recalculation
val updatedTimelineItems by rememberUpdatedState(timelineItems)
// Look for the last visible item with a timestamp, starting from the last visible item and going backwards until we find one or reach the start of the list
val lastVisibleItemWithTimestamp by remember {
derivedStateOf {
var index = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf null
while (index >= 0) {
when (val item = updatedTimelineItems.getOrNull(index)) {
is TimelineItem.Event -> return@derivedStateOf item
is TimelineItem.Virtual -> if (item.model is TimelineItemDaySeparatorModel) return@derivedStateOf item
is TimelineItem.GroupedEvents -> return@derivedStateOf item.events.firstOrNull()
null -> Unit
}
index--
}
null
}
}
// Store the formatted date so we recompute it lazily and can keep it around even if we need to dispose the badge because the timeline items changed
var formattedDate: String? by remember { mutableStateOf(null) }
// Update the formatted date when we have a new non-null timestamp
LaunchedEffect(lastVisibleItemWithTimestamp) {
lastVisibleItemWithTimestamp?.formattedDate()?.let { formattedDate = it }
}
val isAtBottom by remember {
derivedStateOf {
lazyListState.firstVisibleItemIndex < 3 && isLive
}
}
var isBadgeVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
snapshotFlow { lazyListState.isScrollInProgress }
.collectLatest { isScrolling ->
if (isScrolling) {
isBadgeVisible = true
} else {
delay(2000.milliseconds)
isBadgeVisible = false
}
}
}
val showBadge = isBadgeVisible && !isAtBottom && formattedDate != null
AnimatedVisibility(
visible = showBadge,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp + topOffset),
enter = fadeIn(animationSpec = tween(150)),
exit = fadeOut(animationSpec = tween(300)),
) {
formattedDate?.let { dateText ->
FloatingDateBadge(
modifier = Modifier.padding(8.dp),
dateText = dateText,
)
}
}
}
@Composable
internal fun FloatingDateBadge(
dateText: String,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
color = ElementTheme.colors.floatingDateBadgeBackground,
shadowElevation = 4.dp,
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
text = dateText,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
}
@PreviewsDayNight
@Composable
internal fun FloatingDateBadgePreview() = ElementPreview {
Box(modifier = Modifier.padding(16.dp)) {
FloatingDateBadge(dateText = "March 9, 2026")
}
}

View file

@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.draggable
@ -269,7 +270,9 @@ fun TimelineItemEventRow(
if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadRoot) { if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadRoot) {
ThreadSummaryView( ThreadSummaryView(
modifier = if (event.isMine) { modifier = if (event.isMine) {
Modifier.align(Alignment.End).padding(end = 16.dp) Modifier
.align(Alignment.End)
.padding(end = 16.dp)
} else { } else {
if (timelineRoomInfo.isDm) Modifier else Modifier.padding(start = 16.dp) if (timelineRoomInfo.isDm) Modifier else Modifier.padding(start = 16.dp)
}.padding(top = 2.dp), }.padding(top = 2.dp),
@ -742,11 +745,17 @@ private fun MessageEventBubbleContent(
} else { } else {
inReplyToModifier.clickable(onClick = inReplyToClick) inReplyToModifier.clickable(onClick = inReplyToClick)
} }
InReplyToView( Box(
inReplyTo = inReplyTo, modifier = talkbackCompatModifier
hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()), .border(1.dp, ElementTheme.colors.borderInteractiveSecondary, RoundedCornerShape(6.dp))
modifier = talkbackCompatModifier, .background(ElementTheme.colors.bgCanvasDefault, RoundedCornerShape(6.dp))
) .padding(4.dp)
) {
InReplyToView(
inReplyTo = inReplyTo,
hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()),
)
}
} }
if (inReplyToDetails != null) { if (inReplyToDetails != null) {
// Use SubComposeLayout only if necessary as it can have consequences on the performance. // Use SubComposeLayout only if necessary as it can have consequences on the performance.
@ -833,25 +842,28 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
groupPosition = TimelineItemGroupPosition.First, groupPosition = TimelineItemGroupPosition.First,
threadInfo = TimelineItemThreadInfo.ThreadRoot( threadInfo = TimelineItemThreadInfo.ThreadRoot(
latestEventText = "This is the latest message in the thread", latestEventText = "This is the latest message in the thread",
summary = ThreadSummary(AsyncData.Success( summary = ThreadSummary(
EmbeddedEventInfo( latestEvent = AsyncData.Success(
eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")), EmbeddedEventInfo(
content = MessageContent( eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")),
body = "This is the latest message in the thread", content = MessageContent(
inReplyTo = null, body = "This is the latest message in the thread",
isEdited = false, inReplyTo = null,
threadInfo = null, isEdited = false,
type = TextMessageType("This is the latest message in the thread", null) threadInfo = null,
), type = TextMessageType("This is the latest message in the thread", null)
senderId = UserId("@user:id"), ),
senderProfile = ProfileDetails.Ready( senderId = UserId("@user:id"),
displayName = "Alice", senderProfile = ProfileDetails.Ready(
avatarUrl = null, displayName = "Alice",
displayNameAmbiguous = false, avatarUrl = null,
), displayNameAmbiguous = false,
timestamp = 0L, ),
) timestamp = 0L,
), numberOfReplies = 20L) )
),
numberOfReplies = 20L,
)
) )
), ),
displayThreadSummaries = true, displayThreadSummaries = true,

View file

@ -8,10 +8,8 @@
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@ -21,31 +19,22 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
@Composable @Composable
fun TimelineItemLocationView( fun TimelineItemLocationView(
content: TimelineItemLocationContent, content: TimelineItemLocationContent,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier.fillMaxWidth()) { StaticMapView(
content.description?.let { modifier = modifier
Text( .fillMaxWidth()
text = it, .heightIn(max = 188.dp),
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp), pinVariant = content.pinVariant,
) lat = content.location.lat,
} lon = content.location.lon,
zoom = 15.0,
StaticMapView( contentDescription = content.body
modifier = Modifier )
.fillMaxWidth()
.heightIn(max = 188.dp),
lat = content.location.lat,
lon = content.location.lon,
zoom = 15.0,
contentDescription = content.body
)
}
} }
@PreviewsDayNight @PreviewsDayNight

View file

@ -9,8 +9,10 @@
package io.element.android.features.messages.impl.timeline.factories.event package io.element.android.features.messages.impl.timeline.factories.event
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.location.api.Location
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -22,6 +24,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
@ -70,10 +73,10 @@ class TimelineItemContentFactory(
is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent) is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent)
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent) is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
is MessageContent -> { is MessageContent -> {
val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender)
messageFactory.create( messageFactory.create(
senderId = sender,
senderProfile = senderProfile,
content = itemContent, content = itemContent,
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
eventId = eventId, eventId = eventId,
) )
} }
@ -96,6 +99,24 @@ class TimelineItemContentFactory(
is UnableToDecryptContent -> utdFactory.create(itemContent) is UnableToDecryptContent -> utdFactory.create(itemContent)
is CallNotifyContent -> TimelineItemRtcNotificationContent() is CallNotifyContent -> TimelineItemRtcNotificationContent()
is UnknownContent -> TimelineItemUnknownContent is UnknownContent -> TimelineItemUnknownContent
is LiveLocationContent -> {
val lastKnownLocation = itemContent.locations.mapNotNull { beacon ->
Location.fromGeoUri(beacon.geoUri)
}.lastOrNull()
if (lastKnownLocation != null) {
TimelineItemLocationContent(
body = itemContent.body.trimEnd(),
description = itemContent.description?.trimEnd(),
assetType = itemContent.assetType,
senderId = sender,
senderProfile = senderProfile,
location = lastKnownLocation,
mode = TimelineItemLocationContent.Mode.Live(isActive = itemContent.isLive)
)
} else {
TimelineItemUnknownContent
}
}
} }
} }
} }

View file

@ -30,6 +30,7 @@ import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.androidutils.text.safeLinkify import io.element.android.libraries.androidutils.text.safeLinkify
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
@ -39,10 +40,12 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessa
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.ui.messages.toHtmlDocument import io.element.android.libraries.matrix.ui.messages.toHtmlDocument
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -65,11 +68,13 @@ class TimelineItemContentMessageFactory(
) { ) {
fun create( fun create(
content: MessageContent, content: MessageContent,
senderDisambiguatedDisplayName: String, senderId: UserId,
senderProfile: ProfileDetails,
eventId: EventId?, eventId: EventId?,
): TimelineItemEventContent { ): TimelineItemEventContent {
return when (val messageType = content.type) { return when (val messageType = content.type) {
is EmoteMessageType -> { is EmoteMessageType -> {
val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(senderId)
val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}" val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}"
val dom = messageType.formatted?.toHtmlDocument( val dom = messageType.formatted?.toHtmlDocument(
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
@ -135,8 +140,8 @@ class TimelineItemContentMessageFactory(
} }
is LocationMessageType -> { is LocationMessageType -> {
val location = Location.fromGeoUri(messageType.geoUri) val location = Location.fromGeoUri(messageType.geoUri)
val body = messageType.body.trimEnd()
if (location == null) { if (location == null) {
val body = messageType.body.trimEnd()
TimelineItemTextContent( TimelineItemTextContent(
body = body, body = body,
htmlDocument = null, htmlDocument = null,
@ -145,9 +150,13 @@ class TimelineItemContentMessageFactory(
) )
} else { } else {
TimelineItemLocationContent( TimelineItemLocationContent(
body = messageType.body.trimEnd(), body = body,
location = location, location = location,
description = messageType.description description = messageType.description,
senderId = senderId,
senderProfile = senderProfile,
assetType = messageType.assetType,
mode = TimelineItemLocationContent.Mode.Static
) )
} }
} }

View file

@ -66,6 +66,11 @@ class TimelineItemEventFactory(
timestamp = currentTimelineItem.event.timestamp, timestamp = currentTimelineItem.event.timestamp,
mode = DateFormatterMode.TimeOnly, mode = DateFormatterMode.TimeOnly,
) )
val sentDate = dateFormatter.format(
timestamp = currentTimelineItem.event.timestamp,
mode = DateFormatterMode.Day,
useRelative = true,
)
val senderAvatarData = AvatarData( val senderAvatarData = AvatarData(
id = currentSender.value, id = currentSender.value,
name = senderProfile.getDisambiguatedDisplayName(currentSender), name = senderProfile.getDisambiguatedDisplayName(currentSender),
@ -108,6 +113,7 @@ class TimelineItemEventFactory(
canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo, canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo,
sentTimeMillis = currentTimelineItem.event.timestamp, sentTimeMillis = currentTimelineItem.event.timestamp,
sentTime = sentTime, sentTime = sentTime,
sentDate = sentDate,
groupPosition = groupPosition, groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState(), reactionsState = currentTimelineItem.computeReactionsState(),
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers), readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),

View file

@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyCon
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
@ -81,7 +82,8 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean {
RedactedContent, RedactedContent,
is StickerContent, is StickerContent,
is PollContent, is PollContent,
is UnableToDecryptContent -> true is UnableToDecryptContent,
is LiveLocationContent -> true
// Can't be grouped // Can't be grouped
is FailedToParseStateContent, is FailedToParseStateContent,
is ProfileChangeContent, is ProfileChangeContent,

View file

@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -59,6 +60,12 @@ sealed interface TimelineItem {
is GroupedEvents -> "groupedEvent" is GroupedEvents -> "groupedEvent"
} }
fun formattedDate(): String? = when (this) {
is Event -> sentDate.takeIf { it.isNotEmpty() }
is Virtual -> (model as? TimelineItemDaySeparatorModel)?.formattedDate?.takeIf { it.isNotEmpty() }
is GroupedEvents -> null
}
data class Virtual( data class Virtual(
val id: UniqueId, val id: UniqueId,
val model: TimelineItemVirtualModel val model: TimelineItemVirtualModel
@ -75,6 +82,7 @@ sealed interface TimelineItem {
val content: TimelineItemEventContent, val content: TimelineItemEventContent,
val sentTimeMillis: Long = 0L, val sentTimeMillis: Long = 0L,
val sentTime: String = "", val sentTime: String = "",
val sentDate: String = "",
val isMine: Boolean = false, val isMine: Boolean = false,
val isEditable: Boolean, val isEditable: Boolean,
val canBeRepliedTo: Boolean, val canBeRepliedTo: Boolean,

View file

@ -28,14 +28,14 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"), aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"),
aTimelineItemVoiceContent(), aTimelineItemVoiceContent(),
aTimelineItemLocationContent(), aTimelineItemLocationContent(),
aTimelineItemLocationContent("Location description"),
aTimelineItemPollContent(), aTimelineItemPollContent(),
aTimelineItemNoticeContent(), aTimelineItemNoticeContent(),
aTimelineItemRedactedContent(), aTimelineItemRedactedContent(),
aTimelineItemTextContent(), aTimelineItemTextContent(),
aTimelineItemUnknownContent(), aTimelineItemUnknownContent(),
aTimelineItemTextContent().copy(isEdited = true), aTimelineItemTextContent().copy(isEdited = true),
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT) aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT),
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)),
) )
} }

View file

@ -9,11 +9,53 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
data class TimelineItemLocationContent( data class TimelineItemLocationContent(
val body: String, val body: String,
val senderId: UserId,
val senderProfile: ProfileDetails,
val location: Location, val location: Location,
val description: String? = null, val description: String? = null,
val assetType: AssetType? = null,
val mode: Mode,
) : TimelineItemEventContent { ) : TimelineItemEventContent {
val pinVariant = when (mode) {
is Mode.Live -> {
if (mode.isActive) {
PinVariant.UserLocation(avatarData = senderAvatar(), isLive = true)
} else {
PinVariant.StaleLocation
}
}
Mode.Static -> {
when (assetType) {
AssetType.PIN -> PinVariant.PinnedLocation
AssetType.SENDER,
AssetType.UNKNOWN,
null -> PinVariant.UserLocation(avatarData = senderAvatar(), isLive = false)
}
}
}
private fun senderAvatar() = AvatarData(
senderId.value,
name = senderProfile.getDisplayName(),
url = senderProfile.getAvatarUrl(),
size = AvatarSize.LocationPin
)
sealed interface Mode {
data object Static : Mode
data class Live(val isActive: Boolean) : Mode
}
override val type: String = "TimelineItemLocationContent" override val type: String = "TimelineItemLocationContent"
} }

View file

@ -10,21 +10,32 @@ package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
open class TimelineItemLocationContentProvider : PreviewParameterProvider<TimelineItemLocationContent> { open class TimelineItemLocationContentProvider : PreviewParameterProvider<TimelineItemLocationContent> {
override val values: Sequence<TimelineItemLocationContent> override val values: Sequence<TimelineItemLocationContent>
get() = sequenceOf( get() = sequenceOf(
aTimelineItemLocationContent(), aTimelineItemLocationContent(),
aTimelineItemLocationContent("This is a description!"), aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)),
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = false)),
) )
} }
fun aTimelineItemLocationContent(description: String? = null) = TimelineItemLocationContent( fun aTimelineItemLocationContent(
body = "User location geo:52.2445,0.7186;u=5000", body: String = "",
senderId: UserId = UserId("@sender:matrix.org"),
senderProfile: ProfileDetails = aProfileDetailsReady(),
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static,
) = TimelineItemLocationContent(
body = body,
location = Location( location = Location(
lat = 52.2445, lat = 52.2445,
lon = 0.7186, lon = 0.7186,
accuracy = 5000f, accuracy = 5000f,
), ),
description = description, senderId = senderId,
senderProfile = senderProfile,
mode = mode
) )

View file

@ -17,7 +17,7 @@ import io.element.android.features.call.test.FakeElementCallEntryPoint
import io.element.android.features.forward.test.FakeForwardEntryPoint import io.element.android.features.forward.test.FakeForwardEntryPoint
import io.element.android.features.knockrequests.test.FakeKnockRequestsListEntryPoint import io.element.android.features.knockrequests.test.FakeKnockRequestsListEntryPoint
import io.element.android.features.location.test.FakeLocationService import io.element.android.features.location.test.FakeLocationService
import io.element.android.features.location.test.FakeSendLocationEntryPoint import io.element.android.features.location.test.FakeShareLocationEntryPoint
import io.element.android.features.location.test.FakeShowLocationEntryPoint import io.element.android.features.location.test.FakeShowLocationEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.pinned.banner.createPinnedEventsTimelineProvider import io.element.android.features.messages.impl.pinned.banner.createPinnedEventsTimelineProvider
@ -62,7 +62,7 @@ class DefaultMessagesEntryPointTest {
plugins = plugins, plugins = plugins,
roomListService = FakeRoomListService(), roomListService = FakeRoomListService(),
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
sendLocationEntryPoint = FakeSendLocationEntryPoint(), shareLocationEntryPoint = FakeShareLocationEntryPoint(),
showLocationEntryPoint = FakeShowLocationEntryPoint(), showLocationEntryPoint = FakeShowLocationEntryPoint(),
createPollEntryPoint = FakeCreatePollEntryPoint(), createPollEntryPoint = FakeCreatePollEntryPoint(),
elementCallEntryPoint = FakeElementCallEntryPoint(), elementCallEntryPoint = FakeElementCallEntryPoint(),

View file

@ -31,7 +31,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.core.FakeSendHandle import io.element.android.libraries.matrix.test.core.FakeSendHandle
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
internal fun aMessageEvent( internal fun aMessageEvent(
@ -52,7 +52,7 @@ internal fun aMessageEvent(
eventId = eventId, eventId = eventId,
transactionId = transactionId, transactionId = transactionId,
senderId = A_USER_ID, senderId = A_USER_ID,
senderProfile = aProfileTimelineDetailsReady(displayName = A_USER_NAME), senderProfile = aProfileDetailsReady(displayName = A_USER_NAME),
senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME, size = AvatarSize.TimelineSender), senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME, size = AvatarSize.TimelineSender),
content = content, content = content,
sentTime = "", sentTime = "",

View file

@ -41,6 +41,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.media.ThumbnailInfo import io.element.android.libraries.matrix.api.media.ThumbnailInfo
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
@ -59,8 +60,10 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.media.aMediaSource import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.timeline.aProfileDetails
import io.element.android.libraries.matrix.test.timeline.aStickerContent import io.element.android.libraries.matrix.test.timeline.aStickerContent
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
@ -83,7 +86,8 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = OtherMessageType(msgType = "a_type", body = "body")), content = createMessageContent(type = OtherMessageType(msgType = "a_type", body = "body")),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemTextContent( val expected = TimelineItemTextContent(
@ -98,15 +102,21 @@ class TimelineItemContentMessageFactoryTest {
@Test @Test
fun `test create LocationMessageType not null`() = runTest { fun `test create LocationMessageType not null`() = runTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val assetType = AssetType.SENDER
val result = sut.create( val result = sut.create(
content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description")), content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description", assetType)),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemLocationContent( val expected = TimelineItemLocationContent(
body = "body", body = "body",
location = Location(lat = 1.0, lon = 2.0, accuracy = 0.0F), location = Location(lat = 1.0, lon = 2.0, accuracy = null),
description = "description", description = "description",
assetType = assetType,
mode = TimelineItemLocationContent.Mode.Static,
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
} }
@ -115,8 +125,9 @@ class TimelineItemContentMessageFactoryTest {
fun `test create LocationMessageType null`() = runTest { fun `test create LocationMessageType null`() = runTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = LocationMessageType("body", "", null)), content = createMessageContent(type = LocationMessageType("body", "", null, null)),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemTextContent( val expected = TimelineItemTextContent(
@ -133,7 +144,8 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = TextMessageType("body", null)), content = createMessageContent(type = TextMessageType("body", null)),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemTextContent( val expected = TimelineItemTextContent(
@ -150,7 +162,8 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = TextMessageType("https://www.example.org", null)), content = createMessageContent(type = TextMessageType("https://www.example.org", null)),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) as TimelineItemTextContent ) as TimelineItemTextContent
val expected = TimelineItemTextContent( val expected = TimelineItemTextContent(
@ -197,7 +210,8 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.HTML, expected.toString()) formatted = FormattedBody(MessageFormat.HTML, expected.toString())
) )
), ),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expected) assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expected)
@ -215,7 +229,8 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.UNKNOWN, "formatted") formatted = FormattedBody(MessageFormat.UNKNOWN, "formatted")
) )
), ),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("body")) assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("body"))
@ -226,7 +241,8 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = VideoMessageType("filename", null, null, MediaSource("url"), null)), content = createMessageContent(type = VideoMessageType("filename", null, null, MediaSource("url"), null)),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemVideoContent( val expected = TimelineItemVideoContent(
@ -279,7 +295,8 @@ class TimelineItemContentMessageFactoryTest {
), ),
isEdited = true, isEdited = true,
), ),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemVideoContent( val expected = TimelineItemVideoContent(
@ -309,7 +326,8 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = AudioMessageType("filename", null, null, MediaSource("url"), null)), content = createMessageContent(type = AudioMessageType("filename", null, null, MediaSource("url"), null)),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemAudioContent( val expected = TimelineItemAudioContent(
@ -345,7 +363,8 @@ class TimelineItemContentMessageFactoryTest {
), ),
isEdited = true, isEdited = true,
), ),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemAudioContent( val expected = TimelineItemAudioContent(
@ -368,7 +387,8 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = VoiceMessageType("filename", null, null, MediaSource("url"), null, null)), content = createMessageContent(type = VoiceMessageType("filename", null, null, MediaSource("url"), null, null)),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemVoiceContent( val expected = TimelineItemVoiceContent(
@ -410,7 +430,8 @@ class TimelineItemContentMessageFactoryTest {
), ),
isEdited = true, isEdited = true,
), ),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemVoiceContent( val expected = TimelineItemVoiceContent(
@ -435,7 +456,8 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = ImageMessageType("filename", "body", null, MediaSource("url"), null)), content = createMessageContent(type = ImageMessageType("filename", "body", null, MediaSource("url"), null)),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemImageContent( val expected = TimelineItemImageContent(
@ -515,7 +537,8 @@ class TimelineItemContentMessageFactoryTest {
), ),
isEdited = true, isEdited = true,
), ),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemImageContent( val expected = TimelineItemImageContent(
@ -544,7 +567,8 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = FileMessageType("filename", null, null, MediaSource("url"), null)), content = createMessageContent(type = FileMessageType("filename", null, null, MediaSource("url"), null)),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemFileContent( val expected = TimelineItemFileContent(
@ -586,7 +610,8 @@ class TimelineItemContentMessageFactoryTest {
), ),
isEdited = true, isEdited = true,
), ),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemFileContent( val expected = TimelineItemFileContent(
@ -609,7 +634,8 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = NoticeMessageType("body", null)), content = createMessageContent(type = NoticeMessageType("body", null)),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemNoticeContent( val expected = TimelineItemNoticeContent(
@ -631,7 +657,8 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.HTML, "formatted") formatted = FormattedBody(MessageFormat.HTML, "formatted")
) )
), ),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
(result as TimelineItemNoticeContent).formattedBody.assertSpannedEquals(SpannedString("formatted")) (result as TimelineItemNoticeContent).formattedBody.assertSpannedEquals(SpannedString("formatted"))
@ -642,7 +669,8 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = EmoteMessageType("body", null)), content = createMessageContent(type = EmoteMessageType("body", null)),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails("Bob"),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemEmoteContent( val expected = TimelineItemEmoteContent(
@ -664,7 +692,8 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.HTML, "formatted") formatted = FormattedBody(MessageFormat.HTML, "formatted")
) )
), ),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails("Bob"),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
@ -690,7 +719,8 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.HTML, "Test <a href=\"https://www.example.org\">me@matrix.org</a>") formatted = FormattedBody(MessageFormat.HTML, "Test <a href=\"https://www.example.org\">me@matrix.org</a>")
) )
), ),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
(result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned) (result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned)
@ -715,7 +745,8 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org") formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org")
) )
), ),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
(result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned) (result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned)
@ -741,7 +772,8 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org") formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org")
) )
), ),
senderDisambiguatedDisplayName = "Bob", senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )

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