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:
parent
0c657c258a
commit
e49e183178
145 changed files with 2913 additions and 278 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ data class AdvancedSettingsState(
|
|||
val theme: ThemeOption,
|
||||
val availableThemeOptions: ImmutableList<ThemeOption>,
|
||||
val mediaPreviewConfigState: MediaPreviewConfigState,
|
||||
val liveLocationMinimumDistanceUpdate: Int?,
|
||||
val eventSink: (AdvancedSettingsEvents) -> Unit
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue