Feature : share live location (#6741)

* First live location sharing sending implementation

* Simplify logic around canStop sharing

* Add some debug logs around LiveLocationSharingService

* Add LiveLocationException

* Expose beaconId to identify the current share

* Throttle live location instead of debouncing

* Keep sync alive when sharing live location

* Improve LiveLocation sharing

* Show LiveLocationDisclaimer

* Read minDistanceUpdate in LiveLocationSharingService

* Set minDistanceUpdate in AdvancedSettings

* Display banner in room when sharing live location

* Fix tests around LiveLocationSharing

* Ensure shares are properly restarted/stopped when app is re-launched

* Ensure LLS data is cleared when session is removed

* Update and fix LLS tests

* Handle Start LLS in ui

* Add check LLS permissions

* Remove hardcoded strings

* Fix quality and format

* Create DeviceLocationProvider so we can share location data between sources (presenter/live location service)

* Update screenshots

* Fix warning

* Do not try to stop if it was not sharing

* Revert "Create DeviceLocationProvider so we can share location data between sources (presenter/live location service)"

This reverts commit ba12bd968e82941cc231bdbb449310b24c97c5b8.

* Tweak location provider config values

* Address PR review remarks

* Fix ktlint

* Update screenshots

* Fix some tests after merging develop

* Adjust TimelineItemLocationView ui to match figma

* Update screenshots

* Documentation and cleanup

* Remove temporary resource

---------

Co-authored-by: ElementBot <android@element.io>
Co-authored-by: Benoit Marty <benoit@matrix.org>
Co-authored-by: Benoit Marty <benoitm@matrix.org>
This commit is contained in:
ganfra 2026-05-11 10:19:28 +02:00 committed by GitHub
parent 0c657c258a
commit e49e183178
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
145 changed files with 2913 additions and 278 deletions

View file

@ -20,4 +20,5 @@ sealed interface AdvancedSettingsEvents {
data class SetTheme(val theme: ThemeOption) : AdvancedSettingsEvents
data class SetTimelineMediaPreviewValue(val value: MediaPreviewValue) : AdvancedSettingsEvents
data class SetHideInviteAvatars(val value: Boolean) : AdvancedSettingsEvents
data class SetLiveLocationMinimumDistanceUpdate(val value: Int) : AdvancedSettingsEvents
}

View file

@ -10,12 +10,14 @@ package io.element.android.features.preferences.impl.advanced
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.androidutils.system.openAppSettingsPage
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@ -28,10 +30,12 @@ class AdvancedSettingsNode(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
AdvancedSettingsView(
state = state,
modifier = modifier,
onBackClick = ::navigateUp
onBackClick = ::navigateUp,
onOpenAppSettingsClick = context::openAppSettingsPage
)
}
}

View file

@ -25,8 +25,11 @@ import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
@Inject
@ -53,6 +56,19 @@ class AdvancedSettingsPresenter(
appPreferencesStore.getThemeFlow().mapToTheme(isBlackThemeAllowed)
}.collectAsState(initial = Theme.System)
@OptIn(ExperimentalCoroutinesApi::class)
val liveLocationMinimumDistanceUpdate by produceState<Int?>(null) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing)
.flatMapLatest { isEnabled ->
if (isEnabled) {
appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow()
} else {
emptyFlow()
}
}
.collect { value = it }
}
val mediaPreviewConfigState = mediaPreviewConfigStateStore.state()
val themeOption by remember {
@ -117,6 +133,9 @@ class AdvancedSettingsPresenter(
}
is AdvancedSettingsEvents.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value)
is AdvancedSettingsEvents.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value)
is AdvancedSettingsEvents.SetLiveLocationMinimumDistanceUpdate -> sessionCoroutineScope.launch {
appPreferencesStore.setLiveLocationMinimumDistanceInMetersUpdate(event.value)
}
is AdvancedSettingsEvents.SetCompressImages -> sessionCoroutineScope.launch {
sessionPreferencesStore.setOptimizeImages(event.compress)
}
@ -133,6 +152,7 @@ class AdvancedSettingsPresenter(
theme = themeOption,
availableThemeOptions = availableThemeOptions,
mediaPreviewConfigState = mediaPreviewConfigState,
liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate,
eventSink = ::handleEvent,
)
}

View file

@ -23,6 +23,7 @@ data class AdvancedSettingsState(
val theme: ThemeOption,
val availableThemeOptions: ImmutableList<ThemeOption>,
val mediaPreviewConfigState: MediaPreviewConfigState,
val liveLocationMinimumDistanceUpdate: Int?,
val eventSink: (AdvancedSettingsEvents) -> Unit
)

View file

@ -41,6 +41,7 @@ fun aAdvancedSettingsState(
availableThemeOptions: ImmutableList<ThemeOption> = ThemeOption.entries.toImmutableList(),
hideInviteAvatars: Boolean = false,
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
liveLocationMinimumDistanceUpdate: Int? = 50,
setTimelineMediaPreviewAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
setHideInviteAvatarsAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (AdvancedSettingsEvents) -> Unit = {},
@ -56,5 +57,6 @@ fun aAdvancedSettingsState(
setTimelineMediaPreviewAction = setTimelineMediaPreviewAction,
setHideInviteAvatarsAction = setHideInviteAvatarsAction
),
liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate,
eventSink = eventSink
)

View file

