Merge branch 'release/26.05.1'
This commit is contained in:
commit
c1fcb0f6f6
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:
|
||||
- name: Check membership
|
||||
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
|
||||
with:
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
||||
- uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
|
||||
with:
|
||||
project-url: https://github.com/orgs/element-hq/projects/91
|
||||
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: >
|
||||
github.repository == 'element-hq/element-x-android'
|
||||
steps:
|
||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
||||
- uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
|
||||
with:
|
||||
project-url: https://github.com/orgs/element-hq/projects/43
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
name: Move triaged needs info issues on board
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
||||
- uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
|
||||
id: addItem
|
||||
with:
|
||||
project-url: https://github.com/orgs/element-hq/projects/91
|
||||
|
|
@ -47,7 +47,7 @@ jobs:
|
|||
if: >
|
||||
contains(github.event.issue.labels.*.name, 'Team: Element X Feature')
|
||||
steps:
|
||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
||||
- uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
|
||||
with:
|
||||
project-url: https://github.com/orgs/element-hq/projects/73
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
|
@ -58,7 +58,7 @@ jobs:
|
|||
if: >
|
||||
contains(github.event.issue.labels.*.name, 'Team: Verticals Feature')
|
||||
steps:
|
||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
||||
- uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
|
||||
with:
|
||||
project-url: https://github.com/orgs/element-hq/projects/57
|
||||
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, 'X-Needs-Signoff')
|
||||
steps:
|
||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
||||
- uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
|
||||
with:
|
||||
project-url: https://github.com/orgs/element-hq/projects/69
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
|
@ -81,7 +81,7 @@ jobs:
|
|||
if: >
|
||||
contains(github.event.issue.labels.*.name, 'X-Needs-Signoff')
|
||||
steps:
|
||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
||||
- uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
|
||||
with:
|
||||
project-url: https://github.com/orgs/element-hq/projects/89
|
||||
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
|
||||
=============================
|
||||
|
||||
|
|
|
|||
|
|
@ -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.home.api.HomeEntryPoint
|
||||
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.NetworkStatus
|
||||
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
|
||||
|
|
@ -151,6 +152,7 @@ class LoggedInFlowNode(
|
|||
private val analyticsService: AnalyticsService,
|
||||
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
|
||||
private val createRoomEntryPoint: CreateRoomEntryPoint,
|
||||
private val activeLiveLocationShareManager: ActiveLiveLocationShareManager,
|
||||
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Placeholder,
|
||||
|
|
@ -211,6 +213,7 @@ class LoggedInFlowNode(
|
|||
super.onBuilt()
|
||||
lifecycleScope.launch {
|
||||
sessionEnterpriseService.init()
|
||||
activeLiveLocationShareManager.setup()
|
||||
}
|
||||
lifecycle.subscribe(
|
||||
onCreate = {
|
||||
|
|
@ -219,7 +222,6 @@ class LoggedInFlowNode(
|
|||
loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
|
||||
matrixClient.sessionVerificationService.setListener(verificationListener)
|
||||
mediaPreviewConfigMigration()
|
||||
|
||||
sessionCoroutineScope.launch {
|
||||
// Wait for the network to be connected before pre-fetching the max file upload size
|
||||
networkMonitor.connectivity.first { networkStatus -> networkStatus == NetworkStatus.Connected }
|
||||
|
|
|
|||
|
|
@ -89,16 +89,14 @@ class SyncOrchestrator(
|
|||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun observeStates() = coroutineScope.launch {
|
||||
Timber.tag(tag).d("start observing the app and network state")
|
||||
|
||||
val isAppActiveFlow = combine(
|
||||
val isAppActiveFlows = listOf(
|
||||
appForegroundStateService.isInForeground,
|
||||
appForegroundStateService.isInCall,
|
||||
appForegroundStateService.isSyncingNotificationEvent,
|
||||
appForegroundStateService.hasRingingCall,
|
||||
) { isInForeground, isInCall, isSyncingNotificationEvent, hasRingingCall ->
|
||||
isInForeground || isInCall || isSyncingNotificationEvent || hasRingingCall
|
||||
}
|
||||
|
||||
appForegroundStateService.isSharingLiveLocation
|
||||
)
|
||||
val isAppActiveFlow = combine(isAppActiveFlows) { actives -> actives.any { it } }
|
||||
combine(
|
||||
// small debounce to avoid spamming startSync when the state is changing quickly in case of error.
|
||||
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 androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
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.getValue
|
||||
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.preview.ElementPreview
|
||||
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.ui.strings.CommonStrings
|
||||
import timber.log.Timber
|
||||
|
|
@ -64,94 +64,93 @@ internal fun CallScreenView(
|
|||
requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun handleBack() {
|
||||
if (pipState.supportPip) {
|
||||
pipState.eventSink.invoke(PictureInPictureEvent.EnterPictureInPicture)
|
||||
} else {
|
||||
state.eventSink(CallScreenEvent.Hangup)
|
||||
var callWebView by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
fun handleBack(fromNative: Boolean = false) {
|
||||
when (CallScreenBackPressPolicy.resolve(supportPip = pipState.supportPip, hasWebView = callWebView != null, fromNative)) {
|
||||
CallScreenBackPressAction.EnterPictureInPicture ->
|
||||
pipState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
|
||||
CallScreenBackPressAction.DispatchEscapeToWebView ->
|
||||
callWebView?.dispatchEscKeyEvent()
|
||||
null -> Timber.d("Back press with unsupported pip is a no-op")
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
) { padding ->
|
||||
BackHandler {
|
||||
handleBack()
|
||||
BackHandler {
|
||||
handleBack(fromNative = true)
|
||||
}
|
||||
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) }
|
||||
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) }
|
||||
invalidAudioDeviceReason?.let {
|
||||
InvalidAudioDeviceDialog(invalidAudioDeviceReason = it) {
|
||||
invalidAudioDeviceReason = null
|
||||
}
|
||||
CallWebView(
|
||||
modifier = modifier.consumeWindowInsets(WindowInsets.systemBars).fillMaxSize(),
|
||||
url = state.urlState,
|
||||
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(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.fillMaxSize(),
|
||||
url = state.urlState,
|
||||
userAgent = state.userAgent,
|
||||
onPermissionsRequest = { request ->
|
||||
val androidPermissions = mapWebkitPermissions(request.resources)
|
||||
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
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -248,15 +247,16 @@ private fun WebView.setup(
|
|||
|
||||
private fun WebView.addBackHandler(onBackPressed: () -> Unit) {
|
||||
addJavascriptInterface(
|
||||
object {
|
||||
@Suppress("unused")
|
||||
@JavascriptInterface
|
||||
fun onBackPressed() = onBackPressed()
|
||||
},
|
||||
JavascriptBackHandlerBridge(callback = onBackPressed),
|
||||
"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
|
||||
@Composable
|
||||
internal fun CallScreenViewPreview(
|
||||
|
|
@ -275,3 +275,12 @@ internal fun CallScreenViewPreview(
|
|||
internal fun InvalidAudioDeviceDialogPreview() = ElementPreview {
|
||||
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.content.IntentCompat
|
||||
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 dev.zacsweers.metro.Inject
|
||||
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.meta.BuildMeta
|
||||
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.preferences.api.store.AppPreferencesStore
|
||||
import timber.log.Timber
|
||||
|
|
@ -111,6 +115,27 @@ class ElementCallActivity :
|
|||
val colors by remember(webViewTarget.value?.sessionId) {
|
||||
enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.sessionId)
|
||||
}.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(
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
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.core.RoomId
|
||||
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.preferences.api.store.AppPreferencesStore
|
||||
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_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_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.
|
||||
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_room_access_section_knocking_option_title">"Poproś o dołączenie"</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_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_address_section_title">"Adres pokoju"</string>
|
||||
<string name="screen_create_room_public_option_short_description">"Każdy może dołączyć."</string>
|
||||
<string name="screen_create_room_public_option_title">"Publiczny"</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_access_section_knocking_option_title">"Zezwól na prośbę o dołączenie"</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_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_placeholder">"Dodaj opis…"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -3,22 +3,32 @@
|
|||
<string name="screen_create_room_action_create_room">"Нова кімната"</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_space">"Простір не вдалося створити через невідому помилку. Спробуйте ще раз пізніше."</string>
|
||||
<string name="screen_create_room_name_placeholder">"Додати назву…"</string>
|
||||
<string name="screen_create_room_new_room_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_title">"Приватний"</string>
|
||||
<string name="screen_create_room_public_option_description">"Будь-хто може знайти цю кімнату.
|
||||
Ви можете змінити це в будь-який час у налаштуваннях кімнати."</string>
|
||||
<string name="screen_create_room_public_option_short_description">"Приєднатися може будь-хто."</string>
|
||||
<string name="screen_create_room_public_option_title">"Публічний"</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_description">"Будь-хто може подати запит на приєднання, але адміністратор або модератор повинен схвалити запит."</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_title">"Дозволити запит на приєднання"</string>
|
||||
<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_title">"Приватний"</string>
|
||||
<string name="screen_create_room_room_access_section_public_option_description">"Приєднатися може будь-хто."</string>
|
||||
<string name="screen_create_room_room_access_section_public_option_title">"Публічний"</string>
|
||||
<string name="screen_create_room_room_access_section_restricted_option_description">"Приєднатися може будь-хто з %1$s."</string>
|
||||
<string name="screen_create_room_room_access_section_restricted_option_title">"Стандартний"</string>
|
||||
<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_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_sheet_title">"Додати до простору"</string>
|
||||
<string name="screen_create_room_topic_label">"Тема (необов\'язково)"</string>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_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_list_item_1">"%1$s τον λογαριασμό σου (δεν μπορείς να συνδεθείς ξανά και το αναγνωριστικό σου δεν μπορεί να επαναχρησιμοποιηθεί)."</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_3">"Διαγράψει τα στοιχεία του λογαριασμού σου από τον διακομιστή ταυτότητάς μας."</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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_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>
|
||||
|
|
@ -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_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_title">"Eemalda konto kasutusest"</string>
|
||||
<string name="screen_deactivate_account_title">"Kustuta kasutajakonto"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_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_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_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_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>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_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_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_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_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>
|
||||
|
|
|
|||
|
|
@ -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_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_title">"Akkauntni o‘chirish"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
<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_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_title">"Potwierdź, że to Ty"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Wybierz sposób weryfikacji, aby skonfigurować bezpieczne wiadomości."</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_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>
|
||||
|
|
|
|||
|
|
@ -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.matrix.api.room.CallIntentConsensus
|
||||
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.RoomSummary
|
||||
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_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_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_primary_button_title">"Sisesta oma taastevõti"</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_title_android">"آگاهیها نمیرسند؟"</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="confirm_recovery_key_banner_message">"کلید بازیابی خود را تأیید کنید تا دسترسی به حافظه کلیدها و تاریخچه پیامهایتان حفظ شود ."</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_title">"هنوز گپی وجود ندارد."</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_invites">"دعوتها"</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_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_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_submit">"Skonfiguruj przywracanie"</string>
|
||||
<string name="banner_set_up_recovery_title">"Skonfiguruj przywracanie"</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">"Uzyskaj klucz przywracania"</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_primary_button_title">"Wprowadź klucz 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_unread">"Oznacz jako nieprzeczytane"</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_title">"Potwierdź, że to Ty"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ package io.element.android.features.invite.api
|
|||
import android.os.Parcelable
|
||||
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.isDm
|
||||
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
|
|
|||
|
|
@ -2,4 +2,8 @@
|
|||
<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_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>
|
||||
|
|
|
|||
|
|
@ -2,4 +2,8 @@
|
|||
<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_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>
|
||||
|
|
|
|||
|
|
@ -2,4 +2,8 @@
|
|||
<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_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>
|
||||
|
|
|
|||
|
|
@ -540,7 +540,7 @@ internal class DefaultInvitePeoplePresenterTest {
|
|||
}
|
||||
|
||||
@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 otherUserId = UserId("@frank:server.org")
|
||||
val matrixClient = FakeMatrixClient(sessionId = A_USER_ID).apply {
|
||||
|
|
@ -554,7 +554,7 @@ internal class DefaultInvitePeoplePresenterTest {
|
|||
roomId = dmRoomId,
|
||||
initialRoomInfo = aRoomInfo(
|
||||
id = dmRoomId,
|
||||
isDirect = true,
|
||||
isDm = true,
|
||||
activeMembersCount = 2,
|
||||
currentUserMembership = CurrentUserMembership.JOINED,
|
||||
),
|
||||
|
|
@ -591,7 +591,7 @@ internal class DefaultInvitePeoplePresenterTest {
|
|||
roomId = dmRoomId,
|
||||
initialRoomInfo = aRoomInfo(
|
||||
id = dmRoomId,
|
||||
isDirect = true,
|
||||
isDm = true,
|
||||
activeMembersCount = 2,
|
||||
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.RoomMembershipDetails
|
||||
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.JoinRule
|
||||
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_forget_action">"Забути"</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_restricted_message">"Можливо, вам знадобиться отримати запрошення або стати учасником простору, щоб приєднатися."</string>
|
||||
<string name="screen_join_room_knock_action">"Постукати, щоб приєднатися"</string>
|
||||
|
|
|
|||
|
|
@ -33,9 +33,7 @@ dependencies {
|
|||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
|
||||
testCommonDependencies(libs, true)
|
||||
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.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.powerlevels.permissionsFlow
|
||||
|
||||
|
|
@ -25,14 +23,13 @@ import io.element.android.libraries.matrix.api.room.powerlevels.permissionsFlow
|
|||
object KnockRequestsModule {
|
||||
@Provides
|
||||
@SingleIn(RoomScope::class)
|
||||
fun knockRequestsService(room: JoinedRoom, featureFlagService: FeatureFlagService): KnockRequestsService {
|
||||
fun knockRequestsService(room: JoinedRoom): KnockRequestsService {
|
||||
return KnockRequestsService(
|
||||
knockRequestsFlow = room.knockRequestsFlow,
|
||||
permissionsFlow = room.permissionsFlow(KnockRequestPermissions.DEFAULT) { perms ->
|
||||
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.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
|
|
@ -28,26 +27,20 @@ import kotlinx.coroutines.supervisorScope
|
|||
class KnockRequestsService(
|
||||
knockRequestsFlow: Flow<List<KnockRequest>>,
|
||||
permissionsFlow: Flow<KnockRequestPermissions>,
|
||||
isKnockFeatureEnabledFlow: Flow<Boolean>,
|
||||
coroutineScope: CoroutineScope,
|
||||
) {
|
||||
// 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())
|
||||
|
||||
val knockRequestsFlow = combine(
|
||||
isKnockFeatureEnabledFlow,
|
||||
knockRequestsFlow,
|
||||
handledKnockRequestIds,
|
||||
) { isKnockEnabled, knockRequests, handledKnockIds ->
|
||||
if (!isKnockEnabled) {
|
||||
AsyncData.Success(persistentListOf())
|
||||
} else {
|
||||
val presentableKnockRequests = knockRequests
|
||||
.filter { it.eventId !in handledKnockIds }
|
||||
.map { inner -> KnockRequestWrapper(inner) }
|
||||
.toImmutableList()
|
||||
AsyncData.Success(presentableKnockRequests)
|
||||
}
|
||||
) { knockRequests, handledKnockIds ->
|
||||
val presentableKnockRequests = knockRequests
|
||||
.filter { it.eventId !in handledKnockIds }
|
||||
.map { inner -> KnockRequestWrapper(inner) }
|
||||
.toImmutableList()
|
||||
AsyncData.Success(presentableKnockRequests)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, AsyncData.Loading())
|
||||
|
||||
val permissionsFlow = permissionsFlow.stateIn(
|
||||
|
|
|
|||
|
|
@ -28,18 +28,6 @@ import kotlinx.coroutines.test.runTest
|
|||
import org.junit.Test
|
||||
|
||||
@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
|
||||
fun `present - when empty knock request list then the banner should be hidden`() = runTest {
|
||||
val knockRequests = flowOf(emptyList<KnockRequest>())
|
||||
|
|
@ -229,12 +217,10 @@ import org.junit.Test
|
|||
private fun TestScope.createKnockRequestsBannerPresenter(
|
||||
knockRequestsFlow: Flow<List<KnockRequest>> = flowOf(emptyList()),
|
||||
canAcceptKnockRequests: Boolean = true,
|
||||
isFeatureEnabled: Boolean = true,
|
||||
): KnockRequestsBannerPresenter {
|
||||
val knockRequestsService = KnockRequestsService(
|
||||
knockRequestsFlow = knockRequestsFlow,
|
||||
coroutineScope = backgroundScope,
|
||||
isKnockFeatureEnabledFlow = flowOf(isFeatureEnabled),
|
||||
permissionsFlow = flowOf(KnockRequestPermissions(canAcceptKnockRequests, canAcceptKnockRequests, canAcceptKnockRequests)),
|
||||
)
|
||||
return KnockRequestsBannerPresenter(
|
||||
|
|
|
|||
|
|
@ -298,7 +298,6 @@ internal fun TestScope.createKnockRequestsListPresenter(
|
|||
val knockRequestsService = KnockRequestsService(
|
||||
knockRequestsFlow = knockRequestsFlow,
|
||||
coroutineScope = backgroundScope,
|
||||
isKnockFeatureEnabledFlow = flowOf(true),
|
||||
permissionsFlow = flowOf(KnockRequestPermissions(canAccept, canDecline, canBan)),
|
||||
)
|
||||
return KnockRequestsListPresenter(knockRequestsService = knockRequestsService)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_private_subtitle">"مطمئنید که میخواهید این اتاق را ترک کنید؟ این اتاق عمومی نبوده قادر نخواهید بود بدون دعوت دوباره بپیوندید."</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.room.BaseRoom
|
||||
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.push.api.notifications.conversations.NotificationConversationService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ class LeaveBaseRoomPresenterTest {
|
|||
givenGetRoomResult(
|
||||
roomId = A_ROOM_ID,
|
||||
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"?>
|
||||
<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_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_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_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_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_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_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_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_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>
|
||||
|
|
@ -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_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_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_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.
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
<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_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_qr_code_login_connection_note_secure_state_description">"Не вдалося встановити безпечне з\'єднання з новим пристроєм. Ваші наявні пристрої досі в безпеці, і вам не потрібно про них турбуватися."</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.uiStrings)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.datetime)
|
||||
|
||||
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.matrixui)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.services.appnavstate.api)
|
||||
implementation(libs.accompanist.permission)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.featureflag.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)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
|
|
@ -50,4 +56,7 @@ dependencies {
|
|||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.features.messages.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_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>
|
||||
|
|
|
|||
|
|
@ -16,13 +16,16 @@ sealed interface LocationConstraintsCheck {
|
|||
data object PermissionRationale : LocationConstraintsCheck
|
||||
data object PermissionDenied : LocationConstraintsCheck
|
||||
data object LocationServiceDisabled : LocationConstraintsCheck
|
||||
data object NotEnoughPowerLevel : LocationConstraintsCheck
|
||||
}
|
||||
|
||||
fun checkLocationConstraints(
|
||||
permissionsState: PermissionsState,
|
||||
locationActions: LocationActions,
|
||||
sendLiveLocationPermissions: SendLiveLocationPermissions,
|
||||
): LocationConstraintsCheck {
|
||||
return when {
|
||||
!sendLiveLocationPermissions.hasAll -> LocationConstraintsCheck.NotEnoughPowerLevel
|
||||
permissionsState.isAnyGranted -> {
|
||||
if (locationActions.isLocationEnabled()) {
|
||||
LocationConstraintsCheck.Success
|
||||
|
|
@ -41,5 +44,6 @@ fun LocationConstraintsCheck.toDialogState(): LocationConstraintsDialogState {
|
|||
LocationConstraintsCheck.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale
|
||||
LocationConstraintsCheck.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied
|
||||
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.Immutable
|
||||
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.ui.strings.CommonStrings
|
||||
|
||||
|
|
@ -42,6 +44,10 @@ fun LocationConstraintsDialog(
|
|||
onDismiss = onDismiss,
|
||||
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 PermissionDenied : 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.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -44,6 +45,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||
fun LocationShareRow(
|
||||
item: LocationShareItem,
|
||||
onShareClick: () -> Unit,
|
||||
onStopClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
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) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.ShareAndroid(),
|
||||
contentDescription = stringResource(CommonStrings.action_share),
|
||||
tint = ElementTheme.colors.iconPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -128,8 +143,10 @@ internal fun LocationShareRowPreview() = ElementPreview {
|
|||
formattedTimestamp = "Shared 1 min ago",
|
||||
isLive = true,
|
||||
assetType = AssetType.SENDER,
|
||||
location = Location(0.0, 0.0)
|
||||
location = Location(0.0, 0.0),
|
||||
isOwnUser = true,
|
||||
),
|
||||
onStopClick = {},
|
||||
onShareClick = {},
|
||||
)
|
||||
LocationShareRow(
|
||||
|
|
@ -145,8 +162,10 @@ internal fun LocationShareRowPreview() = ElementPreview {
|
|||
isLive = false,
|
||||
assetType = AssetType.PIN,
|
||||
formattedTimestamp = "Shared 5 hours ago",
|
||||
location = Location(0.0, 0.0)
|
||||
location = Location(0.0, 0.0),
|
||||
isOwnUser = false
|
||||
),
|
||||
onStopClick = {},
|
||||
onShareClick = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import org.maplibre.compose.location.UserLocationState
|
|||
import org.maplibre.compose.location.rememberAndroidLocationProvider
|
||||
import org.maplibre.compose.location.rememberNullLocationProvider
|
||||
import org.maplibre.compose.location.rememberUserLocationState
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
fun UserLocationPuck(
|
||||
|
|
@ -72,9 +72,9 @@ fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState
|
|||
rememberNullLocationProvider()
|
||||
} else {
|
||||
rememberAndroidLocationProvider(
|
||||
updateInterval = 1.minutes,
|
||||
desiredAccuracy = DesiredAccuracy.Balanced,
|
||||
minDistanceMeters = 50f,
|
||||
updateInterval = 5.seconds,
|
||||
desiredAccuracy = DesiredAccuracy.High,
|
||||
minDistanceMeters = 5f,
|
||||
)
|
||||
}
|
||||
return rememberUserLocationState(locationProvider)
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@
|
|||
* 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.ContributesTo
|
||||
import io.element.android.libraries.push.impl.push.FetchPushForegroundService
|
||||
import io.element.android.features.location.impl.live.service.LiveLocationSharingService
|
||||
|
||||
@ContributesTo(AppScope::class)
|
||||
interface PushBindings {
|
||||
fun inject(fetchPushForegroundService: FetchPushForegroundService)
|
||||
interface LocationBindings {
|
||||
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,
|
||||
) : ShareLocationEvent
|
||||
|
||||
data object ShowLiveLocationDurationPicker : ShareLocationEvent
|
||||
data object InitiateLiveLocationShare : ShareLocationEvent
|
||||
data object AcceptLiveLocationDisclaimer : ShareLocationEvent
|
||||
data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent
|
||||
|
||||
data object StartTrackingUserLocation : ShareLocationEvent
|
||||
|
|
|
|||
|
|
@ -21,17 +21,22 @@ import dev.zacsweers.metro.Assisted
|
|||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.Composer
|
||||
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
|
||||
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.SendLiveLocationPermissions
|
||||
import io.element.android.features.location.impl.common.actions.LocationActions
|
||||
import io.element.android.features.location.impl.common.checkLocationConstraints
|
||||
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
||||
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
|
||||
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
||||
import io.element.android.features.location.impl.common.sendLiveLocationPermissions
|
||||
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.libraries.architecture.AsyncAction
|
||||
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.meta.BuildMeta
|
||||
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.JoinedRoom
|
||||
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.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
|
|
@ -63,6 +69,8 @@ class ShareLocationPresenter(
|
|||
private val featureFlagService: FeatureFlagService,
|
||||
private val client: MatrixClient,
|
||||
private val durationFormatter: DurationFormatter,
|
||||
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||
private val liveLocationStore: LiveLocationStore,
|
||||
) : Presenter<ShareLocationState> {
|
||||
@AssistedFactory
|
||||
fun interface Factory {
|
||||
|
|
@ -82,15 +90,39 @@ class ShareLocationPresenter(
|
|||
var dialogState: ShareLocationState.Dialog by remember {
|
||||
mutableStateOf(ShareLocationState.Dialog.None)
|
||||
}
|
||||
val startLiveLocationAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||
val currentUser by client.userProfile.collectAsState()
|
||||
val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms ->
|
||||
perms.sendLiveLocationPermissions()
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun checkLocationConstraints() {
|
||||
val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
|
||||
dialogState = Constraints(locationConstraints.toDialogState())
|
||||
// No need to check SendLiveLocationPermissions here
|
||||
val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||
dialogState = ShareLocationState.Dialog.Constraints(locationConstraints.toDialogState())
|
||||
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() }
|
||||
|
||||
fun handleEvent(event: ShareLocationEvent) {
|
||||
|
|
@ -109,20 +141,23 @@ class ShareLocationPresenter(
|
|||
locationActions.openLocationSettings()
|
||||
dialogState = ShareLocationState.Dialog.None
|
||||
}
|
||||
ShareLocationEvent.ShowLiveLocationDurationPicker -> {
|
||||
val constraintsResult = checkLocationConstraints(permissionsState, locationActions)
|
||||
dialogState = if (constraintsResult is LocationConstraintsCheck.Success) {
|
||||
val durations = LIVE_LOCATION_DURATIONS.map {
|
||||
LiveLocationDuration(duration = it, formatted = durationFormatter.format(it))
|
||||
ShareLocationEvent.InitiateLiveLocationShare -> scope.launch {
|
||||
dialogState = computeLiveLocationDialogState()
|
||||
}
|
||||
ShareLocationEvent.AcceptLiveLocationDisclaimer -> scope.launch {
|
||||
liveLocationStore.setAcceptedLiveLocationDisclaimer()
|
||||
.onSuccess {
|
||||
dialogState = computeLiveLocationDialogState()
|
||||
}
|
||||
ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
|
||||
} else {
|
||||
Constraints(constraintsResult.toDialogState())
|
||||
}
|
||||
}
|
||||
is ShareLocationEvent.StartLiveLocationShare -> scope.launch {
|
||||
dialogState = ShareLocationState.Dialog.None
|
||||
// room.startLiveLocationShare(event.duration.inWholeMilliseconds)
|
||||
startLiveLocationAction.runUpdatingState {
|
||||
liveLocationShareManager.startShare(
|
||||
roomId = room.roomId,
|
||||
duration = event.duration,
|
||||
)
|
||||
}
|
||||
}
|
||||
ShareLocationEvent.RequestPermissions -> {
|
||||
dialogState = ShareLocationState.Dialog.None
|
||||
|
|
@ -136,8 +171,9 @@ class ShareLocationPresenter(
|
|||
dialogState = dialogState,
|
||||
trackUserLocation = trackUserPosition,
|
||||
hasLocationPermission = permissionsState.isAnyGranted,
|
||||
canShareLiveLocation = isLiveLocationSharingEnabled,
|
||||
canShareLiveLocation = isLiveLocationSharingEnabled && timelineMode.canShareLiveLocation(),
|
||||
appName = appName,
|
||||
startLiveLocationAction = startLiveLocationAction.value,
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
package io.element.android.features.location.impl.share
|
||||
|
||||
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 kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
|
|
@ -19,11 +20,13 @@ data class ShareLocationState(
|
|||
val hasLocationPermission: Boolean,
|
||||
val appName: String,
|
||||
val canShareLiveLocation: Boolean,
|
||||
val startLiveLocationAction: AsyncAction<Unit>,
|
||||
val eventSink: (ShareLocationEvent) -> Unit,
|
||||
) {
|
||||
sealed interface Dialog {
|
||||
data object None : Dialog
|
||||
data class Constraints(val state: LocationConstraintsDialogState) : Dialog
|
||||
data object LiveLocationDisclaimer : 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 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.user.MatrixUser
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -51,6 +52,18 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
|
|||
trackUserPosition = 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(
|
||||
dialogState = ShareLocationState.Dialog.LiveLocationDurations(
|
||||
persistentListOf(
|
||||
|
|
@ -73,6 +86,7 @@ fun aShareLocationState(
|
|||
hasLocationPermission: Boolean = false,
|
||||
canShareLiveLocation: Boolean = false,
|
||||
appName: String = APP_NAME,
|
||||
startLiveLocationAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
eventSink: (ShareLocationEvent) -> Unit = {},
|
||||
): ShareLocationState {
|
||||
return ShareLocationState(
|
||||
|
|
@ -82,6 +96,7 @@ fun aShareLocationState(
|
|||
hasLocationPermission = hasLocationPermission,
|
||||
canShareLiveLocation = canShareLiveLocation,
|
||||
appName = appName,
|
||||
startLiveLocationAction = startLiveLocationAction,
|
||||
eventSink = eventSink
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
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.UserLocationPuck
|
||||
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.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.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.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.components.list.RadioButtonListItem
|
||||
|
|
@ -74,7 +78,6 @@ fun ShareLocationView(
|
|||
navigateUp: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
when (val dialogState = state.dialogState) {
|
||||
ShareLocationState.Dialog.None -> Unit
|
||||
is ShareLocationState.Dialog.Constraints -> LocationConstraintsDialog(
|
||||
|
|
@ -85,12 +88,17 @@ fun ShareLocationView(
|
|||
onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) },
|
||||
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(
|
||||
durations = dialogState.durations,
|
||||
onSelectDuration = { duration ->
|
||||
state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration))
|
||||
context.toast("Not implemented yet!")
|
||||
navigateUp()
|
||||
state.eventSink(StartLiveLocationShare(duration))
|
||||
},
|
||||
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
|
||||
)
|
||||
|
|
@ -160,10 +168,46 @@ fun ShareLocationView(
|
|||
.align(Alignment.TopEnd)
|
||||
.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
|
||||
private fun BottomSheetContent(
|
||||
cameraState: CameraState,
|
||||
|
|
@ -202,7 +246,7 @@ private fun BottomSheetContent(
|
|||
}
|
||||
if (state.canShareLiveLocation) {
|
||||
ShareLiveLocationItem {
|
||||
state.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
|
||||
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,4 +17,5 @@ sealed interface ShowLocationEvent {
|
|||
data object RequestPermissions : ShowLocationEvent
|
||||
data object OpenAppSettings : 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.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.features.location.api.Location
|
||||
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.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.checkLocationConstraints
|
||||
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.toImmutableList
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AssistedInject
|
||||
class ShowLocationPresenter(
|
||||
|
|
@ -55,6 +59,7 @@ class ShowLocationPresenter(
|
|||
private val dateFormatter: DateFormatter,
|
||||
private val stringProvider: StringProvider,
|
||||
private val joinedRoom: JoinedRoom,
|
||||
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||
) : Presenter<ShowLocationState> {
|
||||
@AssistedFactory
|
||||
fun interface Factory {
|
||||
|
|
@ -65,6 +70,7 @@ class ShowLocationPresenter(
|
|||
|
||||
@Composable
|
||||
override fun present(): ShowLocationState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val permissionsState: PermissionsState = permissionsPresenter.present()
|
||||
var isTrackMyLocation by remember { mutableStateOf(false) }
|
||||
val appName by remember { derivedStateOf { buildMeta.applicationName } }
|
||||
|
|
@ -85,7 +91,7 @@ class ShowLocationPresenter(
|
|||
}
|
||||
is ShowLocationEvent.TrackMyLocation -> {
|
||||
if (event.enabled) {
|
||||
val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
|
||||
val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||
isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success
|
||||
dialogState = locationConstraints.toDialogState()
|
||||
} else {
|
||||
|
|
@ -102,6 +108,9 @@ class ShowLocationPresenter(
|
|||
dialogState = LocationConstraintsDialogState.None
|
||||
}
|
||||
ShowLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
|
||||
ShowLocationEvent.StopLocationSharing -> coroutineScope.launch {
|
||||
liveLocationShareManager.stopShare(joinedRoom.roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -127,6 +136,7 @@ class ShowLocationPresenter(
|
|||
location = mode.location,
|
||||
isLive = false,
|
||||
assetType = mode.assetType,
|
||||
isOwnUser = mode.senderId == joinedRoom.sessionId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -163,6 +173,7 @@ class ShowLocationPresenter(
|
|||
location = location,
|
||||
isLive = true,
|
||||
assetType = lastLocation.assetType,
|
||||
isOwnUser = share.userId == joinedRoom.sessionId
|
||||
)
|
||||
}
|
||||
.toImmutableList()
|
||||
|
|
|
|||
|
|
@ -38,7 +38,10 @@ data class LocationShareItem(
|
|||
val location: Location,
|
||||
val isLive: Boolean,
|
||||
val assetType: AssetType?,
|
||||
)
|
||||
val isOwnUser: Boolean
|
||||
) {
|
||||
val canStopSharing = isLive && isOwnUser
|
||||
}
|
||||
|
||||
fun LocationShareItem.toMarkerData(): LocationMarkerData {
|
||||
val pinVariant = if (assetType == AssetType.PIN) {
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ fun aLocationShareItem(
|
|||
assetType: AssetType? = null,
|
||||
formattedTimestamp: String = "Shared 1 min ago",
|
||||
location: Location = Location(1.23, 2.34, 4f),
|
||||
isOwnUser: Boolean = false,
|
||||
) = LocationShareItem(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
|
|
@ -89,4 +90,5 @@ fun aLocationShareItem(
|
|||
location = location,
|
||||
isLive = isLive,
|
||||
assetType = assetType,
|
||||
isOwnUser = isOwnUser,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ fun ShowLocationView(
|
|||
LocationShareRow(
|
||||
item = locationShare,
|
||||
onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) },
|
||||
onStopClick = { state.eventSink(ShowLocationEvent.StopLocationSharing) },
|
||||
modifier = Modifier.clickable {
|
||||
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
|
||||
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">
|
||||
<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_missing_permissions">"Nincs jogosultsága az élő tartózkodási helyének megosztására ebben a szobában."</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -2,4 +2,5 @@
|
|||
<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_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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<string name="screen_share_location_live_location_disclaimer_title">"你实时位置历史将存储在房间中,并于会话结束后对其他成员可见。"</string>
|
||||
<string name="screen_share_location_live_location_duration_picker_title">"选择共享实时位置的时长。"</string>
|
||||
<string name="screen_share_location_live_location_missing_permissions">"你无权在此房内共享实时位置。"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -2,4 +2,5 @@
|
|||
<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_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>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class LocationConstraintsCheckTest {
|
|||
)
|
||||
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||
|
||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
||||
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||
|
||||
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ class LocationConstraintsCheckTest {
|
|||
)
|
||||
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||
|
||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
||||
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||
|
||||
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ class LocationConstraintsCheckTest {
|
|||
)
|
||||
val locationActions = FakeLocationActions(isLocationEnabled = false)
|
||||
|
||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
||||
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||
|
||||
assertThat(result).isEqualTo(LocationConstraintsCheck.LocationServiceDisabled)
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ class LocationConstraintsCheckTest {
|
|||
)
|
||||
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||
|
||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
||||
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||
|
||||
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionRationale)
|
||||
}
|
||||
|
|
@ -71,8 +71,20 @@ class LocationConstraintsCheckTest {
|
|||
)
|
||||
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||
|
||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
||||
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||
|
||||
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 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.live.LiveLocationStore
|
||||
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
|
||||
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
|
||||
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.core.aBuildMeta
|
||||
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.tests.testutils.node.TestParentNode
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
|
|
@ -30,16 +34,17 @@ class DefaultShareLocationEntryPointTest {
|
|||
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||
|
||||
@Test
|
||||
fun `test node builder`() {
|
||||
fun `test node builder`() = runTest {
|
||||
val entryPoint = DefaultShareLocationEntryPoint()
|
||||
val parentNode = TestParentNode.create { buildContext, plugins ->
|
||||
val room = FakeJoinedRoom()
|
||||
ShareLocationNode(
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
presenterFactory = { timelineMode: Timeline.Mode ->
|
||||
ShareLocationPresenter(
|
||||
permissionsPresenterFactory = { FakePermissionsPresenter() },
|
||||
room = FakeJoinedRoom(),
|
||||
room = room,
|
||||
timelineMode = timelineMode,
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
messageComposerContext = FakeMessageComposerContext(),
|
||||
|
|
@ -48,6 +53,11 @@ class DefaultShareLocationEntryPointTest {
|
|||
featureFlagService = FakeFeatureFlagService(),
|
||||
client = FakeMatrixClient(),
|
||||
durationFormatter = FakeDurationFormatter(),
|
||||
liveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||
liveLocationStore = LiveLocationStore(
|
||||
preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
|
||||
sessionId = room.sessionId,
|
||||
),
|
||||
)
|
||||
},
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@
|
|||
|
||||
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.moleculeFlow
|
||||
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.PermissionsState
|
||||
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.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.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.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.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.FakeMatrixClient
|
||||
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.powerlevels.FakeRoomPermissions
|
||||
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.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.value
|
||||
import io.element.android.tests.testutils.test
|
||||
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.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
class ShareLocationPresenterTest {
|
||||
@get:Rule
|
||||
|
|
@ -59,13 +82,16 @@ class ShareLocationPresenterTest {
|
|||
|
||||
private val durationFormatter = FakeDurationFormatter()
|
||||
|
||||
private fun createShareLocationPresenter(
|
||||
private fun TestScope.createShareLocationPresenter(
|
||||
joinedRoom: JoinedRoom = FakeJoinedRoom(),
|
||||
timelineMode: Timeline.Mode = Timeline.Mode.Live,
|
||||
locationActions: FakeLocationActions = fakeLocationActions,
|
||||
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||
liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId),
|
||||
): ShareLocationPresenter = ShareLocationPresenter(
|
||||
permissionsPresenterFactory = { fakePermissionsPresenter },
|
||||
room = joinedRoom,
|
||||
timelineMode = Timeline.Mode.Live,
|
||||
timelineMode = timelineMode,
|
||||
analyticsService = fakeAnalyticsService,
|
||||
messageComposerContext = fakeMessageComposerContext,
|
||||
locationActions = locationActions,
|
||||
|
|
@ -73,6 +99,8 @@ class ShareLocationPresenterTest {
|
|||
featureFlagService = fakeFeatureFlagService,
|
||||
client = fakeMatrixClient,
|
||||
durationFormatter = durationFormatter,
|
||||
liveLocationShareManager = liveLocationShareManager,
|
||||
liveLocationStore = liveLocationStore,
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
@ -296,7 +324,15 @@ class ShareLocationPresenterTest {
|
|||
|
||||
@Test
|
||||
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(
|
||||
aPermissionsState(
|
||||
permissions = PermissionsState.Permissions.AllGranted,
|
||||
|
|
@ -307,7 +343,7 @@ class ShareLocationPresenterTest {
|
|||
shareLocationPresenter.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
|
||||
initialState.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||
val durationDialogState = awaitItem()
|
||||
|
||||
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
|
||||
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(
|
||||
aPermissionsState(
|
||||
permissions = PermissionsState.Permissions.NoneGranted,
|
||||
|
|
@ -332,7 +514,7 @@ class ShareLocationPresenterTest {
|
|||
initialState.eventSink(ShareLocationEvent.DismissDialog)
|
||||
val dismissedState = awaitItem()
|
||||
|
||||
dismissedState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
|
||||
dismissedState.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||
val constraintDialogState = awaitItem()
|
||||
|
||||
assertThat(constraintDialogState.dialogState).isEqualTo(
|
||||
|
|
@ -447,4 +629,101 @@ class ShareLocationPresenterTest {
|
|||
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)
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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.impl.common.actions.FakeLocationActions
|
||||
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.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
|
|
@ -34,6 +35,7 @@ class DefaultShowLocationEntryPointTest {
|
|||
fun `test node builder`() {
|
||||
val entryPoint = DefaultShowLocationEntryPoint()
|
||||
val parentNode = TestParentNode.create { buildContext, plugins ->
|
||||
val joinedRoom = FakeJoinedRoom()
|
||||
ShowLocationNode(
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
|
|
@ -45,7 +47,8 @@ class DefaultShowLocationEntryPointTest {
|
|||
buildMeta = aBuildMeta(),
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
stringProvider = FakeStringProvider(),
|
||||
joinedRoom = FakeJoinedRoom(),
|
||||
joinedRoom = joinedRoom,
|
||||
liveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||
)
|
||||
},
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ package io.element.android.features.location.impl.show
|
|||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
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
|
||||
|
||||
class LiveLocationShareComparatorTest {
|
||||
|
|
@ -55,15 +55,3 @@ class LiveLocationShareComparatorTest {
|
|||
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.PermissionsState
|
||||
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.matrix.api.core.UserId
|
||||
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.LastLocation
|
||||
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.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.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.test
|
||||
|
|
@ -60,6 +61,7 @@ class ShowLocationPresenterTest {
|
|||
),
|
||||
locationActions: FakeLocationActions = fakeLocationActions,
|
||||
joinedRoom: JoinedRoom = FakeJoinedRoom(),
|
||||
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||
) = ShowLocationPresenter(
|
||||
mode = mode,
|
||||
permissionsPresenterFactory = { fakePermissionsPresenter },
|
||||
|
|
@ -68,6 +70,7 @@ class ShowLocationPresenterTest {
|
|||
dateFormatter = fakeDateFormatter,
|
||||
stringProvider = FakeStringProvider(),
|
||||
joinedRoom = joinedRoom,
|
||||
liveLocationShareManager = liveLocationShareManager,
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
@ -205,7 +208,7 @@ class ShowLocationPresenterTest {
|
|||
)
|
||||
)
|
||||
val presenter = createShowLocationPresenter()
|
||||
presenter.test {
|
||||
presenter.test {
|
||||
// Skip initial state
|
||||
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
|
||||
fun setup()
|
||||
suspend fun setup()
|
||||
suspend fun authenticate(): AuthenticationResult
|
||||
}
|
||||
|
||||
class NoopBiometricAuthentication : BiometricAuthenticator {
|
||||
override val isActive: Boolean = false
|
||||
override fun setup() = Unit
|
||||
override suspend fun setup() = Unit
|
||||
override suspend fun authenticate() = BiometricAuthenticator.AuthenticationResult.Failure()
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ class DefaultBiometricAuthentication(
|
|||
|
||||
private var cryptoObject: CryptoObject? = null
|
||||
|
||||
override fun setup() {
|
||||
override suspend fun setup() {
|
||||
try {
|
||||
val secretKey = ensureKey()
|
||||
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
|
||||
|
|
@ -86,7 +86,7 @@ class DefaultBiometricAuthentication(
|
|||
}
|
||||
|
||||
@Throws(KeyPermanentlyInvalidatedException::class)
|
||||
private fun ensureKey() = secretKeyRepository.getOrCreateKey(keyAlias, true).also {
|
||||
private suspend fun ensureKey() = secretKeyRepository.getOrCreateKey(keyAlias, true).also {
|
||||
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.SecretKeyRepository
|
||||
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
|
||||
|
||||
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)
|
||||
@SingleIn(AppScope::class)
|
||||
|
|
@ -29,6 +33,8 @@ class DefaultPinCodeManager(
|
|||
) : PinCodeManager {
|
||||
private val callbacks = CopyOnWriteArrayList<PinCodeManager.Callback>()
|
||||
|
||||
private val migrationMutex = Mutex()
|
||||
|
||||
override fun addCallback(callback: PinCodeManager.Callback) {
|
||||
callbacks.add(callback)
|
||||
}
|
||||
|
|
@ -38,11 +44,20 @@ class DefaultPinCodeManager(
|
|||
}
|
||||
|
||||
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 {
|
||||
val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return 0
|
||||
override suspend fun getPinCodeSize(): Int? {
|
||||
val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return null
|
||||
val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false)
|
||||
val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
|
||||
return decryptedPinCode.size
|
||||
|
|
@ -79,6 +94,7 @@ class DefaultPinCodeManager(
|
|||
override suspend fun deletePinCode() {
|
||||
lockScreenStore.deleteEncryptedPinCode()
|
||||
lockScreenStore.resetCounter()
|
||||
secretKeyRepository.deleteKey(SECRET_KEY_ALIAS)
|
||||
callbacks.forEach { it.onPinCodeRemoved() }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,9 +51,9 @@ interface PinCodeManager {
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@
|
|||
|
||||
package io.element.android.features.lockscreen.impl.settings
|
||||
|
||||
sealed interface LockScreenSettingsEvents {
|
||||
data object OnRemovePin : LockScreenSettingsEvents
|
||||
data object ConfirmRemovePin : LockScreenSettingsEvents
|
||||
data object CancelRemovePin : LockScreenSettingsEvents
|
||||
data object ToggleBiometricAllowed : LockScreenSettingsEvents
|
||||
sealed interface LockScreenSettingsEvent {
|
||||
data object OnRemovePin : LockScreenSettingsEvent
|
||||
data object ConfirmRemovePin : LockScreenSettingsEvent
|
||||
data object CancelRemovePin : LockScreenSettingsEvent
|
||||
data object ToggleBiometricAllowed : LockScreenSettingsEvent
|
||||
}
|
||||
|
|
@ -51,10 +51,10 @@ class LockScreenSettingsPresenter(
|
|||
|
||||
val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator()
|
||||
|
||||
fun handleEvent(event: LockScreenSettingsEvents) {
|
||||
fun handleEvent(event: LockScreenSettingsEvent) {
|
||||
when (event) {
|
||||
LockScreenSettingsEvents.CancelRemovePin -> showRemovePinConfirmation = false
|
||||
LockScreenSettingsEvents.ConfirmRemovePin -> {
|
||||
LockScreenSettingsEvent.CancelRemovePin -> showRemovePinConfirmation = false
|
||||
LockScreenSettingsEvent.ConfirmRemovePin -> {
|
||||
coroutineScope.launch {
|
||||
if (showRemovePinConfirmation) {
|
||||
showRemovePinConfirmation = false
|
||||
|
|
@ -62,8 +62,8 @@ class LockScreenSettingsPresenter(
|
|||
}
|
||||
}
|
||||
}
|
||||
LockScreenSettingsEvents.OnRemovePin -> showRemovePinConfirmation = true
|
||||
LockScreenSettingsEvents.ToggleBiometricAllowed -> {
|
||||
LockScreenSettingsEvent.OnRemovePin -> showRemovePinConfirmation = true
|
||||
LockScreenSettingsEvent.ToggleBiometricAllowed -> {
|
||||
coroutineScope.launch {
|
||||
if (!isBiometricEnabled) {
|
||||
biometricUnlock.setup()
|
||||
|
|
|
|||
|
|
@ -13,5 +13,5 @@ data class LockScreenSettingsState(
|
|||
val isBiometricEnabled: Boolean,
|
||||
val showRemovePinConfirmation: Boolean,
|
||||
val showToggleBiometric: Boolean,
|
||||
val eventSink: (LockScreenSettingsEvents) -> Unit
|
||||
val eventSink: (LockScreenSettingsEvent) -> Unit
|
||||
)
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ fun LockScreenSettingsView(
|
|||
},
|
||||
style = ListItemStyle.Destructive,
|
||||
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),
|
||||
isChecked = state.isBiometricEnabled,
|
||||
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),
|
||||
content = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_message),
|
||||
onSubmitClick = {
|
||||
state.eventSink(LockScreenSettingsEvents.ConfirmRemovePin)
|
||||
state.eventSink(LockScreenSettingsEvent.ConfirmRemovePin)
|
||||
},
|
||||
onDismiss = {
|
||||
state.eventSink(LockScreenSettingsEvents.CancelRemovePin)
|
||||
state.eventSink(LockScreenSettingsEvent.CancelRemovePin)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
package io.element.android.features.lockscreen.impl.setup.biometric
|
||||
|
||||
sealed interface SetupBiometricEvents {
|
||||
data object AllowBiometric : SetupBiometricEvents
|
||||
data object UsePin : SetupBiometricEvents
|
||||
sealed interface SetupBiometricEvent {
|
||||
data object AllowBiometric : SetupBiometricEvent
|
||||
data object UsePin : SetupBiometricEvent
|
||||
}
|
||||
|
|
@ -35,16 +35,16 @@ class SetupBiometricPresenter(
|
|||
val coroutineScope = rememberCoroutineScope()
|
||||
val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator()
|
||||
|
||||
fun handleEvent(event: SetupBiometricEvents) {
|
||||
fun handleEvent(event: SetupBiometricEvent) {
|
||||
when (event) {
|
||||
SetupBiometricEvents.AllowBiometric -> coroutineScope.launch {
|
||||
SetupBiometricEvent.AllowBiometric -> coroutineScope.launch {
|
||||
biometricUnlock.setup()
|
||||
if (biometricUnlock.authenticate() == BiometricAuthenticator.AuthenticationResult.Success) {
|
||||
lockScreenStore.setIsBiometricUnlockAllowed(true)
|
||||
isBiometricSetupDone = true
|
||||
}
|
||||
}
|
||||
SetupBiometricEvents.UsePin -> coroutineScope.launch {
|
||||
SetupBiometricEvent.UsePin -> coroutineScope.launch {
|
||||
lockScreenStore.setIsBiometricUnlockAllowed(false)
|
||||
isBiometricSetupDone = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,5 +10,5 @@ package io.element.android.features.lockscreen.impl.setup.biometric
|
|||
|
||||
data class SetupBiometricState(
|
||||
val isBiometricSetupDone: Boolean,
|
||||
val eventSink: (SetupBiometricEvents) -> Unit
|
||||
val eventSink: (SetupBiometricEvent) -> Unit
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ fun SetupBiometricView(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BackHandler {
|
||||
state.eventSink(SetupBiometricEvents.UsePin)
|
||||
state.eventSink(SetupBiometricEvent.UsePin)
|
||||
}
|
||||
HeaderFooterPage(
|
||||
modifier = modifier.padding(top = 80.dp),
|
||||
|
|
@ -42,8 +42,8 @@ fun SetupBiometricView(
|
|||
},
|
||||
footer = {
|
||||
SetupBiometricFooter(
|
||||
onAllowClick = { state.eventSink(SetupBiometricEvents.AllowBiometric) },
|
||||
onSkipClick = { state.eventSink(SetupBiometricEvents.UsePin) }
|
||||
onAllowClick = { state.eventSink(SetupBiometricEvent.AllowBiometric) },
|
||||
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