Merge branch 'release/26.05.1'
This commit is contained in:
commit
f2eb6f29be
654 changed files with 6752 additions and 2882 deletions
2
.github/workflows/pull_request.yml
vendored
2
.github/workflows/pull_request.yml
vendored
|
|
@ -32,7 +32,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Check membership
|
- name: Check membership
|
||||||
if: github.event.pull_request.user.login != 'renovate[bot]'
|
if: github.event.pull_request.user.login != 'renovate[bot]'
|
||||||
uses: tspascoal/get-user-teams-membership@b1480b119326dde04ceffbeccd98e41892539c74 # v4.0.0
|
uses: tspascoal/get-user-teams-membership@818140d631d5f29f26b151afbe4179f87d9ceb5e # v4.0.1
|
||||||
id: teams
|
id: teams
|
||||||
with:
|
with:
|
||||||
username: ${{ github.event.pull_request.user.login }}
|
username: ${{ github.event.pull_request.user.login }}
|
||||||
|
|
|
||||||
2
.github/workflows/triage-incoming.yml
vendored
2
.github/workflows/triage-incoming.yml
vendored
|
|
@ -10,7 +10,7 @@ jobs:
|
||||||
triage-new-issues:
|
triage-new-issues:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
- uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
|
||||||
with:
|
with:
|
||||||
project-url: https://github.com/orgs/element-hq/projects/91
|
project-url: https://github.com/orgs/element-hq/projects/91
|
||||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
|
|
|
||||||
12
.github/workflows/triage-labelled.yml
vendored
12
.github/workflows/triage-labelled.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
||||||
if: >
|
if: >
|
||||||
github.repository == 'element-hq/element-x-android'
|
github.repository == 'element-hq/element-x-android'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
- uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
|
||||||
with:
|
with:
|
||||||
project-url: https://github.com/orgs/element-hq/projects/43
|
project-url: https://github.com/orgs/element-hq/projects/43
|
||||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
|
|
@ -23,7 +23,7 @@ jobs:
|
||||||
name: Move triaged needs info issues on board
|
name: Move triaged needs info issues on board
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
- uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
|
||||||
id: addItem
|
id: addItem
|
||||||
with:
|
with:
|
||||||
project-url: https://github.com/orgs/element-hq/projects/91
|
project-url: https://github.com/orgs/element-hq/projects/91
|
||||||
|
|
@ -47,7 +47,7 @@ jobs:
|
||||||
if: >
|
if: >
|
||||||
contains(github.event.issue.labels.*.name, 'Team: Element X Feature')
|
contains(github.event.issue.labels.*.name, 'Team: Element X Feature')
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
- uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
|
||||||
with:
|
with:
|
||||||
project-url: https://github.com/orgs/element-hq/projects/73
|
project-url: https://github.com/orgs/element-hq/projects/73
|
||||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
|
|
@ -58,7 +58,7 @@ jobs:
|
||||||
if: >
|
if: >
|
||||||
contains(github.event.issue.labels.*.name, 'Team: Verticals Feature')
|
contains(github.event.issue.labels.*.name, 'Team: Verticals Feature')
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
- uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
|
||||||
with:
|
with:
|
||||||
project-url: https://github.com/orgs/element-hq/projects/57
|
project-url: https://github.com/orgs/element-hq/projects/57
|
||||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
|
|
@ -70,7 +70,7 @@ jobs:
|
||||||
contains(github.event.issue.labels.*.name, 'Team: QA') ||
|
contains(github.event.issue.labels.*.name, 'Team: QA') ||
|
||||||
contains(github.event.issue.labels.*.name, 'X-Needs-Signoff')
|
contains(github.event.issue.labels.*.name, 'X-Needs-Signoff')
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
- uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
|
||||||
with:
|
with:
|
||||||
project-url: https://github.com/orgs/element-hq/projects/69
|
project-url: https://github.com/orgs/element-hq/projects/69
|
||||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
|
|
@ -81,7 +81,7 @@ jobs:
|
||||||
if: >
|
if: >
|
||||||
contains(github.event.issue.labels.*.name, 'X-Needs-Signoff')
|
contains(github.event.issue.labels.*.name, 'X-Needs-Signoff')
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
- uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
|
||||||
with:
|
with:
|
||||||
project-url: https://github.com/orgs/element-hq/projects/89
|
project-url: https://github.com/orgs/element-hq/projects/89
|
||||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
|
|
|
||||||
85
CHANGES.md
85
CHANGES.md
|
|
@ -1,3 +1,88 @@
|
||||||
|
Changes in Element X v26.05.0
|
||||||
|
=============================
|
||||||
|
|
||||||
|
<!-- Release notes generated using configuration in .github/release.yml at v26.05.0 -->
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
### ✨ Features
|
||||||
|
* Add flag for automatic back pagination feature by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6637
|
||||||
|
* Promote "history sharing on invite" out of developer options by @richvdh in https://github.com/element-hq/element-x-android/pull/6647
|
||||||
|
* Remove RoomDirectorySearch feature flag — always enable the feature by @Copilot in https://github.com/element-hq/element-x-android/pull/6736
|
||||||
|
### 🙌 Improvements
|
||||||
|
* Change native back button behavior in EC view (close settings in EC with os native back) by @toger5 in https://github.com/element-hq/element-x-android/pull/6642
|
||||||
|
* Revert PR #6642 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6724
|
||||||
|
* Use 'Report a problem' string instead of 'Report bug' by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6735
|
||||||
|
### 🐛 Bugfixes
|
||||||
|
* Remove distributed tracing of the 'timeline loading' flow by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6644
|
||||||
|
* Set max lines for 'in reply to' view conditionally by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6612
|
||||||
|
* Mention pill cut off by @bmarty in https://github.com/element-hq/element-x-android/pull/6651
|
||||||
|
* Ensure that bottom sheet can scroll by @bmarty in https://github.com/element-hq/element-x-android/pull/6661
|
||||||
|
* Remove legacy `mx-reply` from `toPlainText` formatted event contents by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6683
|
||||||
|
* Fix ANRs when receiving push notifications by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6696
|
||||||
|
* Mitigate a deadlock when loading room timelines by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6674
|
||||||
|
* Fix calls on Huawei devices: skip addWebMessageListener on Chromium < 119 by @manfrommedan in https://github.com/element-hq/element-x-android/pull/6640
|
||||||
|
* Allow cancelling room loading in Home screen by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6723
|
||||||
|
* Let our Json parser accept comments and trailing comma. by @bmarty in https://github.com/element-hq/element-x-android/pull/6700
|
||||||
|
* Fix low width image message by @krbns in https://github.com/element-hq/element-x-android/pull/6692
|
||||||
|
* Make icons in the Chat screen top bar 16dp by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6733
|
||||||
|
* Fix back button sometimes not working after exiting a thread by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6732
|
||||||
|
* Make send event state UI easier to click by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6739
|
||||||
|
### 🗣 Translations
|
||||||
|
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6658
|
||||||
|
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6716
|
||||||
|
### 🧱 Build
|
||||||
|
* Fix record screenshots action permissions by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6679
|
||||||
|
* Fix dependency error by @bmarty in https://github.com/element-hq/element-x-android/pull/6697
|
||||||
|
### 🚧 In development 🚧
|
||||||
|
* [Link new device] Add missing screen to render digits that the user has to type on the other device by @bmarty in https://github.com/element-hq/element-x-android/pull/6680
|
||||||
|
### Dependency upgrades
|
||||||
|
* Update dependency io.nlopez.compose.rules:detekt to v0.5.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6594
|
||||||
|
* Update zizmorcore/zizmor-action action to v0.5.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6630
|
||||||
|
* Update dependency io.sentry:sentry-android to v8.38.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6597
|
||||||
|
* fix(deps): update camera to v1.6.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6514
|
||||||
|
* Update dependency io.sentry:sentry-android to v8.39.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6648
|
||||||
|
* Update dependency io.element.android:element-call-embedded to v0.19.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6662
|
||||||
|
* Update dependencyAnalysis to v3.9.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6657
|
||||||
|
* Update dependency org.matrix.rustcomponents:sdk-android to v26.04.27 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6666
|
||||||
|
* Update dependency io.sentry:sentry-android to v8.40.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6691
|
||||||
|
* Update dependency org.jsoup:jsoup to v1.22.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6660
|
||||||
|
* Update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6687
|
||||||
|
* Update dependency androidx.compose:compose-bom to v2026.04.01 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6693
|
||||||
|
* Update dependency io.nlopez.compose.rules:detekt to v0.5.8 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6711
|
||||||
|
* Update dependency com.posthog:posthog-android to v3.43.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6704
|
||||||
|
* Update dependency org.matrix.rustcomponents:sdk-android to v26.05.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6718
|
||||||
|
* Update roborazzi to v1.60.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6722
|
||||||
|
* Update dependency net.zetetic:sqlcipher-android to v4.15.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6727
|
||||||
|
* Update dependency org.maplibre.gl:android-sdk to v13.1.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6731
|
||||||
|
* Update dependency org.matrix.rustcomponents:sdk-android to v26.05.6 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6734
|
||||||
|
* Update dependencyAnalysis to v3.10.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6742
|
||||||
|
* Update tspascoal/get-user-teams-membership action to v4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6747
|
||||||
|
### Others
|
||||||
|
* devx: fix build sdk script options for macos by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/6636
|
||||||
|
* PR:Fix mention pill cut off by @krbns in https://github.com/element-hq/element-x-android/pull/6622
|
||||||
|
* Update media viewer UI by @bmarty in https://github.com/element-hq/element-x-android/pull/6643
|
||||||
|
* Strip formatting from media captions in room summary by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6670
|
||||||
|
* Update error mappings for Link new device flow by @hughns in https://github.com/element-hq/element-x-android/pull/6677
|
||||||
|
* Rename `OIDC` components and variables to `OAuth` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6686
|
||||||
|
* [Link new device] Add missing error case "already signed in" by @bmarty in https://github.com/element-hq/element-x-android/pull/6688
|
||||||
|
* Improve detection of completion for Link new device flow by @hughns in https://github.com/element-hq/element-x-android/pull/6681
|
||||||
|
* Remove external call support by @bmarty in https://github.com/element-hq/element-x-android/pull/6668
|
||||||
|
* [a11y] Fix a set of issues by @bmarty in https://github.com/element-hq/element-x-android/pull/6650
|
||||||
|
* Add clipping to RoomSummaryRow by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6654
|
||||||
|
* Fix media viewer flickering and crashing by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6715
|
||||||
|
* Rename verification methods by @bmarty in https://github.com/element-hq/element-x-android/pull/6726
|
||||||
|
* Add a way to tweak MAS url. by @bmarty in https://github.com/element-hq/element-x-android/pull/6682
|
||||||
|
* Fix 2 x Crash the app in Developer Options - Update AppDeveloperSettingsView.kt by @escix in https://github.com/element-hq/element-x-android/pull/6708
|
||||||
|
* Introduce UI sample by @bmarty in https://github.com/element-hq/element-x-android/pull/6740
|
||||||
|
|
||||||
|
## New Contributors
|
||||||
|
* @krbns made their first contribution in https://github.com/element-hq/element-x-android/pull/6622
|
||||||
|
* @toger5 made their first contribution in https://github.com/element-hq/element-x-android/pull/6642
|
||||||
|
* @manfrommedan made their first contribution in https://github.com/element-hq/element-x-android/pull/6640
|
||||||
|
* @Copilot made their first contribution in https://github.com/element-hq/element-x-android/pull/6736
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.04.4...v26.05.0
|
||||||
|
|
||||||
Changes in Element X v26.04.4
|
Changes in Element X v26.04.4
|
||||||
=============================
|
=============================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ import io.element.android.features.ftue.api.state.FtueService
|
||||||
import io.element.android.features.ftue.api.state.FtueState
|
import io.element.android.features.ftue.api.state.FtueState
|
||||||
import io.element.android.features.home.api.HomeEntryPoint
|
import io.element.android.features.home.api.HomeEntryPoint
|
||||||
import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
|
import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
|
||||||
|
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
|
||||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
|
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
|
||||||
|
|
@ -151,6 +152,7 @@ class LoggedInFlowNode(
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
|
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
|
||||||
private val createRoomEntryPoint: CreateRoomEntryPoint,
|
private val createRoomEntryPoint: CreateRoomEntryPoint,
|
||||||
|
private val activeLiveLocationShareManager: ActiveLiveLocationShareManager,
|
||||||
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.Placeholder,
|
initialElement = NavTarget.Placeholder,
|
||||||
|
|
@ -211,6 +213,7 @@ class LoggedInFlowNode(
|
||||||
super.onBuilt()
|
super.onBuilt()
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
sessionEnterpriseService.init()
|
sessionEnterpriseService.init()
|
||||||
|
activeLiveLocationShareManager.setup()
|
||||||
}
|
}
|
||||||
lifecycle.subscribe(
|
lifecycle.subscribe(
|
||||||
onCreate = {
|
onCreate = {
|
||||||
|
|
@ -219,7 +222,6 @@ class LoggedInFlowNode(
|
||||||
loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
|
loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
|
||||||
matrixClient.sessionVerificationService.setListener(verificationListener)
|
matrixClient.sessionVerificationService.setListener(verificationListener)
|
||||||
mediaPreviewConfigMigration()
|
mediaPreviewConfigMigration()
|
||||||
|
|
||||||
sessionCoroutineScope.launch {
|
sessionCoroutineScope.launch {
|
||||||
// Wait for the network to be connected before pre-fetching the max file upload size
|
// Wait for the network to be connected before pre-fetching the max file upload size
|
||||||
networkMonitor.connectivity.first { networkStatus -> networkStatus == NetworkStatus.Connected }
|
networkMonitor.connectivity.first { networkStatus -> networkStatus == NetworkStatus.Connected }
|
||||||
|
|
|
||||||
|
|
@ -89,16 +89,14 @@ class SyncOrchestrator(
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||||
internal fun observeStates() = coroutineScope.launch {
|
internal fun observeStates() = coroutineScope.launch {
|
||||||
Timber.tag(tag).d("start observing the app and network state")
|
Timber.tag(tag).d("start observing the app and network state")
|
||||||
|
val isAppActiveFlows = listOf(
|
||||||
val isAppActiveFlow = combine(
|
|
||||||
appForegroundStateService.isInForeground,
|
appForegroundStateService.isInForeground,
|
||||||
appForegroundStateService.isInCall,
|
appForegroundStateService.isInCall,
|
||||||
appForegroundStateService.isSyncingNotificationEvent,
|
appForegroundStateService.isSyncingNotificationEvent,
|
||||||
appForegroundStateService.hasRingingCall,
|
appForegroundStateService.hasRingingCall,
|
||||||
) { isInForeground, isInCall, isSyncingNotificationEvent, hasRingingCall ->
|
appForegroundStateService.isSharingLiveLocation
|
||||||
isInForeground || isInCall || isSyncingNotificationEvent || hasRingingCall
|
)
|
||||||
}
|
val isAppActiveFlow = combine(isAppActiveFlows) { actives -> actives.any { it } }
|
||||||
|
|
||||||
combine(
|
combine(
|
||||||
// small debounce to avoid spamming startSync when the state is changing quickly in case of error.
|
// small debounce to avoid spamming startSync when the state is changing quickly in case of error.
|
||||||
syncService.syncState.debounce(100.milliseconds),
|
syncService.syncState.debounce(100.milliseconds),
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit fb7e9287d9d446012925139842d9aaa8e99a74dc
|
Subproject commit 6781da90aae61cf77dcdbc543e18d76411d578b4
|
||||||
2
fastlane/metadata/android/en-US/changelogs/202605010.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/202605010.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
Main changes in this version: improvements in Element Call, room knocking and room directory are now available, improvements on DMs.
|
||||||
|
Full changelog: https://github.com/element-hq/element-x-android/releases
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.call.impl.ui
|
||||||
|
internal sealed interface CallScreenBackPressAction {
|
||||||
|
data object DispatchEscapeToWebView : CallScreenBackPressAction
|
||||||
|
data object EnterPictureInPicture : CallScreenBackPressAction
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object CallScreenBackPressPolicy {
|
||||||
|
fun resolve(
|
||||||
|
supportPip: Boolean,
|
||||||
|
hasWebView: Boolean,
|
||||||
|
fromNative: Boolean,
|
||||||
|
): CallScreenBackPressAction? {
|
||||||
|
return when {
|
||||||
|
hasWebView && fromNative -> CallScreenBackPressAction.DispatchEscapeToWebView
|
||||||
|
hasWebView && supportPip -> CallScreenBackPressAction.EnterPictureInPicture
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,9 +17,10 @@ import android.webkit.WebChromeClient
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.systemBars
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -45,7 +46,6 @@ import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -64,94 +64,93 @@ internal fun CallScreenView(
|
||||||
requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit,
|
requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
fun handleBack() {
|
var callWebView by remember { mutableStateOf<WebView?>(null) }
|
||||||
if (pipState.supportPip) {
|
|
||||||
pipState.eventSink.invoke(PictureInPictureEvent.EnterPictureInPicture)
|
fun handleBack(fromNative: Boolean = false) {
|
||||||
} else {
|
when (CallScreenBackPressPolicy.resolve(supportPip = pipState.supportPip, hasWebView = callWebView != null, fromNative)) {
|
||||||
state.eventSink(CallScreenEvent.Hangup)
|
CallScreenBackPressAction.EnterPictureInPicture ->
|
||||||
|
pipState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
|
||||||
|
CallScreenBackPressAction.DispatchEscapeToWebView ->
|
||||||
|
callWebView?.dispatchEscKeyEvent()
|
||||||
|
null -> Timber.d("Back press with unsupported pip is a no-op")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
BackHandler {
|
||||||
modifier = modifier,
|
handleBack(fromNative = true)
|
||||||
) { padding ->
|
}
|
||||||
BackHandler {
|
if (state.webViewError != null) {
|
||||||
handleBack()
|
ErrorDialog(
|
||||||
|
content = buildString {
|
||||||
|
append(stringResource(CommonStrings.error_unknown))
|
||||||
|
state.webViewError.takeIf { it.isNotEmpty() }?.let { append("\n\n").append(it) }
|
||||||
|
},
|
||||||
|
onSubmit = { state.eventSink(CallScreenEvent.Hangup) },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
var webViewAudioManager by remember { mutableStateOf<WebViewAudioManager?>(null) }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var invalidAudioDeviceReason by remember { mutableStateOf<InvalidAudioDeviceReason?>(null) }
|
||||||
|
invalidAudioDeviceReason?.let {
|
||||||
|
InvalidAudioDeviceDialog(invalidAudioDeviceReason = it) {
|
||||||
|
invalidAudioDeviceReason = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (state.webViewError != null) {
|
|
||||||
ErrorDialog(
|
|
||||||
content = buildString {
|
|
||||||
append(stringResource(CommonStrings.error_unknown))
|
|
||||||
state.webViewError.takeIf { it.isNotEmpty() }?.let { append("\n\n").append(it) }
|
|
||||||
},
|
|
||||||
onSubmit = { state.eventSink(CallScreenEvent.Hangup) },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
var webViewAudioManager by remember { mutableStateOf<WebViewAudioManager?>(null) }
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
var invalidAudioDeviceReason by remember { mutableStateOf<InvalidAudioDeviceReason?>(null) }
|
CallWebView(
|
||||||
invalidAudioDeviceReason?.let {
|
modifier = modifier.consumeWindowInsets(WindowInsets.systemBars).fillMaxSize(),
|
||||||
InvalidAudioDeviceDialog(invalidAudioDeviceReason = it) {
|
url = state.urlState,
|
||||||
invalidAudioDeviceReason = null
|
userAgent = state.userAgent,
|
||||||
}
|
onPermissionsRequest = { request ->
|
||||||
|
val androidPermissions = mapWebkitPermissions(request.resources)
|
||||||
|
val callback: RequestPermissionCallback = { request.grant(it) }
|
||||||
|
requestPermissions(androidPermissions.toTypedArray(), callback)
|
||||||
|
},
|
||||||
|
onConsoleMessage = onConsoleMessage,
|
||||||
|
onCreateWebView = { webView ->
|
||||||
|
callWebView = webView
|
||||||
|
webView.addBackHandler(onBackPressed = ::handleBack)
|
||||||
|
val interceptor = WebViewWidgetMessageInterceptor(
|
||||||
|
webView = webView,
|
||||||
|
onUrlLoaded = { url ->
|
||||||
|
webView.evaluateJavascript("controls.onBackButtonPressed = () => { backHandler.onBackPressed() }", null)
|
||||||
|
if (webViewAudioManager?.isInCallMode?.get() == false) {
|
||||||
|
Timber.d("URL $url is loaded, starting in-call audio mode")
|
||||||
|
webViewAudioManager?.onCallStarted()
|
||||||
|
} else {
|
||||||
|
Timber.d("Can't start in-call audio mode since the app is already in it.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError = { state.eventSink(CallScreenEvent.OnWebViewError(it)) },
|
||||||
|
)
|
||||||
|
webViewAudioManager = WebViewAudioManager(
|
||||||
|
webView = webView,
|
||||||
|
coroutineScope = coroutineScope,
|
||||||
|
onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
|
||||||
|
)
|
||||||
|
state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor))
|
||||||
|
val pipController = WebViewPipController(webView)
|
||||||
|
pipState.eventSink(PictureInPictureEvent.SetPipController(pipController))
|
||||||
|
},
|
||||||
|
onDestroyWebView = {
|
||||||
|
callWebView = null
|
||||||
|
// Reset audio mode
|
||||||
|
webViewAudioManager?.onCallStopped()
|
||||||
}
|
}
|
||||||
|
)
|
||||||
CallWebView(
|
when (state.urlState) {
|
||||||
modifier = Modifier
|
AsyncData.Uninitialized,
|
||||||
.padding(padding)
|
is AsyncData.Loading ->
|
||||||
.consumeWindowInsets(padding)
|
ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait))
|
||||||
.fillMaxSize(),
|
is AsyncData.Failure -> {
|
||||||
url = state.urlState,
|
Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}")
|
||||||
userAgent = state.userAgent,
|
ErrorDialog(
|
||||||
onPermissionsRequest = { request ->
|
content = state.urlState.error.message.orEmpty(),
|
||||||
val androidPermissions = mapWebkitPermissions(request.resources)
|
onSubmit = { state.eventSink(CallScreenEvent.Hangup) },
|
||||||
val callback: RequestPermissionCallback = { request.grant(it) }
|
)
|
||||||
requestPermissions(androidPermissions.toTypedArray(), callback)
|
|
||||||
},
|
|
||||||
onConsoleMessage = onConsoleMessage,
|
|
||||||
onCreateWebView = { webView ->
|
|
||||||
webView.addBackHandler(onBackPressed = ::handleBack)
|
|
||||||
val interceptor = WebViewWidgetMessageInterceptor(
|
|
||||||
webView = webView,
|
|
||||||
onUrlLoaded = { url ->
|
|
||||||
webView.evaluateJavascript("controls.onBackButtonPressed = () => { backHandler.onBackPressed() }", null)
|
|
||||||
if (webViewAudioManager?.isInCallMode?.get() == false) {
|
|
||||||
Timber.d("URL $url is loaded, starting in-call audio mode")
|
|
||||||
webViewAudioManager?.onCallStarted()
|
|
||||||
} else {
|
|
||||||
Timber.d("Can't start in-call audio mode since the app is already in it.")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError = { state.eventSink(CallScreenEvent.OnWebViewError(it)) },
|
|
||||||
)
|
|
||||||
webViewAudioManager = WebViewAudioManager(
|
|
||||||
webView = webView,
|
|
||||||
coroutineScope = coroutineScope,
|
|
||||||
onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
|
|
||||||
)
|
|
||||||
state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor))
|
|
||||||
val pipController = WebViewPipController(webView)
|
|
||||||
pipState.eventSink(PictureInPictureEvent.SetPipController(pipController))
|
|
||||||
},
|
|
||||||
onDestroyWebView = {
|
|
||||||
// Reset audio mode
|
|
||||||
webViewAudioManager?.onCallStopped()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
when (state.urlState) {
|
|
||||||
AsyncData.Uninitialized,
|
|
||||||
is AsyncData.Loading ->
|
|
||||||
ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait))
|
|
||||||
is AsyncData.Failure -> {
|
|
||||||
Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}")
|
|
||||||
ErrorDialog(
|
|
||||||
content = state.urlState.error.message.orEmpty(),
|
|
||||||
onSubmit = { state.eventSink(CallScreenEvent.Hangup) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is AsyncData.Success -> Unit
|
|
||||||
}
|
}
|
||||||
|
is AsyncData.Success -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -248,15 +247,16 @@ private fun WebView.setup(
|
||||||
|
|
||||||
private fun WebView.addBackHandler(onBackPressed: () -> Unit) {
|
private fun WebView.addBackHandler(onBackPressed: () -> Unit) {
|
||||||
addJavascriptInterface(
|
addJavascriptInterface(
|
||||||
object {
|
JavascriptBackHandlerBridge(callback = onBackPressed),
|
||||||
@Suppress("unused")
|
|
||||||
@JavascriptInterface
|
|
||||||
fun onBackPressed() = onBackPressed()
|
|
||||||
},
|
|
||||||
"backHandler"
|
"backHandler"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun WebView.dispatchEscKeyEvent() {
|
||||||
|
dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_ESCAPE))
|
||||||
|
dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_ESCAPE))
|
||||||
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CallScreenViewPreview(
|
internal fun CallScreenViewPreview(
|
||||||
|
|
@ -275,3 +275,12 @@ internal fun CallScreenViewPreview(
|
||||||
internal fun InvalidAudioDeviceDialogPreview() = ElementPreview {
|
internal fun InvalidAudioDeviceDialogPreview() = ElementPreview {
|
||||||
InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {}
|
InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal class JavascriptBackHandlerBridge(
|
||||||
|
private val callback: () -> Unit,
|
||||||
|
) {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onBackPressed() {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.core.app.PictureInPictureModeChangedInfo
|
import androidx.core.app.PictureInPictureModeChangedInfo
|
||||||
import androidx.core.content.IntentCompat
|
import androidx.core.content.IntentCompat
|
||||||
import androidx.core.util.Consumer
|
import androidx.core.util.Consumer
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
import io.element.android.compound.colors.SemanticColorsLightDark
|
||||||
|
|
@ -52,6 +55,7 @@ import io.element.android.libraries.audio.api.AudioFocusRequester
|
||||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.designsystem.theme.ElementThemeApp
|
import io.element.android.libraries.designsystem.theme.ElementThemeApp
|
||||||
|
import io.element.android.libraries.designsystem.utils.hasCompactHeightWindowSize
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -111,6 +115,27 @@ class ElementCallActivity :
|
||||||
val colors by remember(webViewTarget.value?.sessionId) {
|
val colors by remember(webViewTarget.value?.sessionId) {
|
||||||
enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.sessionId)
|
enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.sessionId)
|
||||||
}.collectAsState(SemanticColorsLightDark.default)
|
}.collectAsState(SemanticColorsLightDark.default)
|
||||||
|
|
||||||
|
// When the height is compact, hide the system bars by default to maximize the space for the call, using immersive mode
|
||||||
|
val hasCompactHeight = hasCompactHeightWindowSize()
|
||||||
|
DisposableEffect(hasCompactHeight, pipState.isInPictureInPicture) {
|
||||||
|
if (hasCompactHeight && !pipState.isInPictureInPicture) {
|
||||||
|
val window = this@ElementCallActivity.window ?: return@DisposableEffect onDispose {}
|
||||||
|
val insetsController = WindowCompat.getInsetsController(window, window.decorView)
|
||||||
|
val systemBarInsets = WindowInsetsCompat.Type.systemBars()
|
||||||
|
insetsController.hide(systemBarInsets)
|
||||||
|
|
||||||
|
insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
insetsController.show(systemBarInsets)
|
||||||
|
insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onDispose {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ElementThemeApp(
|
ElementThemeApp(
|
||||||
appPreferencesStore = appPreferencesStore,
|
appPreferencesStore = appPreferencesStore,
|
||||||
featureFlagService = featureFlagService,
|
featureFlagService = featureFlagService,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.room.isDm
|
|
||||||
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
|
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
|
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.call.ui
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.call.impl.ui.CallScreenBackPressAction
|
||||||
|
import io.element.android.features.call.impl.ui.CallScreenBackPressPolicy
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CallScreenBackPressPolicyTest {
|
||||||
|
@Test
|
||||||
|
fun `resolve returns dispatch escape when a web view is available and native button is pressed`() {
|
||||||
|
val result = CallScreenBackPressPolicy.resolve(
|
||||||
|
supportPip = false,
|
||||||
|
hasWebView = true,
|
||||||
|
fromNative = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(CallScreenBackPressAction.DispatchEscapeToWebView)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolve dispatch escape when there is a web view and pip is supported on native button press`() {
|
||||||
|
val result = CallScreenBackPressPolicy.resolve(
|
||||||
|
supportPip = true,
|
||||||
|
hasWebView = true,
|
||||||
|
fromNative = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(CallScreenBackPressAction.DispatchEscapeToWebView)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolve returns hangup when there is no web view and pip is not supported from native button`() {
|
||||||
|
val result = CallScreenBackPressPolicy.resolve(
|
||||||
|
supportPip = false,
|
||||||
|
hasWebView = false,
|
||||||
|
fromNative = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolve returns hangup when there is no web view even though pip is supported from native button`() {
|
||||||
|
val result = CallScreenBackPressPolicy.resolve(
|
||||||
|
supportPip = true,
|
||||||
|
hasWebView = false,
|
||||||
|
fromNative = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolve goes to pip if its not from native but from the webview`() {
|
||||||
|
val result = CallScreenBackPressPolicy.resolve(
|
||||||
|
supportPip = true,
|
||||||
|
hasWebView = true,
|
||||||
|
fromNative = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(CallScreenBackPressAction.EnterPictureInPicture)
|
||||||
|
}
|
||||||
|
@Test
|
||||||
|
fun `resolve hangs up if its not from native but from the webview and pip is not supported`() {
|
||||||
|
val result = CallScreenBackPressPolicy.resolve(
|
||||||
|
supportPip = false,
|
||||||
|
hasWebView = true,
|
||||||
|
fromNative = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invalid cases (event comes from webview but there is now webview) all result in hangup`() {
|
||||||
|
val withPipSupport = CallScreenBackPressPolicy.resolve(
|
||||||
|
supportPip = true,
|
||||||
|
hasWebView = false,
|
||||||
|
fromNative = false,
|
||||||
|
)
|
||||||
|
assertThat(withPipSupport).isNull()
|
||||||
|
val withOutPipSupport = CallScreenBackPressPolicy.resolve(
|
||||||
|
supportPip = false,
|
||||||
|
hasWebView = false,
|
||||||
|
fromNative = false,
|
||||||
|
)
|
||||||
|
assertThat(withOutPipSupport).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.call.ui
|
||||||
|
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
|
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import io.element.android.features.call.impl.pip.PictureInPictureEvent
|
||||||
|
import io.element.android.features.call.impl.pip.PictureInPictureState
|
||||||
|
import io.element.android.features.call.impl.pip.aPictureInPictureState
|
||||||
|
import io.element.android.features.call.impl.ui.CallScreenEvent
|
||||||
|
import io.element.android.features.call.impl.ui.CallScreenState
|
||||||
|
import io.element.android.features.call.impl.ui.CallScreenView
|
||||||
|
import io.element.android.features.call.impl.ui.JavascriptBackHandlerBridge
|
||||||
|
import io.element.android.features.call.impl.ui.aCallScreenState
|
||||||
|
import io.element.android.tests.testutils.EventsRecorder
|
||||||
|
import io.element.android.tests.testutils.pressBackKey
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.Implementation
|
||||||
|
import org.robolectric.annotation.Implements
|
||||||
|
import org.robolectric.annotation.Resetter
|
||||||
|
import org.robolectric.shadows.ShadowWebView
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class CallScreenViewTest {
|
||||||
|
@Test
|
||||||
|
fun `pressing back key triggers hangup when no web view is available and pip is unsupported`() = runAndroidComposeUiTest {
|
||||||
|
val callEvents = EventsRecorder<CallScreenEvent>()
|
||||||
|
|
||||||
|
setCallScreenView(
|
||||||
|
state = aCallScreenState(eventSink = callEvents),
|
||||||
|
useInspectionMode = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
pressBackKey()
|
||||||
|
|
||||||
|
callEvents.assertEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Config(shadows = [RecordingShadowWebView::class])
|
||||||
|
@Test
|
||||||
|
fun `pressing back key dispatches escape key events to web view when pip is unsupported`() = runAndroidComposeUiTest {
|
||||||
|
setCallScreenView(
|
||||||
|
state = aCallScreenState(),
|
||||||
|
useInspectionMode = false,
|
||||||
|
pipState = aPictureInPictureState(supportPip = false),
|
||||||
|
)
|
||||||
|
|
||||||
|
pressBackKey()
|
||||||
|
|
||||||
|
val dispatchedEvents = RecordingShadowWebView.dispatchedEvents
|
||||||
|
assertEquals(2, dispatchedEvents.size)
|
||||||
|
assertEquals(KeyEvent.ACTION_DOWN, dispatchedEvents[0].action)
|
||||||
|
assertEquals(KeyEvent.KEYCODE_ESCAPE, dispatchedEvents[0].keyCode)
|
||||||
|
assertEquals(KeyEvent.ACTION_UP, dispatchedEvents[1].action)
|
||||||
|
assertEquals(KeyEvent.KEYCODE_ESCAPE, dispatchedEvents[1].keyCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Config(shadows = [RecordingShadowWebView::class])
|
||||||
|
@Test
|
||||||
|
fun `web view javascript back handler emits pip event when pip is supported`() = runAndroidComposeUiTest {
|
||||||
|
val pipEvents = EventsRecorder<PictureInPictureEvent>()
|
||||||
|
|
||||||
|
setCallScreenView(
|
||||||
|
state = aCallScreenState(),
|
||||||
|
useInspectionMode = false,
|
||||||
|
pipState = aPictureInPictureState(
|
||||||
|
supportPip = true,
|
||||||
|
eventSink = pipEvents,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
runOnIdle {
|
||||||
|
RecordingShadowWebView.invokeJavascriptBackHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
pipEvents.assertSize(2)
|
||||||
|
pipEvents.assertTrue(0) { it is PictureInPictureEvent.SetPipController }
|
||||||
|
pipEvents.assertTrue(1) { it is PictureInPictureEvent.EnterPictureInPicture }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
private fun <A : ComponentActivity> AndroidComposeUiTest<A>.setCallScreenView(
|
||||||
|
state: CallScreenState,
|
||||||
|
useInspectionMode: Boolean,
|
||||||
|
pipState: PictureInPictureState = aPictureInPictureState(supportPip = false),
|
||||||
|
) {
|
||||||
|
setContent {
|
||||||
|
// Inspection mode disables AndroidView creation; keep it configurable per test.
|
||||||
|
CompositionLocalProvider(LocalInspectionMode provides useInspectionMode) {
|
||||||
|
CallScreenView(
|
||||||
|
state = state,
|
||||||
|
pipState = pipState,
|
||||||
|
onConsoleMessage = {},
|
||||||
|
requestPermissions = { _, _ -> },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Implements(WebView::class)
|
||||||
|
internal class RecordingShadowWebView : ShadowWebView() {
|
||||||
|
companion object {
|
||||||
|
val dispatchedEvents = mutableListOf<KeyEvent>()
|
||||||
|
private var backHandlerJavascriptInterface: JavascriptBackHandlerBridge? = null
|
||||||
|
|
||||||
|
@Resetter
|
||||||
|
@JvmStatic
|
||||||
|
@Suppress("unused")
|
||||||
|
fun resetRecordedEvents() {
|
||||||
|
dispatchedEvents.clear()
|
||||||
|
backHandlerJavascriptInterface = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invokeJavascriptBackHandler() {
|
||||||
|
val backHandler = checkNotNull(backHandlerJavascriptInterface) { "Expected backHandler JavaScript interface to be registered" }
|
||||||
|
backHandler.onBackPressed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Implementation
|
||||||
|
protected override fun addJavascriptInterface(`object`: Any, name: String) {
|
||||||
|
super.addJavascriptInterface(`object`, name)
|
||||||
|
if (name == "backHandler") {
|
||||||
|
backHandlerJavascriptInterface = `object` as? JavascriptBackHandlerBridge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Implementation
|
||||||
|
@Suppress("unused")
|
||||||
|
fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||||
|
dispatchedEvents += KeyEvent(event)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,14 +3,34 @@
|
||||||
<string name="screen_create_room_action_create_room">"Nowy pokój"</string>
|
<string name="screen_create_room_action_create_room">"Nowy pokój"</string>
|
||||||
<string name="screen_create_room_add_people_title">"Zaproś znajomych"</string>
|
<string name="screen_create_room_add_people_title">"Zaproś znajomych"</string>
|
||||||
<string name="screen_create_room_error_creating_room">"Wystąpił błąd w trakcie tworzenia pokoju"</string>
|
<string name="screen_create_room_error_creating_room">"Wystąpił błąd w trakcie tworzenia pokoju"</string>
|
||||||
<string name="screen_create_room_private_option_description">"Tylko zaproszone osoby mogą dołączyć do tego pokoju. Wszystkie wiadomości są szyfrowane end-to-end."</string>
|
<string name="screen_create_room_error_creating_space">"Nie udało się utworzyć przestrzeni z powodu nieznanego błędu. Spróbuj ponownie później."</string>
|
||||||
|
<string name="screen_create_room_name_placeholder">"Dodaj nazwę…"</string>
|
||||||
|
<string name="screen_create_room_new_room_title">"Nowy pokój"</string>
|
||||||
|
<string name="screen_create_room_new_space_title">"Nowa przestrzeń"</string>
|
||||||
|
<string name="screen_create_room_private_option_description">"Dołączyć mogą tylko zaproszone osoby."</string>
|
||||||
|
<string name="screen_create_room_private_option_title">"Prywatny"</string>
|
||||||
<string name="screen_create_room_public_option_description">"Każdy może znaleźć ten pokój.
|
<string name="screen_create_room_public_option_description">"Każdy może znaleźć ten pokój.
|
||||||
Możesz to zmienić w ustawieniach pokoju."</string>
|
Możesz to zmienić w ustawieniach pokoju."</string>
|
||||||
<string name="screen_create_room_room_access_section_knocking_option_description">"Każdy może poprosić o dołączenie do pokoju, ale administrator lub moderator będzie musiał zatwierdzić prośbę"</string>
|
<string name="screen_create_room_public_option_short_description">"Każdy może dołączyć."</string>
|
||||||
<string name="screen_create_room_room_access_section_knocking_option_title">"Poproś o dołączenie"</string>
|
<string name="screen_create_room_public_option_title">"Publiczny"</string>
|
||||||
<string name="screen_create_room_room_access_section_public_option_description">"Każdy może dołączyć do tego pokoju"</string>
|
<string name="screen_create_room_room_access_section_knocking_option_description">"Każdy może poprosić o dołączenie, ale administrator lub moderator musi to zaakceptować."</string>
|
||||||
<string name="screen_create_room_room_address_section_footer">"Aby ten pokój był widoczny w katalogu pomieszczeń publicznych, będziesz potrzebował adres pokoju."</string>
|
<string name="screen_create_room_room_access_section_knocking_option_title">"Zezwól na prośbę o dołączenie"</string>
|
||||||
<string name="screen_create_room_room_address_section_title">"Adres pokoju"</string>
|
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"Każdy w %1$s może dołączyć, ale wszyscy pozostali muszą poprosić o dostęp."</string>
|
||||||
|
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Poproś o dołączenie"</string>
|
||||||
|
<string name="screen_create_room_room_access_section_private_option_description">"Dołączyć mogą tylko zaproszone osoby."</string>
|
||||||
|
<string name="screen_create_room_room_access_section_private_option_title">"Prywatny"</string>
|
||||||
|
<string name="screen_create_room_room_access_section_public_option_description">"Każdy może dołączyć."</string>
|
||||||
|
<string name="screen_create_room_room_access_section_public_option_title">"Publiczny"</string>
|
||||||
|
<string name="screen_create_room_room_access_section_restricted_option_description">"Każdy w %1$s może dołączyć."</string>
|
||||||
|
<string name="screen_create_room_room_access_section_restricted_option_title">"Standardowy"</string>
|
||||||
|
<string name="screen_create_room_room_access_section_title">"Kto ma dostęp"</string>
|
||||||
|
<string name="screen_create_room_room_address_section_footer">"Aby ten pokój był widoczny w katalogu pomieszczeń publicznych, potrzebny jest adres pokoju."</string>
|
||||||
|
<string name="screen_create_room_room_address_section_title">"Adres"</string>
|
||||||
<string name="screen_create_room_room_visibility_section_title">"Widoczność pomieszczenia"</string>
|
<string name="screen_create_room_room_visibility_section_title">"Widoczność pomieszczenia"</string>
|
||||||
|
<string name="screen_create_room_space_selection_no_space_description">"(brak przestrzeni)"</string>
|
||||||
|
<string name="screen_create_room_space_selection_no_space_option">"Nie dodawaj do przestrzeni"</string>
|
||||||
|
<string name="screen_create_room_space_selection_no_space_title">"Nie wybrano przestrzeni"</string>
|
||||||
|
<string name="screen_create_room_space_selection_sheet_title">"Dodaj do przestrzeni"</string>
|
||||||
<string name="screen_create_room_topic_label">"Temat (opcjonalnie)"</string>
|
<string name="screen_create_room_topic_label">"Temat (opcjonalnie)"</string>
|
||||||
|
<string name="screen_create_room_topic_placeholder">"Dodaj opis…"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,32 @@
|
||||||
<string name="screen_create_room_action_create_room">"Нова кімната"</string>
|
<string name="screen_create_room_action_create_room">"Нова кімната"</string>
|
||||||
<string name="screen_create_room_add_people_title">"Запросити людей"</string>
|
<string name="screen_create_room_add_people_title">"Запросити людей"</string>
|
||||||
<string name="screen_create_room_error_creating_room">"Під час створення кімнати сталася помилка"</string>
|
<string name="screen_create_room_error_creating_room">"Під час створення кімнати сталася помилка"</string>
|
||||||
|
<string name="screen_create_room_error_creating_space">"Простір не вдалося створити через невідому помилку. Спробуйте ще раз пізніше."</string>
|
||||||
<string name="screen_create_room_name_placeholder">"Додати назву…"</string>
|
<string name="screen_create_room_name_placeholder">"Додати назву…"</string>
|
||||||
<string name="screen_create_room_new_room_title">"Нова кімната"</string>
|
<string name="screen_create_room_new_room_title">"Нова кімната"</string>
|
||||||
<string name="screen_create_room_new_space_title">"Новий простір"</string>
|
<string name="screen_create_room_new_space_title">"Новий простір"</string>
|
||||||
<string name="screen_create_room_private_option_description">"Можуть приєднатися лише запрошені люди."</string>
|
<string name="screen_create_room_private_option_description">"Можуть приєднатися лише запрошені люди."</string>
|
||||||
|
<string name="screen_create_room_private_option_title">"Приватний"</string>
|
||||||
<string name="screen_create_room_public_option_description">"Будь-хто може знайти цю кімнату.
|
<string name="screen_create_room_public_option_description">"Будь-хто може знайти цю кімнату.
|
||||||
Ви можете змінити це в будь-який час у налаштуваннях кімнати."</string>
|
Ви можете змінити це в будь-який час у налаштуваннях кімнати."</string>
|
||||||
<string name="screen_create_room_public_option_short_description">"Приєднатися може будь-хто."</string>
|
<string name="screen_create_room_public_option_short_description">"Приєднатися може будь-хто."</string>
|
||||||
|
<string name="screen_create_room_public_option_title">"Публічний"</string>
|
||||||
<string name="screen_create_room_room_access_section_knocking_option_description">"Будь-хто може подати запит на приєднання, але адміністратор або модератор повинен схвалити запит."</string>
|
<string name="screen_create_room_room_access_section_knocking_option_description">"Будь-хто може подати запит на приєднання, але адміністратор або модератор повинен схвалити запит."</string>
|
||||||
<string name="screen_create_room_room_access_section_knocking_option_title">"Дозволити запит на приєднання"</string>
|
<string name="screen_create_room_room_access_section_knocking_option_title">"Дозволити запит на приєднання"</string>
|
||||||
|
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"Будь-хто з %1$s може приєднатися, але всі інші повинні подати запит на доступ."</string>
|
||||||
|
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Запит на приєднання"</string>
|
||||||
<string name="screen_create_room_room_access_section_private_option_description">"Приєднатися можуть лише запрошені особи."</string>
|
<string name="screen_create_room_room_access_section_private_option_description">"Приєднатися можуть лише запрошені особи."</string>
|
||||||
|
<string name="screen_create_room_room_access_section_private_option_title">"Приватний"</string>
|
||||||
<string name="screen_create_room_room_access_section_public_option_description">"Приєднатися може будь-хто."</string>
|
<string name="screen_create_room_room_access_section_public_option_description">"Приєднатися може будь-хто."</string>
|
||||||
|
<string name="screen_create_room_room_access_section_public_option_title">"Публічний"</string>
|
||||||
<string name="screen_create_room_room_access_section_restricted_option_description">"Приєднатися може будь-хто з %1$s."</string>
|
<string name="screen_create_room_room_access_section_restricted_option_description">"Приєднатися може будь-хто з %1$s."</string>
|
||||||
|
<string name="screen_create_room_room_access_section_restricted_option_title">"Стандартний"</string>
|
||||||
<string name="screen_create_room_room_access_section_title">"Хто має доступ"</string>
|
<string name="screen_create_room_room_access_section_title">"Хто має доступ"</string>
|
||||||
<string name="screen_create_room_room_address_section_footer">"Вам знадобиться адреса, щоб зробити її видимою в загальнодоступному каталозі."</string>
|
<string name="screen_create_room_room_address_section_footer">"Вам знадобиться адреса, щоб зробити її видимою в загальнодоступному каталозі."</string>
|
||||||
<string name="screen_create_room_room_address_section_title">"Адреса"</string>
|
<string name="screen_create_room_room_address_section_title">"Адреса"</string>
|
||||||
<string name="screen_create_room_room_visibility_section_title">"Видимість кімнати"</string>
|
<string name="screen_create_room_room_visibility_section_title">"Видимість кімнати"</string>
|
||||||
|
<string name="screen_create_room_space_selection_no_space_description">"(без пробілу)"</string>
|
||||||
|
<string name="screen_create_room_space_selection_no_space_option">"Не додавати до простору"</string>
|
||||||
<string name="screen_create_room_space_selection_no_space_title">"Головна"</string>
|
<string name="screen_create_room_space_selection_no_space_title">"Головна"</string>
|
||||||
<string name="screen_create_room_space_selection_sheet_title">"Додати до простору"</string>
|
<string name="screen_create_room_space_selection_sheet_title">"Додати до простору"</string>
|
||||||
<string name="screen_create_room_topic_label">"Тема (необов\'язково)"</string>
|
<string name="screen_create_room_topic_label">"Тема (необов\'язково)"</string>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="screen_deactivate_account_confirmation_dialog_content">"Παρακαλώ επιβεβαίωσε ότι θες να απενεργοποιήσεις τον λογαριασμό σου. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί."</string>
|
<string name="screen_deactivate_account_confirmation_dialog_content">"Επιβεβαιώστε ότι θέλετε να διαγράψετε τον λογαριασμό σας. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί."</string>
|
||||||
<string name="screen_deactivate_account_delete_all_messages">"Διαγραφή όλων των μηνυμάτων μου"</string>
|
<string name="screen_deactivate_account_delete_all_messages">"Διαγραφή όλων των μηνυμάτων μου"</string>
|
||||||
<string name="screen_deactivate_account_delete_all_messages_notice">"Προειδοποίηση: Οι μελλοντικοί χρήστες ενδέχεται να βλέπουν ελλιπείς συνομιλίες."</string>
|
<string name="screen_deactivate_account_delete_all_messages_notice">"Προειδοποίηση: Οι μελλοντικοί χρήστες ενδέχεται να βλέπουν ελλιπείς συνομιλίες."</string>
|
||||||
<string name="screen_deactivate_account_description">"Η απενεργοποίηση του λογαριασμού σας είναι %1$s, θα:"</string>
|
<string name="screen_deactivate_account_description">"Η διαγραφή του λογαριασμού σας είναι %1$s, και θα:"</string>
|
||||||
<string name="screen_deactivate_account_description_bold_part">"μη αναστρέψιμο"</string>
|
<string name="screen_deactivate_account_description_bold_part">"μη αναστρέψιμο"</string>
|
||||||
<string name="screen_deactivate_account_list_item_1">"%1$s τον λογαριασμό σου (δεν μπορείς να συνδεθείς ξανά και το αναγνωριστικό σου δεν μπορεί να επαναχρησιμοποιηθεί)."</string>
|
<string name="screen_deactivate_account_list_item_1">"%1$s τον λογαριασμό σου (δεν μπορείς να συνδεθείς ξανά και το αναγνωριστικό σου δεν μπορεί να επαναχρησιμοποιηθεί)."</string>
|
||||||
<string name="screen_deactivate_account_list_item_1_bold_part">"Μόνιμη απενεργοποίηση"</string>
|
<string name="screen_deactivate_account_list_item_1_bold_part">"Μόνιμη απενεργοποίηση"</string>
|
||||||
<string name="screen_deactivate_account_list_item_2">"Αποχώρησή σας από όλες τις αίθουσες συνομιλίας."</string>
|
<string name="screen_deactivate_account_list_item_2">"Αποχώρησή σας από όλες τις αίθουσες συνομιλίας."</string>
|
||||||
<string name="screen_deactivate_account_list_item_3">"Διαγράψει τα στοιχεία του λογαριασμού σου από τον διακομιστή ταυτότητάς μας."</string>
|
<string name="screen_deactivate_account_list_item_3">"Διαγράψει τα στοιχεία του λογαριασμού σου από τον διακομιστή ταυτότητάς μας."</string>
|
||||||
<string name="screen_deactivate_account_list_item_4">"Τα μηνύματά σου θα εξακολουθούν να είναι ορατά στους εγγεγραμμένους χρήστες, αλλά δεν θα είναι διαθέσιμα σε νέους ή μη εγγεγραμμένους χρήστες εάν επιλέξεις να τα διαγράψεις."</string>
|
<string name="screen_deactivate_account_list_item_4">"Τα μηνύματά σου θα εξακολουθούν να είναι ορατά στους εγγεγραμμένους χρήστες, αλλά δεν θα είναι διαθέσιμα σε νέους ή μη εγγεγραμμένους χρήστες εάν επιλέξεις να τα διαγράψεις."</string>
|
||||||
<string name="screen_deactivate_account_title">"Απενεργοποίηση λογαριασμού"</string>
|
<string name="screen_deactivate_account_title">"Διαγραφή λογαριασμού"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="screen_deactivate_account_confirmation_dialog_content">"Palun kinnita uuesti, et soovid eemaldada oma konto kasutusest"</string>
|
<string name="screen_deactivate_account_confirmation_dialog_content">"Palun kinnita uuesti, et soovid kustutada oma kasutajakonto. Seda tegevust ei saa tagasi pöörata."</string>
|
||||||
<string name="screen_deactivate_account_delete_all_messages">"Kustuta kõik minu sõnumid"</string>
|
<string name="screen_deactivate_account_delete_all_messages">"Kustuta kõik minu sõnumid"</string>
|
||||||
<string name="screen_deactivate_account_delete_all_messages_notice">"Hoiatus: tulevased kasutajad võivad näha poolikuid vestlusi."</string>
|
<string name="screen_deactivate_account_delete_all_messages_notice">"Hoiatus: tulevased kasutajad võivad näha poolikuid vestlusi."</string>
|
||||||
<string name="screen_deactivate_account_description">"Sinu konto kasutusest eemaldamine on %1$s ja sellega:"</string>
|
<string name="screen_deactivate_account_description">"Sinu konto kasutusest eemaldamine on %1$s ja sellega:"</string>
|
||||||
|
|
@ -10,5 +10,5 @@
|
||||||
<string name="screen_deactivate_account_list_item_2">"Sind logitakse välja kõikidest jututubadest."</string>
|
<string name="screen_deactivate_account_list_item_2">"Sind logitakse välja kõikidest jututubadest."</string>
|
||||||
<string name="screen_deactivate_account_list_item_3">"Kustutatakse sinu andmed meie isikutuvastusserverist."</string>
|
<string name="screen_deactivate_account_list_item_3">"Kustutatakse sinu andmed meie isikutuvastusserverist."</string>
|
||||||
<string name="screen_deactivate_account_list_item_4">"Sinu sõnumid on jätkuvalt nähtavad registreeritud kasutajatele, kuid kui otsustad sõnumid kustutada, siis nad nad pole nähtavad uutele ja registreerimata kasutajatele."</string>
|
<string name="screen_deactivate_account_list_item_4">"Sinu sõnumid on jätkuvalt nähtavad registreeritud kasutajatele, kuid kui otsustad sõnumid kustutada, siis nad nad pole nähtavad uutele ja registreerimata kasutajatele."</string>
|
||||||
<string name="screen_deactivate_account_title">"Eemalda konto kasutusest"</string>
|
<string name="screen_deactivate_account_title">"Kustuta kasutajakonto"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="screen_deactivate_account_confirmation_dialog_content">"Conferma di voler disattivare il tuo account. Questa azione è irreversibile."</string>
|
<string name="screen_deactivate_account_confirmation_dialog_content">"Conferma di voler eliminare il tuo account. Questa azione è irreversibile."</string>
|
||||||
<string name="screen_deactivate_account_delete_all_messages">"Elimina tutti i miei messaggi"</string>
|
<string name="screen_deactivate_account_delete_all_messages">"Elimina tutti i miei messaggi"</string>
|
||||||
<string name="screen_deactivate_account_delete_all_messages_notice">"Attenzione: gli utenti futuri potrebbero vedere conversazioni incomplete."</string>
|
<string name="screen_deactivate_account_delete_all_messages_notice">"Attenzione: gli utenti futuri potrebbero vedere conversazioni incomplete."</string>
|
||||||
<string name="screen_deactivate_account_description">"La disattivazione del tuo account è %1$s , quindi:"</string>
|
<string name="screen_deactivate_account_description">"L\'eliminazione del tuo account è %1$s, e comporterà:"</string>
|
||||||
<string name="screen_deactivate_account_description_bold_part">"irreversibile"</string>
|
<string name="screen_deactivate_account_description_bold_part">"irreversibile"</string>
|
||||||
<string name="screen_deactivate_account_list_item_1">"%1$s il tuo account (non puoi riaccedere e il tuo ID non può essere riutilizzato)."</string>
|
<string name="screen_deactivate_account_list_item_1">"%1$s il tuo account (non puoi riaccedere e il tuo ID non può essere riutilizzato)."</string>
|
||||||
<string name="screen_deactivate_account_list_item_1_bold_part">"Disattiva permanentemente"</string>
|
<string name="screen_deactivate_account_list_item_1_bold_part">"Disattiva permanentemente"</string>
|
||||||
<string name="screen_deactivate_account_list_item_2">"Ti rimuove da tutte le stanze di chat."</string>
|
<string name="screen_deactivate_account_list_item_2">"Ti rimuove da tutte le stanze di chat."</string>
|
||||||
<string name="screen_deactivate_account_list_item_3">"Elimina le informazioni del tuo account dal nostro server di identità."</string>
|
<string name="screen_deactivate_account_list_item_3">"Elimina le informazioni del tuo account dal nostro server di identità."</string>
|
||||||
<string name="screen_deactivate_account_list_item_4">"I tuoi messaggi saranno ancora visibili agli utenti registrati, ma non saranno disponibili per gli utenti nuovi o non registrati se decidi di eliminarli."</string>
|
<string name="screen_deactivate_account_list_item_4">"I tuoi messaggi saranno ancora visibili agli utenti registrati, ma non saranno disponibili per gli utenti nuovi o non registrati se decidi di eliminarli."</string>
|
||||||
<string name="screen_deactivate_account_title">"Disattivazione dell\'account"</string>
|
<string name="screen_deactivate_account_title">"Elimina account"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="screen_deactivate_account_confirmation_dialog_content">"Potwierdź dezaktywacje konta. Tej akcji nie można cofnąć."</string>
|
<string name="screen_deactivate_account_confirmation_dialog_content">"Potwierdź usunięcie konta. Tej akcji nie można cofnąć."</string>
|
||||||
<string name="screen_deactivate_account_delete_all_messages">"Usuń wszystkie moje wiadomości"</string>
|
<string name="screen_deactivate_account_delete_all_messages">"Usuń wszystkie moje wiadomości"</string>
|
||||||
<string name="screen_deactivate_account_delete_all_messages_notice">"Ostrzeżenie: Przyszli użytkownicy mogą zobaczyć niekompletne rozmowy."</string>
|
<string name="screen_deactivate_account_delete_all_messages_notice">"Ostrzeżenie: Przyszli użytkownicy mogą zobaczyć niekompletne rozmowy."</string>
|
||||||
<string name="screen_deactivate_account_description">"Dezaktywacja konta jest %1$s, zostanie:"</string>
|
<string name="screen_deactivate_account_description">"Usunięcie konta jest %1$s, co spowoduje:"</string>
|
||||||
<string name="screen_deactivate_account_description_bold_part">"nieodwracalna"</string>
|
<string name="screen_deactivate_account_description_bold_part">"nieodwracalna"</string>
|
||||||
<string name="screen_deactivate_account_list_item_1">"%1$s twoje konto (nie będziesz mógł się zalogować, a twoje ID przepadnie)."</string>
|
<string name="screen_deactivate_account_list_item_1">"%1$s twoje konto (nie będziesz mógł się zalogować, a twoje ID przepadnie)."</string>
|
||||||
<string name="screen_deactivate_account_list_item_1_bold_part">"Permanentnie wyłączy"</string>
|
<string name="screen_deactivate_account_list_item_1_bold_part">"Permanentnie wyłączy"</string>
|
||||||
<string name="screen_deactivate_account_list_item_2">"Usunie Ciebie ze wszystkich pokoi rozmów."</string>
|
<string name="screen_deactivate_account_list_item_2">"Usunie Ciebie ze wszystkich pokoi rozmów."</string>
|
||||||
<string name="screen_deactivate_account_list_item_3">"Usunięte wszystkie dane konta z naszego serwera tożsamości."</string>
|
<string name="screen_deactivate_account_list_item_3">"Usunięte wszystkie dane konta z naszego serwera tożsamości."</string>
|
||||||
<string name="screen_deactivate_account_list_item_4">"Twoje wiadomości wciąż będą widoczne dla zarejestrowanych użytkowników, ale nie będą dostępne dla nowych lub niezarejestrowanych użytkowników, jeśli je usuniesz."</string>
|
<string name="screen_deactivate_account_list_item_4">"Twoje wiadomości wciąż będą widoczne dla zarejestrowanych użytkowników, ale nie będą dostępne dla nowych lub niezarejestrowanych użytkowników, jeśli je usuniesz."</string>
|
||||||
<string name="screen_deactivate_account_title">"Dezaktywuj konto"</string>
|
<string name="screen_deactivate_account_title">"Usuń konto"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,5 @@
|
||||||
<string name="screen_deactivate_account_list_item_2">"Sizni barcha chat xonalaridan olib tashlash."</string>
|
<string name="screen_deactivate_account_list_item_2">"Sizni barcha chat xonalaridan olib tashlash."</string>
|
||||||
<string name="screen_deactivate_account_list_item_3">"Hisobingiz haqidagi axborotni identifikatsiya serverimizdan o‘chirib tashlang."</string>
|
<string name="screen_deactivate_account_list_item_3">"Hisobingiz haqidagi axborotni identifikatsiya serverimizdan o‘chirib tashlang."</string>
|
||||||
<string name="screen_deactivate_account_list_item_4">"Xabarlaringiz ro‘yxatdan o‘tgan foydalanuvchilarga ko‘rinadi, lekin ularni o‘chirishni tanlasangiz, yangi yoki ro‘yxatdan o‘tmagan foydalanuvchilarga ko‘rinmaydi."</string>
|
<string name="screen_deactivate_account_list_item_4">"Xabarlaringiz ro‘yxatdan o‘tgan foydalanuvchilarga ko‘rinadi, lekin ularni o‘chirishni tanlasangiz, yangi yoki ro‘yxatdan o‘tmagan foydalanuvchilarga ko‘rinmaydi."</string>
|
||||||
|
<string name="screen_deactivate_account_title">"Akkauntni o‘chirish"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="screen_identity_confirmation_cannot_confirm">"Nie możesz potwierdzić?"</string>
|
<string name="screen_identity_confirmation_cannot_confirm">"Nie możesz potwierdzić?"</string>
|
||||||
<string name="screen_identity_confirmation_create_new_recovery_key">"Utwórz nowy klucz przywracania"</string>
|
<string name="screen_identity_confirmation_create_new_recovery_key">"Utwórz nowy klucz przywracania"</string>
|
||||||
<string name="screen_identity_confirmation_subtitle">"Zweryfikuj to urządzenie, aby skonfigurować bezpieczne przesyłanie wiadomości."</string>
|
<string name="screen_identity_confirmation_subtitle">"Wybierz sposób weryfikacji, aby skonfigurować bezpieczne wiadomości."</string>
|
||||||
<string name="screen_identity_confirmation_title">"Potwierdź, że to Ty"</string>
|
<string name="screen_identity_confirmation_title">"Potwierdź swoją tożsamość cyfrową"</string>
|
||||||
<string name="screen_identity_confirmation_use_another_device">"Użyj innego urządzenia"</string>
|
<string name="screen_identity_confirmation_use_another_device">"Użyj innego urządzenia"</string>
|
||||||
<string name="screen_identity_confirmation_use_recovery_key">"Użyj klucza przywracania"</string>
|
<string name="screen_identity_confirmation_use_recovery_key">"Użyj klucza przywracania"</string>
|
||||||
<string name="screen_identity_confirmed_subtitle">"Teraz możesz bezpiecznie czytać i wysyłać wiadomości, każdy z kim czatujesz również może ufać temu urządzeniu."</string>
|
<string name="screen_identity_confirmed_subtitle">"Teraz możesz bezpiecznie czytać i wysyłać wiadomości, każdy z kim czatujesz również może ufać temu urządzeniu."</string>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
|
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
|
||||||
import io.element.android.libraries.matrix.api.room.CallIntentConsensus
|
import io.element.android.libraries.matrix.api.room.CallIntentConsensus
|
||||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||||
import io.element.android.libraries.matrix.api.room.isDm
|
|
||||||
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
|
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
|
||||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<string name="banner_new_sound_title">"Oleme sinu helisid värskendanud"</string>
|
<string name="banner_new_sound_title">"Oleme sinu helisid värskendanud"</string>
|
||||||
<string name="banner_set_up_recovery_content">"Loo uus taastevõti, mida saad kasutada oma krüptitud sõnumite ajaloo taastamisel olukorras, kus kaotad ligipääsu oma seadmetele."</string>
|
<string name="banner_set_up_recovery_content">"Loo uus taastevõti, mida saad kasutada oma krüptitud sõnumite ajaloo taastamisel olukorras, kus kaotad ligipääsu oma seadmetele."</string>
|
||||||
<string name="banner_set_up_recovery_submit">"Seadista andmete taastamine"</string>
|
<string name="banner_set_up_recovery_submit">"Seadista andmete taastamine"</string>
|
||||||
<string name="banner_set_up_recovery_title">"Seadista taastamine"</string>
|
<string name="banner_set_up_recovery_title">"Varunda oma vestlused"</string>
|
||||||
<string name="confirm_recovery_key_banner_message">"Säilitamaks ligipääsu vestluste ja krüptovõtmete varukoopiale, palun sisesta kinnituseks oma taastevõti."</string>
|
<string name="confirm_recovery_key_banner_message">"Säilitamaks ligipääsu vestluste ja krüptovõtmete varukoopiale, palun sisesta kinnituseks oma taastevõti."</string>
|
||||||
<string name="confirm_recovery_key_banner_primary_button_title">"Sisesta oma taastevõti"</string>
|
<string name="confirm_recovery_key_banner_primary_button_title">"Sisesta oma taastevõti"</string>
|
||||||
<string name="confirm_recovery_key_banner_secondary_button_title">"Kas unustasid oma taastevõtme?"</string>
|
<string name="confirm_recovery_key_banner_secondary_button_title">"Kas unustasid oma taastevõtme?"</string>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<string name="banner_battery_optimization_submit_android">"از کار انداختن بهینه سازی"</string>
|
<string name="banner_battery_optimization_submit_android">"از کار انداختن بهینه سازی"</string>
|
||||||
<string name="banner_battery_optimization_title_android">"آگاهیها نمیرسند؟"</string>
|
<string name="banner_battery_optimization_title_android">"آگاهیها نمیرسند؟"</string>
|
||||||
<string name="banner_set_up_recovery_content">"بازگردانی تاریخچهٔ پیامها و هویت رمزنگاشتهتان با کلید بازیابی در صورت از دست دادن همهٔ افزارههای موجودتان."</string>
|
<string name="banner_set_up_recovery_content">"بازگردانی تاریخچهٔ پیامها و هویت رمزنگاشتهتان با کلید بازیابی در صورت از دست دادن همهٔ افزارههای موجودتان."</string>
|
||||||
<string name="banner_set_up_recovery_submit">"برپایی بازیابی"</string>
|
<string name="banner_set_up_recovery_submit">"دریافت کلید بازیابی"</string>
|
||||||
<string name="banner_set_up_recovery_title">"برپایی بازیابی"</string>
|
<string name="banner_set_up_recovery_title">"برپایی بازیابی"</string>
|
||||||
<string name="confirm_recovery_key_banner_message">"کلید بازیابی خود را تأیید کنید تا دسترسی به حافظه کلیدها و تاریخچه پیامهایتان حفظ شود ."</string>
|
<string name="confirm_recovery_key_banner_message">"کلید بازیابی خود را تأیید کنید تا دسترسی به حافظه کلیدها و تاریخچه پیامهایتان حفظ شود ."</string>
|
||||||
<string name="confirm_recovery_key_banner_primary_button_title">"ورود کلید بازیابیتان"</string>
|
<string name="confirm_recovery_key_banner_primary_button_title">"ورود کلید بازیابیتان"</string>
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
<string name="screen_roomlist_empty_message">"آغاز با پیام دادن به کسی."</string>
|
<string name="screen_roomlist_empty_message">"آغاز با پیام دادن به کسی."</string>
|
||||||
<string name="screen_roomlist_empty_title">"هنوز گپی وجود ندارد."</string>
|
<string name="screen_roomlist_empty_title">"هنوز گپی وجود ندارد."</string>
|
||||||
<string name="screen_roomlist_filter_favourites">"علاقهمندیها"</string>
|
<string name="screen_roomlist_filter_favourites">"علاقهمندیها"</string>
|
||||||
|
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"میتوانید در تنظیمات چت، یک چت را به موارد دلخواه خود اضافه کنید. فعلاً میتوانید فیلترها را غیرفعال کنید تا چتهای دیگر خود را ببینید."</string>
|
||||||
<string name="screen_roomlist_filter_favourites_empty_state_title">"هنوز هیچ گپ مورد علاقهای ندارید"</string>
|
<string name="screen_roomlist_filter_favourites_empty_state_title">"هنوز هیچ گپ مورد علاقهای ندارید"</string>
|
||||||
<string name="screen_roomlist_filter_invites">"دعوتها"</string>
|
<string name="screen_roomlist_filter_invites">"دعوتها"</string>
|
||||||
<string name="screen_roomlist_filter_invites_empty_state_title">"هیچ دعوت منتظری ندارید."</string>
|
<string name="screen_roomlist_filter_invites_empty_state_title">"هیچ دعوت منتظری ندارید."</string>
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@
|
||||||
<string name="banner_battery_optimization_title_android">"Powiadomienia nie dochodzą?"</string>
|
<string name="banner_battery_optimization_title_android">"Powiadomienia nie dochodzą?"</string>
|
||||||
<string name="banner_new_sound_message">"Sygnał powiadomień został zaktualizowany — jest wyraźniejszy, szybszy i mniej uciążliwy."</string>
|
<string name="banner_new_sound_message">"Sygnał powiadomień został zaktualizowany — jest wyraźniejszy, szybszy i mniej uciążliwy."</string>
|
||||||
<string name="banner_new_sound_title">"Odświeżyliśmy Twoje dźwięki"</string>
|
<string name="banner_new_sound_title">"Odświeżyliśmy Twoje dźwięki"</string>
|
||||||
<string name="banner_set_up_recovery_content">"Wygeneruj nowy klucz przywracania, którego można użyć do przywrócenia historii wiadomości szyfrowanych w przypadku utraty dostępu do swoich urządzeń."</string>
|
<string name="banner_set_up_recovery_content">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić tę kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
|
||||||
<string name="banner_set_up_recovery_submit">"Skonfiguruj przywracanie"</string>
|
<string name="banner_set_up_recovery_submit">"Uzyskaj klucz przywracania"</string>
|
||||||
<string name="banner_set_up_recovery_title">"Skonfiguruj przywracanie"</string>
|
<string name="banner_set_up_recovery_title">"Utwórz kopię zapasową swoich czatów"</string>
|
||||||
<string name="confirm_recovery_key_banner_message">"Potwierdź klucz przywracania, aby zachować dostęp do magazynu kluczy i historii wiadomości."</string>
|
<string name="confirm_recovery_key_banner_message">"Potwierdź klucz przywracania, aby zachować dostęp do magazynu kluczy i historii wiadomości."</string>
|
||||||
<string name="confirm_recovery_key_banner_primary_button_title">"Wprowadź klucz przywracania"</string>
|
<string name="confirm_recovery_key_banner_primary_button_title">"Wprowadź klucz przywracania"</string>
|
||||||
<string name="confirm_recovery_key_banner_secondary_button_title">"Zapomniałeś klucza przywracania?"</string>
|
<string name="confirm_recovery_key_banner_secondary_button_title">"Zapomniałeś klucza przywracania?"</string>
|
||||||
|
|
@ -50,6 +50,7 @@ Nie masz żadnych nieprzeczytanych wiadomości!"</string>
|
||||||
<string name="screen_roomlist_mark_as_read">"Oznacz jako przeczytane"</string>
|
<string name="screen_roomlist_mark_as_read">"Oznacz jako przeczytane"</string>
|
||||||
<string name="screen_roomlist_mark_as_unread">"Oznacz jako nieprzeczytane"</string>
|
<string name="screen_roomlist_mark_as_unread">"Oznacz jako nieprzeczytane"</string>
|
||||||
<string name="screen_roomlist_tombstoned_room_description">"Ten pokój został ulepszony"</string>
|
<string name="screen_roomlist_tombstoned_room_description">"Ten pokój został ulepszony"</string>
|
||||||
|
<string name="screen_roomlist_your_spaces">"Twoje przestrzenie"</string>
|
||||||
<string name="session_verification_banner_message">"Wygląda na to, że używasz nowego urządzenia. Zweryfikuj się innym urządzeniem, aby uzyskać dostęp do zaszyfrowanych wiadomości."</string>
|
<string name="session_verification_banner_message">"Wygląda na to, że używasz nowego urządzenia. Zweryfikuj się innym urządzeniem, aby uzyskać dostęp do zaszyfrowanych wiadomości."</string>
|
||||||
<string name="session_verification_banner_title">"Potwierdź, że to Ty"</string>
|
<string name="session_verification_banner_title">"Potwierdź, że to Ty"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ package io.element.android.features.invite.api
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.room.RoomInfo
|
import io.element.android.libraries.matrix.api.room.RoomInfo
|
||||||
import io.element.android.libraries.matrix.api.room.isDm
|
|
||||||
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
|
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
|
||||||
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
|
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,8 @@
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="screen_invite_users_already_a_member">"Già membro"</string>
|
<string name="screen_invite_users_already_a_member">"Già membro"</string>
|
||||||
<string name="screen_invite_users_already_invited">"Già invitato"</string>
|
<string name="screen_invite_users_already_invited">"Già invitato"</string>
|
||||||
|
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Al momento non hai conversazioni con questi contatti. Conferma di invitarli in questa stanza prima di continuare."</string>
|
||||||
|
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Al momento non hai converszioni con questo contatto. Conferma di invitarlo in questa stanza prima di continuare."</string>
|
||||||
|
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Invita nuovi contatti in questa stanza?"</string>
|
||||||
|
<string name="screen_invite_users_confirm_dialog_title_one_user">"Invitare un nuovo contatto in questa stanza?"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,8 @@
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="screen_invite_users_already_a_member">"Jest już członkiem"</string>
|
<string name="screen_invite_users_already_a_member">"Jest już członkiem"</string>
|
||||||
<string name="screen_invite_users_already_invited">"Już zaproszony"</string>
|
<string name="screen_invite_users_already_invited">"Już zaproszony"</string>
|
||||||
|
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Obecnie nie prowadzisz żadnych czatów z tymi kontaktami. Potwierdź zaproszenie, zanim przejdziesz dalej."</string>
|
||||||
|
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Obecnie nie posiadasz żadnych czatów z tym kontaktem. Potwierdź zaproszenie, zanim przejdziesz dalej."</string>
|
||||||
|
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Zaprosić nowe kontakty do tego pokoju?"</string>
|
||||||
|
<string name="screen_invite_users_confirm_dialog_title_one_user">"Zaprosić nowy kontakt do tego pokoju?"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,8 @@
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="screen_invite_users_already_a_member">"Уже учасник"</string>
|
<string name="screen_invite_users_already_a_member">"Уже учасник"</string>
|
||||||
<string name="screen_invite_users_already_invited">"Уже запрошені"</string>
|
<string name="screen_invite_users_already_invited">"Уже запрошені"</string>
|
||||||
|
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Наразі у вас немає чатів із цими контактами. Підтвердьте запрошення їх до цієї кімнати, перш ніж продовжувати."</string>
|
||||||
|
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Наразі у вас немає чатів із цим контактом. Підтвердьте запрошення до цієї кімнати, перш ніж продовжувати."</string>
|
||||||
|
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Запросити нових контактів до цієї кімнати?"</string>
|
||||||
|
<string name="screen_invite_users_confirm_dialog_title_one_user">"Запросити нового контакта до цієї кімнати?"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,7 @@ internal class DefaultInvitePeoplePresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - suggestions are loaded from recent direct rooms`() = runTest {
|
fun `present - suggestions are loaded from recent DM rooms`() = runTest {
|
||||||
val dmRoomId = RoomId("!dm_room:server.org")
|
val dmRoomId = RoomId("!dm_room:server.org")
|
||||||
val otherUserId = UserId("@frank:server.org")
|
val otherUserId = UserId("@frank:server.org")
|
||||||
val matrixClient = FakeMatrixClient(sessionId = A_USER_ID).apply {
|
val matrixClient = FakeMatrixClient(sessionId = A_USER_ID).apply {
|
||||||
|
|
@ -554,7 +554,7 @@ internal class DefaultInvitePeoplePresenterTest {
|
||||||
roomId = dmRoomId,
|
roomId = dmRoomId,
|
||||||
initialRoomInfo = aRoomInfo(
|
initialRoomInfo = aRoomInfo(
|
||||||
id = dmRoomId,
|
id = dmRoomId,
|
||||||
isDirect = true,
|
isDm = true,
|
||||||
activeMembersCount = 2,
|
activeMembersCount = 2,
|
||||||
currentUserMembership = CurrentUserMembership.JOINED,
|
currentUserMembership = CurrentUserMembership.JOINED,
|
||||||
),
|
),
|
||||||
|
|
@ -591,7 +591,7 @@ internal class DefaultInvitePeoplePresenterTest {
|
||||||
roomId = dmRoomId,
|
roomId = dmRoomId,
|
||||||
initialRoomInfo = aRoomInfo(
|
initialRoomInfo = aRoomInfo(
|
||||||
id = dmRoomId,
|
id = dmRoomId,
|
||||||
isDirect = true,
|
isDm = true,
|
||||||
activeMembersCount = 2,
|
activeMembersCount = 2,
|
||||||
currentUserMembership = CurrentUserMembership.JOINED,
|
currentUserMembership = CurrentUserMembership.JOINED,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||||
import io.element.android.libraries.matrix.api.room.RoomInfo
|
import io.element.android.libraries.matrix.api.room.RoomInfo
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipDetails
|
import io.element.android.libraries.matrix.api.room.RoomMembershipDetails
|
||||||
import io.element.android.libraries.matrix.api.room.RoomType
|
import io.element.android.libraries.matrix.api.room.RoomType
|
||||||
import io.element.android.libraries.matrix.api.room.isDm
|
|
||||||
import io.element.android.libraries.matrix.api.room.join.JoinRoom
|
import io.element.android.libraries.matrix.api.room.join.JoinRoom
|
||||||
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||||
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
|
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
<string name="screen_join_room_fail_reason">"Вам потрібно отримати запрошення, щоб приєднатися, інакше доступ може бути обмежений."</string>
|
<string name="screen_join_room_fail_reason">"Вам потрібно отримати запрошення, щоб приєднатися, інакше доступ може бути обмежений."</string>
|
||||||
<string name="screen_join_room_forget_action">"Забути"</string>
|
<string name="screen_join_room_forget_action">"Забути"</string>
|
||||||
<string name="screen_join_room_invite_required_message">"Вам потрібне запрошення, щоб приєднатися"</string>
|
<string name="screen_join_room_invite_required_message">"Вам потрібне запрошення, щоб приєднатися"</string>
|
||||||
|
<string name="screen_join_room_invited_by">"Запрошено користувачем"</string>
|
||||||
<string name="screen_join_room_join_action">"Доєднатися"</string>
|
<string name="screen_join_room_join_action">"Доєднатися"</string>
|
||||||
<string name="screen_join_room_join_restricted_message">"Можливо, вам знадобиться отримати запрошення або стати учасником простору, щоб приєднатися."</string>
|
<string name="screen_join_room_join_restricted_message">"Можливо, вам знадобиться отримати запрошення або стати учасником простору, щоб приєднатися."</string>
|
||||||
<string name="screen_join_room_knock_action">"Постукати, щоб приєднатися"</string>
|
<string name="screen_join_room_knock_action">"Постукати, щоб приєднатися"</string>
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,7 @@ dependencies {
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.featureflag.api)
|
|
||||||
|
|
||||||
testCommonDependencies(libs, true)
|
testCommonDependencies(libs, true)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.featureflag.test)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,6 @@ import dev.zacsweers.metro.SingleIn
|
||||||
import io.element.android.features.knockrequests.api.KnockRequestPermissions
|
import io.element.android.features.knockrequests.api.KnockRequestPermissions
|
||||||
import io.element.android.features.knockrequests.api.knockRequestPermissions
|
import io.element.android.features.knockrequests.api.knockRequestPermissions
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsFlow
|
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsFlow
|
||||||
|
|
||||||
|
|
@ -25,14 +23,13 @@ import io.element.android.libraries.matrix.api.room.powerlevels.permissionsFlow
|
||||||
object KnockRequestsModule {
|
object KnockRequestsModule {
|
||||||
@Provides
|
@Provides
|
||||||
@SingleIn(RoomScope::class)
|
@SingleIn(RoomScope::class)
|
||||||
fun knockRequestsService(room: JoinedRoom, featureFlagService: FeatureFlagService): KnockRequestsService {
|
fun knockRequestsService(room: JoinedRoom): KnockRequestsService {
|
||||||
return KnockRequestsService(
|
return KnockRequestsService(
|
||||||
knockRequestsFlow = room.knockRequestsFlow,
|
knockRequestsFlow = room.knockRequestsFlow,
|
||||||
permissionsFlow = room.permissionsFlow(KnockRequestPermissions.DEFAULT) { perms ->
|
permissionsFlow = room.permissionsFlow(KnockRequestPermissions.DEFAULT) { perms ->
|
||||||
perms.knockRequestPermissions()
|
perms.knockRequestPermissions()
|
||||||
},
|
},
|
||||||
isKnockFeatureEnabledFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock),
|
coroutineScope = room.roomCoroutineScope,
|
||||||
coroutineScope = room.roomCoroutineScope
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import io.element.android.features.knockrequests.api.KnockRequestPermissions
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
|
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
|
@ -28,26 +27,20 @@ import kotlinx.coroutines.supervisorScope
|
||||||
class KnockRequestsService(
|
class KnockRequestsService(
|
||||||
knockRequestsFlow: Flow<List<KnockRequest>>,
|
knockRequestsFlow: Flow<List<KnockRequest>>,
|
||||||
permissionsFlow: Flow<KnockRequestPermissions>,
|
permissionsFlow: Flow<KnockRequestPermissions>,
|
||||||
isKnockFeatureEnabledFlow: Flow<Boolean>,
|
|
||||||
coroutineScope: CoroutineScope,
|
coroutineScope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
// Keep track of the knock requests that have been handled, so we don't have to wait for sync to remove them.
|
// Keep track of the knock requests that have been handled, so we don't have to wait for sync to remove them.
|
||||||
private val handledKnockRequestIds = MutableStateFlow<Set<EventId>>(emptySet())
|
private val handledKnockRequestIds = MutableStateFlow<Set<EventId>>(emptySet())
|
||||||
|
|
||||||
val knockRequestsFlow = combine(
|
val knockRequestsFlow = combine(
|
||||||
isKnockFeatureEnabledFlow,
|
|
||||||
knockRequestsFlow,
|
knockRequestsFlow,
|
||||||
handledKnockRequestIds,
|
handledKnockRequestIds,
|
||||||
) { isKnockEnabled, knockRequests, handledKnockIds ->
|
) { knockRequests, handledKnockIds ->
|
||||||
if (!isKnockEnabled) {
|
val presentableKnockRequests = knockRequests
|
||||||
AsyncData.Success(persistentListOf())
|
.filter { it.eventId !in handledKnockIds }
|
||||||
} else {
|
.map { inner -> KnockRequestWrapper(inner) }
|
||||||
val presentableKnockRequests = knockRequests
|
.toImmutableList()
|
||||||
.filter { it.eventId !in handledKnockIds }
|
AsyncData.Success(presentableKnockRequests)
|
||||||
.map { inner -> KnockRequestWrapper(inner) }
|
|
||||||
.toImmutableList()
|
|
||||||
AsyncData.Success(presentableKnockRequests)
|
|
||||||
}
|
|
||||||
}.stateIn(coroutineScope, SharingStarted.Lazily, AsyncData.Loading())
|
}.stateIn(coroutineScope, SharingStarted.Lazily, AsyncData.Loading())
|
||||||
|
|
||||||
val permissionsFlow = permissionsFlow.stateIn(
|
val permissionsFlow = permissionsFlow.stateIn(
|
||||||
|
|
|
||||||
|
|
@ -28,18 +28,6 @@ import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class) class KnockRequestsBannerPresenterTest {
|
@OptIn(ExperimentalCoroutinesApi::class) class KnockRequestsBannerPresenterTest {
|
||||||
@Test
|
|
||||||
fun `present - when feature is disabled then the banner should be hidden`() = runTest {
|
|
||||||
val knockRequests = flowOf(listOf(FakeKnockRequest()))
|
|
||||||
val presenter = createKnockRequestsBannerPresenter(isFeatureEnabled = false, knockRequestsFlow = knockRequests)
|
|
||||||
presenter.test {
|
|
||||||
skipItems(1)
|
|
||||||
awaitItem().also { state ->
|
|
||||||
assertThat(state.isVisible).isFalse()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - when empty knock request list then the banner should be hidden`() = runTest {
|
fun `present - when empty knock request list then the banner should be hidden`() = runTest {
|
||||||
val knockRequests = flowOf(emptyList<KnockRequest>())
|
val knockRequests = flowOf(emptyList<KnockRequest>())
|
||||||
|
|
@ -229,12 +217,10 @@ import org.junit.Test
|
||||||
private fun TestScope.createKnockRequestsBannerPresenter(
|
private fun TestScope.createKnockRequestsBannerPresenter(
|
||||||
knockRequestsFlow: Flow<List<KnockRequest>> = flowOf(emptyList()),
|
knockRequestsFlow: Flow<List<KnockRequest>> = flowOf(emptyList()),
|
||||||
canAcceptKnockRequests: Boolean = true,
|
canAcceptKnockRequests: Boolean = true,
|
||||||
isFeatureEnabled: Boolean = true,
|
|
||||||
): KnockRequestsBannerPresenter {
|
): KnockRequestsBannerPresenter {
|
||||||
val knockRequestsService = KnockRequestsService(
|
val knockRequestsService = KnockRequestsService(
|
||||||
knockRequestsFlow = knockRequestsFlow,
|
knockRequestsFlow = knockRequestsFlow,
|
||||||
coroutineScope = backgroundScope,
|
coroutineScope = backgroundScope,
|
||||||
isKnockFeatureEnabledFlow = flowOf(isFeatureEnabled),
|
|
||||||
permissionsFlow = flowOf(KnockRequestPermissions(canAcceptKnockRequests, canAcceptKnockRequests, canAcceptKnockRequests)),
|
permissionsFlow = flowOf(KnockRequestPermissions(canAcceptKnockRequests, canAcceptKnockRequests, canAcceptKnockRequests)),
|
||||||
)
|
)
|
||||||
return KnockRequestsBannerPresenter(
|
return KnockRequestsBannerPresenter(
|
||||||
|
|
|
||||||
|
|
@ -298,7 +298,6 @@ internal fun TestScope.createKnockRequestsListPresenter(
|
||||||
val knockRequestsService = KnockRequestsService(
|
val knockRequestsService = KnockRequestsService(
|
||||||
knockRequestsFlow = knockRequestsFlow,
|
knockRequestsFlow = knockRequestsFlow,
|
||||||
coroutineScope = backgroundScope,
|
coroutineScope = backgroundScope,
|
||||||
isKnockFeatureEnabledFlow = flowOf(true),
|
|
||||||
permissionsFlow = flowOf(KnockRequestPermissions(canAccept, canDecline, canBan)),
|
permissionsFlow = flowOf(KnockRequestPermissions(canAccept, canDecline, canBan)),
|
||||||
)
|
)
|
||||||
return KnockRequestsListPresenter(knockRequestsService = knockRequestsService)
|
return KnockRequestsListPresenter(knockRequestsService = knockRequestsService)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="leave_conversation_alert_subtitle">"آیا مطمئنید که میخواهید این مکالمه را ترک کنید؟ این مکالمه عمومی نیست و بدون دعوت نمیتوانید دوباره به آن بپیوندید."</string>
|
||||||
<string name="leave_room_alert_empty_subtitle">"مطمئنید که میخواهید این اتاق را ترک کنید؟ تنها فرد اینجا هستید. در صورت ترک، هیچکسی از جمله خودتان در آینده نخواهد توانست به آن بپیوندد."</string>
|
<string name="leave_room_alert_empty_subtitle">"مطمئنید که میخواهید این اتاق را ترک کنید؟ تنها فرد اینجا هستید. در صورت ترک، هیچکسی از جمله خودتان در آینده نخواهد توانست به آن بپیوندد."</string>
|
||||||
<string name="leave_room_alert_private_subtitle">"مطمئنید که میخواهید این اتاق را ترک کنید؟ این اتاق عمومی نبوده قادر نخواهید بود بدون دعوت دوباره بپیوندید."</string>
|
<string name="leave_room_alert_private_subtitle">"مطمئنید که میخواهید این اتاق را ترک کنید؟ این اتاق عمومی نبوده قادر نخواهید بود بدون دعوت دوباره بپیوندید."</string>
|
||||||
<string name="leave_room_alert_select_new_owner_action">"گزینش مالکان"</string>
|
<string name="leave_room_alert_select_new_owner_action">"گزینش مالکان"</string>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.room.isDm
|
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole
|
import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole
|
||||||
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
|
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ class LeaveBaseRoomPresenterTest {
|
||||||
givenGetRoomResult(
|
givenGetRoomResult(
|
||||||
roomId = A_ROOM_ID,
|
roomId = A_ROOM_ID,
|
||||||
result = FakeBaseRoom().apply {
|
result = FakeBaseRoom().apply {
|
||||||
givenRoomInfo(aRoomInfo(isDirect = true, activeMembersCount = 2))
|
givenRoomInfo(aRoomInfo(isDm = true, activeMembersCount = 2))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,33 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="screen_link_new_device_desktop_scanning_title">"Skanuj kod QR"</string>
|
<string name="screen_link_new_device_desktop_scanning_title">"Skanuj kod QR"</string>
|
||||||
|
<string name="screen_link_new_device_desktop_step1">"Otwórz %1$s na laptopie lub komputerze stacjonarnym"</string>
|
||||||
<string name="screen_link_new_device_desktop_step3">"Zeskanuj kod QR za pomocą tego urządzenia"</string>
|
<string name="screen_link_new_device_desktop_step3">"Zeskanuj kod QR za pomocą tego urządzenia"</string>
|
||||||
<string name="screen_link_new_device_desktop_submit">"Gotowy do skanowania"</string>
|
<string name="screen_link_new_device_desktop_submit">"Gotowy do skanowania"</string>
|
||||||
|
<string name="screen_link_new_device_desktop_title">"Otwórz %1$s na komputerze stacjonarnym, aby uzyskać kod QR"</string>
|
||||||
|
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Liczby nie pasują do siebie"</string>
|
||||||
|
<string name="screen_link_new_device_enter_number_notice">"Wprowadź 2-cyfrowy kod"</string>
|
||||||
|
<string name="screen_link_new_device_enter_number_subtitle">"Pozwoli to sprawdzić, czy połączenie z drugim urządzeniem jest bezpieczne."</string>
|
||||||
|
<string name="screen_link_new_device_enter_number_title">"Wprowadź numer wyświetlany na drugim urządzeniu"</string>
|
||||||
<string name="screen_link_new_device_error_app_not_supported_subtitle">"Twój dostawca konta nie obsługuje %1$s."</string>
|
<string name="screen_link_new_device_error_app_not_supported_subtitle">"Twój dostawca konta nie obsługuje %1$s."</string>
|
||||||
<string name="screen_link_new_device_error_app_not_supported_title">"%1$s nie jest wspierany"</string>
|
<string name="screen_link_new_device_error_app_not_supported_title">"%1$s nie jest wspierany"</string>
|
||||||
|
<string name="screen_link_new_device_error_not_supported_subtitle">"Twój dostawca konta nie wspiera logowania na nowym urządzeniu za pomocą kodu QR."</string>
|
||||||
<string name="screen_link_new_device_error_not_supported_title">"Kod QR nie jest wspierany"</string>
|
<string name="screen_link_new_device_error_not_supported_title">"Kod QR nie jest wspierany"</string>
|
||||||
<string name="screen_link_new_device_error_request_cancelled_subtitle">"Logowanie zostało anulowane na drugim urządzeniu."</string>
|
<string name="screen_link_new_device_error_request_cancelled_subtitle">"Logowanie zostało anulowane na drugim urządzeniu."</string>
|
||||||
<string name="screen_link_new_device_error_request_cancelled_title">"Prośba o logowanie została anulowana"</string>
|
<string name="screen_link_new_device_error_request_cancelled_title">"Prośba o logowanie została anulowana"</string>
|
||||||
<string name="screen_link_new_device_error_request_timeout_subtitle">"Logowanie wygasło. Spróbuj ponownie."</string>
|
<string name="screen_link_new_device_error_request_timeout_subtitle">"Logowanie wygasło. Spróbuj ponownie."</string>
|
||||||
<string name="screen_link_new_device_error_request_timeout_title">"Logowanie nie zostało ukończone na czas"</string>
|
<string name="screen_link_new_device_error_request_timeout_title">"Logowanie nie zostało ukończone na czas"</string>
|
||||||
|
<string name="screen_link_new_device_mobile_step1">"Otwórz %1$s na drugim urządzeniu"</string>
|
||||||
<string name="screen_link_new_device_mobile_step2">"Wybierz %1$s"</string>
|
<string name="screen_link_new_device_mobile_step2">"Wybierz %1$s"</string>
|
||||||
|
<string name="screen_link_new_device_mobile_step2_action">"“Zaloguj się za pomocą kodu QR”"</string>
|
||||||
|
<string name="screen_link_new_device_mobile_step3">"Zeskanuj kod QR pokazany tutaj za pomocą drugiego urządzenia"</string>
|
||||||
|
<string name="screen_link_new_device_mobile_title">"Otwórz %1$s na drugim urządzeniu"</string>
|
||||||
|
<string name="screen_link_new_device_root_desktop_computer">"Komputer stacjonarny"</string>
|
||||||
|
<string name="screen_link_new_device_root_loading_qr_code">"Ładowanie kodu QR…"</string>
|
||||||
|
<string name="screen_link_new_device_root_mobile_device">"Urządzenie mobilne"</string>
|
||||||
|
<string name="screen_link_new_device_root_title">"Jakiego typu urządzenie chcesz powiązać?"</string>
|
||||||
|
<string name="screen_link_new_device_wrong_number_subtitle">"Spróbuj ponownie i upewnij się, że 2-cyfrowy kod został wpisany prawidłowo. Jeśli liczby wciąż się nie zgadzają, skontaktuj się ze swoim dostawcą konta."</string>
|
||||||
|
<string name="screen_link_new_device_wrong_number_title">"Liczby nie pasują do siebie"</string>
|
||||||
<string name="screen_qr_code_login_connection_note_secure_state_description">"Nie udało się nawiązać bezpiecznego połączenia z nowym urządzeniem. Twoje istniejące urządzenia są nadal bezpieczne i nie musisz się o nie martwić."</string>
|
<string name="screen_qr_code_login_connection_note_secure_state_description">"Nie udało się nawiązać bezpiecznego połączenia z nowym urządzeniem. Twoje istniejące urządzenia są nadal bezpieczne i nie musisz się o nie martwić."</string>
|
||||||
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Co teraz?"</string>
|
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Co teraz?"</string>
|
||||||
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Spróbuj zalogować się ponownie za pomocą kodu QR, jeśli byłby to problem z siecią"</string>
|
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Spróbuj zalogować się ponownie za pomocą kodu QR, jeśli byłby to problem z siecią"</string>
|
||||||
|
|
@ -23,6 +40,8 @@
|
||||||
<string name="screen_qr_code_login_error_cancelled_title">"Prośba o logowanie została anulowana"</string>
|
<string name="screen_qr_code_login_error_cancelled_title">"Prośba o logowanie została anulowana"</string>
|
||||||
<string name="screen_qr_code_login_error_declined_subtitle">"Logowanie zostało odrzucone na drugim urządzeniu."</string>
|
<string name="screen_qr_code_login_error_declined_subtitle">"Logowanie zostało odrzucone na drugim urządzeniu."</string>
|
||||||
<string name="screen_qr_code_login_error_declined_title">"Logowanie odrzucone"</string>
|
<string name="screen_qr_code_login_error_declined_title">"Logowanie odrzucone"</string>
|
||||||
|
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Nie musisz już robić nic więcej."</string>
|
||||||
|
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Twoje drugie urządzenie jest już zalogowane"</string>
|
||||||
<string name="screen_qr_code_login_error_expired_subtitle">"Logowanie wygasło. Spróbuj ponownie."</string>
|
<string name="screen_qr_code_login_error_expired_subtitle">"Logowanie wygasło. Spróbuj ponownie."</string>
|
||||||
<string name="screen_qr_code_login_error_expired_title">"Logowanie nie zostało ukończone na czas"</string>
|
<string name="screen_qr_code_login_error_expired_title">"Logowanie nie zostało ukończone na czas"</string>
|
||||||
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Twoje drugie urządzenie nie wspiera logowania się do %s za pomocą kodu QR.
|
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Twoje drugie urządzenie nie wspiera logowania się do %s za pomocą kodu QR.
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
<string name="screen_link_new_device_root_loading_qr_code">"Завантаження QR-коду…"</string>
|
<string name="screen_link_new_device_root_loading_qr_code">"Завантаження QR-коду…"</string>
|
||||||
<string name="screen_link_new_device_root_mobile_device">"Мобільний пристрій"</string>
|
<string name="screen_link_new_device_root_mobile_device">"Мобільний пристрій"</string>
|
||||||
<string name="screen_link_new_device_root_title">"Який тип пристрою ви хочете під\'єднати?"</string>
|
<string name="screen_link_new_device_root_title">"Який тип пристрою ви хочете під\'єднати?"</string>
|
||||||
|
<string name="screen_link_new_device_wrong_number_subtitle">"Спробуйте ще раз і переконайтеся, що ви правильно ввели двозначний код. Якщо цифри все одно не збігаються, зверніться до свого провайдера облікового запису."</string>
|
||||||
<string name="screen_link_new_device_wrong_number_title">"Цифри не збігаються"</string>
|
<string name="screen_link_new_device_wrong_number_title">"Цифри не збігаються"</string>
|
||||||
<string name="screen_qr_code_login_connection_note_secure_state_description">"Не вдалося встановити безпечне з\'єднання з новим пристроєм. Ваші наявні пристрої досі в безпеці, і вам не потрібно про них турбуватися."</string>
|
<string name="screen_qr_code_login_connection_note_secure_state_description">"Не вдалося встановити безпечне з\'єднання з новим пристроєм. Ваші наявні пристрої досі в безпеці, і вам не потрібно про них турбуватися."</string>
|
||||||
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Що тепер?"</string>
|
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Що тепер?"</string>
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ dependencies {
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.datetime)
|
||||||
|
|
||||||
testCommonDependencies(libs)
|
testCommonDependencies(libs)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
/*
|
||||||
|
* 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.api
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
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.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Button
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.ButtonSize
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LiveLocationSharingBanner(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onStopClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(ElementTheme.colors.bgCanvasDefault)
|
||||||
|
.drawBannerBorder(ElementTheme.colors.separatorPrimary)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = CompoundIcons.LocationPinSolid(),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = ElementTheme.colors.iconAccentPrimary,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(CommonStrings.screen_room_live_location_banner),
|
||||||
|
style = ElementTheme.typography.fontBodyMdMedium,
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
text = stringResource(CommonStrings.action_stop),
|
||||||
|
onClick = onStopClick,
|
||||||
|
destructive = true,
|
||||||
|
size = ButtonSize.Small,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Modifier.drawBannerBorder(borderColor: Color): Modifier = drawBehind {
|
||||||
|
val strokeWidth = 1.dp.toPx()
|
||||||
|
val bottomY = size.height - strokeWidth / 2
|
||||||
|
drawLine(
|
||||||
|
color = borderColor,
|
||||||
|
start = Offset(0f, strokeWidth / 2),
|
||||||
|
end = Offset(size.width, strokeWidth / 2),
|
||||||
|
strokeWidth = strokeWidth,
|
||||||
|
)
|
||||||
|
drawLine(
|
||||||
|
color = borderColor,
|
||||||
|
start = Offset(0f, bottomY),
|
||||||
|
end = Offset(size.width, bottomY),
|
||||||
|
strokeWidth = strokeWidth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun LiveLocationSharingBannerPreview() = ElementPreview {
|
||||||
|
LiveLocationSharingBanner(
|
||||||
|
onClick = {},
|
||||||
|
onStopClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
* 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.api.live
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.coroutine.mapState
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
interface ActiveLiveLocationShareManager {
|
||||||
|
/** All rooms currently sharing live location on this device. */
|
||||||
|
val sharingRoomIds: StateFlow<Set<RoomId>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the manager.
|
||||||
|
* This will restart or stop current location sharing and set the listener on the SDK
|
||||||
|
* and the session manager.
|
||||||
|
*/
|
||||||
|
suspend fun setup()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts live location sharing in the given room.
|
||||||
|
* Calls room.startLiveLocationShare() on the SDK, registers the share,
|
||||||
|
* and starts the foreground GPS service if not already running.
|
||||||
|
*/
|
||||||
|
suspend fun startShare(roomId: RoomId, duration: Duration): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops live location sharing in the given room.
|
||||||
|
* Calls room.stopLiveLocationShare() on the SDK, removes the share,
|
||||||
|
* and stops the foreground service if no shares remain.
|
||||||
|
*/
|
||||||
|
suspend fun stopShare(roomId: RoomId): Result<Unit>
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ActiveLiveLocationShareManager.isCurrentlySharing(roomId: RoomId): StateFlow<Boolean> {
|
||||||
|
return sharingRoomIds.mapState { roomId in it }
|
||||||
|
}
|
||||||
|
|
@ -37,10 +37,16 @@ dependencies {
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
|
implementation(projects.services.appnavstate.api)
|
||||||
implementation(libs.accompanist.permission)
|
implementation(libs.accompanist.permission)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.featureflag.api)
|
implementation(projects.libraries.featureflag.api)
|
||||||
implementation(projects.libraries.dateformatter.api)
|
implementation(projects.libraries.dateformatter.api)
|
||||||
|
implementation(projects.libraries.preferences.api)
|
||||||
|
implementation(projects.libraries.push.api)
|
||||||
|
implementation(projects.libraries.sessionStorage.api)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(libs.datetime)
|
||||||
|
|
||||||
testCommonDependencies(libs, true)
|
testCommonDependencies(libs, true)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
|
|
@ -50,4 +56,7 @@ dependencies {
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.features.messages.test)
|
testImplementation(projects.features.messages.test)
|
||||||
testImplementation(projects.libraries.featureflag.test)
|
testImplementation(projects.libraries.featureflag.test)
|
||||||
|
testImplementation(projects.libraries.preferences.test)
|
||||||
|
testImplementation(projects.libraries.sessionStorage.test)
|
||||||
|
testImplementation(projects.features.location.test)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,14 @@
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<service
|
||||||
|
android:name=".live.service.LiveLocationSharingService"
|
||||||
|
android:foregroundServiceType="location"
|
||||||
|
android:exported="false" />
|
||||||
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,16 @@ sealed interface LocationConstraintsCheck {
|
||||||
data object PermissionRationale : LocationConstraintsCheck
|
data object PermissionRationale : LocationConstraintsCheck
|
||||||
data object PermissionDenied : LocationConstraintsCheck
|
data object PermissionDenied : LocationConstraintsCheck
|
||||||
data object LocationServiceDisabled : LocationConstraintsCheck
|
data object LocationServiceDisabled : LocationConstraintsCheck
|
||||||
|
data object NotEnoughPowerLevel : LocationConstraintsCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
fun checkLocationConstraints(
|
fun checkLocationConstraints(
|
||||||
permissionsState: PermissionsState,
|
permissionsState: PermissionsState,
|
||||||
locationActions: LocationActions,
|
locationActions: LocationActions,
|
||||||
|
sendLiveLocationPermissions: SendLiveLocationPermissions,
|
||||||
): LocationConstraintsCheck {
|
): LocationConstraintsCheck {
|
||||||
return when {
|
return when {
|
||||||
|
!sendLiveLocationPermissions.hasAll -> LocationConstraintsCheck.NotEnoughPowerLevel
|
||||||
permissionsState.isAnyGranted -> {
|
permissionsState.isAnyGranted -> {
|
||||||
if (locationActions.isLocationEnabled()) {
|
if (locationActions.isLocationEnabled()) {
|
||||||
LocationConstraintsCheck.Success
|
LocationConstraintsCheck.Success
|
||||||
|
|
@ -41,5 +44,6 @@ fun LocationConstraintsCheck.toDialogState(): LocationConstraintsDialogState {
|
||||||
LocationConstraintsCheck.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale
|
LocationConstraintsCheck.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale
|
||||||
LocationConstraintsCheck.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied
|
LocationConstraintsCheck.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied
|
||||||
LocationConstraintsCheck.LocationServiceDisabled -> LocationConstraintsDialogState.LocationServiceDisabled
|
LocationConstraintsCheck.LocationServiceDisabled -> LocationConstraintsDialogState.LocationServiceDisabled
|
||||||
|
LocationConstraintsCheck.NotEnoughPowerLevel -> LocationConstraintsDialogState.NotEnoughPowerLevel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.common
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||||
|
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||||
|
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permissions to send beacon and beacon_info events in the room.
|
||||||
|
*/
|
||||||
|
data class SendLiveLocationPermissions(
|
||||||
|
val canSendBeacon: Boolean,
|
||||||
|
val canSendBeaconInfo: Boolean,
|
||||||
|
) {
|
||||||
|
val hasAll = canSendBeaconInfo && canSendBeacon
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val DEFAULT = SendLiveLocationPermissions(canSendBeacon = false, canSendBeaconInfo = false)
|
||||||
|
val GRANTED = SendLiveLocationPermissions(canSendBeacon = true, canSendBeaconInfo = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun RoomPermissions.sendLiveLocationPermissions(): SendLiveLocationPermissions {
|
||||||
|
return SendLiveLocationPermissions(
|
||||||
|
canSendBeaconInfo = canOwnUserSendState(StateEventType.BeaconInfo),
|
||||||
|
canSendBeacon = canOwnUserSendMessage(MessageEventType.Beacon),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,8 @@ package io.element.android.features.location.impl.common.ui
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import io.element.android.features.location.impl.R
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
|
@ -42,6 +44,10 @@ fun LocationConstraintsDialog(
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
submitText = stringResource(CommonStrings.action_continue),
|
submitText = stringResource(CommonStrings.action_continue),
|
||||||
)
|
)
|
||||||
|
LocationConstraintsDialogState.NotEnoughPowerLevel -> AlertDialog(
|
||||||
|
content = stringResource(R.string.screen_share_location_live_location_missing_permissions),
|
||||||
|
onDismiss = onDismiss
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,4 +57,5 @@ sealed interface LocationConstraintsDialogState {
|
||||||
data object PermissionRationale : LocationConstraintsDialogState
|
data object PermissionRationale : LocationConstraintsDialogState
|
||||||
data object PermissionDenied : LocationConstraintsDialogState
|
data object PermissionDenied : LocationConstraintsDialogState
|
||||||
data object LocationServiceDisabled : LocationConstraintsDialogState
|
data object LocationServiceDisabled : LocationConstraintsDialogState
|
||||||
|
data object NotEnoughPowerLevel : LocationConstraintsDialogState
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -44,6 +45,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
fun LocationShareRow(
|
fun LocationShareRow(
|
||||||
item: LocationShareItem,
|
item: LocationShareItem,
|
||||||
onShareClick: () -> Unit,
|
onShareClick: () -> Unit,
|
||||||
|
onStopClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -101,11 +103,24 @@ fun LocationShareRow(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (item.canStopSharing) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onStopClick,
|
||||||
|
colors = IconButtonDefaults.iconButtonColors(
|
||||||
|
containerColor = ElementTheme.colors.bgCriticalPrimary,
|
||||||
|
contentColor = ElementTheme.colors.iconOnSolidPrimary,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = CompoundIcons.Stop(),
|
||||||
|
contentDescription = stringResource(CommonStrings.action_stop),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
IconButton(onClick = onShareClick) {
|
IconButton(onClick = onShareClick) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = CompoundIcons.ShareAndroid(),
|
imageVector = CompoundIcons.ShareAndroid(),
|
||||||
contentDescription = stringResource(CommonStrings.action_share),
|
contentDescription = stringResource(CommonStrings.action_share),
|
||||||
tint = ElementTheme.colors.iconPrimary,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -128,8 +143,10 @@ internal fun LocationShareRowPreview() = ElementPreview {
|
||||||
formattedTimestamp = "Shared 1 min ago",
|
formattedTimestamp = "Shared 1 min ago",
|
||||||
isLive = true,
|
isLive = true,
|
||||||
assetType = AssetType.SENDER,
|
assetType = AssetType.SENDER,
|
||||||
location = Location(0.0, 0.0)
|
location = Location(0.0, 0.0),
|
||||||
|
isOwnUser = true,
|
||||||
),
|
),
|
||||||
|
onStopClick = {},
|
||||||
onShareClick = {},
|
onShareClick = {},
|
||||||
)
|
)
|
||||||
LocationShareRow(
|
LocationShareRow(
|
||||||
|
|
@ -145,8 +162,10 @@ internal fun LocationShareRowPreview() = ElementPreview {
|
||||||
isLive = false,
|
isLive = false,
|
||||||
assetType = AssetType.PIN,
|
assetType = AssetType.PIN,
|
||||||
formattedTimestamp = "Shared 5 hours ago",
|
formattedTimestamp = "Shared 5 hours ago",
|
||||||
location = Location(0.0, 0.0)
|
location = Location(0.0, 0.0),
|
||||||
|
isOwnUser = false
|
||||||
),
|
),
|
||||||
|
onStopClick = {},
|
||||||
onShareClick = {},
|
onShareClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import org.maplibre.compose.location.UserLocationState
|
||||||
import org.maplibre.compose.location.rememberAndroidLocationProvider
|
import org.maplibre.compose.location.rememberAndroidLocationProvider
|
||||||
import org.maplibre.compose.location.rememberNullLocationProvider
|
import org.maplibre.compose.location.rememberNullLocationProvider
|
||||||
import org.maplibre.compose.location.rememberUserLocationState
|
import org.maplibre.compose.location.rememberUserLocationState
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UserLocationPuck(
|
fun UserLocationPuck(
|
||||||
|
|
@ -72,9 +72,9 @@ fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState
|
||||||
rememberNullLocationProvider()
|
rememberNullLocationProvider()
|
||||||
} else {
|
} else {
|
||||||
rememberAndroidLocationProvider(
|
rememberAndroidLocationProvider(
|
||||||
updateInterval = 1.minutes,
|
updateInterval = 5.seconds,
|
||||||
desiredAccuracy = DesiredAccuracy.Balanced,
|
desiredAccuracy = DesiredAccuracy.High,
|
||||||
minDistanceMeters = 50f,
|
minDistanceMeters = 5f,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return rememberUserLocationState(locationProvider)
|
return rememberUserLocationState(locationProvider)
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@
|
||||||
* Please see LICENSE files in the repository root for full details.
|
* Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.push.impl.di
|
package io.element.android.features.location.impl.di
|
||||||
|
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.ContributesTo
|
import dev.zacsweers.metro.ContributesTo
|
||||||
import io.element.android.libraries.push.impl.push.FetchPushForegroundService
|
import io.element.android.features.location.impl.live.service.LiveLocationSharingService
|
||||||
|
|
||||||
@ContributesTo(AppScope::class)
|
@ContributesTo(AppScope::class)
|
||||||
interface PushBindings {
|
interface LocationBindings {
|
||||||
fun inject(fetchPushForegroundService: FetchPushForegroundService)
|
fun inject(service: LiveLocationSharingService)
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
/*
|
||||||
|
* 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.live
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import dev.zacsweers.metro.SingleIn
|
||||||
|
import dev.zacsweers.metro.binding
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
|
||||||
|
import io.element.android.features.location.impl.live.service.LiveLocationReceiver
|
||||||
|
import io.element.android.features.location.impl.live.service.LiveLocationSharingCoordinator
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.BeaconId
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.LiveLocationException
|
||||||
|
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
||||||
|
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
||||||
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.getAndUpdate
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlin.concurrent.atomics.AtomicBoolean
|
||||||
|
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
@SingleIn(SessionScope::class)
|
||||||
|
@ContributesBinding(SessionScope::class, binding = binding<ActiveLiveLocationShareManager>())
|
||||||
|
class DefaultActiveLiveLocationShareManager(
|
||||||
|
private val matrixClient: MatrixClient,
|
||||||
|
private val coordinator: LiveLocationSharingCoordinator,
|
||||||
|
private val liveLocationStore: LiveLocationStore,
|
||||||
|
private val clock: SystemClock,
|
||||||
|
private val sessionObserver: SessionObserver,
|
||||||
|
) : ActiveLiveLocationShareManager, LiveLocationReceiver {
|
||||||
|
private val isSetup = AtomicBoolean(false)
|
||||||
|
private val cachedRooms = ConcurrentHashMap<RoomId, JoinedRoom>()
|
||||||
|
private val timeoutJobs = ConcurrentHashMap<RoomId, Job>()
|
||||||
|
private val syncedActiveShareIds = MutableStateFlow<Set<BeaconId>>(emptySet())
|
||||||
|
private val localSharingRoomIds = MutableStateFlow<Set<RoomId>>(emptySet())
|
||||||
|
override val sharingRoomIds: StateFlow<Set<RoomId>> = localSharingRoomIds
|
||||||
|
|
||||||
|
override suspend fun setup() = withContext(NonCancellable) {
|
||||||
|
if (isSetup.compareAndSet(expectedValue = false, newValue = true)) {
|
||||||
|
Timber.d("ActiveLiveLocationShareManager setup manager.")
|
||||||
|
|
||||||
|
recoverPersistedShares()
|
||||||
|
|
||||||
|
matrixClient.ownBeaconInfoUpdates
|
||||||
|
.onEach { update ->
|
||||||
|
Timber.d("Received beaconInfoUpdate:$update")
|
||||||
|
// First cancel the local share in this room if any.
|
||||||
|
if (update.roomId in localSharingRoomIds.value) {
|
||||||
|
stopLocalShare(roomId = update.roomId)
|
||||||
|
}
|
||||||
|
syncedActiveShareIds.update {
|
||||||
|
if (update.isLive) {
|
||||||
|
it + update.beaconId
|
||||||
|
} else {
|
||||||
|
it - update.beaconId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(matrixClient.sessionCoroutineScope)
|
||||||
|
|
||||||
|
sessionObserver.addListener(sessionListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sessionListener: SessionListener = object : SessionListener {
|
||||||
|
override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {
|
||||||
|
if (matrixClient.sessionId.value == userId) {
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun startShare(roomId: RoomId, duration: Duration): Result<Unit> = withContext(NonCancellable) {
|
||||||
|
Timber.d("ActiveLiveLocationShareManager starting share for room $roomId with duration ${duration.inWholeSeconds}s")
|
||||||
|
val room = cachedRooms.getOrPut(roomId) {
|
||||||
|
matrixClient.getJoinedRoom(roomId) ?: return@withContext Result.failure(IllegalStateException("No room found for $roomId"))
|
||||||
|
}
|
||||||
|
// Before starting a new location share, stop the current one if any is active.
|
||||||
|
room.stopLiveLocationShare()
|
||||||
|
|
||||||
|
room.startLiveLocationShare(duration.inWholeMilliseconds)
|
||||||
|
.onSuccess { beaconId ->
|
||||||
|
Timber.d("ActiveLiveLocationShareManager wait remote echo of $beaconId")
|
||||||
|
syncedActiveShareIds.first { beaconIds -> beaconIds.contains(beaconId) }
|
||||||
|
val expiresAt = Instant.fromEpochMilliseconds(clock.epochMillis() + duration.inWholeMilliseconds)
|
||||||
|
startLocalShare(roomId, expiresAt)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
Timber.e(it, "ActiveLiveLocationShareManager failed to start share for room $roomId")
|
||||||
|
stopLocalShare(roomId)
|
||||||
|
}
|
||||||
|
.map { }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stopShare(roomId: RoomId): Result<Unit> = withContext(NonCancellable) {
|
||||||
|
Timber.d("ActiveLiveLocationShareManager stopping share for room $roomId")
|
||||||
|
val room = cachedRooms.getOrPut(roomId) {
|
||||||
|
matrixClient.getJoinedRoom(roomId) ?: return@withContext Result.failure(IllegalStateException("No room found for $roomId"))
|
||||||
|
}
|
||||||
|
room.stopLiveLocationShare()
|
||||||
|
.onSuccess {
|
||||||
|
Timber.d("ActiveLiveLocationShareManager share stopped successfully for room $roomId")
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
Timber.e(it, "ActiveLiveLocationShareManager failed to stop share for room $roomId")
|
||||||
|
}
|
||||||
|
.also {
|
||||||
|
stopLocalShare(roomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onLocationUpdate(location: Location) {
|
||||||
|
val activeSharesCount = localSharingRoomIds.value.size
|
||||||
|
Timber.d("ActiveLiveLocationShareManager received location update for $activeSharesCount active share(s)")
|
||||||
|
localSharingRoomIds.value.forEach { roomId ->
|
||||||
|
Timber.d("ActiveLiveLocationShareManager sending location to room $roomId")
|
||||||
|
sendLiveLocation(roomId, location)
|
||||||
|
.onFailure {
|
||||||
|
Timber.e(it, "ActiveLiveLocationShareManager failed to send location to room $roomId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendLiveLocation(roomId: RoomId, location: Location): Result<Unit> {
|
||||||
|
val room = cachedRooms.getOrPut(roomId) {
|
||||||
|
matrixClient.getJoinedRoom(roomId) ?: return Result.failure(IllegalStateException("No room found for $roomId"))
|
||||||
|
}
|
||||||
|
return room.sendLiveLocation(location.toGeoUri())
|
||||||
|
.recoverCatching { exception ->
|
||||||
|
when (exception) {
|
||||||
|
is LiveLocationException.NotLive -> {
|
||||||
|
stopLocalShare(roomId)
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
else -> throw exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun startLocalShare(roomId: RoomId, expiresAt: Instant) {
|
||||||
|
val wasEmpty = localSharingRoomIds.value.isEmpty()
|
||||||
|
Timber.d("ActiveLiveLocationShareManager share started successfully for room $roomId (wasEmpty=$wasEmpty)")
|
||||||
|
localSharingRoomIds.update { it + roomId }
|
||||||
|
liveLocationStore.setLiveLocationExpiry(roomId, expiresAt)
|
||||||
|
scheduleTimeout(roomId, expiresAt)
|
||||||
|
if (wasEmpty) {
|
||||||
|
Timber.d("ActiveLiveLocationShareManager registering with coordinator for session ${matrixClient.sessionId}")
|
||||||
|
coordinator.register(matrixClient.sessionId, this@DefaultActiveLiveLocationShareManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun recoverPersistedShares() {
|
||||||
|
val now = Instant.fromEpochMilliseconds(clock.epochMillis())
|
||||||
|
liveLocationStore.getLiveLocationExpiries().forEach { (roomId, expiresAt) ->
|
||||||
|
if (expiresAt > now) {
|
||||||
|
// Only starts locally as the share is already started remotely
|
||||||
|
startLocalShare(roomId, expiresAt)
|
||||||
|
} else {
|
||||||
|
// Explicitly stop the share on the server.
|
||||||
|
stopShare(roomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleTimeout(roomId: RoomId, expiresAt: Instant) {
|
||||||
|
timeoutJobs.remove(roomId)?.cancel()
|
||||||
|
val delayMillis = expiresAt.toEpochMilliseconds() - clock.epochMillis()
|
||||||
|
timeoutJobs[roomId] = matrixClient.sessionCoroutineScope.launch {
|
||||||
|
delay(delayMillis)
|
||||||
|
stopShare(roomId)
|
||||||
|
.onFailure { error ->
|
||||||
|
Timber.e(error, "ActiveLiveLocationShareManager failed to stop timed out share for room $roomId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun stopLocalShare(roomId: RoomId) {
|
||||||
|
Timber.d("ActiveLiveLocationShareManager stop local share in $roomId")
|
||||||
|
timeoutJobs.remove(roomId)?.cancel()
|
||||||
|
val wasSharing = localSharingRoomIds.getAndUpdate { it - roomId }.isNotEmpty()
|
||||||
|
cachedRooms.remove(roomId)?.close()
|
||||||
|
liveLocationStore.removeLiveLocationExpiry(roomId)
|
||||||
|
if (wasSharing && localSharingRoomIds.value.isEmpty()) {
|
||||||
|
Timber.d("ActiveLiveLocationShareManager unregistering from coordinator for session ${matrixClient.sessionId}")
|
||||||
|
coordinator.unregister(matrixClient.sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun clear() {
|
||||||
|
Timber.d("ActiveLiveLocationShareManager clear state")
|
||||||
|
sessionObserver.removeListener(sessionListener)
|
||||||
|
coordinator.unregister(matrixClient.sessionId)
|
||||||
|
liveLocationStore.clear()
|
||||||
|
for (room in cachedRooms.values) {
|
||||||
|
room.close()
|
||||||
|
timeoutJobs[room.roomId]?.cancel()
|
||||||
|
}
|
||||||
|
timeoutJobs.clear()
|
||||||
|
cachedRooms.clear()
|
||||||
|
localSharingRoomIds.value = emptySet()
|
||||||
|
syncedActiveShareIds.value = emptySet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
/*
|
||||||
|
* 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.live
|
||||||
|
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import dev.zacsweers.metro.SingleIn
|
||||||
|
import io.element.android.libraries.androidutils.hash.hash
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import timber.log.Timber
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
private const val LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR = "="
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@SingleIn(SessionScope::class)
|
||||||
|
class LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory: PreferenceDataStoreFactory,
|
||||||
|
sessionId: SessionId,
|
||||||
|
) {
|
||||||
|
private val store = preferenceDataStoreFactory.create("location_${sessionId.value.hash().take(16)}")
|
||||||
|
private val acceptedLiveLocationDisclaimerKey = booleanPreferencesKey("live_location_disclaimer_accepted")
|
||||||
|
private val liveLocationExpiriesKey = stringSetPreferencesKey("live_location_expiries")
|
||||||
|
|
||||||
|
suspend fun hasAcceptedLiveLocationDisclaimer(): Boolean = runCatchingExceptions {
|
||||||
|
store.data.first()[acceptedLiveLocationDisclaimerKey] ?: false
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
suspend fun setAcceptedLiveLocationDisclaimer(): Result<Unit> = runCatchingExceptions {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs[acceptedLiveLocationDisclaimerKey] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLiveLocationExpiries(): Map<RoomId, Instant> = runCatchingExceptions {
|
||||||
|
val serialized = store.data.first()[liveLocationExpiriesKey].orEmpty()
|
||||||
|
decodeLiveLocationExpiries(serialized)
|
||||||
|
}.onFailure { error ->
|
||||||
|
Timber.e(error, "Failed to decode live location expiry payload")
|
||||||
|
}.getOrDefault(emptyMap())
|
||||||
|
|
||||||
|
suspend fun setLiveLocationExpiry(roomId: RoomId, expiresAt: Instant): Result<Unit> = runCatchingExceptions {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current = decodeLiveLocationExpiries(prefs[liveLocationExpiriesKey].orEmpty())
|
||||||
|
prefs[liveLocationExpiriesKey] = encodeLiveLocationExpiries(current + (roomId to expiresAt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeLiveLocationExpiry(roomId: RoomId): Result<Unit> = runCatchingExceptions {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current = decodeLiveLocationExpiries(prefs[liveLocationExpiriesKey].orEmpty())
|
||||||
|
val updated = current - roomId
|
||||||
|
if (updated.isEmpty()) {
|
||||||
|
prefs.remove(liveLocationExpiriesKey)
|
||||||
|
} else {
|
||||||
|
prefs[liveLocationExpiriesKey] = encodeLiveLocationExpiries(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeLiveLocationExpiries(serialized: Set<String>): Map<RoomId, Instant> {
|
||||||
|
return runCatchingExceptions {
|
||||||
|
serialized
|
||||||
|
.map { it.split(LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR) }
|
||||||
|
.associate { values ->
|
||||||
|
val roomId = RoomId(values[0])
|
||||||
|
val expiresAtMillis = values[1].toLong()
|
||||||
|
roomId to Instant.fromEpochMilliseconds(expiresAtMillis)
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodeLiveLocationExpiries(expiries: Map<RoomId, Instant>): Set<String> {
|
||||||
|
return expiries.entries.map { (roomId, expiresAt) ->
|
||||||
|
"${roomId.value}$LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR${expiresAt.toEpochMilliseconds()}"
|
||||||
|
}.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clear() {
|
||||||
|
store.edit { prefs -> prefs.clear() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
/*
|
||||||
|
* 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.live.notification
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.ChecksSdkIntAtLeast
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
class LiveLocationSharingNotificationCreator(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val CHANNEL_ID = "LIVE_LOCATION_SHARING"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createNotification(): Notification {
|
||||||
|
if (supportNotificationChannels()) {
|
||||||
|
ensureChannelExists()
|
||||||
|
}
|
||||||
|
return NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
|
||||||
|
.setContentTitle(context.getString(CommonStrings.live_location_sharing_foreground_service_title_android, buildMeta.applicationName))
|
||||||
|
.setContentText(context.getString(CommonStrings.live_location_sharing_foreground_service_message_android))
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
private fun ensureChannelExists() {
|
||||||
|
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (notificationManager.getNotificationChannel(CHANNEL_ID) == null) {
|
||||||
|
notificationManager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(CommonStrings.live_location_sharing_foreground_service_channel_title_android)
|
||||||
|
.ifEmpty { "Live Location Sharing" },
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
|
||||||
|
private fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
/*
|
||||||
|
* 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.live.service
|
||||||
|
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
|
||||||
|
fun interface LiveLocationReceiver {
|
||||||
|
suspend fun onLocationUpdate(location: Location)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
/*
|
||||||
|
* 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.live.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import dev.zacsweers.metro.SingleIn
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
|
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlin.concurrent.atomics.AtomicLong
|
||||||
|
import kotlin.concurrent.atomics.AtomicReference
|
||||||
|
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
private val THROTTLE_WINDOW = 3.seconds
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
class LiveLocationSharingCoordinator internal constructor(
|
||||||
|
private val startService: () -> Unit,
|
||||||
|
private val stopService: () -> Unit,
|
||||||
|
private val nowMillis: () -> Long,
|
||||||
|
) {
|
||||||
|
@Inject
|
||||||
|
constructor(@ApplicationContext context: Context, clock: SystemClock) : this(
|
||||||
|
startService = {
|
||||||
|
ContextCompat.startForegroundService(context, Intent(context, LiveLocationSharingService::class.java))
|
||||||
|
},
|
||||||
|
stopService = {
|
||||||
|
context.stopService(Intent(context, LiveLocationSharingService::class.java))
|
||||||
|
},
|
||||||
|
nowMillis = clock::epochMillis
|
||||||
|
)
|
||||||
|
|
||||||
|
private val receivers = ConcurrentHashMap<SessionId, LiveLocationReceiver>()
|
||||||
|
|
||||||
|
private val lastDispatchMillis = AtomicLong(0L)
|
||||||
|
private val lastKnownLocation = AtomicReference<Location?>(null)
|
||||||
|
|
||||||
|
suspend fun register(sessionId: SessionId, receiver: LiveLocationReceiver) {
|
||||||
|
val wasEmpty = receivers.isEmpty()
|
||||||
|
Timber.d("LiveLocationSharingCoordinator registering receiver for session $sessionId (wasEmpty=$wasEmpty)")
|
||||||
|
receivers[sessionId] = receiver
|
||||||
|
if (wasEmpty) {
|
||||||
|
Timber.d("LiveLocationSharingCoordinator starting service")
|
||||||
|
runCatchingExceptions(startService).onFailure {
|
||||||
|
Timber.e(it, "Failed to start live location sharing service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastKnownLocation.load()?.let {
|
||||||
|
dispatch(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregister(sessionId: SessionId) {
|
||||||
|
Timber.d("LiveLocationSharingCoordinator unregistering receiver for session $sessionId")
|
||||||
|
receivers.remove(sessionId)
|
||||||
|
if (receivers.isEmpty()) {
|
||||||
|
lastKnownLocation.store(null)
|
||||||
|
Timber.d("LiveLocationSharingCoordinator stopping service (no more receivers)")
|
||||||
|
runCatchingExceptions(stopService).onFailure {
|
||||||
|
Timber.e(it, "Failed to stop live location sharing service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun dispatch(location: Location) {
|
||||||
|
val currentTimeMillis = nowMillis()
|
||||||
|
val millisSincePrevious = currentTimeMillis - lastDispatchMillis.load()
|
||||||
|
if (millisSincePrevious < THROTTLE_WINDOW.inWholeMilliseconds) {
|
||||||
|
Timber.d("Received location before $THROTTLE_WINDOW, ignore.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastKnownLocation.store(location)
|
||||||
|
lastDispatchMillis.store(currentTimeMillis)
|
||||||
|
receivers.forEach { (sessionId, receiver) ->
|
||||||
|
Timber.d("Dispatch received location for session $sessionId ")
|
||||||
|
runCatchingExceptions {
|
||||||
|
receiver.onLocationUpdate(location)
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Failed to dispatch live location update for session $sessionId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
/*
|
||||||
|
* 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.live.service
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.features.location.impl.di.LocationBindings
|
||||||
|
import io.element.android.features.location.impl.live.notification.LiveLocationSharingNotificationCreator
|
||||||
|
import io.element.android.libraries.architecture.bindings
|
||||||
|
import io.element.android.libraries.core.coroutine.childScope
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
|
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
||||||
|
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||||
|
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import org.maplibre.compose.location.AndroidLocationProvider
|
||||||
|
import org.maplibre.compose.location.DesiredAccuracy
|
||||||
|
import timber.log.Timber
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import io.element.android.features.location.api.Location as ApiLocation
|
||||||
|
|
||||||
|
private const val UPDATE_INTERVAL_IN_SECOND = 10
|
||||||
|
|
||||||
|
class LiveLocationSharingService : Service() {
|
||||||
|
@Inject lateinit var coordinator: LiveLocationSharingCoordinator
|
||||||
|
@Inject lateinit var notificationCreator: LiveLocationSharingNotificationCreator
|
||||||
|
@Inject lateinit var appPreferencesStore: AppPreferencesStore
|
||||||
|
|
||||||
|
@Inject lateinit var appForegroundStateService: AppForegroundStateService
|
||||||
|
|
||||||
|
@AppCoroutineScope
|
||||||
|
@Inject lateinit var appCoroutineScope: CoroutineScope
|
||||||
|
private lateinit var coroutineScope: CoroutineScope
|
||||||
|
|
||||||
|
override fun onBind(p0: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
@SuppressLint("InlinedApi")
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
Timber.d("LiveLocationSharingService onCreate")
|
||||||
|
runCatchingExceptions {
|
||||||
|
bindings<LocationBindings>().inject(this)
|
||||||
|
appForegroundStateService.updateIsSharingLiveLocation(true)
|
||||||
|
coroutineScope = appCoroutineScope.childScope(Dispatchers.Default, "LiveLocationSharingService")
|
||||||
|
val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.LIVE_LOCATION)
|
||||||
|
Timber.d("LiveLocationSharingService starting foreground service with notificationId=$notificationId")
|
||||||
|
ServiceCompat.startForeground(
|
||||||
|
// service =
|
||||||
|
this,
|
||||||
|
// id =
|
||||||
|
notificationId,
|
||||||
|
// notification =
|
||||||
|
notificationCreator.createNotification(),
|
||||||
|
// foregroundServiceType =
|
||||||
|
FOREGROUND_SERVICE_TYPE_LOCATION,
|
||||||
|
)
|
||||||
|
startLocationUpdatesListener()
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Failed to start live location sharing service")
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
private fun startLocationUpdatesListener() {
|
||||||
|
Timber.d("LiveLocationSharingService listening to location updates")
|
||||||
|
appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow()
|
||||||
|
.flatMapLatest { minDistanceMeters ->
|
||||||
|
val locationProvider = AndroidLocationProvider(
|
||||||
|
context = applicationContext,
|
||||||
|
updateInterval = UPDATE_INTERVAL_IN_SECOND.seconds,
|
||||||
|
minDistanceMeters = minDistanceMeters.toFloat(),
|
||||||
|
desiredAccuracy = DesiredAccuracy.Balanced,
|
||||||
|
coroutineScope = coroutineScope
|
||||||
|
)
|
||||||
|
locationProvider.location
|
||||||
|
}
|
||||||
|
.filterNotNull()
|
||||||
|
.map { location ->
|
||||||
|
ApiLocation(
|
||||||
|
lat = location.position.latitude,
|
||||||
|
lon = location.position.longitude,
|
||||||
|
accuracy = location.accuracy.toFloat(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onEach(coordinator::dispatch)
|
||||||
|
.launchIn(coroutineScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
Timber.d("LiveLocationSharingService onStartCommand startId=$startId")
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
Timber.d("LiveLocationSharingService onDestroy")
|
||||||
|
if (::coroutineScope.isInitialized) {
|
||||||
|
coroutineScope.cancel()
|
||||||
|
}
|
||||||
|
appForegroundStateService.updateIsSharingLiveLocation(false)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,8 @@ sealed interface ShareLocationEvent {
|
||||||
val isPinned: Boolean,
|
val isPinned: Boolean,
|
||||||
) : ShareLocationEvent
|
) : ShareLocationEvent
|
||||||
|
|
||||||
data object ShowLiveLocationDurationPicker : ShareLocationEvent
|
data object InitiateLiveLocationShare : ShareLocationEvent
|
||||||
|
data object AcceptLiveLocationDisclaimer : ShareLocationEvent
|
||||||
data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent
|
data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent
|
||||||
|
|
||||||
data object StartTrackingUserLocation : ShareLocationEvent
|
data object StartTrackingUserLocation : ShareLocationEvent
|
||||||
|
|
|
||||||
|
|
@ -21,17 +21,22 @@ import dev.zacsweers.metro.Assisted
|
||||||
import dev.zacsweers.metro.AssistedFactory
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import im.vector.app.features.analytics.plan.Composer
|
import im.vector.app.features.analytics.plan.Composer
|
||||||
|
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
|
||||||
import io.element.android.features.location.impl.common.LocationConstraintsCheck
|
import io.element.android.features.location.impl.common.LocationConstraintsCheck
|
||||||
import io.element.android.features.location.impl.common.MapDefaults
|
import io.element.android.features.location.impl.common.MapDefaults
|
||||||
|
import io.element.android.features.location.impl.common.SendLiveLocationPermissions
|
||||||
import io.element.android.features.location.impl.common.actions.LocationActions
|
import io.element.android.features.location.impl.common.actions.LocationActions
|
||||||
import io.element.android.features.location.impl.common.checkLocationConstraints
|
import io.element.android.features.location.impl.common.checkLocationConstraints
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
|
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
||||||
|
import io.element.android.features.location.impl.common.sendLiveLocationPermissions
|
||||||
import io.element.android.features.location.impl.common.toDialogState
|
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.location.impl.live.LiveLocationStore
|
||||||
import io.element.android.features.messages.api.MessageComposerContext
|
import io.element.android.features.messages.api.MessageComposerContext
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.architecture.runUpdatingState
|
||||||
import io.element.android.libraries.core.extensions.flatMap
|
import io.element.android.libraries.core.extensions.flatMap
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.dateformatter.api.DurationFormatter
|
import io.element.android.libraries.dateformatter.api.DurationFormatter
|
||||||
|
|
@ -41,6 +46,7 @@ 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.CreateTimelineParams
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
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.room.location.AssetType
|
||||||
|
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
|
@ -63,6 +69,8 @@ class ShareLocationPresenter(
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val client: MatrixClient,
|
private val client: MatrixClient,
|
||||||
private val durationFormatter: DurationFormatter,
|
private val durationFormatter: DurationFormatter,
|
||||||
|
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||||
|
private val liveLocationStore: LiveLocationStore,
|
||||||
) : Presenter<ShareLocationState> {
|
) : Presenter<ShareLocationState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
fun interface Factory {
|
fun interface Factory {
|
||||||
|
|
@ -82,15 +90,39 @@ class ShareLocationPresenter(
|
||||||
var dialogState: ShareLocationState.Dialog by remember {
|
var dialogState: ShareLocationState.Dialog by remember {
|
||||||
mutableStateOf(ShareLocationState.Dialog.None)
|
mutableStateOf(ShareLocationState.Dialog.None)
|
||||||
}
|
}
|
||||||
|
val startLiveLocationAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||||
val currentUser by client.userProfile.collectAsState()
|
val currentUser by client.userProfile.collectAsState()
|
||||||
|
val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms ->
|
||||||
|
perms.sendLiveLocationPermissions()
|
||||||
|
}
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
fun checkLocationConstraints() {
|
fun checkLocationConstraints() {
|
||||||
val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
|
// No need to check SendLiveLocationPermissions here
|
||||||
dialogState = Constraints(locationConstraints.toDialogState())
|
val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||||
|
dialogState = ShareLocationState.Dialog.Constraints(locationConstraints.toDialogState())
|
||||||
trackUserPosition = locationConstraints is LocationConstraintsCheck.Success
|
trackUserPosition = locationConstraints is LocationConstraintsCheck.Success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun computeLiveLocationDialogState(): ShareLocationState.Dialog {
|
||||||
|
val hasAcceptedDisclaimer = liveLocationStore.hasAcceptedLiveLocationDisclaimer()
|
||||||
|
val constraintsResult = checkLocationConstraints(permissionsState, locationActions, sendLiveLocationPermissions)
|
||||||
|
return when {
|
||||||
|
!hasAcceptedDisclaimer -> {
|
||||||
|
ShareLocationState.Dialog.LiveLocationDisclaimer
|
||||||
|
}
|
||||||
|
constraintsResult is LocationConstraintsCheck.Success -> {
|
||||||
|
val durations = LIVE_LOCATION_DURATIONS.map {
|
||||||
|
LiveLocationDuration(duration = it, formatted = durationFormatter.format(it))
|
||||||
|
}
|
||||||
|
ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
ShareLocationState.Dialog.Constraints(constraintsResult.toDialogState())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() }
|
LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() }
|
||||||
|
|
||||||
fun handleEvent(event: ShareLocationEvent) {
|
fun handleEvent(event: ShareLocationEvent) {
|
||||||
|
|
@ -109,20 +141,23 @@ class ShareLocationPresenter(
|
||||||
locationActions.openLocationSettings()
|
locationActions.openLocationSettings()
|
||||||
dialogState = ShareLocationState.Dialog.None
|
dialogState = ShareLocationState.Dialog.None
|
||||||
}
|
}
|
||||||
ShareLocationEvent.ShowLiveLocationDurationPicker -> {
|
ShareLocationEvent.InitiateLiveLocationShare -> scope.launch {
|
||||||
val constraintsResult = checkLocationConstraints(permissionsState, locationActions)
|
dialogState = computeLiveLocationDialogState()
|
||||||
dialogState = if (constraintsResult is LocationConstraintsCheck.Success) {
|
}
|
||||||
val durations = LIVE_LOCATION_DURATIONS.map {
|
ShareLocationEvent.AcceptLiveLocationDisclaimer -> scope.launch {
|
||||||
LiveLocationDuration(duration = it, formatted = durationFormatter.format(it))
|
liveLocationStore.setAcceptedLiveLocationDisclaimer()
|
||||||
|
.onSuccess {
|
||||||
|
dialogState = computeLiveLocationDialogState()
|
||||||
}
|
}
|
||||||
ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
|
|
||||||
} else {
|
|
||||||
Constraints(constraintsResult.toDialogState())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
is ShareLocationEvent.StartLiveLocationShare -> scope.launch {
|
is ShareLocationEvent.StartLiveLocationShare -> scope.launch {
|
||||||
dialogState = ShareLocationState.Dialog.None
|
dialogState = ShareLocationState.Dialog.None
|
||||||
// room.startLiveLocationShare(event.duration.inWholeMilliseconds)
|
startLiveLocationAction.runUpdatingState {
|
||||||
|
liveLocationShareManager.startShare(
|
||||||
|
roomId = room.roomId,
|
||||||
|
duration = event.duration,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ShareLocationEvent.RequestPermissions -> {
|
ShareLocationEvent.RequestPermissions -> {
|
||||||
dialogState = ShareLocationState.Dialog.None
|
dialogState = ShareLocationState.Dialog.None
|
||||||
|
|
@ -136,8 +171,9 @@ class ShareLocationPresenter(
|
||||||
dialogState = dialogState,
|
dialogState = dialogState,
|
||||||
trackUserLocation = trackUserPosition,
|
trackUserLocation = trackUserPosition,
|
||||||
hasLocationPermission = permissionsState.isAnyGranted,
|
hasLocationPermission = permissionsState.isAnyGranted,
|
||||||
canShareLiveLocation = isLiveLocationSharingEnabled,
|
canShareLiveLocation = isLiveLocationSharingEnabled && timelineMode.canShareLiveLocation(),
|
||||||
appName = appName,
|
appName = appName,
|
||||||
|
startLiveLocationAction = startLiveLocationAction.value,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -174,4 +210,9 @@ class ShareLocationPresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Timeline.Mode.canShareLiveLocation() = when (this) {
|
||||||
|
is Timeline.Mode.Thread -> false
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
|
||||||
private fun generateBody(uri: String): String = "Location was shared at $uri"
|
private fun generateBody(uri: String): String = "Location was shared at $uri"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
package io.element.android.features.location.impl.share
|
package io.element.android.features.location.impl.share
|
||||||
|
|
||||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
|
@ -19,11 +20,13 @@ data class ShareLocationState(
|
||||||
val hasLocationPermission: Boolean,
|
val hasLocationPermission: Boolean,
|
||||||
val appName: String,
|
val appName: String,
|
||||||
val canShareLiveLocation: Boolean,
|
val canShareLiveLocation: Boolean,
|
||||||
|
val startLiveLocationAction: AsyncAction<Unit>,
|
||||||
val eventSink: (ShareLocationEvent) -> Unit,
|
val eventSink: (ShareLocationEvent) -> Unit,
|
||||||
) {
|
) {
|
||||||
sealed interface Dialog {
|
sealed interface Dialog {
|
||||||
data object None : Dialog
|
data object None : Dialog
|
||||||
data class Constraints(val state: LocationConstraintsDialogState) : Dialog
|
data class Constraints(val state: LocationConstraintsDialogState) : Dialog
|
||||||
|
data object LiveLocationDisclaimer : Dialog
|
||||||
data class LiveLocationDurations(val durations: ImmutableList<LiveLocationDuration>) : Dialog
|
data class LiveLocationDurations(val durations: ImmutableList<LiveLocationDuration>) : Dialog
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ package io.element.android.features.location.impl.share
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
@ -51,6 +52,18 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
|
||||||
trackUserPosition = true,
|
trackUserPosition = true,
|
||||||
hasLocationPermission = true,
|
hasLocationPermission = true,
|
||||||
),
|
),
|
||||||
|
aShareLocationState(
|
||||||
|
dialogState = ShareLocationState.Dialog.None,
|
||||||
|
trackUserPosition = true,
|
||||||
|
hasLocationPermission = true,
|
||||||
|
canShareLiveLocation = true,
|
||||||
|
),
|
||||||
|
aShareLocationState(
|
||||||
|
dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer,
|
||||||
|
trackUserPosition = true,
|
||||||
|
hasLocationPermission = true,
|
||||||
|
canShareLiveLocation = true,
|
||||||
|
),
|
||||||
aShareLocationState(
|
aShareLocationState(
|
||||||
dialogState = ShareLocationState.Dialog.LiveLocationDurations(
|
dialogState = ShareLocationState.Dialog.LiveLocationDurations(
|
||||||
persistentListOf(
|
persistentListOf(
|
||||||
|
|
@ -73,6 +86,7 @@ fun aShareLocationState(
|
||||||
hasLocationPermission: Boolean = false,
|
hasLocationPermission: Boolean = false,
|
||||||
canShareLiveLocation: Boolean = false,
|
canShareLiveLocation: Boolean = false,
|
||||||
appName: String = APP_NAME,
|
appName: String = APP_NAME,
|
||||||
|
startLiveLocationAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
eventSink: (ShareLocationEvent) -> Unit = {},
|
eventSink: (ShareLocationEvent) -> Unit = {},
|
||||||
): ShareLocationState {
|
): ShareLocationState {
|
||||||
return ShareLocationState(
|
return ShareLocationState(
|
||||||
|
|
@ -82,6 +96,7 @@ fun aShareLocationState(
|
||||||
hasLocationPermission = hasLocationPermission,
|
hasLocationPermission = hasLocationPermission,
|
||||||
canShareLiveLocation = canShareLiveLocation,
|
canShareLiveLocation = canShareLiveLocation,
|
||||||
appName = appName,
|
appName = appName,
|
||||||
|
startLiveLocationAction = startLiveLocationAction,
|
||||||
eventSink = eventSink
|
eventSink = eventSink
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -44,11 +43,16 @@ import io.element.android.features.location.impl.common.ui.LocationFloatingActio
|
||||||
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
|
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.UserLocationPuck
|
||||||
import io.element.android.features.location.impl.common.ui.rememberUserLocationState
|
import io.element.android.features.location.impl.common.ui.rememberUserLocationState
|
||||||
import io.element.android.libraries.androidutils.system.toast
|
import io.element.android.features.location.impl.share.ShareLocationEvent.StartLiveLocationShare
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.designsystem.components.LocationPin
|
import io.element.android.libraries.designsystem.components.LocationPin
|
||||||
import io.element.android.libraries.designsystem.components.PinVariant
|
import io.element.android.libraries.designsystem.components.PinVariant
|
||||||
|
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
|
||||||
|
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
|
||||||
|
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
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.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
|
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.ListItemContent
|
||||||
import io.element.android.libraries.designsystem.components.list.RadioButtonListItem
|
import io.element.android.libraries.designsystem.components.list.RadioButtonListItem
|
||||||
|
|
@ -74,7 +78,6 @@ fun ShareLocationView(
|
||||||
navigateUp: () -> Unit,
|
navigateUp: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
when (val dialogState = state.dialogState) {
|
when (val dialogState = state.dialogState) {
|
||||||
ShareLocationState.Dialog.None -> Unit
|
ShareLocationState.Dialog.None -> Unit
|
||||||
is ShareLocationState.Dialog.Constraints -> LocationConstraintsDialog(
|
is ShareLocationState.Dialog.Constraints -> LocationConstraintsDialog(
|
||||||
|
|
@ -85,12 +88,17 @@ fun ShareLocationView(
|
||||||
onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) },
|
onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) },
|
||||||
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
|
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
|
||||||
)
|
)
|
||||||
|
ShareLocationState.Dialog.LiveLocationDisclaimer -> ConfirmationDialog(
|
||||||
|
content = stringResource(R.string.screen_share_location_live_location_disclaimer_title),
|
||||||
|
submitText = stringResource(CommonStrings.action_accept),
|
||||||
|
cancelText = stringResource(CommonStrings.action_decline),
|
||||||
|
onSubmitClick = { state.eventSink(ShareLocationEvent.AcceptLiveLocationDisclaimer) },
|
||||||
|
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
|
||||||
|
)
|
||||||
is ShareLocationState.Dialog.LiveLocationDurations -> LiveLocationDurationDialog(
|
is ShareLocationState.Dialog.LiveLocationDurations -> LiveLocationDurationDialog(
|
||||||
durations = dialogState.durations,
|
durations = dialogState.durations,
|
||||||
onSelectDuration = { duration ->
|
onSelectDuration = { duration ->
|
||||||
state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration))
|
state.eventSink(StartLiveLocationShare(duration))
|
||||||
context.toast("Not implemented yet!")
|
|
||||||
navigateUp()
|
|
||||||
},
|
},
|
||||||
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
|
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
|
||||||
)
|
)
|
||||||
|
|
@ -160,10 +168,46 @@ fun ShareLocationView(
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.padding(all = 16.dp),
|
.padding(all = 16.dp),
|
||||||
)
|
)
|
||||||
|
StartLiveLocationActionView(state.startLiveLocationAction, navigateUp)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StartLiveLocationActionView(
|
||||||
|
action: AsyncAction<Unit>,
|
||||||
|
onActionSuccess: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
val asyncIndicatorState = rememberAsyncIndicatorState()
|
||||||
|
AsyncIndicatorHost(state = asyncIndicatorState)
|
||||||
|
|
||||||
|
when (action) {
|
||||||
|
is AsyncAction.Loading -> {
|
||||||
|
LaunchedEffect(action) {
|
||||||
|
asyncIndicatorState.enqueue {
|
||||||
|
AsyncIndicator.Loading(text = stringResource(CommonStrings.common_waiting_live_location))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is AsyncAction.Failure -> {
|
||||||
|
LaunchedEffect(action) {
|
||||||
|
asyncIndicatorState.enqueue(AsyncIndicator.DURATION_SHORT) {
|
||||||
|
AsyncIndicator.Failure(
|
||||||
|
text = stringResource(CommonStrings.common_something_went_wrong),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is AsyncAction.Success -> {
|
||||||
|
LaunchedEffect(action) { onActionSuccess() }
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BottomSheetContent(
|
private fun BottomSheetContent(
|
||||||
cameraState: CameraState,
|
cameraState: CameraState,
|
||||||
|
|
@ -202,7 +246,7 @@ private fun BottomSheetContent(
|
||||||
}
|
}
|
||||||
if (state.canShareLiveLocation) {
|
if (state.canShareLiveLocation) {
|
||||||
ShareLiveLocationItem {
|
ShareLiveLocationItem {
|
||||||
state.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
|
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,5 @@ sealed interface ShowLocationEvent {
|
||||||
data object RequestPermissions : ShowLocationEvent
|
data object RequestPermissions : ShowLocationEvent
|
||||||
data object OpenAppSettings : ShowLocationEvent
|
data object OpenAppSettings : ShowLocationEvent
|
||||||
data object OpenLocationSettings : ShowLocationEvent
|
data object OpenLocationSettings : ShowLocationEvent
|
||||||
|
data object StopLocationSharing : ShowLocationEvent
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,17 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import dev.zacsweers.metro.Assisted
|
import dev.zacsweers.metro.Assisted
|
||||||
import dev.zacsweers.metro.AssistedFactory
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
import io.element.android.features.location.api.ShowLocationMode
|
import io.element.android.features.location.api.ShowLocationMode
|
||||||
|
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
|
||||||
import io.element.android.features.location.impl.common.LocationConstraintsCheck
|
import io.element.android.features.location.impl.common.LocationConstraintsCheck
|
||||||
import io.element.android.features.location.impl.common.MapDefaults
|
import io.element.android.features.location.impl.common.MapDefaults
|
||||||
|
import io.element.android.features.location.impl.common.SendLiveLocationPermissions
|
||||||
import io.element.android.features.location.impl.common.actions.LocationActions
|
import io.element.android.features.location.impl.common.actions.LocationActions
|
||||||
import io.element.android.features.location.impl.common.checkLocationConstraints
|
import io.element.android.features.location.impl.common.checkLocationConstraints
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
||||||
|
|
@ -45,6 +48,7 @@ import io.element.android.services.toolbox.api.strings.StringProvider
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
class ShowLocationPresenter(
|
class ShowLocationPresenter(
|
||||||
|
|
@ -55,6 +59,7 @@ class ShowLocationPresenter(
|
||||||
private val dateFormatter: DateFormatter,
|
private val dateFormatter: DateFormatter,
|
||||||
private val stringProvider: StringProvider,
|
private val stringProvider: StringProvider,
|
||||||
private val joinedRoom: JoinedRoom,
|
private val joinedRoom: JoinedRoom,
|
||||||
|
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||||
) : Presenter<ShowLocationState> {
|
) : Presenter<ShowLocationState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
fun interface Factory {
|
fun interface Factory {
|
||||||
|
|
@ -65,6 +70,7 @@ class ShowLocationPresenter(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): ShowLocationState {
|
override fun present(): ShowLocationState {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val permissionsState: PermissionsState = permissionsPresenter.present()
|
val permissionsState: PermissionsState = permissionsPresenter.present()
|
||||||
var isTrackMyLocation by remember { mutableStateOf(false) }
|
var isTrackMyLocation by remember { mutableStateOf(false) }
|
||||||
val appName by remember { derivedStateOf { buildMeta.applicationName } }
|
val appName by remember { derivedStateOf { buildMeta.applicationName } }
|
||||||
|
|
@ -85,7 +91,7 @@ class ShowLocationPresenter(
|
||||||
}
|
}
|
||||||
is ShowLocationEvent.TrackMyLocation -> {
|
is ShowLocationEvent.TrackMyLocation -> {
|
||||||
if (event.enabled) {
|
if (event.enabled) {
|
||||||
val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
|
val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||||
isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success
|
isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success
|
||||||
dialogState = locationConstraints.toDialogState()
|
dialogState = locationConstraints.toDialogState()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -102,6 +108,9 @@ class ShowLocationPresenter(
|
||||||
dialogState = LocationConstraintsDialogState.None
|
dialogState = LocationConstraintsDialogState.None
|
||||||
}
|
}
|
||||||
ShowLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
|
ShowLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
|
||||||
|
ShowLocationEvent.StopLocationSharing -> coroutineScope.launch {
|
||||||
|
liveLocationShareManager.stopShare(joinedRoom.roomId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,6 +136,7 @@ class ShowLocationPresenter(
|
||||||
location = mode.location,
|
location = mode.location,
|
||||||
isLive = false,
|
isLive = false,
|
||||||
assetType = mode.assetType,
|
assetType = mode.assetType,
|
||||||
|
isOwnUser = mode.senderId == joinedRoom.sessionId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +173,7 @@ class ShowLocationPresenter(
|
||||||
location = location,
|
location = location,
|
||||||
isLive = true,
|
isLive = true,
|
||||||
assetType = lastLocation.assetType,
|
assetType = lastLocation.assetType,
|
||||||
|
isOwnUser = share.userId == joinedRoom.sessionId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.toImmutableList()
|
.toImmutableList()
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,10 @@ data class LocationShareItem(
|
||||||
val location: Location,
|
val location: Location,
|
||||||
val isLive: Boolean,
|
val isLive: Boolean,
|
||||||
val assetType: AssetType?,
|
val assetType: AssetType?,
|
||||||
)
|
val isOwnUser: Boolean
|
||||||
|
) {
|
||||||
|
val canStopSharing = isLive && isOwnUser
|
||||||
|
}
|
||||||
|
|
||||||
fun LocationShareItem.toMarkerData(): LocationMarkerData {
|
fun LocationShareItem.toMarkerData(): LocationMarkerData {
|
||||||
val pinVariant = if (assetType == AssetType.PIN) {
|
val pinVariant = if (assetType == AssetType.PIN) {
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ fun aLocationShareItem(
|
||||||
assetType: AssetType? = null,
|
assetType: AssetType? = null,
|
||||||
formattedTimestamp: String = "Shared 1 min ago",
|
formattedTimestamp: String = "Shared 1 min ago",
|
||||||
location: Location = Location(1.23, 2.34, 4f),
|
location: Location = Location(1.23, 2.34, 4f),
|
||||||
|
isOwnUser: Boolean = false,
|
||||||
) = LocationShareItem(
|
) = LocationShareItem(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
|
|
@ -89,4 +90,5 @@ fun aLocationShareItem(
|
||||||
location = location,
|
location = location,
|
||||||
isLive = isLive,
|
isLive = isLive,
|
||||||
assetType = assetType,
|
assetType = assetType,
|
||||||
|
isOwnUser = isOwnUser,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@ fun ShowLocationView(
|
||||||
LocationShareRow(
|
LocationShareRow(
|
||||||
item = locationShare,
|
item = locationShare,
|
||||||
onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) },
|
onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) },
|
||||||
|
onStopClick = { state.eventSink(ShowLocationEvent.StopLocationSharing) },
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
|
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
|
||||||
val position = CameraPosition(
|
val position = CameraPosition(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?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_disclaimer_title">"Το ιστορικό ζωντανής τοποθεσίας σας θα αποθηκευτεί στην αίθουσα και θα είναι ορατό στα μέλη μετά το τέλος της συνεδρίας."</string>
|
||||||
|
<string name="screen_share_location_live_location_duration_picker_title">"Επιλέξτε για πόσο χρονικό διάστημα θα κοινοποιείτε την τρέχουσα τοποθεσία σας."</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">"Vali, kui kaua tahad oma reaalajas jagada."</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -2,4 +2,5 @@
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="screen_share_location_live_location_disclaimer_title">"Az élő helymeghatározás története a szobában lesz tárolva, és a munkamenet befejezése után is látható marad a tagok számára."</string>
|
<string name="screen_share_location_live_location_disclaimer_title">"Az élő helymeghatározás története a szobában lesz tárolva, és a munkamenet befejezése után is látható marad a tagok számára."</string>
|
||||||
<string name="screen_share_location_live_location_duration_picker_title">"Válassza ki, mennyi ideig szeretné megosztani az aktuális tartózkodási helyét."</string>
|
<string name="screen_share_location_live_location_duration_picker_title">"Válassza ki, mennyi ideig szeretné megosztani az aktuális tartózkodási helyét."</string>
|
||||||
|
<string name="screen_share_location_live_location_missing_permissions">"Nincs jogosultsága az élő tartózkodási helyének megosztására ebben a szobában."</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,5 @@
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="screen_share_location_live_location_disclaimer_title">"La cronologia delle tue posizioni in tempo reale verrà archiviata nella stanza e sarà visibile ai membri al termine della sessione."</string>
|
<string name="screen_share_location_live_location_disclaimer_title">"La cronologia delle tue posizioni in tempo reale verrà archiviata nella stanza e sarà visibile ai membri al termine della sessione."</string>
|
||||||
<string name="screen_share_location_live_location_duration_picker_title">"Scegli per quanto tempo condividere la tua posizione in tempo reale."</string>
|
<string name="screen_share_location_live_location_duration_picker_title">"Scegli per quanto tempo condividere la tua posizione in tempo reale."</string>
|
||||||
|
<string name="screen_share_location_live_location_missing_permissions">"Non hai l\'autorizzazione per condividere la tua posizione in tempo reale in questa stanza"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?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_disclaimer_title">"Twoja historia lokalizacji na żywo zostanie zapisana w pokoju i będzie widoczna dla członków po zakończeniu sesji."</string>
|
||||||
|
<string name="screen_share_location_live_location_duration_picker_title">"Wybierz, jak długo chcesz udostępniać swoją lokalizację na żywo."</string>
|
||||||
|
<string name="screen_share_location_live_location_missing_permissions">"Nie masz uprawnień do udostępniania swojej lokalizacji na żywo w tym pokoju"</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?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_disclaimer_title">"Ваша історія поточного місцезнаходження зберігатиметься у кімнаті та буде доступна учасникам після завершення сеансу."</string>
|
||||||
|
<string name="screen_share_location_live_location_duration_picker_title">"Виберіть, як довго ділитися своїм місцезнаходженням."</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -2,4 +2,5 @@
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="screen_share_location_live_location_disclaimer_title">"你实时位置历史将存储在房间中,并于会话结束后对其他成员可见。"</string>
|
<string name="screen_share_location_live_location_disclaimer_title">"你实时位置历史将存储在房间中,并于会话结束后对其他成员可见。"</string>
|
||||||
<string name="screen_share_location_live_location_duration_picker_title">"选择共享实时位置的时长。"</string>
|
<string name="screen_share_location_live_location_duration_picker_title">"选择共享实时位置的时长。"</string>
|
||||||
|
<string name="screen_share_location_live_location_missing_permissions">"你无权在此房内共享实时位置。"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,5 @@
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="screen_share_location_live_location_disclaimer_title">"Your live location history will be stored in the room and visible to members after the session ends."</string>
|
<string name="screen_share_location_live_location_disclaimer_title">"Your live location history will be stored in the room and visible to members after the session ends."</string>
|
||||||
<string name="screen_share_location_live_location_duration_picker_title">"Choose how long to share your live location."</string>
|
<string name="screen_share_location_live_location_duration_picker_title">"Choose how long to share your live location."</string>
|
||||||
|
<string name="screen_share_location_live_location_missing_permissions">"You do not have permissions to share your live location in this room"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ class LocationConstraintsCheckTest {
|
||||||
)
|
)
|
||||||
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||||
|
|
||||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||||
|
|
||||||
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
|
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
|
||||||
}
|
}
|
||||||
|
|
@ -33,7 +33,7 @@ class LocationConstraintsCheckTest {
|
||||||
)
|
)
|
||||||
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||||
|
|
||||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||||
|
|
||||||
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
|
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +45,7 @@ class LocationConstraintsCheckTest {
|
||||||
)
|
)
|
||||||
val locationActions = FakeLocationActions(isLocationEnabled = false)
|
val locationActions = FakeLocationActions(isLocationEnabled = false)
|
||||||
|
|
||||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||||
|
|
||||||
assertThat(result).isEqualTo(LocationConstraintsCheck.LocationServiceDisabled)
|
assertThat(result).isEqualTo(LocationConstraintsCheck.LocationServiceDisabled)
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +58,7 @@ class LocationConstraintsCheckTest {
|
||||||
)
|
)
|
||||||
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||||
|
|
||||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||||
|
|
||||||
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionRationale)
|
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionRationale)
|
||||||
}
|
}
|
||||||
|
|
@ -71,8 +71,20 @@ class LocationConstraintsCheckTest {
|
||||||
)
|
)
|
||||||
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||||
|
|
||||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||||
|
|
||||||
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionDenied)
|
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionDenied)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checkLocationConstraints returns NotEnoughPowerLevel when send permissions are not granted`() {
|
||||||
|
val permissionsState = aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.NoneGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||||
|
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.DEFAULT)
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(LocationConstraintsCheck.NotEnoughPowerLevel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,488 @@
|
||||||
|
/*
|
||||||
|
* 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.live
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.location.impl.live.service.LiveLocationSharingCoordinator
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
|
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
|
||||||
|
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
|
||||||
|
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
||||||
|
import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver
|
||||||
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
|
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||||
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import io.element.android.tests.testutils.lambda.assert
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class DefaultActiveLiveLocationShareManagerTest {
|
||||||
|
@get:Rule
|
||||||
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `starting the first share starts the coordinator service after the beacon echo and adds an active share`() = runTest {
|
||||||
|
val startServiceRecorder = lambdaRecorder<Unit> { }
|
||||||
|
val stopServiceRecorder = lambdaRecorder<Unit> { }
|
||||||
|
val coordinator = createCoordinator(
|
||||||
|
startService = startServiceRecorder,
|
||||||
|
stopService = stopServiceRecorder
|
||||||
|
)
|
||||||
|
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
|
||||||
|
val room = FakeJoinedRoom(
|
||||||
|
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
|
||||||
|
stopLiveLocationShareResult = { Result.success(Unit) },
|
||||||
|
)
|
||||||
|
val manager = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
ownBeaconInfoUpdates = beaconInfoUpdates,
|
||||||
|
).apply { givenGetRoomResult(A_ROOM_ID, room) },
|
||||||
|
coordinator = coordinator,
|
||||||
|
clock = FakeSystemClock(epochMillisResult = 123L),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val result = async { manager.startShare(A_ROOM_ID, 60.minutes) }
|
||||||
|
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
|
||||||
|
|
||||||
|
assertThat(result.await().isSuccess).isTrue()
|
||||||
|
assertThat(manager.sharingRoomIds.value).containsExactly(A_ROOM_ID)
|
||||||
|
assert(startServiceRecorder).isCalledOnce()
|
||||||
|
assert(stopServiceRecorder).isNeverCalled()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stopping the last share stops the coordinator service`() = runTest {
|
||||||
|
val startServiceRecorder = lambdaRecorder<Unit> { }
|
||||||
|
val stopServiceRecorder = lambdaRecorder<Unit> { }
|
||||||
|
val coordinator = createCoordinator(
|
||||||
|
startService = startServiceRecorder,
|
||||||
|
stopService = stopServiceRecorder
|
||||||
|
)
|
||||||
|
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
|
||||||
|
val room = FakeJoinedRoom(
|
||||||
|
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
|
||||||
|
stopLiveLocationShareResult = { Result.success(Unit) },
|
||||||
|
)
|
||||||
|
val manager = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
ownBeaconInfoUpdates = beaconInfoUpdates,
|
||||||
|
).apply { givenGetRoomResult(A_ROOM_ID, room) },
|
||||||
|
coordinator = coordinator,
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val startResult = async { manager.startShare(A_ROOM_ID, 15.minutes) }
|
||||||
|
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
|
||||||
|
assertThat(startResult.await().isSuccess).isTrue()
|
||||||
|
|
||||||
|
val result = manager.stopShare(A_ROOM_ID)
|
||||||
|
|
||||||
|
assertThat(result.isSuccess).isTrue()
|
||||||
|
assertThat(manager.sharingRoomIds.value).isEmpty()
|
||||||
|
assert(startServiceRecorder).isCalledOnce()
|
||||||
|
assert(stopServiceRecorder).isCalledOnce()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two managers with the same room id keep isolated state per session`() = runTest {
|
||||||
|
val coordinator = createCoordinator()
|
||||||
|
val beaconInfoUpdatesOne = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
|
||||||
|
val beaconInfoUpdatesTwo = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
|
||||||
|
val managerOne = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
ownBeaconInfoUpdates = beaconInfoUpdatesOne,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
A_ROOM_ID,
|
||||||
|
FakeJoinedRoom(
|
||||||
|
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
|
||||||
|
stopLiveLocationShareResult = { Result.success(Unit) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
coordinator = coordinator,
|
||||||
|
)
|
||||||
|
val managerTwo = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID_2,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
ownBeaconInfoUpdates = beaconInfoUpdatesTwo,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
A_ROOM_ID,
|
||||||
|
FakeJoinedRoom(
|
||||||
|
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
|
||||||
|
stopLiveLocationShareResult = { Result.success(Unit) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
coordinator = coordinator,
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val startResult = async { managerOne.startShare(A_ROOM_ID, 15.minutes) }
|
||||||
|
beaconInfoUpdatesOne.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
|
||||||
|
assertThat(startResult.await().isSuccess).isTrue()
|
||||||
|
|
||||||
|
assertThat(managerOne.sharingRoomIds.value).containsExactly(A_ROOM_ID)
|
||||||
|
assertThat(managerTwo.sharingRoomIds.value).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `start share persists room expiry after beacon echo`() = runTest {
|
||||||
|
val liveLocationStore = createLiveLocationStore()
|
||||||
|
val coordinator = createCoordinator()
|
||||||
|
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
|
||||||
|
val manager = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
ownBeaconInfoUpdates = beaconInfoUpdates,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
A_ROOM_ID,
|
||||||
|
FakeJoinedRoom(
|
||||||
|
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
|
||||||
|
stopLiveLocationShareResult = { Result.success(Unit) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
coordinator = coordinator,
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
|
clock = FakeSystemClock(epochMillisResult = 123L),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val result = async { manager.startShare(A_ROOM_ID, 15.minutes) }
|
||||||
|
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
|
||||||
|
|
||||||
|
assertThat(result.await().isSuccess).isTrue()
|
||||||
|
assertThat(liveLocationStore.getLiveLocationExpiries()).containsKey(A_ROOM_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stop share removes persisted expiry`() = runTest {
|
||||||
|
val liveLocationStore = createLiveLocationStore()
|
||||||
|
val coordinator = createCoordinator()
|
||||||
|
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
|
||||||
|
val manager = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
ownBeaconInfoUpdates = beaconInfoUpdates,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
A_ROOM_ID,
|
||||||
|
FakeJoinedRoom(
|
||||||
|
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
|
||||||
|
stopLiveLocationShareResult = { Result.success(Unit) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
coordinator = coordinator,
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val startResult = async { manager.startShare(A_ROOM_ID, 15.minutes) }
|
||||||
|
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
|
||||||
|
assertThat(startResult.await().isSuccess).isTrue()
|
||||||
|
|
||||||
|
manager.stopShare(A_ROOM_ID)
|
||||||
|
|
||||||
|
assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setup restores unexpired stored share and registers coordinator`() = runTest {
|
||||||
|
val startServiceRecorder = lambdaRecorder<Unit> { }
|
||||||
|
val stopServiceRecorder = lambdaRecorder<Unit> { }
|
||||||
|
val liveLocationStore = createLiveLocationStore().apply {
|
||||||
|
setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(10_000L))
|
||||||
|
}
|
||||||
|
val manager = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(A_ROOM_ID, FakeJoinedRoom())
|
||||||
|
},
|
||||||
|
coordinator = createCoordinator(
|
||||||
|
startService = startServiceRecorder,
|
||||||
|
stopService = stopServiceRecorder,
|
||||||
|
),
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
|
clock = FakeSystemClock(epochMillisResult = 1_000L),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(manager.sharingRoomIds.value).containsExactly(A_ROOM_ID)
|
||||||
|
assert(startServiceRecorder).isCalledOnce()
|
||||||
|
assert(stopServiceRecorder).isNeverCalled()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setup remotely stops expired stored share and removes it from store`() = runTest {
|
||||||
|
val stopLiveLocationShareResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||||
|
val liveLocationStore = createLiveLocationStore().apply {
|
||||||
|
setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L))
|
||||||
|
}
|
||||||
|
createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
A_ROOM_ID,
|
||||||
|
FakeJoinedRoom(stopLiveLocationShareResult = stopLiveLocationShareResult),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
coordinator = createCoordinator(),
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
|
clock = FakeSystemClock(epochMillisResult = 5_000L),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assert(stopLiveLocationShareResult).isCalledOnce()
|
||||||
|
assertThat(liveLocationStore.getLiveLocationExpiries()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stop share closes loaded room and removes persisted expiry when room is not tracked`() = runTest {
|
||||||
|
val stopLiveLocationShareResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||||
|
val room = FakeJoinedRoom(stopLiveLocationShareResult = stopLiveLocationShareResult)
|
||||||
|
val liveLocationStore = createInMemoryLiveLocationStore()
|
||||||
|
val manager = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(A_ROOM_ID, room)
|
||||||
|
},
|
||||||
|
coordinator = createCoordinator(),
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
|
)
|
||||||
|
liveLocationStore.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(10_000L))
|
||||||
|
|
||||||
|
val result = manager.stopShare(A_ROOM_ID)
|
||||||
|
|
||||||
|
assertThat(result.isSuccess).isTrue()
|
||||||
|
assert(stopLiveLocationShareResult).isCalledOnce()
|
||||||
|
assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
|
||||||
|
room.baseRoom.assertDestroyed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `share is automatically stopped when timeout elapses`() = runTest {
|
||||||
|
val liveLocationStore = createInMemoryLiveLocationStore()
|
||||||
|
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
|
||||||
|
val stopLiveLocationShareResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||||
|
val manager = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
ownBeaconInfoUpdates = beaconInfoUpdates,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
A_ROOM_ID,
|
||||||
|
FakeJoinedRoom(
|
||||||
|
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
|
||||||
|
stopLiveLocationShareResult = stopLiveLocationShareResult
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
coordinator = createCoordinator(),
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
|
clock = FakeSystemClock(epochMillisResult = 123L),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val startResult = async { manager.startShare(A_ROOM_ID, 1.minutes) }
|
||||||
|
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
|
||||||
|
assertThat(startResult.await().isSuccess).isTrue()
|
||||||
|
|
||||||
|
manager.sharingRoomIds.test {
|
||||||
|
assertThat(awaitItem()).containsExactly(A_ROOM_ID)
|
||||||
|
assertThat(awaitItem()).isEmpty()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
|
||||||
|
assert(stopLiveLocationShareResult).isCalledExactly(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `restored share is automatically stopped when remaining timeout elapses`() = runTest {
|
||||||
|
val liveLocationStore = createInMemoryLiveLocationStore().apply {
|
||||||
|
setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(6_000L))
|
||||||
|
}
|
||||||
|
val stopLiveLocationShareLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||||
|
val manager = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
A_ROOM_ID,
|
||||||
|
FakeJoinedRoom(
|
||||||
|
stopLiveLocationShareResult = stopLiveLocationShareLambda
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
coordinator = createCoordinator(),
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
|
clock = FakeSystemClock(epochMillisResult = 1_000L),
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.sharingRoomIds.test {
|
||||||
|
assertThat(awaitItem()).containsExactly(A_ROOM_ID)
|
||||||
|
assertThat(awaitItem()).isEmpty()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
|
||||||
|
assert(stopLiveLocationShareLambda).isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `session deleted clears local state`() = runTest {
|
||||||
|
val startServiceRecorder = lambdaRecorder<Unit> { }
|
||||||
|
val stopServiceRecorder = lambdaRecorder<Unit> { }
|
||||||
|
val liveLocationStore = createInMemoryLiveLocationStore()
|
||||||
|
val sessionObserver = FakeSessionObserver()
|
||||||
|
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
|
||||||
|
val manager = createManager(
|
||||||
|
client = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
ownBeaconInfoUpdates = beaconInfoUpdates,
|
||||||
|
).apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
A_ROOM_ID,
|
||||||
|
FakeJoinedRoom(
|
||||||
|
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
|
||||||
|
stopLiveLocationShareResult = { Result.success(Unit) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
coordinator = createCoordinator(
|
||||||
|
startService = startServiceRecorder,
|
||||||
|
stopService = stopServiceRecorder,
|
||||||
|
),
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
|
sessionObserver = sessionObserver,
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val firstStart = async { manager.startShare(A_ROOM_ID, 15.minutes) }
|
||||||
|
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
|
||||||
|
assertThat(firstStart.await().isSuccess).isTrue()
|
||||||
|
|
||||||
|
sessionObserver.onSessionDeleted(A_SESSION_ID.value)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(manager.sharingRoomIds.value).isEmpty()
|
||||||
|
assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
|
||||||
|
assert(startServiceRecorder).isCalledOnce()
|
||||||
|
assert(stopServiceRecorder).isCalledOnce()
|
||||||
|
|
||||||
|
val secondStart = async { manager.startShare(A_ROOM_ID, 15.minutes) }
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(secondStart.isCompleted).isFalse()
|
||||||
|
|
||||||
|
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
|
||||||
|
assertThat(secondStart.await().isSuccess).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createManager(
|
||||||
|
client: FakeMatrixClient = FakeMatrixClient(sessionId = A_SESSION_ID),
|
||||||
|
coordinator: LiveLocationSharingCoordinator = createCoordinator(),
|
||||||
|
liveLocationStore: LiveLocationStore = createLiveLocationStore(),
|
||||||
|
clock: SystemClock = FakeSystemClock(),
|
||||||
|
sessionObserver: SessionObserver = FakeSessionObserver(),
|
||||||
|
): DefaultActiveLiveLocationShareManager {
|
||||||
|
return DefaultActiveLiveLocationShareManager(
|
||||||
|
matrixClient = client,
|
||||||
|
coordinator = coordinator,
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
|
clock = clock,
|
||||||
|
sessionObserver = sessionObserver,
|
||||||
|
).apply {
|
||||||
|
setup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCoordinator(
|
||||||
|
startService: () -> Unit = {},
|
||||||
|
stopService: () -> Unit = {},
|
||||||
|
nowMillis: () -> Long = { 0L },
|
||||||
|
): LiveLocationSharingCoordinator {
|
||||||
|
return LiveLocationSharingCoordinator(
|
||||||
|
startService = startService,
|
||||||
|
stopService = stopService,
|
||||||
|
nowMillis = nowMillis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createLiveLocationStore(
|
||||||
|
sessionId: io.element.android.libraries.matrix.api.core.SessionId = A_SESSION_ID,
|
||||||
|
preferenceDataStoreFactory: PreferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
|
||||||
|
): LiveLocationStore {
|
||||||
|
return LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||||
|
sessionId = sessionId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createInMemoryLiveLocationStore(
|
||||||
|
sessionId: io.element.android.libraries.matrix.api.core.SessionId = A_SESSION_ID,
|
||||||
|
): LiveLocationStore {
|
||||||
|
val preferenceDataStoreFactory = object : PreferenceDataStoreFactory {
|
||||||
|
override fun create(name: String): DataStore<Preferences> {
|
||||||
|
var preferences: Preferences = emptyPreferences()
|
||||||
|
return object : DataStore<Preferences> {
|
||||||
|
override val data: Flow<Preferences>
|
||||||
|
get() = flowOf(preferences)
|
||||||
|
|
||||||
|
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
|
||||||
|
preferences = transform(preferences)
|
||||||
|
return preferences
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createLiveLocationStore(
|
||||||
|
sessionId = sessionId,
|
||||||
|
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
/*
|
||||||
|
* 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.live
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.features.location.impl.live.service.LiveLocationReceiver
|
||||||
|
import io.element.android.features.location.impl.live.service.LiveLocationSharingCoordinator
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LiveLocationSharingCoordinatorTest {
|
||||||
|
@Test
|
||||||
|
fun `first registration starts the service and last unregister stops it`() = runTest {
|
||||||
|
var startCount = 0
|
||||||
|
var stopCount = 0
|
||||||
|
val coordinator = LiveLocationSharingCoordinator(
|
||||||
|
startService = { startCount++ },
|
||||||
|
stopService = { stopCount++ },
|
||||||
|
nowMillis = { 0L },
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator.register(A_SESSION_ID, LiveLocationReceiver { })
|
||||||
|
coordinator.unregister(A_SESSION_ID)
|
||||||
|
|
||||||
|
assertThat(startCount).isEqualTo(1)
|
||||||
|
assertThat(stopCount).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dispatch isolates receiver failures and still reaches later receivers`() = runTest {
|
||||||
|
val delivered = mutableListOf<Location>()
|
||||||
|
val coordinator = LiveLocationSharingCoordinator(
|
||||||
|
startService = { },
|
||||||
|
stopService = { },
|
||||||
|
nowMillis = { 4_000L },
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator.register(A_SESSION_ID) { error("boom") }
|
||||||
|
coordinator.register(A_SESSION_ID_2) { location -> delivered += location }
|
||||||
|
coordinator.dispatch(Location(lat = 1.0, lon = 2.0, accuracy = 3f))
|
||||||
|
|
||||||
|
assertThat(delivered).containsExactly(Location(lat = 1.0, lon = 2.0, accuracy = 3f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dispatch delivers first location immediately`() = runTest {
|
||||||
|
var nowMillis = 4_000L
|
||||||
|
val delivered = mutableListOf<Location>()
|
||||||
|
val coordinator = LiveLocationSharingCoordinator(
|
||||||
|
startService = { },
|
||||||
|
stopService = { },
|
||||||
|
nowMillis = { nowMillis },
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator.register(A_SESSION_ID) { location -> delivered += location }
|
||||||
|
|
||||||
|
val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
|
||||||
|
|
||||||
|
coordinator.dispatch(firstLocation)
|
||||||
|
|
||||||
|
assertThat(delivered).containsExactly(firstLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dispatch drops updates inside the throttle window`() = runTest {
|
||||||
|
var nowMillis = 4_000L
|
||||||
|
val delivered = mutableListOf<Location>()
|
||||||
|
val coordinator = LiveLocationSharingCoordinator(
|
||||||
|
startService = { },
|
||||||
|
stopService = { },
|
||||||
|
nowMillis = { nowMillis },
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator.register(A_SESSION_ID) { location -> delivered += location }
|
||||||
|
|
||||||
|
val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
|
||||||
|
val secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f)
|
||||||
|
|
||||||
|
coordinator.dispatch(firstLocation)
|
||||||
|
nowMillis += 500
|
||||||
|
coordinator.dispatch(secondLocation)
|
||||||
|
|
||||||
|
assertThat(delivered).containsExactly(firstLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dispatch delivers next update after the throttle window elapses`() = runTest {
|
||||||
|
var nowMillis = 4_000L
|
||||||
|
val delivered = mutableListOf<Location>()
|
||||||
|
val coordinator = LiveLocationSharingCoordinator(
|
||||||
|
startService = { },
|
||||||
|
stopService = { },
|
||||||
|
nowMillis = { nowMillis },
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator.register(A_SESSION_ID) { location -> delivered += location }
|
||||||
|
|
||||||
|
val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
|
||||||
|
val secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f)
|
||||||
|
|
||||||
|
coordinator.dispatch(firstLocation)
|
||||||
|
nowMillis += 3_000
|
||||||
|
coordinator.dispatch(secondLocation)
|
||||||
|
|
||||||
|
assertThat(delivered).containsExactly(firstLocation, secondLocation).inOrder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,8 @@ import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
||||||
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
|
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
|
||||||
|
import io.element.android.features.location.impl.live.LiveLocationStore
|
||||||
|
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
|
||||||
import io.element.android.features.messages.test.FakeMessageComposerContext
|
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||||
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
|
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
|
|
@ -20,8 +22,10 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
|
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.node.TestParentNode
|
import io.element.android.tests.testutils.node.TestParentNode
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
@ -30,16 +34,17 @@ class DefaultShareLocationEntryPointTest {
|
||||||
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test node builder`() {
|
fun `test node builder`() = runTest {
|
||||||
val entryPoint = DefaultShareLocationEntryPoint()
|
val entryPoint = DefaultShareLocationEntryPoint()
|
||||||
val parentNode = TestParentNode.create { buildContext, plugins ->
|
val parentNode = TestParentNode.create { buildContext, plugins ->
|
||||||
|
val room = FakeJoinedRoom()
|
||||||
ShareLocationNode(
|
ShareLocationNode(
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
presenterFactory = { timelineMode: Timeline.Mode ->
|
presenterFactory = { timelineMode: Timeline.Mode ->
|
||||||
ShareLocationPresenter(
|
ShareLocationPresenter(
|
||||||
permissionsPresenterFactory = { FakePermissionsPresenter() },
|
permissionsPresenterFactory = { FakePermissionsPresenter() },
|
||||||
room = FakeJoinedRoom(),
|
room = room,
|
||||||
timelineMode = timelineMode,
|
timelineMode = timelineMode,
|
||||||
analyticsService = FakeAnalyticsService(),
|
analyticsService = FakeAnalyticsService(),
|
||||||
messageComposerContext = FakeMessageComposerContext(),
|
messageComposerContext = FakeMessageComposerContext(),
|
||||||
|
|
@ -48,6 +53,11 @@ class DefaultShareLocationEntryPointTest {
|
||||||
featureFlagService = FakeFeatureFlagService(),
|
featureFlagService = FakeFeatureFlagService(),
|
||||||
client = FakeMatrixClient(),
|
client = FakeMatrixClient(),
|
||||||
durationFormatter = FakeDurationFormatter(),
|
durationFormatter = FakeDurationFormatter(),
|
||||||
|
liveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||||
|
liveLocationStore = LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
|
||||||
|
sessionId = room.sessionId,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
analyticsService = FakeAnalyticsService(),
|
analyticsService = FakeAnalyticsService(),
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@
|
||||||
|
|
||||||
package io.element.android.features.location.impl.share
|
package io.element.android.features.location.impl.share
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
import app.cash.molecule.RecompositionMode
|
import app.cash.molecule.RecompositionMode
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
|
|
@ -22,28 +25,48 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
||||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||||
|
import io.element.android.features.location.impl.live.LiveLocationStore
|
||||||
|
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
|
||||||
import io.element.android.features.messages.test.FakeMessageComposerContext
|
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||||
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
|
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||||
|
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
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.Timeline
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_THREAD_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
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.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
||||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||||
|
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
|
||||||
|
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import io.element.android.tests.testutils.lambda.assert
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
|
||||||
class ShareLocationPresenterTest {
|
class ShareLocationPresenterTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
|
|
@ -59,13 +82,16 @@ class ShareLocationPresenterTest {
|
||||||
|
|
||||||
private val durationFormatter = FakeDurationFormatter()
|
private val durationFormatter = FakeDurationFormatter()
|
||||||
|
|
||||||
private fun createShareLocationPresenter(
|
private fun TestScope.createShareLocationPresenter(
|
||||||
joinedRoom: JoinedRoom = FakeJoinedRoom(),
|
joinedRoom: JoinedRoom = FakeJoinedRoom(),
|
||||||
|
timelineMode: Timeline.Mode = Timeline.Mode.Live,
|
||||||
locationActions: FakeLocationActions = fakeLocationActions,
|
locationActions: FakeLocationActions = fakeLocationActions,
|
||||||
|
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||||
|
liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId),
|
||||||
): ShareLocationPresenter = ShareLocationPresenter(
|
): ShareLocationPresenter = ShareLocationPresenter(
|
||||||
permissionsPresenterFactory = { fakePermissionsPresenter },
|
permissionsPresenterFactory = { fakePermissionsPresenter },
|
||||||
room = joinedRoom,
|
room = joinedRoom,
|
||||||
timelineMode = Timeline.Mode.Live,
|
timelineMode = timelineMode,
|
||||||
analyticsService = fakeAnalyticsService,
|
analyticsService = fakeAnalyticsService,
|
||||||
messageComposerContext = fakeMessageComposerContext,
|
messageComposerContext = fakeMessageComposerContext,
|
||||||
locationActions = locationActions,
|
locationActions = locationActions,
|
||||||
|
|
@ -73,6 +99,8 @@ class ShareLocationPresenterTest {
|
||||||
featureFlagService = fakeFeatureFlagService,
|
featureFlagService = fakeFeatureFlagService,
|
||||||
client = fakeMatrixClient,
|
client = fakeMatrixClient,
|
||||||
durationFormatter = durationFormatter,
|
durationFormatter = durationFormatter,
|
||||||
|
liveLocationShareManager = liveLocationShareManager,
|
||||||
|
liveLocationStore = liveLocationStore,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -296,7 +324,15 @@ class ShareLocationPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `ShowLiveLocationDurationPicker shows duration dialog when constraints pass`() = runTest {
|
fun `ShowLiveLocationDurationPicker shows duration dialog when constraints pass`() = runTest {
|
||||||
val shareLocationPresenter = createShareLocationPresenter()
|
val joinedRoom = FakeJoinedRoom(
|
||||||
|
baseRoom = FakeBaseRoom(
|
||||||
|
roomPermissions = grantedSendLiveLocationPermissions()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId).apply {
|
||||||
|
setAcceptedLiveLocationDisclaimer().getOrThrow()
|
||||||
|
}
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter(joinedRoom = joinedRoom, liveLocationStore = locationStore)
|
||||||
fakePermissionsPresenter.givenState(
|
fakePermissionsPresenter.givenState(
|
||||||
aPermissionsState(
|
aPermissionsState(
|
||||||
permissions = PermissionsState.Permissions.AllGranted,
|
permissions = PermissionsState.Permissions.AllGranted,
|
||||||
|
|
@ -307,7 +343,7 @@ class ShareLocationPresenterTest {
|
||||||
shareLocationPresenter.test {
|
shareLocationPresenter.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
|
initialState.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||||
val durationDialogState = awaitItem()
|
val durationDialogState = awaitItem()
|
||||||
|
|
||||||
assertThat(durationDialogState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java)
|
assertThat(durationDialogState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java)
|
||||||
|
|
@ -315,9 +351,155 @@ class ShareLocationPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ShowLiveLocationDurationPicker shows disclaimer when acceptance is missing`() = runTest {
|
||||||
|
val presenter = createShareLocationPresenter()
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.AllGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val state = awaitItem()
|
||||||
|
|
||||||
|
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||||
|
val dialogState = awaitItem()
|
||||||
|
|
||||||
|
assertThat(dialogState.dialogState).isEqualTo(ShareLocationState.Dialog.LiveLocationDisclaimer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `AcceptLiveLocationDisclaimer persists acceptance and shows durations`() = runTest {
|
||||||
|
val joinedRoom = FakeJoinedRoom(
|
||||||
|
baseRoom = FakeBaseRoom(
|
||||||
|
roomPermissions = grantedSendLiveLocationPermissions()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId)
|
||||||
|
val presenter = createShareLocationPresenter(joinedRoom = joinedRoom, liveLocationStore = locationStore)
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.AllGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val state = awaitItem()
|
||||||
|
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||||
|
awaitItem()
|
||||||
|
|
||||||
|
state.eventSink(ShareLocationEvent.AcceptLiveLocationDisclaimer)
|
||||||
|
val durationState = awaitItem()
|
||||||
|
|
||||||
|
assertThat(locationStore.hasAcceptedLiveLocationDisclaimer()).isTrue()
|
||||||
|
assertThat(durationState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `AcceptLiveLocationDisclaimer keeps disclaimer gate active when persistence fails`() = runTest {
|
||||||
|
val joinedRoom = FakeJoinedRoom()
|
||||||
|
val presenter = createShareLocationPresenter(
|
||||||
|
joinedRoom = joinedRoom,
|
||||||
|
liveLocationStore = createFailingLiveLocationStore(sessionId = joinedRoom.sessionId),
|
||||||
|
)
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.AllGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val state = awaitItem()
|
||||||
|
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||||
|
val disclaimerState = awaitItem()
|
||||||
|
|
||||||
|
disclaimerState.eventSink(ShareLocationEvent.AcceptLiveLocationDisclaimer)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
expectNoEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ShowLiveLocationDurationPicker bypasses disclaimer when already accepted`() = runTest {
|
||||||
|
val joinedRoom = FakeJoinedRoom(
|
||||||
|
baseRoom = FakeBaseRoom(
|
||||||
|
roomPermissions = grantedSendLiveLocationPermissions()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId).apply {
|
||||||
|
setAcceptedLiveLocationDisclaimer().getOrThrow()
|
||||||
|
}
|
||||||
|
val presenter = createShareLocationPresenter(joinedRoom = joinedRoom, liveLocationStore = locationStore)
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.AllGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val state = awaitItem()
|
||||||
|
|
||||||
|
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||||
|
val durationState = awaitItem()
|
||||||
|
|
||||||
|
assertThat(durationState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ShowLiveLocationDurationPicker uses the active session disclaimer state`() = runTest {
|
||||||
|
val joinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = SessionId("@alice:server")))
|
||||||
|
createLiveLocationStore(sessionId = SessionId("@bob:server"))
|
||||||
|
.setAcceptedLiveLocationDisclaimer()
|
||||||
|
.getOrThrow()
|
||||||
|
val presenter = createShareLocationPresenter(
|
||||||
|
joinedRoom = joinedRoom,
|
||||||
|
liveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId),
|
||||||
|
)
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.AllGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val state = awaitItem()
|
||||||
|
|
||||||
|
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||||
|
val dialogState = awaitItem()
|
||||||
|
|
||||||
|
assertThat(dialogState.dialogState).isEqualTo(ShareLocationState.Dialog.LiveLocationDisclaimer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `ShowLiveLocationDurationPicker shows constraint dialog when permissions denied`() = runTest {
|
fun `ShowLiveLocationDurationPicker shows constraint dialog when permissions denied`() = runTest {
|
||||||
val shareLocationPresenter = createShareLocationPresenter()
|
val joinedRoom = FakeJoinedRoom(
|
||||||
|
baseRoom = FakeBaseRoom(
|
||||||
|
roomPermissions = grantedSendLiveLocationPermissions()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId).apply {
|
||||||
|
setAcceptedLiveLocationDisclaimer().getOrThrow()
|
||||||
|
}
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter(
|
||||||
|
joinedRoom = joinedRoom,
|
||||||
|
liveLocationStore = locationStore,
|
||||||
|
)
|
||||||
fakePermissionsPresenter.givenState(
|
fakePermissionsPresenter.givenState(
|
||||||
aPermissionsState(
|
aPermissionsState(
|
||||||
permissions = PermissionsState.Permissions.NoneGranted,
|
permissions = PermissionsState.Permissions.NoneGranted,
|
||||||
|
|
@ -332,7 +514,7 @@ class ShareLocationPresenterTest {
|
||||||
initialState.eventSink(ShareLocationEvent.DismissDialog)
|
initialState.eventSink(ShareLocationEvent.DismissDialog)
|
||||||
val dismissedState = awaitItem()
|
val dismissedState = awaitItem()
|
||||||
|
|
||||||
dismissedState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
|
dismissedState.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||||
val constraintDialogState = awaitItem()
|
val constraintDialogState = awaitItem()
|
||||||
|
|
||||||
assertThat(constraintDialogState.dialogState).isEqualTo(
|
assertThat(constraintDialogState.dialogState).isEqualTo(
|
||||||
|
|
@ -447,4 +629,101 @@ class ShareLocationPresenterTest {
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `StartLiveLocationShare event calls manager startShare`() = runTest {
|
||||||
|
val startShareLambda = lambdaRecorder { _: RoomId, _: Duration -> Result.success(Unit) }
|
||||||
|
val manager = FakeActiveLiveLocationShareManager(
|
||||||
|
startShareLambda = startShareLambda,
|
||||||
|
)
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter(liveLocationShareManager = manager)
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.AllGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
shareLocationPresenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val state = awaitItem()
|
||||||
|
state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration = 1.hours))
|
||||||
|
advanceUntilIdle()
|
||||||
|
assert(startShareLambda).isCalledOnce().with(
|
||||||
|
value(A_ROOM_ID),
|
||||||
|
value(1.hours)
|
||||||
|
)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `canShareLiveLocation is false when the feature is disabled`() = runTest {
|
||||||
|
fakeFeatureFlagService.setFeatureEnabled(FeatureFlags.LiveLocationSharing, false)
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter(
|
||||||
|
timelineMode = Timeline.Mode.Live,
|
||||||
|
)
|
||||||
|
shareLocationPresenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val state = awaitItem()
|
||||||
|
assertThat(state.canShareLiveLocation).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `canShareLiveLocation is true when the feature is enabled`() = runTest {
|
||||||
|
fakeFeatureFlagService.setFeatureEnabled(FeatureFlags.LiveLocationSharing, true)
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter(
|
||||||
|
timelineMode = Timeline.Mode.Live,
|
||||||
|
)
|
||||||
|
shareLocationPresenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val state = awaitItem()
|
||||||
|
assertThat(state.canShareLiveLocation).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `canShareLiveLocation is false in thread timeline`() = runTest {
|
||||||
|
fakeFeatureFlagService.setFeatureEnabled(FeatureFlags.LiveLocationSharing, true)
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter(
|
||||||
|
timelineMode = Timeline.Mode.Thread(A_THREAD_ID),
|
||||||
|
)
|
||||||
|
shareLocationPresenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val state = awaitItem()
|
||||||
|
assertThat(state.canShareLiveLocation).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createLiveLocationStore(
|
||||||
|
sessionId: SessionId = A_SESSION_ID,
|
||||||
|
preferenceDataStoreFactory: PreferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
|
||||||
|
): LiveLocationStore {
|
||||||
|
return LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||||
|
sessionId = sessionId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createFailingLiveLocationStore(sessionId: SessionId = A_SESSION_ID): LiveLocationStore {
|
||||||
|
val failingPreferenceDataStoreFactory = object : PreferenceDataStoreFactory {
|
||||||
|
override fun create(name: String): DataStore<Preferences> = object : DataStore<Preferences> {
|
||||||
|
override val data: Flow<Preferences> = flowOf(emptyPreferences())
|
||||||
|
|
||||||
|
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
|
||||||
|
error("Failed to update preferences")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createLiveLocationStore(
|
||||||
|
sessionId = sessionId,
|
||||||
|
preferenceDataStoreFactory = failingPreferenceDataStoreFactory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun grantedSendLiveLocationPermissions(): FakeRoomPermissions = FakeRoomPermissions(
|
||||||
|
canSendState = { it is StateEventType.BeaconInfo },
|
||||||
|
canSendMessage = { it is MessageEventType.Beacon }
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,38 @@ class ShareLocationViewTest {
|
||||||
clickOn(CommonStrings.action_cancel)
|
clickOn(CommonStrings.action_cancel)
|
||||||
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
|
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when disclaimer is displayed user can accept`() = runAndroidComposeUiTest {
|
||||||
|
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
|
||||||
|
setShareLocationView(
|
||||||
|
aShareLocationState(
|
||||||
|
dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer,
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
canShareLiveLocation = true,
|
||||||
|
),
|
||||||
|
navigateUp = EnsureNeverCalled(),
|
||||||
|
)
|
||||||
|
|
||||||
|
clickOn(CommonStrings.action_accept)
|
||||||
|
eventsRecorder.assertSingle(ShareLocationEvent.AcceptLiveLocationDisclaimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when disclaimer is displayed user can decline`() = runAndroidComposeUiTest {
|
||||||
|
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
|
||||||
|
setShareLocationView(
|
||||||
|
aShareLocationState(
|
||||||
|
dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer,
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
canShareLiveLocation = true,
|
||||||
|
),
|
||||||
|
navigateUp = EnsureNeverCalled(),
|
||||||
|
)
|
||||||
|
|
||||||
|
clickOn(CommonStrings.action_decline)
|
||||||
|
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AndroidComposeUiTest<ComponentActivity>.setShareLocationView(
|
private fun AndroidComposeUiTest<ComponentActivity>.setShareLocationView(
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import io.element.android.features.location.api.ShowLocationEntryPoint
|
||||||
import io.element.android.features.location.api.ShowLocationMode
|
import io.element.android.features.location.api.ShowLocationMode
|
||||||
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
||||||
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
|
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
|
||||||
|
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
|
||||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
|
|
@ -34,6 +35,7 @@ class DefaultShowLocationEntryPointTest {
|
||||||
fun `test node builder`() {
|
fun `test node builder`() {
|
||||||
val entryPoint = DefaultShowLocationEntryPoint()
|
val entryPoint = DefaultShowLocationEntryPoint()
|
||||||
val parentNode = TestParentNode.create { buildContext, plugins ->
|
val parentNode = TestParentNode.create { buildContext, plugins ->
|
||||||
|
val joinedRoom = FakeJoinedRoom()
|
||||||
ShowLocationNode(
|
ShowLocationNode(
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
|
|
@ -45,7 +47,8 @@ class DefaultShowLocationEntryPointTest {
|
||||||
buildMeta = aBuildMeta(),
|
buildMeta = aBuildMeta(),
|
||||||
dateFormatter = FakeDateFormatter(),
|
dateFormatter = FakeDateFormatter(),
|
||||||
stringProvider = FakeStringProvider(),
|
stringProvider = FakeStringProvider(),
|
||||||
joinedRoom = FakeJoinedRoom(),
|
joinedRoom = joinedRoom,
|
||||||
|
liveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
analyticsService = FakeAnalyticsService(),
|
analyticsService = FakeAnalyticsService(),
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ package io.element.android.features.location.impl.show
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class LiveLocationShareComparatorTest {
|
class LiveLocationShareComparatorTest {
|
||||||
|
|
@ -55,15 +55,3 @@ class LiveLocationShareComparatorTest {
|
||||||
assertThat(sortedShares).containsExactly(newerShare, olderShare).inOrder()
|
assertThat(sortedShares).containsExactly(newerShare, olderShare).inOrder()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun aLiveLocationShare(
|
|
||||||
userId: UserId,
|
|
||||||
startTimestamp: Long,
|
|
||||||
): LiveLocationShare {
|
|
||||||
return LiveLocationShare(
|
|
||||||
userId = userId,
|
|
||||||
lastLocation = null,
|
|
||||||
startTimestamp = startTimestamp,
|
|
||||||
endTimestamp = startTimestamp + 1_000L,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,15 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
||||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||||
|
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
|
||||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
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.room.location.AssetType
|
||||||
import io.element.android.libraries.matrix.api.room.location.LastLocation
|
|
||||||
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
|
||||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
|
|
@ -60,6 +61,7 @@ class ShowLocationPresenterTest {
|
||||||
),
|
),
|
||||||
locationActions: FakeLocationActions = fakeLocationActions,
|
locationActions: FakeLocationActions = fakeLocationActions,
|
||||||
joinedRoom: JoinedRoom = FakeJoinedRoom(),
|
joinedRoom: JoinedRoom = FakeJoinedRoom(),
|
||||||
|
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||||
) = ShowLocationPresenter(
|
) = ShowLocationPresenter(
|
||||||
mode = mode,
|
mode = mode,
|
||||||
permissionsPresenterFactory = { fakePermissionsPresenter },
|
permissionsPresenterFactory = { fakePermissionsPresenter },
|
||||||
|
|
@ -68,6 +70,7 @@ class ShowLocationPresenterTest {
|
||||||
dateFormatter = fakeDateFormatter,
|
dateFormatter = fakeDateFormatter,
|
||||||
stringProvider = FakeStringProvider(),
|
stringProvider = FakeStringProvider(),
|
||||||
joinedRoom = joinedRoom,
|
joinedRoom = joinedRoom,
|
||||||
|
liveLocationShareManager = liveLocationShareManager,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -205,7 +208,7 @@ class ShowLocationPresenterTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val presenter = createShowLocationPresenter()
|
val presenter = createShowLocationPresenter()
|
||||||
presenter.test {
|
presenter.test {
|
||||||
// Skip initial state
|
// Skip initial state
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
|
||||||
|
|
@ -464,23 +467,3 @@ class ShowLocationPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun aLiveLocationShare(
|
|
||||||
userId: UserId,
|
|
||||||
geoUri: String = "geo:48.8584,2.2945",
|
|
||||||
timestamp: Long = 0L,
|
|
||||||
startTimestamp: Long = 0L,
|
|
||||||
endTimestamp: Long = Long.MAX_VALUE,
|
|
||||||
assetType: AssetType = AssetType.SENDER,
|
|
||||||
): LiveLocationShare {
|
|
||||||
return LiveLocationShare(
|
|
||||||
userId = userId,
|
|
||||||
lastLocation = LastLocation(
|
|
||||||
geoUri = geoUri,
|
|
||||||
timestamp = timestamp,
|
|
||||||
assetType = assetType,
|
|
||||||
),
|
|
||||||
startTimestamp = startTimestamp,
|
|
||||||
endTimestamp = endTimestamp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
/*
|
||||||
|
* 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.store
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.mutablePreferencesOf
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.location.impl.live.LiveLocationStore
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
|
||||||
|
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class LiveLocationStoreTest {
|
||||||
|
private val preferenceDataStoreFactory = FakePreferenceDataStoreFactory()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disclaimer defaults to false`() = runTest {
|
||||||
|
val store = LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(store.hasAcceptedLiveLocationDisclaimer()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disclaimer acceptance is isolated per session`() = runTest {
|
||||||
|
val firstStore = LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
)
|
||||||
|
val secondStore = LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||||
|
sessionId = SessionId("@other:server"),
|
||||||
|
)
|
||||||
|
|
||||||
|
firstStore.setAcceptedLiveLocationDisclaimer().getOrThrow()
|
||||||
|
|
||||||
|
assertThat(firstStore.hasAcceptedLiveLocationDisclaimer()).isTrue()
|
||||||
|
assertThat(secondStore.hasAcceptedLiveLocationDisclaimer()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `can persist and read expiry per room`() = runTest {
|
||||||
|
val store = LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L)).getOrThrow()
|
||||||
|
|
||||||
|
assertThat(store.getLiveLocationExpiries())
|
||||||
|
.containsExactly(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removing one expiry leaves others untouched`() = runTest {
|
||||||
|
val otherRoomId = RoomId("!other:server")
|
||||||
|
val store = LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L)).getOrThrow()
|
||||||
|
store.setLiveLocationExpiry(otherRoomId, Instant.fromEpochMilliseconds(2_000L)).getOrThrow()
|
||||||
|
store.removeLiveLocationExpiry(A_ROOM_ID).getOrThrow()
|
||||||
|
|
||||||
|
assertThat(store.getLiveLocationExpiries())
|
||||||
|
.containsExactly(otherRoomId, Instant.fromEpochMilliseconds(2_000L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setting expiry twice replaces the existing room value`() = runTest {
|
||||||
|
val store = LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L)).getOrThrow()
|
||||||
|
store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(2_000L)).getOrThrow()
|
||||||
|
|
||||||
|
assertThat(store.getLiveLocationExpiries())
|
||||||
|
.containsExactly(A_ROOM_ID, Instant.fromEpochMilliseconds(2_000L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `malformed expiry payload returns empty map`() = runTest {
|
||||||
|
val store = LiveLocationStore(
|
||||||
|
preferenceDataStoreFactory = createMalformedExpiryPreferenceDataStoreFactory(),
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(store.getLiveLocationExpiries()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMalformedExpiryPreferenceDataStoreFactory(): PreferenceDataStoreFactory {
|
||||||
|
return object : PreferenceDataStoreFactory {
|
||||||
|
override fun create(name: String): DataStore<Preferences> {
|
||||||
|
var preferences: Preferences = mutablePreferencesOf(
|
||||||
|
stringPreferencesKey("live_location_expiries") to "not valid"
|
||||||
|
)
|
||||||
|
return object : DataStore<Preferences> {
|
||||||
|
override val data: Flow<Preferences>
|
||||||
|
get() = flowOf(preferences)
|
||||||
|
|
||||||
|
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
|
||||||
|
preferences = transform(preferences)
|
||||||
|
return preferences
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* 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.test
|
||||||
|
|
||||||
|
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
import io.element.android.tests.testutils.simulateLongTask
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
class FakeActiveLiveLocationShareManager(
|
||||||
|
val setupLambda: () -> Unit = { lambdaError() },
|
||||||
|
val startShareLambda: (roomId: RoomId, duration: Duration) -> Result<Unit> = { _, _ -> lambdaError() },
|
||||||
|
val stopShareLambda: (roomId: RoomId) -> Result<Unit> = { _ -> lambdaError() },
|
||||||
|
) : ActiveLiveLocationShareManager {
|
||||||
|
private val _sharingRoomIds = MutableStateFlow(emptySet<RoomId>())
|
||||||
|
override val sharingRoomIds: StateFlow<Set<RoomId>> = _sharingRoomIds
|
||||||
|
|
||||||
|
override suspend fun setup() {
|
||||||
|
setupLambda()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun startShare(roomId: RoomId, duration: Duration): Result<Unit> = simulateLongTask {
|
||||||
|
startShareLambda(roomId, duration).onSuccess {
|
||||||
|
_sharingRoomIds.update {
|
||||||
|
it + roomId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stopShare(roomId: RoomId): Result<Unit> = simulateLongTask {
|
||||||
|
stopShareLambda(roomId).onSuccess {
|
||||||
|
_sharingRoomIds.update {
|
||||||
|
it - roomId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,13 +36,13 @@ interface BiometricAuthenticator {
|
||||||
}
|
}
|
||||||
|
|
||||||
val isActive: Boolean
|
val isActive: Boolean
|
||||||
fun setup()
|
suspend fun setup()
|
||||||
suspend fun authenticate(): AuthenticationResult
|
suspend fun authenticate(): AuthenticationResult
|
||||||
}
|
}
|
||||||
|
|
||||||
class NoopBiometricAuthentication : BiometricAuthenticator {
|
class NoopBiometricAuthentication : BiometricAuthenticator {
|
||||||
override val isActive: Boolean = false
|
override val isActive: Boolean = false
|
||||||
override fun setup() = Unit
|
override suspend fun setup() = Unit
|
||||||
override suspend fun authenticate() = BiometricAuthenticator.AuthenticationResult.Failure()
|
override suspend fun authenticate() = BiometricAuthenticator.AuthenticationResult.Failure()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,7 +58,7 @@ class DefaultBiometricAuthentication(
|
||||||
|
|
||||||
private var cryptoObject: CryptoObject? = null
|
private var cryptoObject: CryptoObject? = null
|
||||||
|
|
||||||
override fun setup() {
|
override suspend fun setup() {
|
||||||
try {
|
try {
|
||||||
val secretKey = ensureKey()
|
val secretKey = ensureKey()
|
||||||
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
|
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
|
||||||
|
|
@ -86,7 +86,7 @@ class DefaultBiometricAuthentication(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(KeyPermanentlyInvalidatedException::class)
|
@Throws(KeyPermanentlyInvalidatedException::class)
|
||||||
private fun ensureKey() = secretKeyRepository.getOrCreateKey(keyAlias, true).also {
|
private suspend fun ensureKey() = secretKeyRepository.getOrCreateKey(keyAlias, true).also {
|
||||||
encryptionDecryptionService.createEncryptionCipher(it)
|
encryptionDecryptionService.createEncryptionCipher(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,13 @@ import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
|
||||||
import io.element.android.libraries.cryptography.api.EncryptionResult
|
import io.element.android.libraries.cryptography.api.EncryptionResult
|
||||||
import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
private const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_PIN_CODE"
|
internal const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_PIN_CODE"
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
|
|
@ -29,6 +33,8 @@ class DefaultPinCodeManager(
|
||||||
) : PinCodeManager {
|
) : PinCodeManager {
|
||||||
private val callbacks = CopyOnWriteArrayList<PinCodeManager.Callback>()
|
private val callbacks = CopyOnWriteArrayList<PinCodeManager.Callback>()
|
||||||
|
|
||||||
|
private val migrationMutex = Mutex()
|
||||||
|
|
||||||
override fun addCallback(callback: PinCodeManager.Callback) {
|
override fun addCallback(callback: PinCodeManager.Callback) {
|
||||||
callbacks.add(callback)
|
callbacks.add(callback)
|
||||||
}
|
}
|
||||||
|
|
@ -38,11 +44,20 @@ class DefaultPinCodeManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hasPinCode(): Flow<Boolean> {
|
override fun hasPinCode(): Flow<Boolean> {
|
||||||
return lockScreenStore.hasPinCode()
|
return secretKeyRepository.hasKey(SECRET_KEY_ALIAS)
|
||||||
|
.onStart {
|
||||||
|
migrationMutex.withLock {
|
||||||
|
val hasKey = secretKeyRepository.hasKey(SECRET_KEY_ALIAS).first()
|
||||||
|
if (hasKey && lockScreenStore.getEncryptedCode() == null) {
|
||||||
|
// Remove the key if there is no pin code
|
||||||
|
secretKeyRepository.deleteKey(SECRET_KEY_ALIAS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPinCodeSize(): Int {
|
override suspend fun getPinCodeSize(): Int? {
|
||||||
val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return 0
|
val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return null
|
||||||
val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false)
|
val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false)
|
||||||
val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
|
val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
|
||||||
return decryptedPinCode.size
|
return decryptedPinCode.size
|
||||||
|
|
@ -79,6 +94,7 @@ class DefaultPinCodeManager(
|
||||||
override suspend fun deletePinCode() {
|
override suspend fun deletePinCode() {
|
||||||
lockScreenStore.deleteEncryptedPinCode()
|
lockScreenStore.deleteEncryptedPinCode()
|
||||||
lockScreenStore.resetCounter()
|
lockScreenStore.resetCounter()
|
||||||
|
secretKeyRepository.deleteKey(SECRET_KEY_ALIAS)
|
||||||
callbacks.forEach { it.onPinCodeRemoved() }
|
callbacks.forEach { it.onPinCodeRemoved() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,9 @@ interface PinCodeManager {
|
||||||
fun hasPinCode(): Flow<Boolean>
|
fun hasPinCode(): Flow<Boolean>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return the size of the saved pin code.
|
* @return the size of the saved pin code. Return null if no pin code is saved.
|
||||||
*/
|
*/
|
||||||
suspend fun getPinCodeSize(): Int
|
suspend fun getPinCodeSize(): Int?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new encrypted pin code.
|
* Creates a new encrypted pin code.
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.settings
|
package io.element.android.features.lockscreen.impl.settings
|
||||||
|
|
||||||
sealed interface LockScreenSettingsEvents {
|
sealed interface LockScreenSettingsEvent {
|
||||||
data object OnRemovePin : LockScreenSettingsEvents
|
data object OnRemovePin : LockScreenSettingsEvent
|
||||||
data object ConfirmRemovePin : LockScreenSettingsEvents
|
data object ConfirmRemovePin : LockScreenSettingsEvent
|
||||||
data object CancelRemovePin : LockScreenSettingsEvents
|
data object CancelRemovePin : LockScreenSettingsEvent
|
||||||
data object ToggleBiometricAllowed : LockScreenSettingsEvents
|
data object ToggleBiometricAllowed : LockScreenSettingsEvent
|
||||||
}
|
}
|
||||||
|
|
@ -51,10 +51,10 @@ class LockScreenSettingsPresenter(
|
||||||
|
|
||||||
val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator()
|
val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator()
|
||||||
|
|
||||||
fun handleEvent(event: LockScreenSettingsEvents) {
|
fun handleEvent(event: LockScreenSettingsEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
LockScreenSettingsEvents.CancelRemovePin -> showRemovePinConfirmation = false
|
LockScreenSettingsEvent.CancelRemovePin -> showRemovePinConfirmation = false
|
||||||
LockScreenSettingsEvents.ConfirmRemovePin -> {
|
LockScreenSettingsEvent.ConfirmRemovePin -> {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
if (showRemovePinConfirmation) {
|
if (showRemovePinConfirmation) {
|
||||||
showRemovePinConfirmation = false
|
showRemovePinConfirmation = false
|
||||||
|
|
@ -62,8 +62,8 @@ class LockScreenSettingsPresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LockScreenSettingsEvents.OnRemovePin -> showRemovePinConfirmation = true
|
LockScreenSettingsEvent.OnRemovePin -> showRemovePinConfirmation = true
|
||||||
LockScreenSettingsEvents.ToggleBiometricAllowed -> {
|
LockScreenSettingsEvent.ToggleBiometricAllowed -> {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
if (!isBiometricEnabled) {
|
if (!isBiometricEnabled) {
|
||||||
biometricUnlock.setup()
|
biometricUnlock.setup()
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,5 @@ data class LockScreenSettingsState(
|
||||||
val isBiometricEnabled: Boolean,
|
val isBiometricEnabled: Boolean,
|
||||||
val showRemovePinConfirmation: Boolean,
|
val showRemovePinConfirmation: Boolean,
|
||||||
val showToggleBiometric: Boolean,
|
val showToggleBiometric: Boolean,
|
||||||
val eventSink: (LockScreenSettingsEvents) -> Unit
|
val eventSink: (LockScreenSettingsEvent) -> Unit
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ fun LockScreenSettingsView(
|
||||||
},
|
},
|
||||||
style = ListItemStyle.Destructive,
|
style = ListItemStyle.Destructive,
|
||||||
onClick = {
|
onClick = {
|
||||||
state.eventSink(LockScreenSettingsEvents.OnRemovePin)
|
state.eventSink(LockScreenSettingsEvent.OnRemovePin)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +61,7 @@ fun LockScreenSettingsView(
|
||||||
title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock),
|
title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock),
|
||||||
isChecked = state.isBiometricEnabled,
|
isChecked = state.isBiometricEnabled,
|
||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
|
state.eventSink(LockScreenSettingsEvent.ToggleBiometricAllowed)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -72,10 +72,10 @@ fun LockScreenSettingsView(
|
||||||
title = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_title),
|
title = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_title),
|
||||||
content = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_message),
|
content = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_message),
|
||||||
onSubmitClick = {
|
onSubmitClick = {
|
||||||
state.eventSink(LockScreenSettingsEvents.ConfirmRemovePin)
|
state.eventSink(LockScreenSettingsEvent.ConfirmRemovePin)
|
||||||
},
|
},
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
state.eventSink(LockScreenSettingsEvents.CancelRemovePin)
|
state.eventSink(LockScreenSettingsEvent.CancelRemovePin)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.setup.biometric
|
package io.element.android.features.lockscreen.impl.setup.biometric
|
||||||
|
|
||||||
sealed interface SetupBiometricEvents {
|
sealed interface SetupBiometricEvent {
|
||||||
data object AllowBiometric : SetupBiometricEvents
|
data object AllowBiometric : SetupBiometricEvent
|
||||||
data object UsePin : SetupBiometricEvents
|
data object UsePin : SetupBiometricEvent
|
||||||
}
|
}
|
||||||
|
|
@ -35,16 +35,16 @@ class SetupBiometricPresenter(
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator()
|
val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator()
|
||||||
|
|
||||||
fun handleEvent(event: SetupBiometricEvents) {
|
fun handleEvent(event: SetupBiometricEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
SetupBiometricEvents.AllowBiometric -> coroutineScope.launch {
|
SetupBiometricEvent.AllowBiometric -> coroutineScope.launch {
|
||||||
biometricUnlock.setup()
|
biometricUnlock.setup()
|
||||||
if (biometricUnlock.authenticate() == BiometricAuthenticator.AuthenticationResult.Success) {
|
if (biometricUnlock.authenticate() == BiometricAuthenticator.AuthenticationResult.Success) {
|
||||||
lockScreenStore.setIsBiometricUnlockAllowed(true)
|
lockScreenStore.setIsBiometricUnlockAllowed(true)
|
||||||
isBiometricSetupDone = true
|
isBiometricSetupDone = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SetupBiometricEvents.UsePin -> coroutineScope.launch {
|
SetupBiometricEvent.UsePin -> coroutineScope.launch {
|
||||||
lockScreenStore.setIsBiometricUnlockAllowed(false)
|
lockScreenStore.setIsBiometricUnlockAllowed(false)
|
||||||
isBiometricSetupDone = true
|
isBiometricSetupDone = true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,5 @@ package io.element.android.features.lockscreen.impl.setup.biometric
|
||||||
|
|
||||||
data class SetupBiometricState(
|
data class SetupBiometricState(
|
||||||
val isBiometricSetupDone: Boolean,
|
val isBiometricSetupDone: Boolean,
|
||||||
val eventSink: (SetupBiometricEvents) -> Unit
|
val eventSink: (SetupBiometricEvent) -> Unit
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ fun SetupBiometricView(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
BackHandler {
|
BackHandler {
|
||||||
state.eventSink(SetupBiometricEvents.UsePin)
|
state.eventSink(SetupBiometricEvent.UsePin)
|
||||||
}
|
}
|
||||||
HeaderFooterPage(
|
HeaderFooterPage(
|
||||||
modifier = modifier.padding(top = 80.dp),
|
modifier = modifier.padding(top = 80.dp),
|
||||||
|
|
@ -42,8 +42,8 @@ fun SetupBiometricView(
|
||||||
},
|
},
|
||||||
footer = {
|
footer = {
|
||||||
SetupBiometricFooter(
|
SetupBiometricFooter(
|
||||||
onAllowClick = { state.eventSink(SetupBiometricEvents.AllowBiometric) },
|
onAllowClick = { state.eventSink(SetupBiometricEvent.AllowBiometric) },
|
||||||
onSkipClick = { state.eventSink(SetupBiometricEvents.UsePin) }
|
onSkipClick = { state.eventSink(SetupBiometricEvent.UsePin) }
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue