Merge branch 'release/26.05.1'

This commit is contained in:
Jorge Martín 2026-05-13 13:12:43 +02:00
commit c1fcb0f6f6
654 changed files with 6752 additions and 2882 deletions

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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
=============================

View file

@ -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 }

View file

@ -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

View 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

View file

@ -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
}
}
}

View file

@ -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()
}
}

View file

@ -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,

View file

@ -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

View file

@ -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()
}
}

View file

@ -0,0 +1,151 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.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
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 ochirib tashlang."</string>
<string name="screen_deactivate_account_list_item_4">"Xabarlaringiz royxatdan otgan foydalanuvchilarga korinadi, lekin ularni ochirishni tanlasangiz, yangi yoki royxatdan otmagan foydalanuvchilarga korinmaydi."</string>
<string name="screen_deactivate_account_title">"Akkauntni ochirish"</string>
</resources>

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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,
),

View file

@ -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

View file

@ -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>

View file

@ -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)
}

View file

@ -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,
)
}
}

View file

@ -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(

View file

@ -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(

View file

@ -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)

View file

@ -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>

View file

@ -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

View file

@ -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))
},
)
}

View file

@ -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.

View file

@ -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>

View file

@ -71,6 +71,7 @@ dependencies {
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
implementation(libs.coil.compose)
implementation(libs.datetime)
testCommonDependencies(libs)
}

View file

@ -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 = {},
)
}

View file

@ -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 }
}

View file

@ -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)
}

View file

@ -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>

View file

@ -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
}
}

View file

@ -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),
)
}

View file

@ -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
}

View file

@ -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 = {},
)
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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()
}
}

View file

@ -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() }
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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")
}
}
}
}

View file

@ -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()
}
}

View file

@ -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

View file

@ -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"

View file

@ -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
}
}

View file

@ -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
)
}

View file

@ -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)
}
}
}

View file

@ -17,4 +17,5 @@ sealed interface ShowLocationEvent {
data object RequestPermissions : ShowLocationEvent
data object OpenAppSettings : ShowLocationEvent
data object OpenLocationSettings : ShowLocationEvent
data object StopLocationSharing : ShowLocationEvent
}

View file

@ -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()

View file

@ -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) {

View file

@ -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,
)

View file

@ -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(

View file

@ -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>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_duration_picker_title">"Vali, kui kaua tahad oma reaalajas jagada."</string>
</resources>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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)
}
}

View file

@ -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,
)
}
}

View file

@ -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()
}
}

View file

@ -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(),

View file

@ -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 }
)

View file

@ -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(

View file

@ -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(),

View file

@ -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,
)
}

View file

@ -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,
)
}

View file

@ -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
}
}
}
}
}
}

View file

@ -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
}
}
}
}

View file

@ -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)
}
}

View file

@ -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() }
}

View file

@ -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.

View file

@ -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
}

View file

@ -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()

View file

@ -13,5 +13,5 @@ data class LockScreenSettingsState(
val isBiometricEnabled: Boolean,
val showRemovePinConfirmation: Boolean,
val showToggleBiometric: Boolean,
val eventSink: (LockScreenSettingsEvents) -> Unit
val eventSink: (LockScreenSettingsEvent) -> Unit
)

View file

@ -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)
}
)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
)

View file

@ -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