Merge branch 'release/26.04.0'
This commit is contained in:
5
.github/pull_request_template.md
vendored
5
.github/pull_request_template.md
vendored
@@ -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. -->
|
||||
|
||||
- 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
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] You've made a self review of your PR
|
||||
|
||||
1
.github/workflows/fork-pr-notice.yml
vendored
1
.github/workflows/fork-pr-notice.yml
vendored
@@ -29,6 +29,7 @@ jobs:
|
||||
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:
|
||||
|
||||
- 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.
|
||||
- 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\`.
|
||||
|
||||
4
.github/workflows/generate_github_pages.yml
vendored
4
.github/workflows/generate_github_pages.yml
vendored
@@ -12,9 +12,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip in forks
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- 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
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
|
||||
2
.github/workflows/nightlyReports.yml
vendored
2
.github/workflows/nightlyReports.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
swap-storage: false
|
||||
|
||||
- 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
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
|
||||
4
.github/workflows/recordScreenshots.yml
vendored
4
.github/workflows/recordScreenshots.yml
vendored
@@ -43,13 +43,13 @@ jobs:
|
||||
labels: Record-Screenshots
|
||||
- name: ⏬ Checkout with LFS (PR)
|
||||
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:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
|
||||
- name: ⏬ Checkout with LFS (Branch)
|
||||
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:
|
||||
persist-credentials: false
|
||||
- name: ☕️ Use JDK 21
|
||||
|
||||
@@ -19,6 +19,9 @@ adb install -r $1
|
||||
echo "Starting the screen recording..."
|
||||
adb push .github/workflows/scripts/maestro/local-recording.sh /data/local/tmp/
|
||||
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" &
|
||||
set +e
|
||||
~/.maestro/bin/maestro test .maestro/allTests.yaml
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
sudo swapon /mnt/swapfile
|
||||
sudo swapon --show
|
||||
- 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:
|
||||
# Ensure we are building the branch and not the branch after being merged on develop
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
|
||||
2
.github/workflows/validate-lfs.yml
vendored
2
.github/workflows/validate-lfs.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Validate
|
||||
steps:
|
||||
- uses: nschloe/action-cached-lfs-checkout@f46300cd8952454b9f0a21a3d133d4bd5684cfc2 # v1.2.3
|
||||
- uses: nschloe/action-cached-lfs-checkout@1c185ad576953eab13e35ffe1bffef437d97e9d2 # v1.2.4
|
||||
|
||||
- run: |
|
||||
./tools/git/validate_lfs.sh
|
||||
|
||||
4
.idea/kotlinc.xml
generated
4
.idea/kotlinc.xml
generated
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="2.3.10" />
|
||||
<option name="version" value="2.3.20" />
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
||||
|
||||
@@ -2,14 +2,14 @@ appId: ${MAESTRO_APP_ID}
|
||||
---
|
||||
- tapOn:
|
||||
id: "home_screen-settings"
|
||||
- tapOn: "Sign out"
|
||||
- tapOn: "Remove this device"
|
||||
- takeScreenshot: build/maestro/900-SignOutScreen
|
||||
- back
|
||||
- tapOn: "Sign out"
|
||||
- tapOn: "Remove this device"
|
||||
# Ensure cancel cancels
|
||||
- tapOn:
|
||||
id: "dialog-negative"
|
||||
- tapOn: "Sign out"
|
||||
- tapOn: "Remove this device"
|
||||
- tapOn:
|
||||
id: "dialog-positive"
|
||||
- runFlow: ../assertions/assertInitDisplayed.yaml
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
appId: ${MAESTRO_APP_ID}
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible: "Confirm your identity"
|
||||
visible: "Confirm your digital identity"
|
||||
timeout: 60000
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
appId: ${MAESTRO_APP_ID}
|
||||
---
|
||||
# Purpose: Test the creation and deletion of a DM room.
|
||||
- tapOn: "Create a new conversation or room"
|
||||
- tapOn: "Create room"
|
||||
- tapOn: "Search for someone"
|
||||
- inputText: ${MAESTRO_INVITEE1_MXID}
|
||||
- tapOn:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
appId: ${MAESTRO_APP_ID}
|
||||
---
|
||||
# Purpose: Test the creation and deletion of a room
|
||||
- tapOn: "Create a new conversation or room"
|
||||
- tapOn: "Create room"
|
||||
- tapOn: "New room"
|
||||
- tapOn: "Add name…"
|
||||
- inputText: "aRoomName"
|
||||
|
||||
@@ -2,6 +2,6 @@ appId: ${MAESTRO_APP_ID}
|
||||
---
|
||||
- takeScreenshot: build/maestro/520-Timeline
|
||||
- tapOn: "Add attachment"
|
||||
- tapOn: "Location"
|
||||
- tapOn: "Share my location"
|
||||
- tapOn: "Share location"
|
||||
- tapOn: "Share selected location"
|
||||
- takeScreenshot: build/maestro/521-Timeline
|
||||
|
||||
118
AGENTS.md
Normal file
118
AGENTS.md
Normal 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`).
|
||||
68
CHANGES.md
68
CHANGES.md
@@ -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
|
||||
=============================
|
||||
|
||||
|
||||
@@ -307,6 +307,7 @@ licensee {
|
||||
allow("BSD-2-Clause")
|
||||
allow("BSD-3-Clause")
|
||||
allow("EPL-1.0")
|
||||
allowUrl("https://opensource.org/license/bsd-3-clause")
|
||||
allowUrl("https://opensource.org/licenses/MIT")
|
||||
allowUrl("https://developer.android.com/studio/terms.html")
|
||||
allowUrl("https://www.zetetic.net/sqlcipher/license/")
|
||||
|
||||
2
fastlane/metadata/android/en-US/changelogs/202604000.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/202604000.txt
Normal 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
|
||||
@@ -2,8 +2,8 @@
|
||||
<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_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_title">"Bekræft din identitet"</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 digitale identitet"</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_confirmed_subtitle">"Nu kan du læse eller sende beskeder sikkert, og enhver du samtaler med kan også stole på denne enhed."</string>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<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_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_submit">"Opsæt gendannelse"</string>
|
||||
<string name="banner_set_up_recovery_title">"Konfigurer gendannelse for at beskytte din konto"</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">"Hent gendannelsesnøgle"</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_primary_button_title">"Indtast din gendannelsesnøgle"</string>
|
||||
<string name="confirm_recovery_key_banner_secondary_button_title">"Har du glemt din gendannelsesnøgle?"</string>
|
||||
|
||||
@@ -19,7 +19,7 @@ private const val GEO_URI_REGEX = """geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitu
|
||||
data class Location(
|
||||
val lat: Double,
|
||||
val lon: Double,
|
||||
val accuracy: Float,
|
||||
val accuracy: Float? = null,
|
||||
) : Parcelable {
|
||||
companion object {
|
||||
fun fromGeoUri(geoUri: String): Location? {
|
||||
@@ -27,12 +27,15 @@ data class Location(
|
||||
return Location(
|
||||
lat = result.groups["latitude"]?.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 {
|
||||
return "geo:$lat,$lon;u=$accuracy"
|
||||
fun toGeoUri(): String = buildString {
|
||||
append("geo:$lat,$lon")
|
||||
if (accuracy != null) {
|
||||
append(";u=$accuracy")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
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.
|
||||
*/
|
||||
interface SendLocationEntryPoint : FeatureEntryPoint {
|
||||
interface ShareLocationEntryPoint : FeatureEntryPoint {
|
||||
fun createNode(
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
@@ -15,8 +15,7 @@ import io.element.android.libraries.architecture.NodeInputs
|
||||
|
||||
interface ShowLocationEntryPoint : FeatureEntryPoint {
|
||||
data class Inputs(
|
||||
val location: Location,
|
||||
val description: String?,
|
||||
val mode: ShowLocationMode,
|
||||
) : NodeInputs
|
||||
|
||||
fun createNode(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.StaticMapUrlBuilder
|
||||
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.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.
|
||||
@@ -45,6 +44,7 @@ fun StaticMapView(
|
||||
lat: Double,
|
||||
lon: Double,
|
||||
zoom: Double,
|
||||
pinVariant: PinVariant,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
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.
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
Icon(
|
||||
resourceId = CommonDrawables.pin,
|
||||
contentDescription = null,
|
||||
tint = Color.Unspecified,
|
||||
modifier = Modifier.centerBottomEdge(this),
|
||||
)
|
||||
LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this))
|
||||
} else {
|
||||
StaticMapPlaceholder(
|
||||
showProgress = collectedState.value.isLoading(),
|
||||
@@ -127,6 +122,7 @@ internal fun StaticMapViewPreview() = ElementPreview {
|
||||
lon = 0.0,
|
||||
zoom = 0.0,
|
||||
contentDescription = null,
|
||||
pinVariant = PinVariant.PinnedLocation,
|
||||
modifier = Modifier.size(400.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ internal class MapTilerStaticMapUrlBuilder(
|
||||
// image smaller than the available space in pixels.
|
||||
// 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.
|
||||
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()
|
||||
|
||||
@@ -33,13 +33,13 @@ internal class LocationKtTest {
|
||||
assertThat(Location.fromGeoUri("geo:1.234,5.678")).isEqualTo(Location(
|
||||
lat = 1.234,
|
||||
lon = 5.678,
|
||||
accuracy = 0f,
|
||||
accuracy = null,
|
||||
))
|
||||
|
||||
assertThat(Location.fromGeoUri("geo:1,5")).isEqualTo(Location(
|
||||
lat = 1.0,
|
||||
lon = 5.0,
|
||||
accuracy = 0f,
|
||||
accuracy = null,
|
||||
))
|
||||
|
||||
assertThat(Location.fromGeoUri("geo:1.234,5.678;u=3000")).isEqualTo(Location(
|
||||
@@ -68,7 +68,13 @@ internal class LocationKtTest {
|
||||
}
|
||||
|
||||
@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())
|
||||
.isEqualTo("geo:1.0,2.0;u=3.0")
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||
height = 600,
|
||||
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
|
||||
@@ -62,7 +62,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||
height = 900,
|
||||
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
|
||||
@@ -77,7 +77,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||
height = 1200,
|
||||
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
|
||||
@@ -92,7 +92,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||
height = 1800,
|
||||
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
|
||||
@@ -107,7 +107,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||
height = 2048,
|
||||
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(
|
||||
builder.build(
|
||||
@@ -119,7 +119,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||
height = 4096,
|
||||
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(
|
||||
builder.build(
|
||||
@@ -131,7 +131,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||
height = 2048,
|
||||
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(
|
||||
builder.build(
|
||||
@@ -143,7 +143,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||
height = 4096,
|
||||
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(
|
||||
builder.build(
|
||||
@@ -155,7 +155,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||
height = Int.MAX_VALUE,
|
||||
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
|
||||
@@ -170,7 +170,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||
height = 0,
|
||||
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(
|
||||
builder.build(
|
||||
@@ -182,7 +182,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||
height = 0,
|
||||
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(
|
||||
builder.build(
|
||||
@@ -194,6 +194,6 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||
height = Int.MIN_VALUE,
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ setupDependencyInjection()
|
||||
dependencies {
|
||||
api(projects.features.location.api)
|
||||
implementation(projects.features.messages.api)
|
||||
implementation(projects.libraries.maplibreCompose)
|
||||
implementation(libs.maplibre.compose)
|
||||
implementation(libs.coil)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.di)
|
||||
@@ -35,14 +36,18 @@ dependencies {
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(libs.accompanist.permission)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.dateformatter.api)
|
||||
|
||||
testCommonDependencies(libs, true)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.dateformatter.test)
|
||||
testImplementation(projects.services.toolbox.test)
|
||||
testImplementation(projects.libraries.testtags)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.features.messages.test)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -9,57 +9,35 @@
|
||||
package io.element.android.features.location.impl.common
|
||||
|
||||
import android.Manifest
|
||||
import android.view.Gravity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.maplibre.compose.MapLocationSettings
|
||||
import io.element.android.libraries.maplibre.compose.MapSymbolManagerSettings
|
||||
import io.element.android.libraries.maplibre.compose.MapUiSettings
|
||||
import org.maplibre.android.camera.CameraPosition
|
||||
import org.maplibre.android.geometry.LatLng
|
||||
import androidx.compose.ui.Alignment
|
||||
import org.maplibre.compose.camera.CameraPosition
|
||||
import org.maplibre.compose.map.GestureOptions
|
||||
import org.maplibre.compose.map.MapOptions
|
||||
import org.maplibre.compose.map.OrnamentOptions
|
||||
import org.maplibre.compose.map.RenderOptions
|
||||
import org.maplibre.spatialk.geojson.Position
|
||||
|
||||
/**
|
||||
* Common configuration values for the map.
|
||||
*/
|
||||
object MapDefaults {
|
||||
val uiSettings: MapUiSettings
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = MapUiSettings(
|
||||
compassEnabled = false,
|
||||
rotationGesturesEnabled = false,
|
||||
scrollGesturesEnabled = true,
|
||||
tiltGesturesEnabled = false,
|
||||
zoomGesturesEnabled = true,
|
||||
logoGravity = Gravity.TOP,
|
||||
attributionGravity = Gravity.TOP,
|
||||
attributionTintColor = ElementTheme.colors.iconPrimary
|
||||
val options = MapOptions(
|
||||
renderOptions = RenderOptions.Standard,
|
||||
gestureOptions = GestureOptions.Standard,
|
||||
ornamentOptions = OrnamentOptions(
|
||||
isLogoEnabled = true,
|
||||
logoAlignment = Alignment.BottomStart,
|
||||
isAttributionEnabled = true,
|
||||
attributionAlignment = Alignment.BottomEnd,
|
||||
isCompassEnabled = false,
|
||||
isScaleBarEnabled = false,
|
||||
)
|
||||
)
|
||||
|
||||
val symbolManagerSettings: MapSymbolManagerSettings
|
||||
get() = MapSymbolManagerSettings(
|
||||
iconAllowOverlap = true
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
val defaultCameraPosition = CameraPosition(
|
||||
target = Position(0.0, 0.0),
|
||||
zoom = 0.0,
|
||||
)
|
||||
const val DEFAULT_ZOOM = 15.0
|
||||
|
||||
val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -10,8 +10,11 @@ package io.element.android.features.location.impl.common.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.location.LocationManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.location.LocationManagerCompat
|
||||
import androidx.core.net.toUri
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
@@ -40,9 +43,26 @@ class AndroidLocationActions(
|
||||
}
|
||||
}
|
||||
|
||||
override fun openSettings() {
|
||||
override fun openAppSettings() {
|
||||
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
|
||||
|
||||
@@ -12,5 +12,7 @@ import io.element.android.features.location.api.Location
|
||||
|
||||
interface LocationActions {
|
||||
fun share(location: Location, label: String?)
|
||||
fun openSettings()
|
||||
fun openAppSettings()
|
||||
fun isLocationEnabled(): Boolean
|
||||
fun openLocationSettings()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
package io.element.android.features.location.impl.common.ui
|
||||
|
||||
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.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -30,13 +30,11 @@ internal fun LocationFloatingActionButton(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FloatingActionButton(
|
||||
shape = FloatingActionButtonDefaults.smallShape,
|
||||
shape = CircleShape,
|
||||
containerColor = ElementTheme.colors.bgCanvasDefault,
|
||||
contentColor = ElementTheme.colors.iconPrimary,
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
// Note: design is 40dp, but min is 48 for accessibility.
|
||||
.size(48.dp),
|
||||
modifier = modifier.size(48.dp),
|
||||
) {
|
||||
val iconImage = if (isMapCenteredOnUser) {
|
||||
CompoundIcons.LocationNavigatorCentred()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -6,26 +6,26 @@
|
||||
* 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.node.Node
|
||||
import dev.zacsweers.metro.AppScope
|
||||
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.matrix.api.timeline.Timeline
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultSendLocationEntryPoint : SendLocationEntryPoint {
|
||||
class DefaultShareLocationEntryPoint : ShareLocationEntryPoint {
|
||||
override fun createNode(
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
timelineMode: Timeline.Mode,
|
||||
): Node {
|
||||
return parentNode.createNode<SendLocationNode>(
|
||||
return parentNode.createNode<ShareLocationNode>(
|
||||
buildContext = buildContext,
|
||||
plugins = listOf(SendLocationNode.Inputs(timelineMode))
|
||||
plugins = listOf(ShareLocationNode.Inputs(timelineMode))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* 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.ui.Modifier
|
||||
@@ -26,10 +26,10 @@ import io.element.android.services.analytics.api.AnalyticsService
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
@AssistedInject
|
||||
class SendLocationNode(
|
||||
class ShareLocationNode(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: SendLocationPresenter.Factory,
|
||||
presenterFactory: ShareLocationPresenter.Factory,
|
||||
analyticsService: AnalyticsService,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
data class Inputs(
|
||||
@@ -48,7 +48,7 @@ class SendLocationNode(
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
SendLocationView(
|
||||
ShareLocationView(
|
||||
state = presenter.present(),
|
||||
modifier = modifier,
|
||||
navigateUp = ::navigateUp,
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -40,7 +40,7 @@ class ShowLocationNode(
|
||||
}
|
||||
|
||||
private val inputs: ShowLocationEntryPoint.Inputs = inputs()
|
||||
private val presenter = presenterFactory.create(inputs.location, inputs.description)
|
||||
private val presenter = presenterFactory.create(inputs.mode)
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
|
||||
@@ -18,26 +18,38 @@ import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.features.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.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.common.ui.LocationConstraintsDialogState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
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
|
||||
class ShowLocationPresenter(
|
||||
@Assisted private val location: Location,
|
||||
@Assisted private val description: String?,
|
||||
@Assisted private val mode: ShowLocationMode,
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||
private val locationActions: LocationActions,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val dateFormatter: DateFormatter,
|
||||
private val stringProvider: StringProvider,
|
||||
) : Presenter<ShowLocationState> {
|
||||
@AssistedFactory
|
||||
fun interface Factory {
|
||||
fun create(location: Location, description: String?): ShowLocationPresenter
|
||||
fun create(mode: ShowLocationMode): ShowLocationPresenter
|
||||
}
|
||||
|
||||
private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
|
||||
@@ -47,43 +59,75 @@ class ShowLocationPresenter(
|
||||
val permissionsState: PermissionsState = permissionsPresenter.present()
|
||||
var isTrackMyLocation by remember { mutableStateOf(false) }
|
||||
val appName by remember { derivedStateOf { buildMeta.applicationName } }
|
||||
var permissionDialog: ShowLocationState.Dialog by remember {
|
||||
mutableStateOf(ShowLocationState.Dialog.None)
|
||||
var dialogState: LocationConstraintsDialogState by remember {
|
||||
mutableStateOf(LocationConstraintsDialogState.None)
|
||||
}
|
||||
|
||||
LaunchedEffect(permissionsState.permissions) {
|
||||
if (permissionsState.isAnyGranted) {
|
||||
permissionDialog = ShowLocationState.Dialog.None
|
||||
dialogState = LocationConstraintsDialogState.None
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEvent(event: ShowLocationEvents) {
|
||||
fun handleEvent(event: ShowLocationEvent) {
|
||||
when (event) {
|
||||
ShowLocationEvents.Share -> locationActions.share(location, description)
|
||||
is ShowLocationEvents.TrackMyLocation -> {
|
||||
is ShowLocationEvent.Share -> {
|
||||
locationActions.share(event.location, null)
|
||||
}
|
||||
is ShowLocationEvent.TrackMyLocation -> {
|
||||
if (event.enabled) {
|
||||
when {
|
||||
permissionsState.isAnyGranted -> isTrackMyLocation = true
|
||||
permissionsState.shouldShowRationale -> permissionDialog = ShowLocationState.Dialog.PermissionRationale
|
||||
else -> permissionDialog = ShowLocationState.Dialog.PermissionDenied
|
||||
}
|
||||
val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
|
||||
isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success
|
||||
dialogState = locationConstraints.toDialogState()
|
||||
} else {
|
||||
isTrackMyLocation = false
|
||||
}
|
||||
}
|
||||
ShowLocationEvents.DismissDialog -> permissionDialog = ShowLocationState.Dialog.None
|
||||
ShowLocationEvents.OpenAppSettings -> {
|
||||
locationActions.openSettings()
|
||||
permissionDialog = ShowLocationState.Dialog.None
|
||||
ShowLocationEvent.DismissDialog -> dialogState = LocationConstraintsDialogState.None
|
||||
ShowLocationEvent.OpenAppSettings -> {
|
||||
locationActions.openAppSettings()
|
||||
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(
|
||||
permissionDialog = permissionDialog,
|
||||
location = location,
|
||||
description = description,
|
||||
dialogState = dialogState,
|
||||
locationShares = locationShares,
|
||||
hasLocationPermission = permissionsState.isAnyGranted,
|
||||
isTrackMyLocation = isTrackMyLocation,
|
||||
appName = appName,
|
||||
|
||||
@@ -9,19 +9,47 @@
|
||||
package io.element.android.features.location.impl.show
|
||||
|
||||
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(
|
||||
val permissionDialog: Dialog,
|
||||
val location: Location,
|
||||
val description: String?,
|
||||
val dialogState: LocationConstraintsDialogState,
|
||||
val locationShares: ImmutableList<LocationShareItem>,
|
||||
val hasLocationPermission: Boolean,
|
||||
val isTrackMyLocation: Boolean,
|
||||
val appName: String,
|
||||
val eventSink: (ShowLocationEvents) -> Unit,
|
||||
val eventSink: (ShowLocationEvent) -> Unit,
|
||||
) {
|
||||
sealed interface Dialog {
|
||||
data object None : Dialog
|
||||
data object PermissionRationale : Dialog
|
||||
data object PermissionDenied : Dialog
|
||||
}
|
||||
val isSheetDraggable = locationShares.any { item -> item.isLive }
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,18 +10,26 @@ package io.element.android.features.location.impl.show
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.location.api.Location
|
||||
|
||||
private const val APP_NAME = "ApplicationName"
|
||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||
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> {
|
||||
override val values: Sequence<ShowLocationState>
|
||||
get() = sequenceOf(
|
||||
aShowLocationState(),
|
||||
aShowLocationState(
|
||||
permissionDialog = ShowLocationState.Dialog.PermissionDenied,
|
||||
constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
|
||||
),
|
||||
aShowLocationState(
|
||||
permissionDialog = ShowLocationState.Dialog.PermissionRationale,
|
||||
constraintsDialogState = LocationConstraintsDialogState.PermissionRationale,
|
||||
),
|
||||
aShowLocationState(
|
||||
constraintsDialogState = LocationConstraintsDialogState.LocationServiceDisabled,
|
||||
hasLocationPermission = true,
|
||||
),
|
||||
aShowLocationState(
|
||||
hasLocationPermission = true,
|
||||
@@ -30,33 +38,48 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
|
||||
hasLocationPermission = 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(
|
||||
permissionDialog: ShowLocationState.Dialog = ShowLocationState.Dialog.None,
|
||||
location: Location = Location(1.23, 2.34, 4f),
|
||||
description: String? = null,
|
||||
constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None,
|
||||
locationShares: List<LocationShareItem> = listOf(aLocationShareItem()),
|
||||
hasLocationPermission: Boolean = false,
|
||||
isTrackMyLocation: Boolean = false,
|
||||
appName: String = APP_NAME,
|
||||
eventSink: (ShowLocationEvents) -> Unit = {},
|
||||
) = ShowLocationState(
|
||||
permissionDialog = permissionDialog,
|
||||
eventSink: (ShowLocationEvent) -> Unit = {},
|
||||
): ShowLocationState {
|
||||
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,
|
||||
description = description,
|
||||
hasLocationPermission = hasLocationPermission,
|
||||
isTrackMyLocation = isTrackMyLocation,
|
||||
appName = appName,
|
||||
eventSink = eventSink,
|
||||
isLive = isLive,
|
||||
assetType = assetType,
|
||||
)
|
||||
|
||||
@@ -6,49 +6,48 @@
|
||||
* 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
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.BottomSheetDefaults
|
||||
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.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.unit.dp
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.compound.tokens.generated.TypographyTokens
|
||||
import io.element.android.features.location.api.internal.rememberTileStyleUrl
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
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.LocationConstraintsDialog
|
||||
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.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.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.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 kotlinx.collections.immutable.toImmutableMap
|
||||
import org.maplibre.android.camera.CameraPosition
|
||||
import org.maplibre.android.geometry.LatLng
|
||||
import kotlinx.coroutines.launch
|
||||
import org.maplibre.compose.camera.CameraMoveReason
|
||||
import org.maplibre.compose.camera.CameraPosition
|
||||
import org.maplibre.compose.camera.rememberCameraState
|
||||
import org.maplibre.spatialk.geojson.Position
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -57,46 +56,53 @@ fun ShowLocationView(
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (state.permissionDialog) {
|
||||
ShowLocationState.Dialog.None -> Unit
|
||||
ShowLocationState.Dialog.PermissionDenied -> PermissionDeniedDialog(
|
||||
onContinue = { state.eventSink(ShowLocationEvents.OpenAppSettings) },
|
||||
onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) },
|
||||
appName = state.appName,
|
||||
)
|
||||
ShowLocationState.Dialog.PermissionRationale -> PermissionRationaleDialog(
|
||||
onContinue = { state.eventSink(ShowLocationEvents.RequestPermissions) },
|
||||
onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) },
|
||||
appName = state.appName,
|
||||
)
|
||||
}
|
||||
LocationConstraintsDialog(
|
||||
state = state.dialogState,
|
||||
appName = state.appName,
|
||||
onRequestPermissions = { state.eventSink(ShowLocationEvent.RequestPermissions) },
|
||||
onOpenAppSettings = { state.eventSink(ShowLocationEvent.OpenAppSettings) },
|
||||
onOpenLocationSettings = { state.eventSink(ShowLocationEvent.OpenLocationSettings) },
|
||||
onDismiss = { state.eventSink(ShowLocationEvent.DismissDialog) },
|
||||
)
|
||||
|
||||
val cameraPositionState = rememberCameraPositionState {
|
||||
position = CameraPosition.Builder()
|
||||
.target(LatLng(state.location.lat, state.location.lon))
|
||||
.zoom(MapDefaults.DEFAULT_ZOOM)
|
||||
.build()
|
||||
val initialPosition = remember {
|
||||
if (state.locationShares.isEmpty()) {
|
||||
MapDefaults.defaultCameraPosition
|
||||
} else {
|
||||
val firstLocation = state.locationShares.first().location
|
||||
CameraPosition(
|
||||
target = Position(latitude = firstLocation.lat, longitude = firstLocation.lon),
|
||||
zoom = MapDefaults.DEFAULT_ZOOM
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.isTrackMyLocation) {
|
||||
when (state.isTrackMyLocation) {
|
||||
false -> cameraPositionState.cameraMode = CameraMode.NONE
|
||||
true -> {
|
||||
cameraPositionState.position = CameraPosition.Builder()
|
||||
.zoom(MapDefaults.DEFAULT_ZOOM)
|
||||
.build()
|
||||
cameraPositionState.cameraMode = CameraMode.TRACKING
|
||||
}
|
||||
val cameraState = rememberCameraState(firstPosition = initialPosition)
|
||||
val userLocationState = rememberUserLocationState(state.hasLocationPermission)
|
||||
LaunchedEffect(cameraState.isCameraMoving) {
|
||||
if (cameraState.moveReason == CameraMoveReason.GESTURE) {
|
||||
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(cameraPositionState.isMoving) {
|
||||
if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) {
|
||||
state.eventSink(ShowLocationEvents.TrackMyLocation(false))
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
val scaffoldState = rememberBottomSheetScaffoldState(
|
||||
bottomSheetState = rememberStandardBottomSheetState(
|
||||
initialValue =
|
||||
if (state.isSheetDraggable) {
|
||||
SheetValue.PartiallyExpanded
|
||||
} else {
|
||||
SheetValue.Expanded
|
||||
}
|
||||
)
|
||||
)
|
||||
MapBottomSheetScaffold(
|
||||
sheetDragHandle = if (state.isSheetDraggable) {
|
||||
{ BottomSheetDefaults.DragHandle() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
sheetSwipeEnabled = state.isSheetDraggable,
|
||||
scaffoldState = scaffoldState,
|
||||
cameraState = cameraState,
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -106,65 +112,56 @@ fun ShowLocationView(
|
||||
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(
|
||||
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
|
||||
@@ -175,5 +172,3 @@ internal fun ShowLocationViewPreview(@PreviewParameter(ShowLocationStateProvider
|
||||
onBackClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
private const val PIN_ID = "pin"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
4
features/location/impl/src/main/res/values/localazy.xml
Normal file
4
features/location/impl/src/main/res/values/localazy.xml
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ package io.element.android.features.location.impl.common.actions
|
||||
|
||||
import io.element.android.features.location.api.Location
|
||||
|
||||
class FakeLocationActions : LocationActions {
|
||||
class FakeLocationActions(
|
||||
private var isLocationEnabled: Boolean = true,
|
||||
) : LocationActions {
|
||||
var sharedLocation: Location? = null
|
||||
private set
|
||||
|
||||
@@ -20,12 +22,27 @@ class FakeLocationActions : LocationActions {
|
||||
var openSettingsInvocationsCount = 0
|
||||
private set
|
||||
|
||||
var openLocationSettingsInvocationsCount = 0
|
||||
private set
|
||||
|
||||
override fun share(location: Location, label: String?) {
|
||||
sharedLocation = location
|
||||
sharedLabel = label
|
||||
}
|
||||
|
||||
override fun openSettings() {
|
||||
override fun openAppSettings() {
|
||||
openSettingsInvocationsCount++
|
||||
}
|
||||
|
||||
override fun isLocationEnabled(): Boolean {
|
||||
return isLocationEnabled
|
||||
}
|
||||
|
||||
override fun openLocationSettings() {
|
||||
openLocationSettingsInvocationsCount++
|
||||
}
|
||||
|
||||
fun givenLocationEnabled(enabled: Boolean) {
|
||||
isLocationEnabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* 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 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.permissions.FakePermissionsPresenter
|
||||
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.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
@@ -22,19 +25,19 @@ import io.element.android.tests.testutils.node.TestParentNode
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultSendLocationEntryPointTest {
|
||||
class DefaultShareLocationEntryPointTest {
|
||||
@get:Rule
|
||||
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||
|
||||
@Test
|
||||
fun `test node builder`() {
|
||||
val entryPoint = DefaultSendLocationEntryPoint()
|
||||
val entryPoint = DefaultShareLocationEntryPoint()
|
||||
val parentNode = TestParentNode.create { buildContext, plugins ->
|
||||
SendLocationNode(
|
||||
ShareLocationNode(
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
presenterFactory = { timelineMode: Timeline.Mode ->
|
||||
SendLocationPresenter(
|
||||
ShareLocationPresenter(
|
||||
permissionsPresenterFactory = { FakePermissionsPresenter() },
|
||||
room = FakeJoinedRoom(),
|
||||
timelineMode = timelineMode,
|
||||
@@ -42,6 +45,9 @@ class DefaultSendLocationEntryPointTest {
|
||||
messageComposerContext = FakeMessageComposerContext(),
|
||||
locationActions = FakeLocationActions(),
|
||||
buildMeta = aBuildMeta(),
|
||||
featureFlagService = FakeFeatureFlagService(),
|
||||
client = FakeMatrixClient(),
|
||||
durationFormatter = FakeDurationFormatter(),
|
||||
)
|
||||
},
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
@@ -53,7 +59,7 @@ class DefaultSendLocationEntryPointTest {
|
||||
buildContext = BuildContext.root(null),
|
||||
timelineMode = timelineMode,
|
||||
)
|
||||
assertThat(result).isInstanceOf(SendLocationNode::class.java)
|
||||
assertThat(result.plugins).contains(SendLocationNode.Inputs(timelineMode))
|
||||
assertThat(result).isInstanceOf(ShareLocationNode::class.java)
|
||||
assertThat(result.plugins).contains(ShareLocationNode.Inputs(timelineMode))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,14 @@ import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.location.api.Location
|
||||
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.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.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||
import io.element.android.tests.testutils.node.TestParentNode
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -32,21 +36,28 @@ class DefaultShowLocationEntryPointTest {
|
||||
ShowLocationNode(
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
presenterFactory = { location: Location, description: String? ->
|
||||
ShowLocationPresenter(
|
||||
presenterFactory = object : ShowLocationPresenter.Factory {
|
||||
override fun create(mode: ShowLocationMode) = ShowLocationPresenter(
|
||||
mode = mode,
|
||||
permissionsPresenterFactory = { FakePermissionsPresenter() },
|
||||
locationActions = FakeLocationActions(),
|
||||
buildMeta = aBuildMeta(),
|
||||
location = location,
|
||||
description = description,
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
stringProvider = FakeStringProvider()
|
||||
)
|
||||
},
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
)
|
||||
}
|
||||
val inputs = ShowLocationEntryPoint.Inputs(
|
||||
location = Location(37.4219983, -122.084, 10f),
|
||||
description = "My location",
|
||||
mode = ShowLocationMode.Static(
|
||||
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(
|
||||
parentNode = parentNode,
|
||||
|
||||
@@ -13,14 +13,19 @@ import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
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.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.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.services.toolbox.test.strings.FakeStringProvider
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
@@ -33,15 +38,26 @@ class ShowLocationPresenterTest {
|
||||
private val fakePermissionsPresenter = FakePermissionsPresenter()
|
||||
private val fakeLocationActions = FakeLocationActions()
|
||||
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
|
||||
private val fakeDateFormatter = FakeDateFormatter()
|
||||
private val location = Location(1.23, 4.56, 7.8f)
|
||||
private val presenter = ShowLocationPresenter(
|
||||
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
|
||||
override fun create(permissions: List<String>): PermissionsPresenter = fakePermissionsPresenter
|
||||
},
|
||||
locationActions = fakeLocationActions,
|
||||
|
||||
private fun createShowLocationPresenter(
|
||||
mode: ShowLocationMode = ShowLocationMode.Static(
|
||||
location = location,
|
||||
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,
|
||||
location = location,
|
||||
description = A_DESCRIPTION,
|
||||
dateFormatter = fakeDateFormatter,
|
||||
stringProvider = FakeStringProvider()
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -53,12 +69,9 @@ class ShowLocationPresenterTest {
|
||||
)
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val presenter = createShowLocationPresenter()
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.location).isEqualTo(location)
|
||||
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
|
||||
assertThat(initialState.hasLocationPermission).isFalse()
|
||||
assertThat(initialState.isTrackMyLocation).isFalse()
|
||||
}
|
||||
@@ -73,12 +86,9 @@ class ShowLocationPresenterTest {
|
||||
)
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val presenter = createShowLocationPresenter()
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.location).isEqualTo(location)
|
||||
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
|
||||
assertThat(initialState.hasLocationPermission).isFalse()
|
||||
assertThat(initialState.isTrackMyLocation).isFalse()
|
||||
}
|
||||
@@ -88,12 +98,9 @@ class ShowLocationPresenterTest {
|
||||
fun `emits initial state with location permission`() = runTest {
|
||||
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val presenter = createShowLocationPresenter()
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.location).isEqualTo(location)
|
||||
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
|
||||
assertThat(initialState.hasLocationPermission).isTrue()
|
||||
assertThat(initialState.isTrackMyLocation).isFalse()
|
||||
}
|
||||
@@ -103,12 +110,9 @@ class ShowLocationPresenterTest {
|
||||
fun `emits initial state with partial location permission`() = runTest {
|
||||
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted))
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val presenter = createShowLocationPresenter()
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.location).isEqualTo(location)
|
||||
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
|
||||
assertThat(initialState.hasLocationPermission).isTrue()
|
||||
assertThat(initialState.isTrackMyLocation).isFalse()
|
||||
}
|
||||
@@ -116,14 +120,12 @@ class ShowLocationPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `uses action to share location`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val presenter = createShowLocationPresenter()
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(ShowLocationEvents.Share)
|
||||
initialState.eventSink(ShowLocationEvent.Share(location))
|
||||
|
||||
assertThat(fakeLocationActions.sharedLocation).isEqualTo(location)
|
||||
assertThat(fakeLocationActions.sharedLabel).isEqualTo(A_DESCRIPTION)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,14 +133,13 @@ class ShowLocationPresenterTest {
|
||||
fun `centers on user location`() = runTest {
|
||||
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val presenter = createShowLocationPresenter()
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.hasLocationPermission).isTrue()
|
||||
assertThat(initialState.isTrackMyLocation).isFalse()
|
||||
|
||||
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
|
||||
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
|
||||
val trackMyLocationState = awaitItem()
|
||||
|
||||
delay(1)
|
||||
@@ -147,9 +148,9 @@ class ShowLocationPresenterTest {
|
||||
assertThat(trackMyLocationState.isTrackMyLocation).isTrue()
|
||||
|
||||
// Swipe the map to switch mode
|
||||
initialState.eventSink(ShowLocationEvents.TrackMyLocation(false))
|
||||
initialState.eventSink(ShowLocationEvent.TrackMyLocation(false))
|
||||
val trackLocationDisabledState = awaitItem()
|
||||
assertThat(trackLocationDisabledState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None)
|
||||
assertThat(trackLocationDisabledState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
|
||||
assertThat(trackLocationDisabledState.isTrackMyLocation).isFalse()
|
||||
assertThat(trackLocationDisabledState.hasLocationPermission).isTrue()
|
||||
}
|
||||
@@ -164,23 +165,22 @@ class ShowLocationPresenterTest {
|
||||
)
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val presenter = createShowLocationPresenter()
|
||||
presenter.test {
|
||||
// Skip initial state
|
||||
val initialState = awaitItem()
|
||||
|
||||
// Click on the button to switch mode
|
||||
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
|
||||
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
|
||||
val trackLocationState = awaitItem()
|
||||
assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionRationale)
|
||||
assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale)
|
||||
assertThat(trackLocationState.isTrackMyLocation).isFalse()
|
||||
assertThat(trackLocationState.hasLocationPermission).isFalse()
|
||||
|
||||
// Dismiss the dialog
|
||||
initialState.eventSink(ShowLocationEvents.DismissDialog)
|
||||
initialState.eventSink(ShowLocationEvent.DismissDialog)
|
||||
val dialogDismissedState = awaitItem()
|
||||
assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None)
|
||||
assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
|
||||
assertThat(dialogDismissedState.isTrackMyLocation).isFalse()
|
||||
assertThat(dialogDismissedState.hasLocationPermission).isFalse()
|
||||
}
|
||||
@@ -194,22 +194,20 @@ class ShowLocationPresenterTest {
|
||||
shouldShowRationale = true,
|
||||
)
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val presenter = createShowLocationPresenter()
|
||||
presenter.test {
|
||||
// Skip initial state
|
||||
val initialState = awaitItem()
|
||||
|
||||
// Click on the button to switch mode
|
||||
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
|
||||
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
|
||||
val trackLocationState = awaitItem()
|
||||
assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionRationale)
|
||||
assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale)
|
||||
assertThat(trackLocationState.isTrackMyLocation).isFalse()
|
||||
assertThat(trackLocationState.hasLocationPermission).isFalse()
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -223,23 +221,22 @@ class ShowLocationPresenterTest {
|
||||
)
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val presenter = createShowLocationPresenter()
|
||||
presenter.test {
|
||||
// Skip initial state
|
||||
val initialState = awaitItem()
|
||||
|
||||
// Click on the button to switch mode
|
||||
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
|
||||
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
|
||||
val trackLocationState = awaitItem()
|
||||
assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionDenied)
|
||||
assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionDenied)
|
||||
assertThat(trackLocationState.isTrackMyLocation).isFalse()
|
||||
assertThat(trackLocationState.hasLocationPermission).isFalse()
|
||||
|
||||
// Dismiss the dialog
|
||||
initialState.eventSink(ShowLocationEvents.DismissDialog)
|
||||
initialState.eventSink(ShowLocationEvent.DismissDialog)
|
||||
val dialogDismissedState = awaitItem()
|
||||
assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None)
|
||||
assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
|
||||
assertThat(dialogDismissedState.isTrackMyLocation).isFalse()
|
||||
assertThat(dialogDismissedState.hasLocationPermission).isFalse()
|
||||
}
|
||||
@@ -254,20 +251,19 @@ class ShowLocationPresenterTest {
|
||||
)
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val presenter = createShowLocationPresenter()
|
||||
presenter.test {
|
||||
// Skip initial state
|
||||
val initialState = awaitItem()
|
||||
|
||||
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
|
||||
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
|
||||
val dialogShownState = awaitItem()
|
||||
|
||||
// Open settings
|
||||
dialogShownState.eventSink(ShowLocationEvents.OpenAppSettings)
|
||||
dialogShownState.eventSink(ShowLocationEvent.OpenAppSettings)
|
||||
val settingsOpenedState = awaitItem()
|
||||
|
||||
assertThat(settingsOpenedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None)
|
||||
assertThat(settingsOpenedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
|
||||
assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
@@ -275,14 +271,51 @@ class ShowLocationPresenterTest {
|
||||
@Test
|
||||
fun `application name is in state`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
createShowLocationPresenter().present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.appName).isEqualTo("app name")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val A_DESCRIPTION = "My happy place"
|
||||
@Test
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
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.api.Location
|
||||
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
|
||||
@@ -35,7 +37,7 @@ class ShowLocationViewTest {
|
||||
|
||||
@Test
|
||||
fun `test back action`() {
|
||||
val eventsRecorder = EventsRecorder<ShowLocationEvents>(expectEvents = false)
|
||||
val eventsRecorder = EventsRecorder<ShowLocationEvent>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setShowLocationView(
|
||||
state = aShowLocationState(
|
||||
@@ -49,7 +51,7 @@ class ShowLocationViewTest {
|
||||
|
||||
@Test
|
||||
fun `test share action`() {
|
||||
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
|
||||
val eventsRecorder = EventsRecorder<ShowLocationEvent>()
|
||||
rule.setShowLocationView(
|
||||
aShowLocationState(
|
||||
eventSink = eventsRecorder
|
||||
@@ -58,12 +60,13 @@ class ShowLocationViewTest {
|
||||
)
|
||||
val shareContentDescription = rule.activity.getString(CommonStrings.action_share)
|
||||
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
|
||||
fun `test fab click`() {
|
||||
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
|
||||
val eventsRecorder = EventsRecorder<ShowLocationEvent>()
|
||||
rule.setShowLocationView(
|
||||
aShowLocationState(
|
||||
eventSink = eventsRecorder
|
||||
@@ -71,63 +74,63 @@ class ShowLocationViewTest {
|
||||
onBackClick = EnsureNeverCalled(),
|
||||
)
|
||||
rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick()
|
||||
eventsRecorder.assertSingle(ShowLocationEvents.TrackMyLocation(true))
|
||||
eventsRecorder.assertSingle(ShowLocationEvent.TrackMyLocation(true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when permission denied is displayed user can open the settings`() {
|
||||
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
|
||||
val eventsRecorder = EventsRecorder<ShowLocationEvent>()
|
||||
rule.setShowLocationView(
|
||||
aShowLocationState(
|
||||
permissionDialog = ShowLocationState.Dialog.PermissionDenied,
|
||||
constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onBackClick = EnsureNeverCalled(),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_continue)
|
||||
eventsRecorder.assertSingle(ShowLocationEvents.OpenAppSettings)
|
||||
eventsRecorder.assertSingle(ShowLocationEvent.OpenAppSettings)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when permission denied is displayed user can close the dialog`() {
|
||||
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
|
||||
val eventsRecorder = EventsRecorder<ShowLocationEvent>()
|
||||
rule.setShowLocationView(
|
||||
aShowLocationState(
|
||||
permissionDialog = ShowLocationState.Dialog.PermissionDenied,
|
||||
constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onBackClick = EnsureNeverCalled(),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_cancel)
|
||||
eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog)
|
||||
eventsRecorder.assertSingle(ShowLocationEvent.DismissDialog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when permission rationale is displayed user can request permissions`() {
|
||||
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
|
||||
val eventsRecorder = EventsRecorder<ShowLocationEvent>()
|
||||
rule.setShowLocationView(
|
||||
aShowLocationState(
|
||||
permissionDialog = ShowLocationState.Dialog.PermissionRationale,
|
||||
constraintsDialogState = LocationConstraintsDialogState.PermissionRationale,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onBackClick = EnsureNeverCalled(),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_continue)
|
||||
eventsRecorder.assertSingle(ShowLocationEvents.RequestPermissions)
|
||||
eventsRecorder.assertSingle(ShowLocationEvent.RequestPermissions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when permission rationale is displayed user can close the dialog`() {
|
||||
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
|
||||
val eventsRecorder = EventsRecorder<ShowLocationEvent>()
|
||||
rule.setShowLocationView(
|
||||
aShowLocationState(
|
||||
permissionDialog = ShowLocationState.Dialog.PermissionRationale,
|
||||
constraintsDialogState = LocationConstraintsDialogState.PermissionRationale,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onBackClick = EnsureNeverCalled(),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_cancel)
|
||||
eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog)
|
||||
eventsRecorder.assertSingle(ShowLocationEvent.DismissDialog)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ package io.element.android.features.location.test
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
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.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeSendLocationEntryPoint : SendLocationEntryPoint {
|
||||
class FakeShareLocationEntryPoint : ShareLocationEntryPoint {
|
||||
override fun createNode(
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
@@ -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_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_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">
|
||||
<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>
|
||||
@@ -34,5 +34,5 @@ Vælg noget mindeværdigt. Hvis du glemmer denne pinkode, bliver du logget ud af
|
||||
</plurals>
|
||||
<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_signout_in_progress_dialog_content">"Logger ud…"</string>
|
||||
<string name="screen_signout_in_progress_dialog_content">"Fjerner enhed…"</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_submit">"Log ud"</string>
|
||||
<string name="screen_signout_confirmation_dialog_title">"Log ud"</string>
|
||||
<string name="screen_signout_in_progress_dialog_content">"Logger ud…"</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_title">"Du har slået sikkerhedskopiering fra"</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_confirmation_dialog_content">"Er du sikker på, at ønsker at fjerne denne enhed?"</string>
|
||||
<string name="screen_signout_confirmation_dialog_submit">"Fjern denne enhed"</string>
|
||||
<string name="screen_signout_confirmation_dialog_title">"Fjern denne enhed"</string>
|
||||
<string name="screen_signout_in_progress_dialog_content">"Fjerner enhed…"</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 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 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_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_preference_item">"Log ud"</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_title">"Gendannelse er ikke konfigureret"</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_preference_item">"Fjern denne enhed"</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">"Du er ved at miste adgangen til dine krypterede chats"</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>
|
||||
|
||||
@@ -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.forward.api.ForwardEntryPoint
|
||||
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.SendLocationEntryPoint
|
||||
import io.element.android.features.location.api.ShareLocationEntryPoint
|
||||
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.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
|
||||
@@ -102,7 +102,7 @@ class MessagesFlowNode(
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val roomListService: RoomListService,
|
||||
private val sessionId: SessionId,
|
||||
private val sendLocationEntryPoint: SendLocationEntryPoint,
|
||||
private val shareLocationEntryPoint: ShareLocationEntryPoint,
|
||||
private val showLocationEntryPoint: ShowLocationEntryPoint,
|
||||
private val createPollEntryPoint: CreatePollEntryPoint,
|
||||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||
@@ -148,7 +148,7 @@ class MessagesFlowNode(
|
||||
data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment, val inReplyToEventId: EventId?) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class LocationViewer(val location: Location, val description: String?) : NavTarget
|
||||
data class LocationViewer(val mode: ShowLocationMode) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget
|
||||
@@ -336,7 +336,7 @@ class MessagesFlowNode(
|
||||
createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs))
|
||||
}
|
||||
is NavTarget.LocationViewer -> {
|
||||
val inputs = ShowLocationEntryPoint.Inputs(navTarget.location, navTarget.description)
|
||||
val inputs = ShowLocationEntryPoint.Inputs(navTarget.mode)
|
||||
showLocationEntryPoint.createNode(
|
||||
parentNode = this,
|
||||
buildContext = buildContext,
|
||||
@@ -374,7 +374,7 @@ class MessagesFlowNode(
|
||||
createNode<ReportMessageNode>(buildContext, listOf(inputs))
|
||||
}
|
||||
is NavTarget.SendLocation -> {
|
||||
sendLocationEntryPoint.createNode(
|
||||
shareLocationEntryPoint.createNode(
|
||||
parentNode = this,
|
||||
buildContext = buildContext,
|
||||
timelineMode = navTarget.timelineMode,
|
||||
@@ -558,9 +558,16 @@ class MessagesFlowNode(
|
||||
)
|
||||
}
|
||||
is TimelineItemLocationContent -> {
|
||||
NavTarget.LocationViewer(
|
||||
val mode = ShowLocationMode.Static(
|
||||
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() }
|
||||
}
|
||||
else -> null
|
||||
|
||||
@@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
@@ -464,6 +465,9 @@ private fun MessagesViewContent(
|
||||
val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberScrollBehavior(
|
||||
pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0,
|
||||
)
|
||||
val density = LocalDensity.current
|
||||
var pinnedBannerHeightDp by remember { mutableStateOf(0.dp) }
|
||||
|
||||
TimelineView(
|
||||
state = state.timelineState,
|
||||
timelineProtectionState = state.timelineProtectionState,
|
||||
@@ -479,11 +483,13 @@ private fun MessagesViewContent(
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
|
||||
floatingDateTopOffset = pinnedBannerHeightDp,
|
||||
)
|
||||
|
||||
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
|
||||
AnimatedVisibility(
|
||||
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
|
||||
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
|
||||
@@ -149,6 +149,9 @@ class TimelinePresenter(
|
||||
val displayThreadSummaries by produceState(false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
|
||||
}
|
||||
val displayFloatingDateBadge by produceState(false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.FloatingDateBadge)
|
||||
}
|
||||
|
||||
fun handleEvent(event: TimelineEvent) {
|
||||
when (event) {
|
||||
@@ -315,6 +318,7 @@ class TimelinePresenter(
|
||||
messageShieldDialogData = messageShieldDialogData.value,
|
||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
displayFloatingDateBadge = displayFloatingDateBadge,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ data class TimelineState(
|
||||
val messageShieldDialogData: MessageShieldData?,
|
||||
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
|
||||
val displayThreadSummaries: Boolean,
|
||||
val displayFloatingDateBadge: Boolean,
|
||||
val eventSink: (TimelineEvent) -> Unit,
|
||||
) {
|
||||
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
|
||||
|
||||
@@ -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.MessageShield
|
||||
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.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -56,6 +56,7 @@ fun aTimelineState(
|
||||
messageShield: MessageShield? = null,
|
||||
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
|
||||
displayThreadSummaries: Boolean = false,
|
||||
displayFloatingDateBadge: Boolean = false,
|
||||
eventSink: (TimelineEvent) -> Unit = {},
|
||||
): TimelineState {
|
||||
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
|
||||
@@ -75,6 +76,7 @@ fun aTimelineState(
|
||||
messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
|
||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
displayFloatingDateBadge = displayFloatingDateBadge,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
@@ -166,7 +168,7 @@ internal fun aTimelineItemEvent(
|
||||
isMine = isMine,
|
||||
isEditable = isEditable,
|
||||
canBeRepliedTo = canBeRepliedTo,
|
||||
senderProfile = aProfileTimelineDetailsReady(
|
||||
senderProfile = aProfileDetailsReady(
|
||||
displayName = senderDisplayName,
|
||||
displayNameAmbiguous = displayNameAmbiguous,
|
||||
),
|
||||
|
||||
@@ -47,10 +47,12 @@ import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.Dp
|
||||
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.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.toText
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
@@ -105,6 +107,7 @@ fun TimelineView(
|
||||
lazyListState: LazyListState = rememberLazyListState(),
|
||||
forceJumpToBottomVisibility: Boolean = false,
|
||||
nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
|
||||
floatingDateTopOffset: Dp = 0.dp,
|
||||
) {
|
||||
fun clearFocusRequestState() {
|
||||
state.eventSink(TimelineEvent.ClearFocusRequestState)
|
||||
@@ -210,6 +213,15 @@ fun TimelineView(
|
||||
onJumpToLive = ::onJumpToLive,
|
||||
onFocusEventRender = ::onFocusEventRender,
|
||||
)
|
||||
|
||||
if (state.displayFloatingDateBadge && useReverseLayout) {
|
||||
FloatingDateBadgeOverlay(
|
||||
lazyListState = lazyListState,
|
||||
timelineItems = state.timelineItems,
|
||||
isLive = state.isLive,
|
||||
topOffset = floatingDateTopOffset,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
@@ -269,7 +270,9 @@ fun TimelineItemEventRow(
|
||||
if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadRoot) {
|
||||
ThreadSummaryView(
|
||||
modifier = if (event.isMine) {
|
||||
Modifier.align(Alignment.End).padding(end = 16.dp)
|
||||
Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(end = 16.dp)
|
||||
} else {
|
||||
if (timelineRoomInfo.isDm) Modifier else Modifier.padding(start = 16.dp)
|
||||
}.padding(top = 2.dp),
|
||||
@@ -742,11 +745,17 @@ private fun MessageEventBubbleContent(
|
||||
} else {
|
||||
inReplyToModifier.clickable(onClick = inReplyToClick)
|
||||
}
|
||||
InReplyToView(
|
||||
inReplyTo = inReplyTo,
|
||||
hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()),
|
||||
modifier = talkbackCompatModifier,
|
||||
)
|
||||
Box(
|
||||
modifier = talkbackCompatModifier
|
||||
.border(1.dp, ElementTheme.colors.borderInteractiveSecondary, RoundedCornerShape(6.dp))
|
||||
.background(ElementTheme.colors.bgCanvasDefault, RoundedCornerShape(6.dp))
|
||||
.padding(4.dp)
|
||||
) {
|
||||
InReplyToView(
|
||||
inReplyTo = inReplyTo,
|
||||
hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (inReplyToDetails != null) {
|
||||
// Use SubComposeLayout only if necessary as it can have consequences on the performance.
|
||||
@@ -833,25 +842,28 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
|
||||
groupPosition = TimelineItemGroupPosition.First,
|
||||
threadInfo = TimelineItemThreadInfo.ThreadRoot(
|
||||
latestEventText = "This is the latest message in the thread",
|
||||
summary = ThreadSummary(AsyncData.Success(
|
||||
EmbeddedEventInfo(
|
||||
eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")),
|
||||
content = MessageContent(
|
||||
body = "This is the latest message in the thread",
|
||||
inReplyTo = null,
|
||||
isEdited = false,
|
||||
threadInfo = null,
|
||||
type = TextMessageType("This is the latest message in the thread", null)
|
||||
),
|
||||
senderId = UserId("@user:id"),
|
||||
senderProfile = ProfileDetails.Ready(
|
||||
displayName = "Alice",
|
||||
avatarUrl = null,
|
||||
displayNameAmbiguous = false,
|
||||
),
|
||||
timestamp = 0L,
|
||||
)
|
||||
), numberOfReplies = 20L)
|
||||
summary = ThreadSummary(
|
||||
latestEvent = AsyncData.Success(
|
||||
EmbeddedEventInfo(
|
||||
eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")),
|
||||
content = MessageContent(
|
||||
body = "This is the latest message in the thread",
|
||||
inReplyTo = null,
|
||||
isEdited = false,
|
||||
threadInfo = null,
|
||||
type = TextMessageType("This is the latest message in the thread", null)
|
||||
),
|
||||
senderId = UserId("@user:id"),
|
||||
senderProfile = ProfileDetails.Ready(
|
||||
displayName = "Alice",
|
||||
avatarUrl = null,
|
||||
displayNameAmbiguous = false,
|
||||
),
|
||||
timestamp = 0L,
|
||||
)
|
||||
),
|
||||
numberOfReplies = 20L,
|
||||
)
|
||||
)
|
||||
),
|
||||
displayThreadSummaries = true,
|
||||
|
||||
@@ -8,10 +8,8 @@
|
||||
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
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.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
||||
@Composable
|
||||
fun TimelineItemLocationView(
|
||||
content: TimelineItemLocationContent,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
content.description?.let {
|
||||
Text(
|
||||
text = it,
|
||||
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
StaticMapView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 188.dp),
|
||||
lat = content.location.lat,
|
||||
lon = content.location.lon,
|
||||
zoom = 15.0,
|
||||
contentDescription = content.body
|
||||
)
|
||||
}
|
||||
StaticMapView(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 188.dp),
|
||||
pinVariant = content.pinVariant,
|
||||
lat = content.location.lat,
|
||||
lon = content.location.lon,
|
||||
zoom = 15.0,
|
||||
contentDescription = content.body
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
package io.element.android.features.messages.impl.timeline.factories.event
|
||||
|
||||
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.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.TimelineItemUnknownContent
|
||||
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.FailedToParseStateContent
|
||||
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.PollContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
|
||||
@@ -70,10 +73,10 @@ class TimelineItemContentFactory(
|
||||
is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent)
|
||||
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
|
||||
is MessageContent -> {
|
||||
val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender)
|
||||
messageFactory.create(
|
||||
senderId = sender,
|
||||
senderProfile = senderProfile,
|
||||
content = itemContent,
|
||||
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
|
||||
eventId = eventId,
|
||||
)
|
||||
}
|
||||
@@ -96,6 +99,24 @@ class TimelineItemContentFactory(
|
||||
is UnableToDecryptContent -> utdFactory.create(itemContent)
|
||||
is CallNotifyContent -> TimelineItemRtcNotificationContent()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.core.mimetype.MimeTypes
|
||||
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.timeline.item.event.AudioMessageType
|
||||
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.NoticeMessageType
|
||||
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.TextMessageType
|
||||
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.getDisambiguatedDisplayName
|
||||
import io.element.android.libraries.matrix.ui.messages.toHtmlDocument
|
||||
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -65,11 +68,13 @@ class TimelineItemContentMessageFactory(
|
||||
) {
|
||||
fun create(
|
||||
content: MessageContent,
|
||||
senderDisambiguatedDisplayName: String,
|
||||
senderId: UserId,
|
||||
senderProfile: ProfileDetails,
|
||||
eventId: EventId?,
|
||||
): TimelineItemEventContent {
|
||||
return when (val messageType = content.type) {
|
||||
is EmoteMessageType -> {
|
||||
val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(senderId)
|
||||
val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}"
|
||||
val dom = messageType.formatted?.toHtmlDocument(
|
||||
permalinkParser = permalinkParser,
|
||||
@@ -135,8 +140,8 @@ class TimelineItemContentMessageFactory(
|
||||
}
|
||||
is LocationMessageType -> {
|
||||
val location = Location.fromGeoUri(messageType.geoUri)
|
||||
val body = messageType.body.trimEnd()
|
||||
if (location == null) {
|
||||
val body = messageType.body.trimEnd()
|
||||
TimelineItemTextContent(
|
||||
body = body,
|
||||
htmlDocument = null,
|
||||
@@ -145,9 +150,13 @@ class TimelineItemContentMessageFactory(
|
||||
)
|
||||
} else {
|
||||
TimelineItemLocationContent(
|
||||
body = messageType.body.trimEnd(),
|
||||
body = body,
|
||||
location = location,
|
||||
description = messageType.description
|
||||
description = messageType.description,
|
||||
senderId = senderId,
|
||||
senderProfile = senderProfile,
|
||||
assetType = messageType.assetType,
|
||||
mode = TimelineItemLocationContent.Mode.Static
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,11 @@ class TimelineItemEventFactory(
|
||||
timestamp = currentTimelineItem.event.timestamp,
|
||||
mode = DateFormatterMode.TimeOnly,
|
||||
)
|
||||
val sentDate = dateFormatter.format(
|
||||
timestamp = currentTimelineItem.event.timestamp,
|
||||
mode = DateFormatterMode.Day,
|
||||
useRelative = true,
|
||||
)
|
||||
val senderAvatarData = AvatarData(
|
||||
id = currentSender.value,
|
||||
name = senderProfile.getDisambiguatedDisplayName(currentSender),
|
||||
@@ -108,6 +113,7 @@ class TimelineItemEventFactory(
|
||||
canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo,
|
||||
sentTimeMillis = currentTimelineItem.event.timestamp,
|
||||
sentTime = sentTime,
|
||||
sentDate = sentDate,
|
||||
groupPosition = groupPosition,
|
||||
reactionsState = currentTimelineItem.computeReactionsState(),
|
||||
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
|
||||
|
||||
@@ -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.FailedToParseStateContent
|
||||
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.PollContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
|
||||
@@ -81,7 +82,8 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean {
|
||||
RedactedContent,
|
||||
is StickerContent,
|
||||
is PollContent,
|
||||
is UnableToDecryptContent -> true
|
||||
is UnableToDecryptContent,
|
||||
is LiveLocationContent -> true
|
||||
// Can't be grouped
|
||||
is FailedToParseStateContent,
|
||||
is ProfileChangeContent,
|
||||
|
||||
@@ -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.TimelineItemTextBasedContent
|
||||
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.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -59,6 +60,12 @@ sealed interface TimelineItem {
|
||||
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(
|
||||
val id: UniqueId,
|
||||
val model: TimelineItemVirtualModel
|
||||
@@ -75,6 +82,7 @@ sealed interface TimelineItem {
|
||||
val content: TimelineItemEventContent,
|
||||
val sentTimeMillis: Long = 0L,
|
||||
val sentTime: String = "",
|
||||
val sentDate: String = "",
|
||||
val isMine: Boolean = false,
|
||||
val isEditable: Boolean,
|
||||
val canBeRepliedTo: Boolean,
|
||||
|
||||
@@ -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"),
|
||||
aTimelineItemVoiceContent(),
|
||||
aTimelineItemLocationContent(),
|
||||
aTimelineItemLocationContent("Location description"),
|
||||
aTimelineItemPollContent(),
|
||||
aTimelineItemNoticeContent(),
|
||||
aTimelineItemRedactedContent(),
|
||||
aTimelineItemTextContent(),
|
||||
aTimelineItemUnknownContent(),
|
||||
aTimelineItemTextContent().copy(isEdited = true),
|
||||
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT)
|
||||
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT),
|
||||
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,53 @@
|
||||
package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
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(
|
||||
val body: String,
|
||||
val senderId: UserId,
|
||||
val senderProfile: ProfileDetails,
|
||||
val location: Location,
|
||||
val description: String? = null,
|
||||
val assetType: AssetType? = null,
|
||||
val mode: Mode,
|
||||
) : 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"
|
||||
}
|
||||
|
||||
@@ -10,21 +10,32 @@ package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
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> {
|
||||
override val values: Sequence<TimelineItemLocationContent>
|
||||
get() = sequenceOf(
|
||||
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(
|
||||
body = "User location geo:52.2445,0.7186;u=5000",
|
||||
fun aTimelineItemLocationContent(
|
||||
body: String = "",
|
||||
senderId: UserId = UserId("@sender:matrix.org"),
|
||||
senderProfile: ProfileDetails = aProfileDetailsReady(),
|
||||
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static,
|
||||
) = TimelineItemLocationContent(
|
||||
body = body,
|
||||
location = Location(
|
||||
lat = 52.2445,
|
||||
lon = 0.7186,
|
||||
accuracy = 5000f,
|
||||
),
|
||||
description = description,
|
||||
senderId = senderId,
|
||||
senderProfile = senderProfile,
|
||||
mode = mode
|
||||
)
|
||||
|
||||
@@ -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.knockrequests.test.FakeKnockRequestsListEntryPoint
|
||||
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.messages.api.MessagesEntryPoint
|
||||
import io.element.android.features.messages.impl.pinned.banner.createPinnedEventsTimelineProvider
|
||||
@@ -62,7 +62,7 @@ class DefaultMessagesEntryPointTest {
|
||||
plugins = plugins,
|
||||
roomListService = FakeRoomListService(),
|
||||
sessionId = A_SESSION_ID,
|
||||
sendLocationEntryPoint = FakeSendLocationEntryPoint(),
|
||||
shareLocationEntryPoint = FakeShareLocationEntryPoint(),
|
||||
showLocationEntryPoint = FakeShowLocationEntryPoint(),
|
||||
createPollEntryPoint = FakeCreatePollEntryPoint(),
|
||||
elementCallEntryPoint = FakeElementCallEntryPoint(),
|
||||
|
||||
@@ -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.core.FakeSendHandle
|
||||
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
|
||||
|
||||
internal fun aMessageEvent(
|
||||
@@ -52,7 +52,7 @@ internal fun aMessageEvent(
|
||||
eventId = eventId,
|
||||
transactionId = transactionId,
|
||||
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),
|
||||
content = content,
|
||||
sentTime = "",
|
||||
|
||||
@@ -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.VideoInfo
|
||||
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.event.AudioMessageType
|
||||
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.VoiceMessageType
|
||||
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.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.ui.components.A_BLUR_HASH
|
||||
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
||||
@@ -83,7 +86,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = OtherMessageType(msgType = "a_type", body = "body")),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemTextContent(
|
||||
@@ -98,15 +102,21 @@ class TimelineItemContentMessageFactoryTest {
|
||||
@Test
|
||||
fun `test create LocationMessageType not null`() = runTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val assetType = AssetType.SENDER
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description")),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description", assetType)),
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemLocationContent(
|
||||
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",
|
||||
assetType = assetType,
|
||||
mode = TimelineItemLocationContent.Mode.Static,
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
}
|
||||
@@ -115,8 +125,9 @@ class TimelineItemContentMessageFactoryTest {
|
||||
fun `test create LocationMessageType null`() = runTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = LocationMessageType("body", "", null)),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
content = createMessageContent(type = LocationMessageType("body", "", null, null)),
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemTextContent(
|
||||
@@ -133,7 +144,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = TextMessageType("body", null)),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemTextContent(
|
||||
@@ -150,7 +162,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = TextMessageType("https://www.example.org", null)),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
) as TimelineItemTextContent
|
||||
val expected = TimelineItemTextContent(
|
||||
@@ -197,7 +210,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
formatted = FormattedBody(MessageFormat.HTML, expected.toString())
|
||||
)
|
||||
),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expected)
|
||||
@@ -215,7 +229,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
formatted = FormattedBody(MessageFormat.UNKNOWN, "formatted")
|
||||
)
|
||||
),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("body"))
|
||||
@@ -226,7 +241,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = VideoMessageType("filename", null, null, MediaSource("url"), null)),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemVideoContent(
|
||||
@@ -279,7 +295,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
),
|
||||
isEdited = true,
|
||||
),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemVideoContent(
|
||||
@@ -309,7 +326,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = AudioMessageType("filename", null, null, MediaSource("url"), null)),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemAudioContent(
|
||||
@@ -345,7 +363,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
),
|
||||
isEdited = true,
|
||||
),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemAudioContent(
|
||||
@@ -368,7 +387,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = VoiceMessageType("filename", null, null, MediaSource("url"), null, null)),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemVoiceContent(
|
||||
@@ -410,7 +430,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
),
|
||||
isEdited = true,
|
||||
),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemVoiceContent(
|
||||
@@ -435,7 +456,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = ImageMessageType("filename", "body", null, MediaSource("url"), null)),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemImageContent(
|
||||
@@ -515,7 +537,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
),
|
||||
isEdited = true,
|
||||
),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemImageContent(
|
||||
@@ -544,7 +567,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = FileMessageType("filename", null, null, MediaSource("url"), null)),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemFileContent(
|
||||
@@ -586,7 +610,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
),
|
||||
isEdited = true,
|
||||
),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemFileContent(
|
||||
@@ -609,7 +634,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = NoticeMessageType("body", null)),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemNoticeContent(
|
||||
@@ -631,7 +657,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
formatted = FormattedBody(MessageFormat.HTML, "formatted")
|
||||
)
|
||||
),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
(result as TimelineItemNoticeContent).formattedBody.assertSpannedEquals(SpannedString("formatted"))
|
||||
@@ -642,7 +669,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = EmoteMessageType("body", null)),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails("Bob"),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemEmoteContent(
|
||||
@@ -664,7 +692,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
formatted = FormattedBody(MessageFormat.HTML, "formatted")
|
||||
)
|
||||
),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails("Bob"),
|
||||
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>")
|
||||
)
|
||||
),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
(result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned)
|
||||
@@ -715,7 +745,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org")
|
||||
)
|
||||
),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
(result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned)
|
||||
@@ -741,7 +772,8 @@ class TimelineItemContentMessageFactoryTest {
|
||||
formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org")
|
||||
)
|
||||
),
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user