@ -8,15 +8,24 @@
package io.element.android.features.preferences.impl.advanced
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SliderDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R
@ -33,10 +42,12 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.stringWithLink
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults
import io.element.android.libraries.designsystem.theme.components.Slider
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
@ -47,11 +58,13 @@ import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.compose.LocalAnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlin.math.roundToInt
@Composable
fun AdvancedSettingsView(
state: AdvancedSettingsState,
onBackClick: () -> Unit,
onOpenAppSettingsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val analyticsService = LocalAnalyticsService.current
@ -190,6 +203,15 @@ fun AdvancedSettingsView(
}
ModerationAndSafety(state)
if (state.liveLocationMinimumDistanceUpdate != null) {
LiveLocationUpdatesSection(
value = state.liveLocationMinimumDistanceUpdate,
onValueSaved = { value ->
state.eventSink(AdvancedSettingsEvents.SetLiveLocationMinimumDistanceUpdate(value))
},
onOpenAppPermissionsClick = onOpenAppSettingsClick,
)
}
}
}
@ -314,6 +336,78 @@ private fun ModerationAndSafety(
}
}
@Composable
private fun LiveLocationUpdatesSection(
value: Int,
onValueSaved: (Int) -> Unit,
onOpenAppPermissionsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
PreferenceCategory(
modifier = modifier,
showTopDivider = true,
) {
ListSectionHeader(
title = stringResource(R.string.screen_advanced_settings_live_location_section_title),
description = {
ListSupportingText(
text = stringResource(R.string.screen_advanced_settings_live_location_section_description),
contentPadding = ListSupportingTextDefaults.Padding.None,
)
}
)
var sliderValue by remember(value) { mutableIntStateOf(value) }
Column(
modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = pluralStringResource(
R.plurals.screen_advanced_settings_live_location_update_distance,
sliderValue,
sliderValue,
),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
val valueRange = 1f..100f
val start = valueRange.start.toInt()
val end = valueRange.endInclusive.toInt()
Row(verticalAlignment = Alignment.CenterVertically) {
Text("${start}m", color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdRegular)
Slider(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
value = sliderValue.toFloat(),
onValueChange = { sliderValue = it.roundToInt() },
onValueChangeFinish = {
onValueSaved(sliderValue)
},
valueRange = valueRange,
colors = SliderDefaults.colors(
thumbColor = ElementTheme.colors.iconAccentPrimary,
activeTrackColor = ElementTheme.colors.iconAccentPrimary,
inactiveTrackColor = ElementTheme.colors.bgBadgeAccent,
inactiveTickColor = ElementTheme.colors.iconAccentPrimary,
)
)
Text("${end}m", color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdRegular)
}
}
val footerText = stringWithLink(
textRes = R.string.screen_advanced_settings_live_location_section_footer,
url = "",
linkTextRes = R.string.screen_advanced_settings_live_location_section_footer_link,
onLinkClick = { onOpenAppPermissionsClick() },
)
ListSupportingText(
annotatedString = footerText,
contentPadding = ListSupportingTextDefaults.Padding.Default,
)
}
}
@PreviewWithLargeHeight
@Composable
internal fun AdvancedSettingsViewLightPreview(@PreviewParameter(AdvancedSettingsStateProvider::class) state: AdvancedSettingsState) =
@ -334,7 +428,8 @@ internal fun AdvancedSettingsViewBlackPreview(@PreviewParameter(AdvancedSettings
private fun ContentToPreview(state: AdvancedSettingsState) {
AdvancedSettingsView(
state = state,
onBackClick = { }
onBackClick = { },
onOpenAppSettingsClick = {}
)
}

View file

@ -17,6 +17,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
@ -209,6 +210,72 @@ class AdvancedSettingsPresenterTest {
}
}
@Test
fun `present - live location minimum distance is null when feature is disabled`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore(
liveLocationMinimumDistanceUpdate = 50,
)
val featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.LiveLocationSharing, false)
}
val presenter = createAdvancedSettingsPresenter(appPreferencesStore = appPreferencesStore, featureFlagService = featureFlagService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
with(awaitItem()) {
assertThat(liveLocationMinimumDistanceUpdate).isNull()
}
}
}
@Test
fun `present - exposes live location minimum distance from app preferences`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore(
liveLocationMinimumDistanceUpdate = 50,
)
val featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.LiveLocationSharing, true)
}
val presenter = createAdvancedSettingsPresenter(appPreferencesStore = appPreferencesStore, featureFlagService = featureFlagService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
with(awaitItem()) {
assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(50)
}
}
}
@Test
fun `present - saving live location minimum distance updates app preferences`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore(
liveLocationMinimumDistanceUpdate = 10,
)
val featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.LiveLocationSharing, true)
}
val presenter = createAdvancedSettingsPresenter(appPreferencesStore = appPreferencesStore, featureFlagService = featureFlagService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
with(awaitItem()) {
assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(10)
eventSink(AdvancedSettingsEvents.SetLiveLocationMinimumDistanceUpdate(42))
}
with(awaitItem()) {
assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(42)
}
}
}
@Test
fun `present - black theme option shown when feature flag enabled`() = runTest {
val presenter = createAdvancedSettingsPresenter(
@ -338,7 +405,7 @@ class AdvancedSettingsPresenterTest {
}
private fun CoroutineScope.createAdvancedSettingsPresenter(
appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),

View file

@ -250,6 +250,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setAdvancedSettingsView(
state: AdvancedSettingsState,
analyticsService: AnalyticsService = FakeAnalyticsService(),
onBackClick: () -> Unit = EnsureNeverCalled(),
onOpenAppSettings: () -> Unit = EnsureNeverCalled(),
) {
setContent {
CompositionLocalProvider(
@ -258,6 +259,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setAdvancedSettingsView(
AdvancedSettingsView(
state = state,
onBackClick = onBackClick,
onOpenAppSettingsClick = onOpenAppSettings
)
}
}