diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5461766425..a4ee1c8459 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -307,6 +307,7 @@ licensee { allow("BSD-2-Clause") allow("BSD-3-Clause") allow("EPL-1.0") + allowUrl("https://opensource.org/license/bsd-3-clause") allowUrl("https://opensource.org/licenses/MIT") allowUrl("https://developer.android.com/studio/terms.html") allowUrl("https://www.zetetic.net/sqlcipher/license/") diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt index 7593867207..eac624fe8b 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt @@ -19,7 +19,7 @@ private const val GEO_URI_REGEX = """geo:(?-?\d+(?:\.\d+)?),(? { + if (locationActions.isLocationEnabled()) { + LocationConstraintsCheck.Success + } else { + LocationConstraintsCheck.LocationServiceDisabled + } + } + permissionsState.shouldShowRationale -> LocationConstraintsCheck.PermissionRationale + else -> LocationConstraintsCheck.PermissionDenied + } +} + +fun LocationConstraintsCheck.toDialogState(): LocationConstraintsDialogState { + return when (this) { + LocationConstraintsCheck.Success -> LocationConstraintsDialogState.None + LocationConstraintsCheck.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale + LocationConstraintsCheck.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied + LocationConstraintsCheck.LocationServiceDisabled -> LocationConstraintsDialogState.LocationServiceDisabled + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt index d01f3e7dd2..1093e5760a 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt @@ -9,57 +9,35 @@ package io.element.android.features.location.impl.common import android.Manifest -import android.view.Gravity -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.ui.graphics.Color -import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.maplibre.compose.MapLocationSettings -import io.element.android.libraries.maplibre.compose.MapSymbolManagerSettings -import io.element.android.libraries.maplibre.compose.MapUiSettings -import org.maplibre.android.camera.CameraPosition -import org.maplibre.android.geometry.LatLng +import androidx.compose.ui.Alignment +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.map.GestureOptions +import org.maplibre.compose.map.MapOptions +import org.maplibre.compose.map.OrnamentOptions +import org.maplibre.compose.map.RenderOptions +import org.maplibre.spatialk.geojson.Position /** * Common configuration values for the map. */ object MapDefaults { - val uiSettings: MapUiSettings - @Composable - @ReadOnlyComposable - get() = MapUiSettings( - compassEnabled = false, - rotationGesturesEnabled = false, - scrollGesturesEnabled = true, - tiltGesturesEnabled = false, - zoomGesturesEnabled = true, - logoGravity = Gravity.TOP, - attributionGravity = Gravity.TOP, - attributionTintColor = ElementTheme.colors.iconPrimary + val options = MapOptions( + renderOptions = RenderOptions.Standard, + gestureOptions = GestureOptions.Standard, + ornamentOptions = OrnamentOptions( + isLogoEnabled = true, + logoAlignment = Alignment.BottomStart, + isAttributionEnabled = true, + attributionAlignment = Alignment.BottomEnd, + isCompassEnabled = false, + isScaleBarEnabled = false, ) + ) - val symbolManagerSettings: MapSymbolManagerSettings - get() = MapSymbolManagerSettings( - iconAllowOverlap = true - ) - - val locationSettings: MapLocationSettings - get() = MapLocationSettings( - locationEnabled = false, - backgroundTintColor = Color.White, - foregroundTintColor = Color.Black, - backgroundStaleTintColor = Color.White, - foregroundStaleTintColor = Color.Black, - accuracyColor = Color.Black, - pulseEnabled = true, - pulseColor = Color.Black, - ) - - val centerCameraPosition = CameraPosition.Builder() - .target(LatLng(49.843, 9.902056)) - .zoom(2.7) - .build() - + val defaultCameraPosition = CameraPosition( + target = Position(0.0, 0.0), + zoom = 0.0, + ) const val DEFAULT_ZOOM = 15.0 val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionDeniedDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionDeniedDialog.kt deleted file mode 100644 index 6817f579e5..0000000000 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionDeniedDialog.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector 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 androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog -import io.element.android.libraries.ui.strings.CommonStrings - -@Composable -internal fun PermissionDeniedDialog( - onContinue: () -> Unit, - onDismiss: () -> Unit, - appName: String, -) { - ConfirmationDialog( - content = stringResource(CommonStrings.error_missing_location_auth_android, appName), - onSubmitClick = onContinue, - onDismiss = onDismiss, - submitText = stringResource(CommonStrings.action_continue), - cancelText = stringResource(CommonStrings.action_cancel), - ) -} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionRationaleDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionRationaleDialog.kt deleted file mode 100644 index 7aef07e32b..0000000000 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionRationaleDialog.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector 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 androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog -import io.element.android.libraries.ui.strings.CommonStrings - -@Composable -internal fun PermissionRationaleDialog( - onContinue: () -> Unit, - onDismiss: () -> Unit, - appName: String, -) { - ConfirmationDialog( - content = stringResource(CommonStrings.error_missing_location_rationale_android, appName), - onSubmitClick = onContinue, - onDismiss = onDismiss, - submitText = stringResource(CommonStrings.action_continue), - cancelText = stringResource(CommonStrings.action_cancel), - ) -} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt index 0284c25a0a..7994a6e6b1 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt @@ -10,8 +10,11 @@ package io.element.android.features.location.impl.common.actions import android.content.Context import android.content.Intent +import android.location.LocationManager import android.net.Uri +import android.provider.Settings import androidx.annotation.VisibleForTesting +import androidx.core.location.LocationManagerCompat import androidx.core.net.toUri import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -40,9 +43,26 @@ class AndroidLocationActions( } } - override fun openSettings() { + override fun openAppSettings() { context.openAppSettingsPage() } + + override fun isLocationEnabled(): Boolean { + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + return LocationManagerCompat.isLocationEnabled(locationManager) + } + + override fun openLocationSettings() { + runCatchingExceptions { + val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + }.onSuccess { + Timber.v("Open location settings succeed") + }.onFailure { + Timber.e(it, "Open location settings failed") + } + } } // Ref: https://developer.android.com/guide/components/intents-common#ViewMap diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/LocationActions.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/LocationActions.kt index cd9efbd261..bc8e558c55 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/LocationActions.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/LocationActions.kt @@ -12,5 +12,7 @@ import io.element.android.features.location.api.Location interface LocationActions { fun share(location: Location, label: String?) - fun openSettings() + fun openAppSettings() + fun isLocationEnabled(): Boolean + fun openLocationSettings() } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt new file mode 100644 index 0000000000..95f5129f91 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt @@ -0,0 +1,54 @@ +/* + * 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.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun LocationConstraintsDialog( + state: LocationConstraintsDialogState, + appName: String, + onRequestPermissions: () -> Unit, + onOpenAppSettings: () -> Unit, + onOpenLocationSettings: () -> Unit, + onDismiss: () -> Unit, +) { + when (state) { + LocationConstraintsDialogState.None -> Unit + LocationConstraintsDialogState.PermissionRationale -> ConfirmationDialog( + content = stringResource(CommonStrings.error_missing_location_rationale_android, appName), + onSubmitClick = onRequestPermissions, + onDismiss = onDismiss, + submitText = stringResource(CommonStrings.action_continue), + ) + LocationConstraintsDialogState.PermissionDenied -> ConfirmationDialog( + content = stringResource(CommonStrings.error_missing_location_auth_android, appName), + onSubmitClick = onOpenAppSettings, + onDismiss = onDismiss, + submitText = stringResource(CommonStrings.action_continue), + ) + LocationConstraintsDialogState.LocationServiceDisabled -> ConfirmationDialog( + content = stringResource(CommonStrings.error_location_service_disabled_android), + onSubmitClick = onOpenLocationSettings, + onDismiss = onDismiss, + submitText = stringResource(CommonStrings.action_continue), + ) + } +} + +@Immutable +sealed interface LocationConstraintsDialogState { + data object None : LocationConstraintsDialogState + data object PermissionRationale : LocationConstraintsDialogState + data object PermissionDenied : LocationConstraintsDialogState + data object LocationServiceDisabled : LocationConstraintsDialogState +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationFloatingActionButton.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationFloatingActionButton.kt index 773544375f..28e3f1992e 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationFloatingActionButton.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationFloatingActionButton.kt @@ -9,7 +9,7 @@ package io.element.android.features.location.impl.common.ui import androidx.compose.foundation.layout.size -import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -30,13 +30,11 @@ internal fun LocationFloatingActionButton( modifier: Modifier = Modifier, ) { FloatingActionButton( - shape = FloatingActionButtonDefaults.smallShape, + shape = CircleShape, containerColor = ElementTheme.colors.bgCanvasDefault, contentColor = ElementTheme.colors.iconPrimary, onClick = onClick, - modifier = modifier - // Note: design is 40dp, but min is 48 for accessibility. - .size(48.dp), + modifier = modifier.size(48.dp), ) { val iconImage = if (isMapCenteredOnUser) { CompoundIcons.LocationNavigatorCentred() diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt new file mode 100644 index 0000000000..762a719cf4 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt @@ -0,0 +1,172 @@ +/* + * 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.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.location.api.Location +import io.element.android.libraries.designsystem.components.PinVariant +import io.element.android.libraries.designsystem.components.rememberLocationPinBitmap +import kotlinx.serialization.json.JsonPrimitive +import org.maplibre.compose.expressions.dsl.and +import org.maplibre.compose.expressions.dsl.asString +import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.expressions.dsl.eq +import org.maplibre.compose.expressions.dsl.feature +import org.maplibre.compose.expressions.dsl.image +import org.maplibre.compose.expressions.dsl.not +import org.maplibre.compose.expressions.value.SymbolAnchor +import org.maplibre.compose.layers.CircleLayer +import org.maplibre.compose.layers.SymbolLayer +import org.maplibre.compose.sources.GeoJsonData +import org.maplibre.compose.sources.GeoJsonOptions +import org.maplibre.compose.sources.GeoJsonSource +import org.maplibre.compose.sources.rememberGeoJsonSource +import org.maplibre.compose.util.ClickResult +import org.maplibre.spatialk.geojson.Feature +import org.maplibre.spatialk.geojson.FeatureCollection +import org.maplibre.spatialk.geojson.Point +import org.maplibre.spatialk.geojson.Position +import org.maplibre.spatialk.geojson.toJson + +private const val LOCATION_MARKER_ID = "LOCATION_MARKER_ID" + +/** + * Data class representing a marker on the map. + * + * @param id Unique identifier for the marker + * @param location The geographic location of the marker + * @param variant The visual variant of the pin (user location, pinned, stale) + */ +data class LocationMarkerData( + val id: String, + val location: Location, + val variant: PinVariant, +) + +/** + * A composable that renders location markers on a MapLibre map with clustering support. + * + * Uses GeoJSON source with clustering enabled to group nearby markers. + * Individual markers are rendered using Canvas-based pin rendering with Coil for avatar loading. + * Clusters are rendered as circles with point counts. + * + * Must be used within a MaplibreMap content block. + * + * @param markers List of markers to display on the map + * @param onMarkerClick Callback when a marker is clicked + * @param onClusterClick Callback when a cluster is clicked, provides cluster center position + */ +@Composable +fun LocationPinMarkers( + markers: List, + onMarkerClick: ((LocationMarkerData) -> Unit)? = null, + onClusterClick: ((Position) -> Unit)? = null, +) { + if (markers.isEmpty()) return + val clusterColor = ElementTheme.colors.bgAccentRest + val clusterStrokeColor = ElementTheme.colors.iconOnSolidPrimary + val clusterTextColor = ElementTheme.colors.textOnSolidPrimary + val clusterTextStyle = ElementTheme.typography.fontBodyMdMedium + + // Convert markers to GeoJSON + val geoJsonString = remember(markers) { + val features = markers.map { marker -> + Feature( + id = JsonPrimitive(marker.id), + geometry = Point(Position(marker.location.lon, marker.location.lat)), + properties = mapOf( + LOCATION_MARKER_ID to JsonPrimitive(marker.id), + ) + ) + } + FeatureCollection(features).toJson() + } + + // Create GeoJSON source with clustering + val markersSource = rememberGeoJsonSource( + data = GeoJsonData.JsonString(geoJsonString), + options = GeoJsonOptions( + cluster = true, + clusterMinPoints = 3, + clusterRadius = 30 + ), + ) + + // Cluster circle layer + CircleLayer( + id = "cluster-circles", + source = markersSource, + filter = feature.has("point_count"), + color = const(clusterColor), + radius = const(24.dp), + strokeWidth = const(1.dp), + strokeColor = const(clusterStrokeColor), + onClick = { features -> + features.firstOrNull()?.let { feat -> + val point = feat.geometry as? Point + if (point != null && onClusterClick != null) { + onClusterClick(point.coordinates) + ClickResult.Consume + } else { + ClickResult.Pass + } + } ?: ClickResult.Pass + }, + ) + + // Cluster count text layer + SymbolLayer( + id = "cluster-count", + source = markersSource, + filter = feature.has("point_count"), + textField = feature["point_count_abbreviated"].asString(), + textColor = const(clusterTextColor), + textSize = const(clusterTextStyle.fontSize), + textFont = const(listOfNotNull(clusterTextStyle.fontFamily?.toString())), + textLetterSpacing = const(clusterTextStyle.letterSpacing), + ) + + // Individual marker layers - one per marker for unique avatars + markers.forEach { marker -> + LocationPinMarkerLayer( + marker = marker, + source = markersSource, + onMarkerClick = onMarkerClick, + ) + } +} + +@Composable +private fun LocationPinMarkerLayer( + marker: LocationMarkerData, + source: GeoJsonSource, + onMarkerClick: ((LocationMarkerData) -> Unit)?, +) { + val imageBitmap = rememberLocationPinBitmap(marker.variant) + if (imageBitmap != null) { + SymbolLayer( + id = "pin-marker-${marker.id}", + source = source, + filter = !feature.has("point_count") and (feature[LOCATION_MARKER_ID].asString() eq const(marker.id)), + iconImage = image(imageBitmap), + iconAnchor = const(SymbolAnchor.Bottom), + iconAllowOverlap = const(true), + onClick = { features -> + if (features.isNotEmpty() && onMarkerClick != null) { + onMarkerClick(marker) + ClickResult.Consume + } else { + ClickResult.Pass + } + }, + ) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt new file mode 100644 index 0000000000..b949f55c76 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt @@ -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.location.impl.common.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +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.features.location.api.Location +import io.element.android.features.location.impl.show.LocationShareItem +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun LocationShareRow( + item: LocationShareItem, + onShareClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + avatarData = item.avatarData, + avatarType = AvatarType.User, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = item.displayName, + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (item.isLive) { + Icon( + imageVector = CompoundIcons.LocationPinSolid(), + contentDescription = null, + tint = ElementTheme.colors.iconAccentPrimary, + modifier = Modifier.size(16.dp), + ) + } else { + val icon = if (item.assetType == AssetType.PIN) { + CompoundIcons.LocationNavigator() + } else { + CompoundIcons.LocationNavigatorCentred() + } + Icon( + imageVector = icon, + contentDescription = null, + tint = ElementTheme.colors.iconSecondary, + modifier = Modifier.size(16.dp), + ) + } + Text( + text = item.formattedTimestamp, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + IconButton(onClick = onShareClick) { + Icon( + imageVector = CompoundIcons.ShareAndroid(), + contentDescription = stringResource(CommonStrings.action_share), + tint = ElementTheme.colors.iconPrimary, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun LocationShareRowPreview() = ElementPreview { + Column { + LocationShareRow( + item = LocationShareItem( + userId = UserId("@alice:matrix.org"), + displayName = "Alice", + avatarData = AvatarData( + id = "@alice:matrix.org", + name = "Alice", + url = null, + size = AvatarSize.UserListItem, + ), + formattedTimestamp = "Shared 1 min ago", + isLive = true, + assetType = AssetType.SENDER, + location = Location(0.0, 0.0) + ), + onShareClick = {}, + ) + LocationShareRow( + item = LocationShareItem( + userId = UserId("@bob:matrix.org"), + displayName = "Bob", + avatarData = AvatarData( + id = "@bob:matrix.org", + name = "Bob", + url = null, + size = AvatarSize.UserListItem, + ), + isLive = false, + assetType = AssetType.PIN, + formattedTimestamp = "Shared 5 hours ago", + location = Location(0.0, 0.0) + ), + onShareClick = {}, + ) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt new file mode 100644 index 0000000000..fbaed9c854 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt @@ -0,0 +1,138 @@ +/* + * 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.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.BottomSheetScaffoldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max +import io.element.android.features.location.api.internal.rememberTileStyleUrl +import io.element.android.features.location.impl.common.MapDefaults +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold +import org.maplibre.compose.camera.CameraState +import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.map.MapOptions +import org.maplibre.compose.map.MaplibreMap +import org.maplibre.compose.style.BaseStyle +import org.maplibre.compose.util.MaplibreComposable +import kotlin.math.roundToInt + +/** + * A reusable scaffold component for map views with a bottom sheet. + * + * Handles the layout complexity of: + * - Calculating the visible sheet height dynamically + * - Updating camera position padding based on sheet height + * - Rendering the MaplibreMap with proper ornament positioning + * + * @param modifier Modifier for the root layout + * @param scaffoldState State for the bottom sheet scaffold + * @param cameraState The camera state for the map + * @param mapOptions The options to configure the map + * @param sheetPeekHeight The height of the sheet when collapsed + * @param sheetDragHandle Optional drag handle for the sheet + * @param sheetSwipeEnabled Whether the sheet can be swiped + * @param topBar The top app bar content + * @param snackbarHost The snackbar host content + * @param sheetContent The content to display in the bottom sheet + * @param mapContent The content inside the MaplibreMap (layers, location pucks, etc.) + * @param overlayContent Content to overlay on top of the map (FAB, pin icons, etc.) + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MapBottomSheetScaffold( + modifier: Modifier = Modifier, + scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.PartiallyExpanded) + ), + cameraState: CameraState = rememberCameraState(), + mapOptions: MapOptions = MapDefaults.options, + sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight, + sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + sheetSwipeEnabled: Boolean = true, + topBar: (@Composable () -> Unit)? = null, + snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, + sheetContent: @Composable ColumnScope.(PaddingValues) -> Unit = {}, + mapContent: @Composable @MaplibreComposable () -> Unit = {}, + overlayContent: @Composable BoxScope.(sheetPadding: PaddingValues) -> Unit = {}, +) { + val density = LocalDensity.current + + val windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) + BoxWithConstraints(modifier = modifier.windowInsetsPadding(windowInsets)) { + val layoutHeightPx by rememberUpdatedState(constraints.maxHeight) + val sheetPadding by remember { + derivedStateOf { + val sheetOffset = tryOrNull { scaffoldState.bottomSheetState.requireOffset() } ?: 0f + val sheetVisibleHeightPx = layoutHeightPx - sheetOffset + val bottomPadding = with(density) { max(sheetVisibleHeightPx.roundToInt().toDp(), 0.dp) } + PaddingValues(bottom = bottomPadding) + } + } + // Update camera position when sheet padding changes + LaunchedEffect(sheetPadding) { + cameraState.position = cameraState.position.copy(padding = sheetPadding) + } + BottomSheetScaffold( + modifier = Modifier, + sheetPeekHeight = sheetPeekHeight, + sheetContent = { + sheetContent(sheetPadding) + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) + }, + scaffoldState = scaffoldState, + sheetDragHandle = sheetDragHandle, + sheetSwipeEnabled = sheetSwipeEnabled, + snackbarHost = snackbarHost, + topBar = topBar, + ) { + val ornamentOptions = mapOptions.ornamentOptions.copy(padding = sheetPadding) + val mapOptions = mapOptions.copy(ornamentOptions = ornamentOptions) + Box { + MaplibreMap( + options = mapOptions, + baseStyle = BaseStyle.Uri(rememberTileStyleUrl()), + modifier = Modifier.fillMaxSize(), + cameraState = cameraState, + content = mapContent, + ) + overlayContent(sheetPadding) + } + } + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt new file mode 100644 index 0000000000..8b89f77be4 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt @@ -0,0 +1,81 @@ +/* + * 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.ui + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.location.impl.common.MapDefaults +import org.maplibre.compose.camera.CameraState +import org.maplibre.compose.location.DesiredAccuracy +import org.maplibre.compose.location.LocationPuck +import org.maplibre.compose.location.LocationPuckColors +import org.maplibre.compose.location.LocationPuckSizes +import org.maplibre.compose.location.LocationTrackingEffect +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 + +@Composable +fun UserLocationPuck( + cameraState: CameraState, + locationState: UserLocationState, + trackUserLocation: Boolean, +) { + LocationTrackingEffect( + locationState = locationState, + enabled = trackUserLocation, + ) { + val finalPosition = cameraState.position.copy( + target = currentLocation.position, + bearing = currentLocation.bearing ?: cameraState.position.bearing, + zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM) + ) + cameraState.animateTo(finalPosition) + } + val location = locationState.location + if (location != null) { + LocationPuck( + idPrefix = "user-location", + locationState = locationState, + cameraState = cameraState, + accuracyThreshold = Float.POSITIVE_INFINITY, + showBearingAccuracy = false, + showBearing = false, + sizes = LocationPuckSizes( + dotRadius = 8.dp, + dotStrokeWidth = 2.dp, + ), + colors = LocationPuckColors( + dotFillColorCurrentLocation = ElementTheme.colors.iconAccentPrimary, + dotFillColorOldLocation = ElementTheme.colors.iconAccentTertiary, + dotStrokeColor = ElementTheme.colors.bgCanvasDefault, + ) + ) + } +} + +@SuppressLint("MissingPermission") +@Composable +fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState { + val isPreview = LocalInspectionMode.current + val locationProvider = if (isPreview || !hasLocationPermission) { + rememberNullLocationProvider() + } else { + rememberAndroidLocationProvider( + updateInterval = 1.minutes, + desiredAccuracy = DesiredAccuracy.Balanced, + minDistanceMeters = 50f, + ) + } + return rememberUserLocationState(locationProvider) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEvents.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEvents.kt deleted file mode 100644 index 0d266eefc7..0000000000 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEvents.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector 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.send - -import io.element.android.features.location.api.Location - -sealed interface SendLocationEvents { - data class SendLocation( - val cameraPosition: CameraPosition, - val location: Location?, - ) : SendLocationEvents { - data class CameraPosition( - val lat: Double, - val lon: Double, - val zoom: Double, - ) - } - - data object SwitchToMyLocationMode : SendLocationEvents - data object SwitchToPinLocationMode : SendLocationEvents - data object DismissDialog : SendLocationEvents - data object RequestPermissions : SendLocationEvents - data object OpenAppSettings : SendLocationEvents -} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt deleted file mode 100644 index b753820e55..0000000000 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector 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.send - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -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 im.vector.app.features.analytics.plan.Composer -import io.element.android.features.location.impl.common.MapDefaults -import io.element.android.features.location.impl.common.actions.LocationActions -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.messages.api.MessageComposerContext -import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.core.extensions.flatMap -import io.element.android.libraries.core.meta.BuildMeta -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.timeline.Timeline -import io.element.android.libraries.textcomposer.model.MessageComposerMode -import io.element.android.services.analytics.api.AnalyticsService -import kotlinx.coroutines.launch - -@AssistedInject -class SendLocationPresenter( - permissionsPresenterFactory: PermissionsPresenter.Factory, - private val room: JoinedRoom, - @Assisted private val timelineMode: Timeline.Mode, - private val analyticsService: AnalyticsService, - private val messageComposerContext: MessageComposerContext, - private val locationActions: LocationActions, - private val buildMeta: BuildMeta, -) : Presenter { - @AssistedFactory - fun interface Factory { - fun create(timelineMode: Timeline.Mode): SendLocationPresenter - } - - private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions) - - @Composable - override fun present(): SendLocationState { - val permissionsState: PermissionsState = permissionsPresenter.present() - var mode: SendLocationState.Mode by remember { - mutableStateOf( - if (permissionsState.isAnyGranted) { - SendLocationState.Mode.SenderLocation - } else { - SendLocationState.Mode.PinLocation - } - ) - } - val appName by remember { derivedStateOf { buildMeta.applicationName } } - var permissionDialog: SendLocationState.Dialog by remember { - mutableStateOf(SendLocationState.Dialog.None) - } - val scope = rememberCoroutineScope() - - LaunchedEffect(permissionsState.permissions) { - if (permissionsState.isAnyGranted) { - mode = SendLocationState.Mode.SenderLocation - permissionDialog = SendLocationState.Dialog.None - } - } - - fun handleEvent(event: SendLocationEvents) { - when (event) { - is SendLocationEvents.SendLocation -> scope.launch { - sendLocation(event, mode) - } - SendLocationEvents.SwitchToMyLocationMode -> when { - permissionsState.isAnyGranted -> mode = SendLocationState.Mode.SenderLocation - permissionsState.shouldShowRationale -> permissionDialog = SendLocationState.Dialog.PermissionRationale - else -> permissionDialog = SendLocationState.Dialog.PermissionDenied - } - SendLocationEvents.SwitchToPinLocationMode -> mode = SendLocationState.Mode.PinLocation - SendLocationEvents.DismissDialog -> permissionDialog = SendLocationState.Dialog.None - SendLocationEvents.OpenAppSettings -> { - locationActions.openSettings() - permissionDialog = SendLocationState.Dialog.None - } - SendLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) - } - } - - return SendLocationState( - permissionDialog = permissionDialog, - mode = mode, - hasLocationPermission = permissionsState.isAnyGranted, - appName = appName, - eventSink = ::handleEvent, - ) - } - - private suspend fun sendLocation( - event: SendLocationEvents.SendLocation, - mode: SendLocationState.Mode, - ) { - val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply - val inReplyToEventId = replyMode?.eventId - when (mode) { - SendLocationState.Mode.PinLocation -> { - val geoUri = event.cameraPosition.toGeoUri() - getTimeline().flatMap { - it.sendLocation( - body = generateBody(geoUri), - geoUri = geoUri, - description = null, - zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), - assetType = AssetType.PIN, - inReplyToEventId = inReplyToEventId, - ) - } - analyticsService.capture( - Composer( - inThread = messageComposerContext.composerMode.inThread, - isEditing = messageComposerContext.composerMode.isEditing, - isReply = messageComposerContext.composerMode.isReply, - messageType = Composer.MessageType.LocationPin, - ) - ) - } - SendLocationState.Mode.SenderLocation -> { - val geoUri = event.toGeoUri() - getTimeline().flatMap { - it.sendLocation( - body = generateBody(geoUri), - geoUri = geoUri, - description = null, - zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), - assetType = AssetType.SENDER, - inReplyToEventId = inReplyToEventId, - ) - } - analyticsService.capture( - Composer( - inThread = messageComposerContext.composerMode.inThread, - isEditing = messageComposerContext.composerMode.isEditing, - isReply = messageComposerContext.composerMode.isReply, - messageType = Composer.MessageType.LocationUser, - ) - ) - } - } - } - - private suspend fun getTimeline(): Result { - return when (timelineMode) { - is Timeline.Mode.Thread -> room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId)) - else -> Result.success(room.liveTimeline) - } - } -} - -private fun SendLocationEvents.SendLocation.toGeoUri(): String = location?.toGeoUri() ?: cameraPosition.toGeoUri() - -private fun SendLocationEvents.SendLocation.CameraPosition.toGeoUri(): String = "geo:$lat,$lon" - -private fun generateBody(uri: String): String = "Location was shared at $uri" diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationState.kt deleted file mode 100644 index 4ca84c47a5..0000000000 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationState.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector 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.send - -data class SendLocationState( - val permissionDialog: Dialog, - val mode: Mode, - val hasLocationPermission: Boolean, - val appName: String, - val eventSink: (SendLocationEvents) -> Unit, -) { - sealed interface Mode { - data object SenderLocation : Mode - data object PinLocation : Mode - } - - sealed interface Dialog { - data object None : Dialog - data object PermissionRationale : Dialog - data object PermissionDenied : Dialog - } -} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationStateProvider.kt deleted file mode 100644 index 238201cbec..0000000000 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationStateProvider.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector 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.send - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider - -private const val APP_NAME = "ApplicationName" - -class SendLocationStateProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - aSendLocationState( - permissionDialog = SendLocationState.Dialog.None, - mode = SendLocationState.Mode.PinLocation, - hasLocationPermission = false, - ), - aSendLocationState( - permissionDialog = SendLocationState.Dialog.PermissionDenied, - mode = SendLocationState.Mode.PinLocation, - hasLocationPermission = false, - ), - aSendLocationState( - permissionDialog = SendLocationState.Dialog.PermissionRationale, - mode = SendLocationState.Mode.PinLocation, - hasLocationPermission = false, - ), - aSendLocationState( - permissionDialog = SendLocationState.Dialog.None, - mode = SendLocationState.Mode.PinLocation, - hasLocationPermission = true, - ), - aSendLocationState( - permissionDialog = SendLocationState.Dialog.None, - mode = SendLocationState.Mode.SenderLocation, - hasLocationPermission = true, - ), - ) -} - -private fun aSendLocationState( - permissionDialog: SendLocationState.Dialog, - mode: SendLocationState.Mode, - hasLocationPermission: Boolean, -): SendLocationState { - return SendLocationState( - permissionDialog = permissionDialog, - mode = mode, - hasLocationPermission = hasLocationPermission, - appName = APP_NAME, - eventSink = {} - ) -} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt deleted file mode 100644 index 452a4aa8ae..0000000000 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt +++ /dev/null @@ -1,212 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector 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.send - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ListItem -import androidx.compose.material3.SheetValue -import androidx.compose.material3.rememberBottomSheetScaffoldState -import androidx.compose.material3.rememberStandardBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import io.element.android.features.location.api.Location -import io.element.android.features.location.api.internal.centerBottomEdge -import io.element.android.features.location.api.internal.rememberTileStyleUrl -import io.element.android.features.location.impl.R -import io.element.android.features.location.impl.common.MapDefaults -import io.element.android.features.location.impl.common.PermissionDeniedDialog -import io.element.android.features.location.impl.common.PermissionRationaleDialog -import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton -import io.element.android.libraries.designsystem.components.button.BackButton -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold -import io.element.android.libraries.designsystem.theme.components.Icon -import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.designsystem.theme.components.TopAppBar -import io.element.android.libraries.designsystem.utils.CommonDrawables -import io.element.android.libraries.maplibre.compose.CameraMode -import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason -import io.element.android.libraries.maplibre.compose.MapLibreMap -import io.element.android.libraries.maplibre.compose.rememberCameraPositionState -import io.element.android.libraries.ui.strings.CommonStrings -import org.maplibre.android.camera.CameraPosition - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SendLocationView( - state: SendLocationState, - navigateUp: () -> Unit, - modifier: Modifier = Modifier, -) { - LaunchedEffect(Unit) { - state.eventSink(SendLocationEvents.RequestPermissions) - } - - when (state.permissionDialog) { - SendLocationState.Dialog.None -> Unit - SendLocationState.Dialog.PermissionDenied -> PermissionDeniedDialog( - onContinue = { state.eventSink(SendLocationEvents.OpenAppSettings) }, - onDismiss = { state.eventSink(SendLocationEvents.DismissDialog) }, - appName = state.appName, - ) - SendLocationState.Dialog.PermissionRationale -> PermissionRationaleDialog( - onContinue = { state.eventSink(SendLocationEvents.RequestPermissions) }, - onDismiss = { state.eventSink(SendLocationEvents.DismissDialog) }, - appName = state.appName, - ) - } - - val cameraPositionState = rememberCameraPositionState { - position = MapDefaults.centerCameraPosition - } - - LaunchedEffect(state.mode) { - when (state.mode) { - SendLocationState.Mode.PinLocation -> { - cameraPositionState.cameraMode = CameraMode.NONE - } - SendLocationState.Mode.SenderLocation -> { - cameraPositionState.position = CameraPosition.Builder() - .zoom(MapDefaults.DEFAULT_ZOOM) - .build() - cameraPositionState.cameraMode = CameraMode.TRACKING - } - } - } - - LaunchedEffect(cameraPositionState.isMoving) { - if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) { - state.eventSink(SendLocationEvents.SwitchToPinLocationMode) - } - } - - // BottomSheetScaffold doesn't manage the system insets for sheetContent and the FAB, so we need to do it manually. - val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() - - BottomSheetScaffold( - sheetContent = { - Spacer(modifier = Modifier.height(16.dp)) - ListItem( - headlineContent = { - Text( - stringResource( - when (state.mode) { - SendLocationState.Mode.PinLocation -> CommonStrings.screen_share_this_location_action - SendLocationState.Mode.SenderLocation -> CommonStrings.screen_share_my_location_action - } - ) - ) - }, - modifier = Modifier.clickable( - // target is null when the map hasn't loaded (or api key is wrong) so we disable the button - enabled = cameraPositionState.position.target != null - ) { - state.eventSink( - SendLocationEvents.SendLocation( - cameraPosition = SendLocationEvents.SendLocation.CameraPosition( - lat = cameraPositionState.position.target!!.latitude, - lon = cameraPositionState.position.target!!.longitude, - zoom = cameraPositionState.position.zoom, - ), - location = cameraPositionState.location?.let { - Location( - lat = it.latitude, - lon = it.longitude, - accuracy = it.accuracy, - ) - } - ) - ) - navigateUp() - }, - leadingContent = { - Icon( - resourceId = R.drawable.pin_small, - contentDescription = null, - tint = Color.Unspecified, - ) - }, - ) - Spacer(modifier = Modifier.height(16.dp + navBarPadding)) - }, - modifier = modifier, - scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded), - ), - sheetDragHandle = {}, - sheetSwipeEnabled = false, - topBar = { - TopAppBar( - titleStr = stringResource(CommonStrings.screen_share_location_title), - navigationIcon = { - BackButton(onClick = navigateUp) - }, - ) - }, - ) { - Box( - modifier = Modifier - .padding(it) - .consumeWindowInsets(it), - contentAlignment = Alignment.Center - ) { - MapLibreMap( - styleUri = rememberTileStyleUrl(), - modifier = Modifier.fillMaxSize(), - cameraPositionState = cameraPositionState, - uiSettings = MapDefaults.uiSettings, - symbolManagerSettings = MapDefaults.symbolManagerSettings, - locationSettings = MapDefaults.locationSettings.copy( - locationEnabled = state.hasLocationPermission, - ), - ) - Icon( - resourceId = CommonDrawables.pin, - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier.centerBottomEdge(this), - ) - LocationFloatingActionButton( - isMapCenteredOnUser = state.mode == SendLocationState.Mode.SenderLocation, - onClick = { state.eventSink(SendLocationEvents.SwitchToMyLocationMode) }, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = 18.dp, bottom = 72.dp + navBarPadding), - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun SendLocationViewPreview( - @PreviewParameter(SendLocationStateProvider::class) state: SendLocationState -) = ElementPreview { - SendLocationView( - state = state, - navigateUp = {}, - ) -} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPoint.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPoint.kt similarity index 70% rename from features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPoint.kt rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPoint.kt index 56399f7e9d..e077453ad9 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPoint.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPoint.kt @@ -6,26 +6,26 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.location.impl.send +package io.element.android.features.location.impl.share import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.features.location.api.SendLocationEntryPoint +import io.element.android.features.location.api.ShareLocationEntryPoint import io.element.android.libraries.architecture.createNode import io.element.android.libraries.matrix.api.timeline.Timeline @ContributesBinding(AppScope::class) -class DefaultSendLocationEntryPoint : SendLocationEntryPoint { +class DefaultShareLocationEntryPoint : ShareLocationEntryPoint { override fun createNode( parentNode: Node, buildContext: BuildContext, timelineMode: Timeline.Mode, ): Node { - return parentNode.createNode( + return parentNode.createNode( buildContext = buildContext, - plugins = listOf(SendLocationNode.Inputs(timelineMode)) + plugins = listOf(ShareLocationNode.Inputs(timelineMode)) ) } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt new file mode 100644 index 0000000000..6a45ce3def --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt @@ -0,0 +1,15 @@ +/* + * 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.share + +import kotlin.time.Duration + +data class LiveLocationDuration( + val duration: Duration, + val formatted: String +) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt new file mode 100644 index 0000000000..d9ebc8b5af --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector 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.share + +import io.element.android.features.location.api.Location +import kotlin.time.Duration + +sealed interface ShareLocationEvent { + data class ShareStaticLocation( + val location: Location, + val isPinned: Boolean, + ) : ShareLocationEvent + + data object ShowLiveLocationDurationPicker : ShareLocationEvent + data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent + + data object StartTrackingUserLocation : ShareLocationEvent + data object StopTrackingUserLocation : ShareLocationEvent + data object DismissDialog : ShareLocationEvent + + data object RequestPermissions : ShareLocationEvent + data object OpenAppSettings : ShareLocationEvent + data object OpenLocationSettings : ShareLocationEvent +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationNode.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationNode.kt similarity index 91% rename from features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationNode.kt rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationNode.kt index 2184b52b44..0b61ff0df8 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationNode.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationNode.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.location.impl.send +package io.element.android.features.location.impl.share import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -26,10 +26,10 @@ import io.element.android.services.analytics.api.AnalyticsService @ContributesNode(RoomScope::class) @AssistedInject -class SendLocationNode( +class ShareLocationNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, - presenterFactory: SendLocationPresenter.Factory, + presenterFactory: ShareLocationPresenter.Factory, analyticsService: AnalyticsService, ) : Node(buildContext, plugins = plugins) { data class Inputs( @@ -48,7 +48,7 @@ class SendLocationNode( @Composable override fun View(modifier: Modifier) { - SendLocationView( + ShareLocationView( state = presenter.present(), modifier = modifier, navigateUp = ::navigateUp, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt new file mode 100644 index 0000000000..10fddf1e50 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector 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.share + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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 im.vector.app.features.analytics.plan.Composer +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.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.toDialogState +import io.element.android.features.location.impl.share.ShareLocationState.Dialog.Constraints +import io.element.android.features.messages.api.MessageComposerContext +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.extensions.flatMap +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.dateformatter.api.DurationFormatter +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +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.timeline.Timeline +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +private val LIVE_LOCATION_DURATIONS = listOf(15.minutes, 1.hours, 8.hours) + +@AssistedInject +class ShareLocationPresenter( + permissionsPresenterFactory: PermissionsPresenter.Factory, + private val room: JoinedRoom, + @Assisted private val timelineMode: Timeline.Mode, + private val analyticsService: AnalyticsService, + private val messageComposerContext: MessageComposerContext, + private val locationActions: LocationActions, + private val buildMeta: BuildMeta, + private val featureFlagService: FeatureFlagService, + private val client: MatrixClient, + private val durationFormatter: DurationFormatter, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create(timelineMode: Timeline.Mode): ShareLocationPresenter + } + + private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions) + + @Composable + override fun present(): ShareLocationState { + val permissionsState: PermissionsState = permissionsPresenter.present() + var trackUserPosition: Boolean by remember { mutableStateOf(permissionsState.isAnyGranted && locationActions.isLocationEnabled()) } + val isLiveLocationSharingEnabled by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing) + }.collectAsState(false) + val appName by remember { derivedStateOf { buildMeta.applicationName } } + var dialogState: ShareLocationState.Dialog by remember { + mutableStateOf(ShareLocationState.Dialog.None) + } + val currentUser by client.userProfile.collectAsState() + val scope = rememberCoroutineScope() + + fun checkLocationConstraints() { + val locationConstraints = checkLocationConstraints(permissionsState, locationActions) + dialogState = Constraints(locationConstraints.toDialogState()) + trackUserPosition = locationConstraints is LocationConstraintsCheck.Success + } + + LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() } + + fun handleEvent(event: ShareLocationEvent) { + when (event) { + is ShareLocationEvent.ShareStaticLocation -> scope.launch { + shareStaticLocation(event) + } + ShareLocationEvent.StartTrackingUserLocation -> checkLocationConstraints() + ShareLocationEvent.StopTrackingUserLocation -> trackUserPosition = false + ShareLocationEvent.DismissDialog -> dialogState = ShareLocationState.Dialog.None + ShareLocationEvent.OpenAppSettings -> { + locationActions.openAppSettings() + dialogState = ShareLocationState.Dialog.None + } + ShareLocationEvent.OpenLocationSettings -> { + 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)) + } + ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList()) + } else { + Constraints(constraintsResult.toDialogState()) + } + } + is ShareLocationEvent.StartLiveLocationShare -> scope.launch { + dialogState = ShareLocationState.Dialog.None + // room.startLiveLocationShare(event.duration.inWholeMilliseconds) + } + ShareLocationEvent.RequestPermissions -> { + dialogState = ShareLocationState.Dialog.None + permissionsState.eventSink(PermissionsEvents.RequestPermissions) + } + } + } + + return ShareLocationState( + currentUser = currentUser, + dialogState = dialogState, + trackUserLocation = trackUserPosition, + hasLocationPermission = permissionsState.isAnyGranted, + canShareLiveLocation = isLiveLocationSharingEnabled, + appName = appName, + eventSink = ::handleEvent, + ) + } + + private suspend fun shareStaticLocation(event: ShareLocationEvent.ShareStaticLocation) { + val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply + val inReplyToEventId = replyMode?.eventId + val geoUri = event.location.toGeoUri() + getTimeline().flatMap { + it.sendLocation( + body = generateBody(geoUri), + geoUri = geoUri, + description = null, + zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), + assetType = if (event.isPinned) AssetType.PIN else AssetType.SENDER, + inReplyToEventId = inReplyToEventId, + ) + } + analyticsService.capture( + Composer( + inThread = messageComposerContext.composerMode.inThread, + isEditing = messageComposerContext.composerMode.isEditing, + isReply = messageComposerContext.composerMode.isReply, + messageType = if (event.isPinned) Composer.MessageType.LocationPin else Composer.MessageType.LocationUser + ) + ) + } + + private suspend fun getTimeline(): Result { + return when (timelineMode) { + is Timeline.Mode.Thread -> room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId)) + else -> Result.success(room.liveTimeline) + } + } +} + +private fun generateBody(uri: String): String = "Location was shared at $uri" diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt new file mode 100644 index 0000000000..8b1f494f1e --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector 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.share + +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList + +data class ShareLocationState( + val currentUser: MatrixUser, + val dialogState: Dialog, + val trackUserLocation: Boolean, + val hasLocationPermission: Boolean, + val appName: String, + val canShareLiveLocation: Boolean, + val eventSink: (ShareLocationEvent) -> Unit, +) { + sealed interface Dialog { + data object None : Dialog + data class Constraints(val state: LocationConstraintsDialogState) : Dialog + data class LiveLocationDurations(val durations: ImmutableList) : Dialog + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt new file mode 100644 index 0000000000..facef74346 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector 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.share + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.persistentListOf +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +private const val APP_NAME = "ApplicationName" + +class ShareLocationStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aShareLocationState( + dialogState = ShareLocationState.Dialog.None, + trackUserPosition = false, + hasLocationPermission = false, + ), + aShareLocationState( + dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied), + trackUserPosition = false, + hasLocationPermission = false, + ), + aShareLocationState( + dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale), + trackUserPosition = false, + hasLocationPermission = false, + ), + aShareLocationState( + dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled), + trackUserPosition = false, + hasLocationPermission = true, + ), + aShareLocationState( + dialogState = ShareLocationState.Dialog.None, + trackUserPosition = false, + hasLocationPermission = true, + ), + aShareLocationState( + dialogState = ShareLocationState.Dialog.None, + trackUserPosition = true, + hasLocationPermission = true, + ), + aShareLocationState( + dialogState = ShareLocationState.Dialog.LiveLocationDurations( + persistentListOf( + LiveLocationDuration(15.minutes, "15 minutes"), + LiveLocationDuration(1.hours, "1 hour"), + LiveLocationDuration(8.hours, "8 hours"), + ) + ), + trackUserPosition = true, + hasLocationPermission = true, + canShareLiveLocation = true, + ), + ) +} + +fun aShareLocationState( + currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")), + dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None, + trackUserPosition: Boolean = false, + hasLocationPermission: Boolean = false, + canShareLiveLocation: Boolean = false, + appName: String = APP_NAME, + eventSink: (ShareLocationEvent) -> Unit = {}, +): ShareLocationState { + return ShareLocationState( + currentUser = currentUser, + dialogState = dialogState, + trackUserLocation = trackUserPosition, + hasLocationPermission = hasLocationPermission, + canShareLiveLocation = canShareLiveLocation, + appName = appName, + eventSink = eventSink + ) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt new file mode 100644 index 0000000000..1e163f417d --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH") + +package io.element.android.features.location.impl.share + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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 +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.location.api.Location +import io.element.android.features.location.api.internal.centerBottomEdge +import io.element.android.features.location.impl.R +import io.element.android.features.location.impl.common.MapDefaults +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog +import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton +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.libraries.designsystem.components.LocationPin +import io.element.android.libraries.designsystem.components.PinVariant +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.ListDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.list.RadioButtonListItem +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import org.maplibre.compose.camera.CameraMoveReason +import org.maplibre.compose.camera.CameraState +import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.location.UserLocationState +import kotlin.time.Duration + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareLocationView( + state: ShareLocationState, + navigateUp: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + when (val dialogState = state.dialogState) { + ShareLocationState.Dialog.None -> Unit + is ShareLocationState.Dialog.Constraints -> LocationConstraintsDialog( + state = dialogState.state, + appName = state.appName, + onRequestPermissions = { state.eventSink(ShareLocationEvent.RequestPermissions) }, + onOpenAppSettings = { state.eventSink(ShareLocationEvent.OpenAppSettings) }, + onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) }, + 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() + }, + onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, + ) + } + + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded) + ) + val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition) + val userLocationState = rememberUserLocationState(state.hasLocationPermission) + + LaunchedEffect(cameraState.isCameraMoving) { + if (cameraState.moveReason == CameraMoveReason.GESTURE) { + state.eventSink(ShareLocationEvent.StopTrackingUserLocation) + } + } + + MapBottomSheetScaffold( + cameraState = cameraState, + modifier = modifier, + scaffoldState = scaffoldState, + sheetDragHandle = null, + sheetSwipeEnabled = false, + topBar = { + TopAppBar( + titleStr = stringResource(CommonStrings.screen_share_location_title), + navigationIcon = { + BackButton(onClick = navigateUp) + }, + ) + }, + sheetContent = { + BottomSheetContent( + cameraState = cameraState, + state = state, + userLocationState = userLocationState, + navigateUp = navigateUp + ) + }, + mapContent = { + UserLocationPuck( + cameraState = cameraState, + locationState = userLocationState, + trackUserLocation = state.trackUserLocation + ) + }, + overlayContent = { sheetPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(sheetPadding) + ) { + val variant = if (state.trackUserLocation) { + PinVariant.UserLocation(isLive = false, avatarData = state.currentUser.getAvatarData(AvatarSize.LocationPin)) + } else { + PinVariant.PinnedLocation + } + LocationPin( + variant = variant, + modifier = Modifier.centerBottomEdge(this), + ) + } + LocationFloatingActionButton( + isMapCenteredOnUser = state.trackUserLocation, + onClick = { state.eventSink(ShareLocationEvent.StartTrackingUserLocation) }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(all = 16.dp), + ) + } + ) +} + +@Composable +private fun BottomSheetContent( + cameraState: CameraState, + state: ShareLocationState, + userLocationState: UserLocationState, + navigateUp: () -> Unit, +) { + Spacer(Modifier.height(20.dp)) + val userLocation = userLocationState.location + if (state.trackUserLocation && userLocation != null) { + ShareCurrentLocationItem { + state.eventSink( + ShareLocationEvent.ShareStaticLocation( + location = Location( + lat = userLocation.position.latitude, + lon = userLocation.position.longitude + ), + isPinned = false + ) + ) + navigateUp() + } + } else { + SharePinLocationItem( + onClick = { + val positionTarget = cameraState.position.target + state.eventSink( + ShareLocationEvent.ShareStaticLocation( + location = Location(lat = positionTarget.latitude, lon = positionTarget.longitude), + isPinned = true + ) + ) + navigateUp() + } + ) + } + if (state.canShareLiveLocation) { + ShareLiveLocationItem { + state.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker) + } + } +} + +@Composable +private fun ShareCurrentLocationItem( + onClick: () -> Unit, +) { + ListItem( + headlineContent = { + Text(stringResource(CommonStrings.screen_share_my_location_action)) + }, + onClick = onClick, + leadingContent = ListItemContent.Icon( + iconSource = IconSource.Vector(CompoundIcons.LocationNavigatorCentred()) + ) + ) +} + +@Composable +private fun SharePinLocationItem( + onClick: () -> Unit, +) { + ListItem( + headlineContent = { + Text(stringResource(CommonStrings.screen_share_this_location_action)) + }, + onClick = onClick, + leadingContent = ListItemContent.Icon( + iconSource = IconSource.Vector(CompoundIcons.LocationNavigator()) + ) + ) +} + +@Composable +private fun ShareLiveLocationItem( + onClick: () -> Unit, +) { + ListItem( + headlineContent = { + Text(stringResource(CommonStrings.action_share_live_location)) + }, + onClick = onClick, + leadingContent = ListItemContent.Icon( + iconSource = IconSource.Vector(CompoundIcons.LocationPinSolid()), + tintColor = ElementTheme.colors.iconAccentPrimary, + ) + ) +} + +@Composable +private fun LiveLocationDurationDialog( + durations: ImmutableList, + onSelectDuration: (Duration) -> Unit, + onDismiss: () -> Unit, +) { + var selectedIndex by remember { mutableIntStateOf(0) } + ListDialog( + title = stringResource(R.string.screen_share_location_live_location_duration_picker_title), + submitText = stringResource(CommonStrings.action_continue), + onSubmit = { onSelectDuration(durations[selectedIndex].duration) }, + onDismissRequest = onDismiss, + applyPaddingToContents = false, + verticalArrangement = Arrangement.Top + ) { + itemsIndexed(durations) { index, duration -> + RadioButtonListItem( + headline = duration.formatted, + selected = index == selectedIndex, + onSelect = { selectedIndex = index }, + compactLayout = true, + modifier = Modifier.padding(start = 8.dp) + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun ShareLocationViewPreview( + @PreviewParameter(ShareLocationStateProvider::class) state: ShareLocationState +) = ElementPreview { + ShareLocationView( + state = state, + navigateUp = {}, + ) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvent.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvent.kt new file mode 100644 index 0000000000..6a3e3521e0 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvent.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector 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.show + +import io.element.android.features.location.api.Location + +sealed interface ShowLocationEvent { + data class Share(val location: Location) : ShowLocationEvent + data class TrackMyLocation(val enabled: Boolean) : ShowLocationEvent + data object DismissDialog : ShowLocationEvent + data object RequestPermissions : ShowLocationEvent + data object OpenAppSettings : ShowLocationEvent + data object OpenLocationSettings : ShowLocationEvent +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvents.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvents.kt deleted file mode 100644 index 12f368fa11..0000000000 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvents.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector 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.show - -sealed interface ShowLocationEvents { - data object Share : ShowLocationEvents - data class TrackMyLocation(val enabled: Boolean) : ShowLocationEvents - data object DismissDialog : ShowLocationEvents - data object RequestPermissions : ShowLocationEvents - data object OpenAppSettings : ShowLocationEvents -} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt index 86d7741752..f318851f99 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt @@ -40,7 +40,7 @@ class ShowLocationNode( } private val inputs: ShowLocationEntryPoint.Inputs = inputs() - private val presenter = presenterFactory.create(inputs.location, inputs.description) + private val presenter = presenterFactory.create(inputs.mode) @Composable override fun View(modifier: Modifier) { diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index 3dcccef886..a2c9a3702d 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -18,26 +18,38 @@ 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.impl.common.LocationConstraintsCheck import io.element.android.features.location.impl.common.MapDefaults 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.toDialogState +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.toolbox.api.strings.StringProvider +import kotlinx.collections.immutable.persistentListOf @AssistedInject class ShowLocationPresenter( - @Assisted private val location: Location, - @Assisted private val description: String?, + @Assisted private val mode: ShowLocationMode, permissionsPresenterFactory: PermissionsPresenter.Factory, private val locationActions: LocationActions, private val buildMeta: BuildMeta, + private val dateFormatter: DateFormatter, + private val stringProvider: StringProvider, ) : Presenter { @AssistedFactory fun interface Factory { - fun create(location: Location, description: String?): ShowLocationPresenter + fun create(mode: ShowLocationMode): ShowLocationPresenter } private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions) @@ -47,43 +59,75 @@ class ShowLocationPresenter( val permissionsState: PermissionsState = permissionsPresenter.present() var isTrackMyLocation by remember { mutableStateOf(false) } val appName by remember { derivedStateOf { buildMeta.applicationName } } - var permissionDialog: ShowLocationState.Dialog by remember { - mutableStateOf(ShowLocationState.Dialog.None) + var dialogState: LocationConstraintsDialogState by remember { + mutableStateOf(LocationConstraintsDialogState.None) } LaunchedEffect(permissionsState.permissions) { if (permissionsState.isAnyGranted) { - permissionDialog = ShowLocationState.Dialog.None + dialogState = LocationConstraintsDialogState.None } } - fun handleEvent(event: ShowLocationEvents) { + fun handleEvent(event: ShowLocationEvent) { when (event) { - ShowLocationEvents.Share -> locationActions.share(location, description) - is ShowLocationEvents.TrackMyLocation -> { + is ShowLocationEvent.Share -> { + locationActions.share(event.location, null) + } + is ShowLocationEvent.TrackMyLocation -> { if (event.enabled) { - when { - permissionsState.isAnyGranted -> isTrackMyLocation = true - permissionsState.shouldShowRationale -> permissionDialog = ShowLocationState.Dialog.PermissionRationale - else -> permissionDialog = ShowLocationState.Dialog.PermissionDenied - } + val locationConstraints = checkLocationConstraints(permissionsState, locationActions) + isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success + dialogState = locationConstraints.toDialogState() } else { isTrackMyLocation = false } } - ShowLocationEvents.DismissDialog -> permissionDialog = ShowLocationState.Dialog.None - ShowLocationEvents.OpenAppSettings -> { - locationActions.openSettings() - permissionDialog = ShowLocationState.Dialog.None + ShowLocationEvent.DismissDialog -> dialogState = LocationConstraintsDialogState.None + ShowLocationEvent.OpenAppSettings -> { + locationActions.openAppSettings() + dialogState = LocationConstraintsDialogState.None } - ShowLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) + ShowLocationEvent.OpenLocationSettings -> { + locationActions.openLocationSettings() + dialogState = LocationConstraintsDialogState.None + } + ShowLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) + } + } + + val locationShares = remember { + when (mode) { + is ShowLocationMode.Static -> { + val relativeTime = dateFormatter.format(timestamp = mode.timestamp, mode = DateFormatterMode.Full, useRelative = true) + val formattedTimestamp = stringProvider.getString( + CommonStrings.screen_static_location_sheet_timestamp_description, + relativeTime + ) + persistentListOf( + LocationShareItem( + userId = mode.senderId, + displayName = mode.senderName, + avatarData = AvatarData( + id = mode.senderId.value, + name = mode.senderName, + url = mode.senderAvatarUrl, + size = AvatarSize.UserListItem, + ), + formattedTimestamp = formattedTimestamp, + location = mode.location, + isLive = false, + assetType = mode.assetType, + ) + ) + } + ShowLocationMode.Live -> persistentListOf() } } return ShowLocationState( - permissionDialog = permissionDialog, - location = location, - description = description, + dialogState = dialogState, + locationShares = locationShares, hasLocationPermission = permissionsState.isAnyGranted, isTrackMyLocation = isTrackMyLocation, appName = appName, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt index 96635d6df8..9494db12ec 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt @@ -9,19 +9,47 @@ package io.element.android.features.location.impl.show import io.element.android.features.location.api.Location +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState +import io.element.android.features.location.impl.common.ui.LocationMarkerData +import io.element.android.libraries.designsystem.components.PinVariant +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.location.AssetType +import kotlinx.collections.immutable.ImmutableList data class ShowLocationState( - val permissionDialog: Dialog, - val location: Location, - val description: String?, + val dialogState: LocationConstraintsDialogState, + val locationShares: ImmutableList, val hasLocationPermission: Boolean, val isTrackMyLocation: Boolean, val appName: String, - val eventSink: (ShowLocationEvents) -> Unit, + val eventSink: (ShowLocationEvent) -> Unit, ) { - sealed interface Dialog { - data object None : Dialog - data object PermissionRationale : Dialog - data object PermissionDenied : Dialog - } + val isSheetDraggable = locationShares.any { item -> item.isLive } +} + +data class LocationShareItem( + val userId: UserId, + val displayName: String, + val avatarData: AvatarData, + val formattedTimestamp: String, + val location: Location, + val isLive: Boolean, + val assetType: AssetType?, +) + +fun LocationShareItem.toMarkerData(): LocationMarkerData { + val pinVariant = if (assetType == AssetType.PIN) { + PinVariant.PinnedLocation + } else { + PinVariant.UserLocation( + avatarData = avatarData, + isLive = isLive, + ) + } + return LocationMarkerData( + id = userId.value, + location = location, + variant = pinVariant, + ) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt index 7d03a1ebb2..8bee410715 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt @@ -10,18 +10,26 @@ package io.element.android.features.location.impl.show import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.location.api.Location - -private const val APP_NAME = "ApplicationName" +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.location.AssetType +import kotlinx.collections.immutable.toImmutableList class ShowLocationStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aShowLocationState(), aShowLocationState( - permissionDialog = ShowLocationState.Dialog.PermissionDenied, + constraintsDialogState = LocationConstraintsDialogState.PermissionDenied, ), aShowLocationState( - permissionDialog = ShowLocationState.Dialog.PermissionRationale, + constraintsDialogState = LocationConstraintsDialogState.PermissionRationale, + ), + aShowLocationState( + constraintsDialogState = LocationConstraintsDialogState.LocationServiceDisabled, + hasLocationPermission = true, ), aShowLocationState( hasLocationPermission = true, @@ -30,33 +38,48 @@ class ShowLocationStateProvider : PreviewParameterProvider { hasLocationPermission = true, isTrackMyLocation = true, ), - aShowLocationState( - description = "My favourite place!", - ), - aShowLocationState( - description = "For some reason I decided to to write a small essay that wraps at just two lines!", - ), - aShowLocationState( - description = "For some reason I decided to write a small essay in the location description. " + - "It is so long that it will wrap onto more than two lines!", - ), ) } +private const val APP_NAME = "ApplicationName" + fun aShowLocationState( - permissionDialog: ShowLocationState.Dialog = ShowLocationState.Dialog.None, - location: Location = Location(1.23, 2.34, 4f), - description: String? = null, + constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None, + locationShares: List = listOf(aLocationShareItem()), hasLocationPermission: Boolean = false, isTrackMyLocation: Boolean = false, appName: String = APP_NAME, - eventSink: (ShowLocationEvents) -> Unit = {}, -) = ShowLocationState( - permissionDialog = permissionDialog, + eventSink: (ShowLocationEvent) -> Unit = {}, +): ShowLocationState { + return ShowLocationState( + dialogState = constraintsDialogState, + locationShares = locationShares.toImmutableList(), + hasLocationPermission = hasLocationPermission, + isTrackMyLocation = isTrackMyLocation, + appName = appName, + eventSink = eventSink, + ) +} + +fun aLocationShareItem( + userId: UserId = UserId("@alice:matrix.org"), + displayName: String = "Alice", + avatarData: AvatarData = AvatarData( + id = userId.value, + name = displayName, + url = null, + size = AvatarSize.UserListItem, + ), + formattedTimestamp: String = "Shared 1 min ago", + location: Location = Location(1.23, 2.34, 4f), + isLive: Boolean = false, + assetType: AssetType? = null, +) = LocationShareItem( + userId = userId, + displayName = displayName, + avatarData = avatarData, + formattedTimestamp = formattedTimestamp, location = location, - description = description, - hasLocationPermission = hasLocationPermission, - isTrackMyLocation = isTrackMyLocation, - appName = appName, - eventSink = eventSink, + isLive = isLive, + assetType = assetType, ) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index 6bc9e27bc6..ad2d4cb8ca 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -6,49 +6,48 @@ * Please see LICENSE files in the repository root for full details. */ +@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH") + package io.element.android.features.location.impl.show -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.compound.tokens.generated.TypographyTokens -import io.element.android.features.location.api.internal.rememberTileStyleUrl +import io.element.android.compound.theme.ElementTheme import io.element.android.features.location.impl.common.MapDefaults -import io.element.android.features.location.impl.common.PermissionDeniedDialog -import io.element.android.features.location.impl.common.PermissionRationaleDialog +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton +import io.element.android.features.location.impl.common.ui.LocationPinMarkers +import io.element.android.features.location.impl.common.ui.LocationShareRow +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.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.Icon -import io.element.android.libraries.designsystem.theme.components.IconButton -import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar -import io.element.android.libraries.designsystem.utils.CommonDrawables -import io.element.android.libraries.maplibre.compose.CameraMode -import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason -import io.element.android.libraries.maplibre.compose.IconAnchor -import io.element.android.libraries.maplibre.compose.MapLibreMap -import io.element.android.libraries.maplibre.compose.Symbol -import io.element.android.libraries.maplibre.compose.rememberCameraPositionState -import io.element.android.libraries.maplibre.compose.rememberSymbolState import io.element.android.libraries.ui.strings.CommonStrings -import kotlinx.collections.immutable.toImmutableMap -import org.maplibre.android.camera.CameraPosition -import org.maplibre.android.geometry.LatLng +import kotlinx.coroutines.launch +import org.maplibre.compose.camera.CameraMoveReason +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.spatialk.geojson.Position @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -57,46 +56,53 @@ fun ShowLocationView( onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - when (state.permissionDialog) { - ShowLocationState.Dialog.None -> Unit - ShowLocationState.Dialog.PermissionDenied -> PermissionDeniedDialog( - onContinue = { state.eventSink(ShowLocationEvents.OpenAppSettings) }, - onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) }, - appName = state.appName, - ) - ShowLocationState.Dialog.PermissionRationale -> PermissionRationaleDialog( - onContinue = { state.eventSink(ShowLocationEvents.RequestPermissions) }, - onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) }, - appName = state.appName, - ) - } + LocationConstraintsDialog( + state = state.dialogState, + appName = state.appName, + onRequestPermissions = { state.eventSink(ShowLocationEvent.RequestPermissions) }, + onOpenAppSettings = { state.eventSink(ShowLocationEvent.OpenAppSettings) }, + onOpenLocationSettings = { state.eventSink(ShowLocationEvent.OpenLocationSettings) }, + onDismiss = { state.eventSink(ShowLocationEvent.DismissDialog) }, + ) - val cameraPositionState = rememberCameraPositionState { - position = CameraPosition.Builder() - .target(LatLng(state.location.lat, state.location.lon)) - .zoom(MapDefaults.DEFAULT_ZOOM) - .build() + val initialPosition = remember { + if (state.locationShares.isEmpty()) { + MapDefaults.defaultCameraPosition + } else { + val firstLocation = state.locationShares.first().location + CameraPosition( + target = Position(latitude = firstLocation.lat, longitude = firstLocation.lon), + zoom = MapDefaults.DEFAULT_ZOOM + ) + } } - - LaunchedEffect(state.isTrackMyLocation) { - when (state.isTrackMyLocation) { - false -> cameraPositionState.cameraMode = CameraMode.NONE - true -> { - cameraPositionState.position = CameraPosition.Builder() - .zoom(MapDefaults.DEFAULT_ZOOM) - .build() - cameraPositionState.cameraMode = CameraMode.TRACKING - } + val cameraState = rememberCameraState(firstPosition = initialPosition) + val userLocationState = rememberUserLocationState(state.hasLocationPermission) + LaunchedEffect(cameraState.isCameraMoving) { + if (cameraState.moveReason == CameraMoveReason.GESTURE) { + state.eventSink(ShowLocationEvent.TrackMyLocation(false)) } } - LaunchedEffect(cameraPositionState.isMoving) { - if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) { - state.eventSink(ShowLocationEvents.TrackMyLocation(false)) - } - } - - Scaffold( + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState( + initialValue = + if (state.isSheetDraggable) { + SheetValue.PartiallyExpanded + } else { + SheetValue.Expanded + } + ) + ) + MapBottomSheetScaffold( + sheetDragHandle = if (state.isSheetDraggable) { + { BottomSheetDefaults.DragHandle() } + } else { + null + }, + sheetSwipeEnabled = state.isSheetDraggable, + scaffoldState = scaffoldState, + cameraState = cameraState, modifier = modifier, topBar = { TopAppBar( @@ -106,65 +112,56 @@ fun ShowLocationView( onClick = onBackClick, ) }, - actions = { - IconButton( - onClick = { state.eventSink(ShowLocationEvents.Share) } - ) { - Icon( - imageVector = CompoundIcons.ShareAndroid(), - contentDescription = stringResource(CommonStrings.action_share), - ) - } - } ) }, - floatingActionButton = { + sheetContent = { sheetPaddings -> + val coroutineScope = rememberCoroutineScope() + Spacer(Modifier.height(20.dp)) + Text( + text = stringResource(CommonStrings.screen_static_location_sheet_title), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + state.locationShares.forEach { locationShare -> + LocationShareRow( + item = locationShare, + onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) }, + modifier = Modifier.clickable { + state.eventSink(ShowLocationEvent.TrackMyLocation(false)) + val position = CameraPosition( + padding = sheetPaddings, + target = Position(locationShare.location.lon, locationShare.location.lat), + zoom = MapDefaults.DEFAULT_ZOOM + ) + coroutineScope.launch { + cameraState.animateTo(finalPosition = position) + } + } + ) + } + }, + mapContent = { + UserLocationPuck( + cameraState = cameraState, + locationState = userLocationState, + trackUserLocation = state.isTrackMyLocation + ) + val markers = remember(state.locationShares) { + state.locationShares.map { it.toMarkerData() } + } + LocationPinMarkers(markers) + }, + overlayContent = { LocationFloatingActionButton( isMapCenteredOnUser = state.isTrackMyLocation, - onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) }, + onClick = { state.eventSink(ShowLocationEvent.TrackMyLocation(true)) }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(all = 16.dp), ) - }, - ) { paddingValues -> - Column( - modifier = Modifier - .padding(paddingValues) - .consumeWindowInsets(paddingValues) - .fillMaxSize(), - ) { - state.description?.let { - Text( - text = it, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = TypographyTokens.fontBodyMdRegular, - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - ) - } - - MapLibreMap( - styleUri = rememberTileStyleUrl(), - modifier = Modifier.fillMaxSize(), - images = mapOf(PIN_ID to CommonDrawables.pin).toImmutableMap(), - cameraPositionState = cameraPositionState, - uiSettings = MapDefaults.uiSettings, - symbolManagerSettings = MapDefaults.symbolManagerSettings, - locationSettings = MapDefaults.locationSettings.copy( - locationEnabled = state.hasLocationPermission, - ), - ) { - Symbol( - iconId = PIN_ID, - state = rememberSymbolState( - position = LatLng(state.location.lat, state.location.lon) - ), - iconAnchor = IconAnchor.BOTTOM, - ) - } } - } + ) } @PreviewsDayNight @@ -175,5 +172,3 @@ internal fun ShowLocationViewPreview(@PreviewParameter(ShowLocationStateProvider onBackClick = {}, ) } - -private const val PIN_ID = "pin" diff --git a/features/location/impl/src/main/res/drawable-night/pin_small.xml b/features/location/impl/src/main/res/drawable-night/pin_small.xml deleted file mode 100644 index 2e8a54b70e..0000000000 --- a/features/location/impl/src/main/res/drawable-night/pin_small.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - diff --git a/features/location/impl/src/main/res/drawable/pin_small.xml b/features/location/impl/src/main/res/drawable/pin_small.xml deleted file mode 100644 index 0e277a1ed2..0000000000 --- a/features/location/impl/src/main/res/drawable/pin_small.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - diff --git a/features/location/impl/src/main/res/values-fi/translations.xml b/features/location/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000000..bc7e84e7b0 --- /dev/null +++ b/features/location/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,4 @@ + + + "Valitse, kuinka kauan haluat jakaa reaaliaikaisen sijaintisi." + diff --git a/features/location/impl/src/main/res/values-fr/translations.xml b/features/location/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..46689488e1 --- /dev/null +++ b/features/location/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,4 @@ + + + "Choisissez la durée pendant laquelle vous partagerez votre position en direct." + diff --git a/features/location/impl/src/main/res/values/localazy.xml b/features/location/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..04538049db --- /dev/null +++ b/features/location/impl/src/main/res/values/localazy.xml @@ -0,0 +1,4 @@ + + + "Choose how long to share your live location." + diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt new file mode 100644 index 0000000000..c8e1f21a48 --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 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 com.google.common.truth.Truth.assertThat +import io.element.android.features.location.impl.aPermissionsState +import io.element.android.features.location.impl.common.actions.FakeLocationActions +import io.element.android.features.location.impl.common.permissions.PermissionsState +import org.junit.Test + +class LocationConstraintsCheckTest { + @Test + fun `checkLocationConstraints returns Success when permissions granted and location enabled`() { + val permissionsState = aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + ) + val locationActions = FakeLocationActions(isLocationEnabled = true) + + val result = checkLocationConstraints(permissionsState, locationActions) + + assertThat(result).isEqualTo(LocationConstraintsCheck.Success) + } + + @Test + fun `checkLocationConstraints returns Success when some permissions granted and location enabled`() { + val permissionsState = aPermissionsState( + permissions = PermissionsState.Permissions.SomeGranted, + ) + val locationActions = FakeLocationActions(isLocationEnabled = true) + + val result = checkLocationConstraints(permissionsState, locationActions) + + assertThat(result).isEqualTo(LocationConstraintsCheck.Success) + } + + @Test + fun `checkLocationConstraints returns LocationServiceDisabled when permissions granted but location disabled`() { + val permissionsState = aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + ) + val locationActions = FakeLocationActions(isLocationEnabled = false) + + val result = checkLocationConstraints(permissionsState, locationActions) + + assertThat(result).isEqualTo(LocationConstraintsCheck.LocationServiceDisabled) + } + + @Test + fun `checkLocationConstraints returns PermissionRationale when permissions denied with rationale`() { + val permissionsState = aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = true, + ) + val locationActions = FakeLocationActions(isLocationEnabled = true) + + val result = checkLocationConstraints(permissionsState, locationActions) + + assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionRationale) + } + + @Test + fun `checkLocationConstraints returns PermissionDenied when permissions denied without rationale`() { + val permissionsState = aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + val locationActions = FakeLocationActions(isLocationEnabled = true) + + val result = checkLocationConstraints(permissionsState, locationActions) + + assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionDenied) + } +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/FakeLocationActions.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/FakeLocationActions.kt index 94dc972213..e05787d6a6 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/FakeLocationActions.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/FakeLocationActions.kt @@ -10,7 +10,9 @@ package io.element.android.features.location.impl.common.actions import io.element.android.features.location.api.Location -class FakeLocationActions : LocationActions { +class FakeLocationActions( + private var isLocationEnabled: Boolean = true, +) : LocationActions { var sharedLocation: Location? = null private set @@ -20,12 +22,27 @@ class FakeLocationActions : LocationActions { var openSettingsInvocationsCount = 0 private set + var openLocationSettingsInvocationsCount = 0 + private set + override fun share(location: Location, label: String?) { sharedLocation = location sharedLabel = label } - override fun openSettings() { + override fun openAppSettings() { openSettingsInvocationsCount++ } + + override fun isLocationEnabled(): Boolean { + return isLocationEnabled + } + + override fun openLocationSettings() { + openLocationSettingsInvocationsCount++ + } + + fun givenLocationEnabled(enabled: Boolean) { + isLocationEnabled = enabled + } } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt deleted file mode 100644 index 23ab3847cd..0000000000 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt +++ /dev/null @@ -1,496 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector 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.send - -import app.cash.molecule.RecompositionMode -import app.cash.molecule.moleculeFlow -import app.cash.turbine.test -import com.google.common.truth.Truth.assertThat -import im.vector.app.features.analytics.plan.Composer -import io.element.android.features.location.api.Location -import io.element.android.features.location.impl.aPermissionsState -import io.element.android.features.location.impl.common.actions.FakeLocationActions -import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter -import io.element.android.features.location.impl.common.permissions.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.messages.test.FakeMessageComposerContext -import io.element.android.libraries.matrix.api.core.EventId -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.timeline.Timeline -import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId -import io.element.android.libraries.matrix.test.AN_EVENT_ID -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.timeline.FakeTimeline -import io.element.android.libraries.textcomposer.model.MessageComposerMode -import io.element.android.services.analytics.test.FakeAnalyticsService -import io.element.android.tests.testutils.WarmUpRule -import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.lambda.value -import kotlinx.coroutines.delay -import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test - -class SendLocationPresenterTest { - @get:Rule - val warmUpRule = WarmUpRule() - - private val fakePermissionsPresenter = FakePermissionsPresenter() - private val fakeAnalyticsService = FakeAnalyticsService() - private val fakeMessageComposerContext = FakeMessageComposerContext() - private val fakeLocationActions = FakeLocationActions() - private val fakeBuildMeta = aBuildMeta(applicationName = "app name") - - private fun createSendLocationPresenter( - joinedRoom: JoinedRoom = FakeJoinedRoom(), - ): SendLocationPresenter = SendLocationPresenter( - permissionsPresenterFactory = object : PermissionsPresenter.Factory { - override fun create(permissions: List): PermissionsPresenter = fakePermissionsPresenter - }, - room = joinedRoom, - timelineMode = Timeline.Mode.Live, - analyticsService = fakeAnalyticsService, - messageComposerContext = fakeMessageComposerContext, - locationActions = fakeLocationActions, - buildMeta = fakeBuildMeta, - ) - - @Test - fun `initial state with permissions granted`() = runTest { - val sendLocationPresenter = createSendLocationPresenter() - fakePermissionsPresenter.givenState( - aPermissionsState( - permissions = PermissionsState.Permissions.AllGranted, - shouldShowRationale = false, - ) - ) - - moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) - assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.SenderLocation) - assertThat(initialState.hasLocationPermission).isTrue() - - // Swipe the map to switch mode - initialState.eventSink(SendLocationEvents.SwitchToPinLocationMode) - val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) - assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) - assertThat(myLocationState.hasLocationPermission).isTrue() - } - } - - @Test - fun `initial state with permissions partially granted`() = runTest { - val sendLocationPresenter = createSendLocationPresenter() - fakePermissionsPresenter.givenState( - aPermissionsState( - permissions = PermissionsState.Permissions.SomeGranted, - shouldShowRationale = false, - ) - ) - - moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) - assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.SenderLocation) - assertThat(initialState.hasLocationPermission).isTrue() - - // Swipe the map to switch mode - initialState.eventSink(SendLocationEvents.SwitchToPinLocationMode) - val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) - assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) - assertThat(myLocationState.hasLocationPermission).isTrue() - } - } - - @Test - fun `initial state with permissions denied`() = runTest { - val sendLocationPresenter = createSendLocationPresenter() - fakePermissionsPresenter.givenState( - aPermissionsState( - permissions = PermissionsState.Permissions.NoneGranted, - shouldShowRationale = false, - ) - ) - - moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) - assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.PinLocation) - assertThat(initialState.hasLocationPermission).isFalse() - - // Click on the button to switch mode - initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) - val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionDenied) - assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) - assertThat(myLocationState.hasLocationPermission).isFalse() - } - } - - @Test - fun `initial state with permissions denied once`() = runTest { - val sendLocationPresenter = createSendLocationPresenter() - fakePermissionsPresenter.givenState( - aPermissionsState( - permissions = PermissionsState.Permissions.NoneGranted, - shouldShowRationale = true, - ) - ) - - moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) - assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.PinLocation) - assertThat(initialState.hasLocationPermission).isFalse() - - // Click on the button to switch mode - initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) - val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale) - assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) - assertThat(myLocationState.hasLocationPermission).isFalse() - } - } - - @Test - fun `rationale dialog dismiss`() = runTest { - val sendLocationPresenter = createSendLocationPresenter() - fakePermissionsPresenter.givenState( - aPermissionsState( - permissions = PermissionsState.Permissions.NoneGranted, - shouldShowRationale = true, - ) - ) - - moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() - }.test { - // Skip initial state - val initialState = awaitItem() - - // Click on the button to switch mode - initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) - val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale) - assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) - assertThat(myLocationState.hasLocationPermission).isFalse() - - // Dismiss the dialog - myLocationState.eventSink(SendLocationEvents.DismissDialog) - val dialogDismissedState = awaitItem() - assertThat(dialogDismissedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) - assertThat(dialogDismissedState.mode).isEqualTo(SendLocationState.Mode.PinLocation) - assertThat(dialogDismissedState.hasLocationPermission).isFalse() - } - } - - @Test - fun `rationale dialog continue`() = runTest { - val sendLocationPresenter = createSendLocationPresenter() - fakePermissionsPresenter.givenState( - aPermissionsState( - permissions = PermissionsState.Permissions.NoneGranted, - shouldShowRationale = true, - ) - ) - - moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() - }.test { - // Skip initial state - val initialState = awaitItem() - - // Click on the button to switch mode - initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) - val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale) - assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) - assertThat(myLocationState.hasLocationPermission).isFalse() - - // Continue the dialog sends permission request to the permissions presenter - myLocationState.eventSink(SendLocationEvents.RequestPermissions) - assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) - } - } - - @Test - fun `permission denied dialog dismiss`() = runTest { - val sendLocationPresenter = createSendLocationPresenter() - fakePermissionsPresenter.givenState( - aPermissionsState( - permissions = PermissionsState.Permissions.NoneGranted, - shouldShowRationale = false, - ) - ) - - moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() - }.test { - // Skip initial state - val initialState = awaitItem() - - // Click on the button to switch mode - initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) - val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionDenied) - assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) - assertThat(myLocationState.hasLocationPermission).isFalse() - - // Dismiss the dialog - myLocationState.eventSink(SendLocationEvents.DismissDialog) - val dialogDismissedState = awaitItem() - assertThat(dialogDismissedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) - assertThat(dialogDismissedState.mode).isEqualTo(SendLocationState.Mode.PinLocation) - assertThat(dialogDismissedState.hasLocationPermission).isFalse() - } - } - - @Test - fun `share sender location`() = runTest { - val sendLocationResult = lambdaRecorder> { _, _, _, _, _, _ -> - Result.success(Unit) - } - val joinedRoom = FakeJoinedRoom( - liveTimeline = FakeTimeline().apply { - sendLocationLambda = sendLocationResult - }, - ) - val sendLocationPresenter = createSendLocationPresenter(joinedRoom) - fakePermissionsPresenter.givenState( - aPermissionsState( - permissions = PermissionsState.Permissions.AllGranted, - shouldShowRationale = false, - ) - ) - - moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() - }.test { - // Skip initial state - val initialState = awaitItem() - - // Send location - initialState.eventSink( - SendLocationEvents.SendLocation( - cameraPosition = SendLocationEvents.SendLocation.CameraPosition( - lat = 0.0, - lon = 1.0, - zoom = 2.0, - ), - location = Location( - lat = 3.0, - lon = 4.0, - accuracy = 5.0f, - ) - ) - ) - - delay(1) // Wait for the coroutine to finish - - sendLocationResult.assertions().isCalledOnce() - .with( - value("Location was shared at geo:3.0,4.0;u=5.0"), - value("geo:3.0,4.0;u=5.0"), - value(null), - value(15), - value(AssetType.SENDER), - value(null), - ) - - assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1) - assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo( - Composer( - inThread = false, - isEditing = false, - isReply = false, - messageType = Composer.MessageType.LocationUser, - ) - ) - } - } - - @Test - fun `share pin location`() = runTest { - val sendLocationResult = lambdaRecorder> { _, _, _, _, _, _ -> - Result.success(Unit) - } - val joinedRoom = FakeJoinedRoom( - liveTimeline = FakeTimeline().apply { - sendLocationLambda = sendLocationResult - }, - ) - val sendLocationPresenter = createSendLocationPresenter(joinedRoom) - fakePermissionsPresenter.givenState( - aPermissionsState( - permissions = PermissionsState.Permissions.NoneGranted, - shouldShowRationale = false, - ) - ) - - moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() - }.test { - // Skip initial state - val initialState = awaitItem() - - // Send location - initialState.eventSink( - SendLocationEvents.SendLocation( - cameraPosition = SendLocationEvents.SendLocation.CameraPosition( - lat = 0.0, - lon = 1.0, - zoom = 2.0, - ), - location = Location( - lat = 3.0, - lon = 4.0, - accuracy = 5.0f, - ) - ) - ) - - delay(1) // Wait for the coroutine to finish - - sendLocationResult.assertions().isCalledOnce() - .with( - value("Location was shared at geo:0.0,1.0"), - value("geo:0.0,1.0"), - value(null), - value(15), - value(AssetType.PIN), - value(null), - ) - - assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1) - assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo( - Composer( - inThread = false, - isEditing = false, - isReply = false, - messageType = Composer.MessageType.LocationPin, - ) - ) - } - } - - @Test - fun `composer context passes through analytics`() = runTest { - val sendLocationResult = lambdaRecorder> { _, _, _, _, _, _ -> - Result.success(Unit) - } - val joinedRoom = FakeJoinedRoom( - liveTimeline = FakeTimeline().apply { - sendLocationLambda = sendLocationResult - }, - ) - val sendLocationPresenter = createSendLocationPresenter(joinedRoom) - fakePermissionsPresenter.givenState( - aPermissionsState( - permissions = PermissionsState.Permissions.NoneGranted, - shouldShowRationale = false, - ) - ) - fakeMessageComposerContext.apply { - composerMode = MessageComposerMode.Edit( - eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(), - content = "" - ) - } - - moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() - }.test { - // Skip initial state - val initialState = awaitItem() - - // Send location - initialState.eventSink( - SendLocationEvents.SendLocation( - cameraPosition = SendLocationEvents.SendLocation.CameraPosition( - lat = 0.0, - lon = 1.0, - zoom = 2.0, - ), - location = null - ) - ) - - delay(1) // Wait for the coroutine to finish - - assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1) - assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo( - Composer( - inThread = false, - isEditing = true, - isReply = false, - messageType = Composer.MessageType.LocationPin, - ) - ) - } - } - - @Test - fun `open settings activity`() = runTest { - val sendLocationPresenter = createSendLocationPresenter() - fakePermissionsPresenter.givenState( - aPermissionsState( - permissions = PermissionsState.Permissions.NoneGranted, - shouldShowRationale = false, - ) - ) - fakeMessageComposerContext.apply { - composerMode = MessageComposerMode.Edit( - eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(), - content = "" - ) - } - - moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() - }.test { - // Skip initial state - val initialState = awaitItem() - - initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) - val dialogShownState = awaitItem() - - // Open settings - dialogShownState.eventSink(SendLocationEvents.OpenAppSettings) - val settingsOpenedState = awaitItem() - - assertThat(settingsOpenedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) - assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1) - } - } - - @Test - fun `application name is in state`() = runTest { - val sendLocationPresenter = createSendLocationPresenter() - moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.appName).isEqualTo("app name") - } - } -} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt similarity index 73% rename from features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPointTest.kt rename to features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt index a90afd5a9d..edd000e02c 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.location.impl.send +package io.element.android.features.location.impl.share import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext @@ -14,7 +14,10 @@ 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.messages.test.FakeMessageComposerContext +import io.element.android.libraries.dateformatter.test.FakeDurationFormatter +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService 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.services.analytics.test.FakeAnalyticsService @@ -22,19 +25,19 @@ import io.element.android.tests.testutils.node.TestParentNode import org.junit.Rule import org.junit.Test -class DefaultSendLocationEntryPointTest { +class DefaultShareLocationEntryPointTest { @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() @Test fun `test node builder`() { - val entryPoint = DefaultSendLocationEntryPoint() + val entryPoint = DefaultShareLocationEntryPoint() val parentNode = TestParentNode.create { buildContext, plugins -> - SendLocationNode( + ShareLocationNode( buildContext = buildContext, plugins = plugins, presenterFactory = { timelineMode: Timeline.Mode -> - SendLocationPresenter( + ShareLocationPresenter( permissionsPresenterFactory = { FakePermissionsPresenter() }, room = FakeJoinedRoom(), timelineMode = timelineMode, @@ -42,6 +45,9 @@ class DefaultSendLocationEntryPointTest { messageComposerContext = FakeMessageComposerContext(), locationActions = FakeLocationActions(), buildMeta = aBuildMeta(), + featureFlagService = FakeFeatureFlagService(), + client = FakeMatrixClient(), + durationFormatter = FakeDurationFormatter(), ) }, analyticsService = FakeAnalyticsService(), @@ -53,7 +59,7 @@ class DefaultSendLocationEntryPointTest { buildContext = BuildContext.root(null), timelineMode = timelineMode, ) - assertThat(result).isInstanceOf(SendLocationNode::class.java) - assertThat(result.plugins).contains(SendLocationNode.Inputs(timelineMode)) + assertThat(result).isInstanceOf(ShareLocationNode::class.java) + assertThat(result.plugins).contains(ShareLocationNode.Inputs(timelineMode)) } } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt new file mode 100644 index 0000000000..92c27d9f21 --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt @@ -0,0 +1,447 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector 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.share + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.Composer +import io.element.android.features.location.api.Location +import io.element.android.features.location.impl.aPermissionsState +import io.element.android.features.location.impl.common.actions.FakeLocationActions +import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter +import io.element.android.features.location.impl.common.permissions.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.messages.test.FakeMessageComposerContext +import io.element.android.libraries.dateformatter.test.FakeDurationFormatter +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.core.EventId +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.timeline.Timeline +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.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.WarmUpRule +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.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ShareLocationPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val fakePermissionsPresenter = FakePermissionsPresenter() + private val fakeAnalyticsService = FakeAnalyticsService() + private val fakeMessageComposerContext = FakeMessageComposerContext() + private val fakeLocationActions = FakeLocationActions() + private val fakeBuildMeta = aBuildMeta(applicationName = "app name") + private val fakeFeatureFlagService = FakeFeatureFlagService() + private val fakeMatrixClient = FakeMatrixClient(sessionId = A_USER_ID) + + private val durationFormatter = FakeDurationFormatter() + + private fun createShareLocationPresenter( + joinedRoom: JoinedRoom = FakeJoinedRoom(), + locationActions: FakeLocationActions = fakeLocationActions, + ): ShareLocationPresenter = ShareLocationPresenter( + permissionsPresenterFactory = { fakePermissionsPresenter }, + room = joinedRoom, + timelineMode = Timeline.Mode.Live, + analyticsService = fakeAnalyticsService, + messageComposerContext = fakeMessageComposerContext, + locationActions = locationActions, + buildMeta = fakeBuildMeta, + featureFlagService = fakeFeatureFlagService, + client = fakeMatrixClient, + durationFormatter = durationFormatter, + ) + + @Test + fun `initial state with permissions granted and location enabled`() = runTest { + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + val shareLocationPresenter = createShareLocationPresenter() + shareLocationPresenter.test { + skipItems(1) + val state = awaitItem() + assertThat(state.trackUserLocation).isTrue() + assertThat(state.hasLocationPermission).isTrue() + assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None)) + } + } + + @Test + fun `initial state with permissions partially granted and location enabled`() = runTest { + val shareLocationPresenter = createShareLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.SomeGranted, + shouldShowRationale = false, + ) + ) + + moleculeFlow(RecompositionMode.Immediate) { + shareLocationPresenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.trackUserLocation).isTrue() + assertThat(initialState.hasLocationPermission).isTrue() + assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None)) + } + } + + @Test + fun `initial state with permissions denied`() = runTest { + val shareLocationPresenter = createShareLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + ) + + moleculeFlow(RecompositionMode.Immediate) { + shareLocationPresenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.trackUserLocation).isFalse() + assertThat(initialState.hasLocationPermission).isFalse() + assertThat(initialState.dialogState).isEqualTo( + ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied) + ) + } + } + + @Test + fun `initial state with permissions denied with rationale`() = runTest { + val shareLocationPresenter = createShareLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = true, + ) + ) + + shareLocationPresenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.trackUserLocation).isFalse() + assertThat(initialState.hasLocationPermission).isFalse() + assertThat(initialState.dialogState).isEqualTo( + ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale) + ) + } + } + + @Test + fun `initial state with location services disabled`() = runTest { + val locationActions = FakeLocationActions(isLocationEnabled = false) + val shareLocationPresenter = createShareLocationPresenter(locationActions = locationActions) + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + shareLocationPresenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.trackUserLocation).isFalse() + assertThat(initialState.hasLocationPermission).isTrue() + assertThat(initialState.dialogState).isEqualTo( + ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled) + ) + } + } + + @Test + fun `StopTrackingUserLocation event sets trackUserLocation to false`() = runTest { + val shareLocationPresenter = createShareLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + shareLocationPresenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.trackUserLocation).isTrue() + + initialState.eventSink(ShareLocationEvent.StopTrackingUserLocation) + val stoppedState = awaitItem() + assertThat(stoppedState.trackUserLocation).isFalse() + } + } + + @Test + fun `DismissDialog event clears dialog state`() = runTest { + val shareLocationPresenter = createShareLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = true, + ) + ) + + shareLocationPresenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.dialogState).isEqualTo( + ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale) + ) + + initialState.eventSink(ShareLocationEvent.DismissDialog) + val dismissedState = awaitItem() + assertThat(dismissedState.dialogState).isEqualTo(ShareLocationState.Dialog.None) + } + } + + @Test + fun `RequestPermissions event triggers permission request`() = runTest { + val shareLocationPresenter = createShareLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = true, + ) + ) + + shareLocationPresenter.test { + val initialState = awaitItem() + initialState.eventSink(ShareLocationEvent.RequestPermissions) + + // Wait for dialog to be dismissed + awaitItem() + + assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `OpenAppSettings event opens settings and clears dialog`() = runTest { + val shareLocationPresenter = createShareLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + ) + + shareLocationPresenter.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(ShareLocationEvent.OpenAppSettings) + val settingsOpenedState = awaitItem() + + assertThat(settingsOpenedState.dialogState).isEqualTo(ShareLocationState.Dialog.None) + assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1) + } + } + + @Test + fun `OpenLocationSettings event opens location settings and clears dialog`() = runTest { + val locationActions = FakeLocationActions(isLocationEnabled = false) + val shareLocationPresenter = createShareLocationPresenter(locationActions = locationActions) + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + shareLocationPresenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.dialogState).isEqualTo( + ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled) + ) + + initialState.eventSink(ShareLocationEvent.OpenLocationSettings) + val settingsOpenedState = awaitItem() + + assertThat(settingsOpenedState.dialogState).isEqualTo(ShareLocationState.Dialog.None) + assertThat(locationActions.openLocationSettingsInvocationsCount).isEqualTo(1) + } + } + + @Test + fun `ShowLiveLocationDurationPicker shows duration dialog when constraints pass`() = runTest { + val shareLocationPresenter = createShareLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + shareLocationPresenter.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker) + val durationDialogState = awaitItem() + + assertThat(durationDialogState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `ShowLiveLocationDurationPicker shows constraint dialog when permissions denied`() = runTest { + val shareLocationPresenter = createShareLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + ) + + shareLocationPresenter.test { + skipItems(1) + val initialState = awaitItem() + // Dismiss initial dialog + initialState.eventSink(ShareLocationEvent.DismissDialog) + val dismissedState = awaitItem() + + dismissedState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker) + val constraintDialogState = awaitItem() + + assertThat(constraintDialogState.dialogState).isEqualTo( + ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied) + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `ShareStaticLocation sends user location`() = runTest { + val sendLocationResult = lambdaRecorder { _: String, _: String, _: String?, _: Int?, _: AssetType?, _: EventId? -> + Result.success(Unit) + } + val joinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendLocationLambda = sendLocationResult + }, + ) + val shareLocationPresenter = createShareLocationPresenter(joinedRoom) + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + shareLocationPresenter.test { + skipItems(1) + val initialState = awaitItem() + + initialState.eventSink( + ShareLocationEvent.ShareStaticLocation( + location = Location(lat = 3.0, lon = 4.0, accuracy = 5.0f), + isPinned = false, + ) + ) + + advanceUntilIdle() + + sendLocationResult.assertions().isCalledOnce() + .with( + value("Location was shared at geo:3.0,4.0;u=5.0"), + value("geo:3.0,4.0;u=5.0"), + value(null), + value(15), + value(AssetType.SENDER), + value(null), + ) + + assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1) + assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo( + Composer( + inThread = false, + isEditing = false, + isReply = false, + messageType = Composer.MessageType.LocationUser, + ) + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `ShareStaticLocation sends pinned location`() = runTest { + val sendLocationResult = lambdaRecorder { _: String, _: String, _: String?, _: Int?, _: AssetType?, _: EventId? -> + Result.success(Unit) + } + val joinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendLocationLambda = sendLocationResult + }, + ) + val shareLocationPresenter = createShareLocationPresenter(joinedRoom) + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + shareLocationPresenter.test { + val initialState = awaitItem() + + initialState.eventSink( + ShareLocationEvent.ShareStaticLocation( + location = Location(lat = 1.0, lon = 2.0, accuracy = 3.0f), + isPinned = true, + ) + ) + + advanceUntilIdle() + sendLocationResult.assertions().isCalledOnce() + .with( + value("Location was shared at geo:1.0,2.0;u=3.0"), + value("geo:1.0,2.0;u=3.0"), + value(null), + value(15), + value(AssetType.PIN), + value(null), + ) + + assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1) + assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo( + Composer( + inThread = false, + isEditing = false, + isReply = false, + messageType = Composer.MessageType.LocationPin, + ) + ) + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationViewTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationViewTest.kt new file mode 100644 index 0000000000..317fbf8fed --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationViewTest.kt @@ -0,0 +1,162 @@ +/* + * 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.share + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ShareLocationViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `test back action`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setShareLocationView( + state = aShareLocationState( + eventSink = eventsRecorder + ), + navigateUp = callback, + ) + rule.pressBack() + } + } + + @Test + fun `test fab click`() { + val eventsRecorder = EventsRecorder() + rule.setShareLocationView( + aShareLocationState( + eventSink = eventsRecorder + ), + navigateUp = EnsureNeverCalled(), + ) + rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick() + eventsRecorder.assertSingle(ShareLocationEvent.StartTrackingUserLocation) + } + + @Test + fun `when permission denied is displayed user can open the settings`() { + val eventsRecorder = EventsRecorder() + rule.setShareLocationView( + aShareLocationState( + dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied), + eventSink = eventsRecorder + ), + navigateUp = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertSingle(ShareLocationEvent.OpenAppSettings) + } + + @Test + fun `when permission denied is displayed user can close the dialog`() { + val eventsRecorder = EventsRecorder() + rule.setShareLocationView( + aShareLocationState( + dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied), + eventSink = eventsRecorder + ), + navigateUp = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog) + } + + @Test + fun `when permission rationale is displayed user can request permissions`() { + val eventsRecorder = EventsRecorder() + rule.setShareLocationView( + aShareLocationState( + dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale), + eventSink = eventsRecorder + ), + navigateUp = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertSingle(ShareLocationEvent.RequestPermissions) + } + + @Test + fun `when permission rationale is displayed user can close the dialog`() { + val eventsRecorder = EventsRecorder() + rule.setShareLocationView( + aShareLocationState( + dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale), + eventSink = eventsRecorder + ), + navigateUp = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog) + } + + @Test + fun `when location service disabled is displayed user can open location settings`() { + val eventsRecorder = EventsRecorder() + rule.setShareLocationView( + aShareLocationState( + dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled), + hasLocationPermission = true, + eventSink = eventsRecorder + ), + navigateUp = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertSingle(ShareLocationEvent.OpenLocationSettings) + } + + @Test + fun `when location service disabled is displayed user can close the dialog`() { + val eventsRecorder = EventsRecorder() + rule.setShareLocationView( + aShareLocationState( + dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled), + hasLocationPermission = true, + eventSink = eventsRecorder + ), + navigateUp = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog) + } +} + +private fun AndroidComposeTestRule.setShareLocationView( + state: ShareLocationState, + navigateUp: () -> Unit = EnsureNeverCalled(), +) { + setContent { + // Simulate a LocalInspectionMode for MapLibreMap + CompositionLocalProvider(LocalInspectionMode provides true) { + ShareLocationView( + state = state, + navigateUp = navigateUp, + ) + } + } +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt index a49b887a42..451531fc7e 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt @@ -13,10 +13,14 @@ import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat import io.element.android.features.location.api.Location 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.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.tests.testutils.node.TestParentNode import org.junit.Rule import org.junit.Test @@ -32,21 +36,28 @@ class DefaultShowLocationEntryPointTest { ShowLocationNode( buildContext = buildContext, plugins = plugins, - presenterFactory = { location: Location, description: String? -> - ShowLocationPresenter( + presenterFactory = object : ShowLocationPresenter.Factory { + override fun create(mode: ShowLocationMode) = ShowLocationPresenter( + mode = mode, permissionsPresenterFactory = { FakePermissionsPresenter() }, locationActions = FakeLocationActions(), buildMeta = aBuildMeta(), - location = location, - description = description, + dateFormatter = FakeDateFormatter(), + stringProvider = FakeStringProvider() ) }, analyticsService = FakeAnalyticsService(), ) } val inputs = ShowLocationEntryPoint.Inputs( - location = Location(37.4219983, -122.084, 10f), - description = "My location", + mode = ShowLocationMode.Static( + location = Location(37.4219983, -122.084, 10f), + senderName = "Alice", + senderId = UserId("@alice:matrix.org"), + senderAvatarUrl = null, + timestamp = System.currentTimeMillis(), + assetType = null, + ), ) val result = entryPoint.createNode( parentNode = parentNode, diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index df22863c7c..931dd55cea 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -13,14 +13,19 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.location.api.Location +import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.impl.aPermissionsState import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.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.ui.LocationConstraintsDialogState +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 +import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -33,15 +38,26 @@ class ShowLocationPresenterTest { private val fakePermissionsPresenter = FakePermissionsPresenter() private val fakeLocationActions = FakeLocationActions() private val fakeBuildMeta = aBuildMeta(applicationName = "app name") + private val fakeDateFormatter = FakeDateFormatter() private val location = Location(1.23, 4.56, 7.8f) - private val presenter = ShowLocationPresenter( - permissionsPresenterFactory = object : PermissionsPresenter.Factory { - override fun create(permissions: List): PermissionsPresenter = fakePermissionsPresenter - }, - locationActions = fakeLocationActions, + + private fun createShowLocationPresenter( + mode: ShowLocationMode = ShowLocationMode.Static( + location = location, + senderName = "Alice", + senderId = UserId("@alice:matrix.org"), + senderAvatarUrl = null, + timestamp = System.currentTimeMillis(), + assetType = null, + ), + locationActions: FakeLocationActions = fakeLocationActions, + ) = ShowLocationPresenter( + mode = mode, + permissionsPresenterFactory = { fakePermissionsPresenter }, + locationActions = locationActions, buildMeta = fakeBuildMeta, - location = location, - description = A_DESCRIPTION, + dateFormatter = fakeDateFormatter, + stringProvider = FakeStringProvider() ) @Test @@ -53,12 +69,9 @@ class ShowLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() - assertThat(initialState.location).isEqualTo(location) - assertThat(initialState.description).isEqualTo(A_DESCRIPTION) assertThat(initialState.hasLocationPermission).isFalse() assertThat(initialState.isTrackMyLocation).isFalse() } @@ -73,12 +86,9 @@ class ShowLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() - assertThat(initialState.location).isEqualTo(location) - assertThat(initialState.description).isEqualTo(A_DESCRIPTION) assertThat(initialState.hasLocationPermission).isFalse() assertThat(initialState.isTrackMyLocation).isFalse() } @@ -88,12 +98,9 @@ class ShowLocationPresenterTest { fun `emits initial state with location permission`() = runTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() - assertThat(initialState.location).isEqualTo(location) - assertThat(initialState.description).isEqualTo(A_DESCRIPTION) assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.isTrackMyLocation).isFalse() } @@ -103,12 +110,9 @@ class ShowLocationPresenterTest { fun `emits initial state with partial location permission`() = runTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted)) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() - assertThat(initialState.location).isEqualTo(location) - assertThat(initialState.description).isEqualTo(A_DESCRIPTION) assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.isTrackMyLocation).isFalse() } @@ -116,14 +120,12 @@ class ShowLocationPresenterTest { @Test fun `uses action to share location`() = runTest { - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() - initialState.eventSink(ShowLocationEvents.Share) + initialState.eventSink(ShowLocationEvent.Share(location)) assertThat(fakeLocationActions.sharedLocation).isEqualTo(location) - assertThat(fakeLocationActions.sharedLabel).isEqualTo(A_DESCRIPTION) } } @@ -131,14 +133,13 @@ class ShowLocationPresenterTest { fun `centers on user location`() = runTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.isTrackMyLocation).isFalse() - initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + initialState.eventSink(ShowLocationEvent.TrackMyLocation(true)) val trackMyLocationState = awaitItem() delay(1) @@ -147,9 +148,9 @@ class ShowLocationPresenterTest { assertThat(trackMyLocationState.isTrackMyLocation).isTrue() // Swipe the map to switch mode - initialState.eventSink(ShowLocationEvents.TrackMyLocation(false)) + initialState.eventSink(ShowLocationEvent.TrackMyLocation(false)) val trackLocationDisabledState = awaitItem() - assertThat(trackLocationDisabledState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) + assertThat(trackLocationDisabledState.dialogState).isEqualTo(LocationConstraintsDialogState.None) assertThat(trackLocationDisabledState.isTrackMyLocation).isFalse() assertThat(trackLocationDisabledState.hasLocationPermission).isTrue() } @@ -164,23 +165,22 @@ class ShowLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { // Skip initial state val initialState = awaitItem() // Click on the button to switch mode - initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + initialState.eventSink(ShowLocationEvent.TrackMyLocation(true)) val trackLocationState = awaitItem() - assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionRationale) + assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale) assertThat(trackLocationState.isTrackMyLocation).isFalse() assertThat(trackLocationState.hasLocationPermission).isFalse() // Dismiss the dialog - initialState.eventSink(ShowLocationEvents.DismissDialog) + initialState.eventSink(ShowLocationEvent.DismissDialog) val dialogDismissedState = awaitItem() - assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) + assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None) assertThat(dialogDismissedState.isTrackMyLocation).isFalse() assertThat(dialogDismissedState.hasLocationPermission).isFalse() } @@ -194,22 +194,20 @@ class ShowLocationPresenterTest { shouldShowRationale = true, ) ) - - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { // Skip initial state val initialState = awaitItem() // Click on the button to switch mode - initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + initialState.eventSink(ShowLocationEvent.TrackMyLocation(true)) val trackLocationState = awaitItem() - assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionRationale) + assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale) assertThat(trackLocationState.isTrackMyLocation).isFalse() assertThat(trackLocationState.hasLocationPermission).isFalse() // Continue the dialog sends permission request to the permissions presenter - trackLocationState.eventSink(ShowLocationEvents.RequestPermissions) + trackLocationState.eventSink(ShowLocationEvent.RequestPermissions) assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) } } @@ -223,23 +221,22 @@ class ShowLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { // Skip initial state val initialState = awaitItem() // Click on the button to switch mode - initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + initialState.eventSink(ShowLocationEvent.TrackMyLocation(true)) val trackLocationState = awaitItem() - assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionDenied) + assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionDenied) assertThat(trackLocationState.isTrackMyLocation).isFalse() assertThat(trackLocationState.hasLocationPermission).isFalse() // Dismiss the dialog - initialState.eventSink(ShowLocationEvents.DismissDialog) + initialState.eventSink(ShowLocationEvent.DismissDialog) val dialogDismissedState = awaitItem() - assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) + assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None) assertThat(dialogDismissedState.isTrackMyLocation).isFalse() assertThat(dialogDismissedState.hasLocationPermission).isFalse() } @@ -254,20 +251,19 @@ class ShowLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { // Skip initial state val initialState = awaitItem() - initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + initialState.eventSink(ShowLocationEvent.TrackMyLocation(true)) val dialogShownState = awaitItem() // Open settings - dialogShownState.eventSink(ShowLocationEvents.OpenAppSettings) + dialogShownState.eventSink(ShowLocationEvent.OpenAppSettings) val settingsOpenedState = awaitItem() - assertThat(settingsOpenedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) + assertThat(settingsOpenedState.dialogState).isEqualTo(LocationConstraintsDialogState.None) assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1) } } @@ -275,14 +271,51 @@ class ShowLocationPresenterTest { @Test fun `application name is in state`() = runTest { moleculeFlow(RecompositionMode.Immediate) { - presenter.present() + createShowLocationPresenter().present() }.test { val initialState = awaitItem() assertThat(initialState.appName).isEqualTo("app name") } } - companion object { - private const val A_DESCRIPTION = "My happy place" + @Test + fun `location service disabled shows dialog`() = runTest { + fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) + fakeLocationActions.givenLocationEnabled(false) + + val presenter = createShowLocationPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.hasLocationPermission).isTrue() + + // Try to track location when location services are disabled + initialState.eventSink(ShowLocationEvent.TrackMyLocation(true)) + val dialogShownState = awaitItem() + + assertThat(dialogShownState.dialogState).isEqualTo(LocationConstraintsDialogState.LocationServiceDisabled) + assertThat(dialogShownState.isTrackMyLocation).isFalse() + } + } + + @Test + fun `open location settings from dialog`() = runTest { + fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) + fakeLocationActions.givenLocationEnabled(false) + + val presenter = createShowLocationPresenter() + presenter.test { + val initialState = awaitItem() + + initialState.eventSink(ShowLocationEvent.TrackMyLocation(true)) + val dialogShownState = awaitItem() + assertThat(dialogShownState.dialogState).isEqualTo(LocationConstraintsDialogState.LocationServiceDisabled) + + // Open location settings + dialogShownState.eventSink(ShowLocationEvent.OpenLocationSettings) + val settingsOpenedState = awaitItem() + + assertThat(settingsOpenedState.dialogState).isEqualTo(LocationConstraintsDialogState.None) + assertThat(fakeLocationActions.openLocationSettingsInvocationsCount).isEqualTo(1) + } } } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt index 2245360bb2..fecbbdbf89 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt @@ -17,6 +17,8 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.location.api.Location +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled @@ -35,7 +37,7 @@ class ShowLocationViewTest { @Test fun `test back action`() { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder(expectEvents = false) ensureCalledOnce { callback -> rule.setShowLocationView( state = aShowLocationState( @@ -49,7 +51,7 @@ class ShowLocationViewTest { @Test fun `test share action`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setShowLocationView( aShowLocationState( eventSink = eventsRecorder @@ -58,12 +60,13 @@ class ShowLocationViewTest { ) val shareContentDescription = rule.activity.getString(CommonStrings.action_share) rule.onNodeWithContentDescription(shareContentDescription).performClick() - eventsRecorder.assertSingle(ShowLocationEvents.Share) + // The default aStaticLocationMode uses Location(1.23, 2.34, 4f) + eventsRecorder.assertSingle(ShowLocationEvent.Share(Location(1.23, 2.34, 4f))) } @Test fun `test fab click`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setShowLocationView( aShowLocationState( eventSink = eventsRecorder @@ -71,63 +74,63 @@ class ShowLocationViewTest { onBackClick = EnsureNeverCalled(), ) rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick() - eventsRecorder.assertSingle(ShowLocationEvents.TrackMyLocation(true)) + eventsRecorder.assertSingle(ShowLocationEvent.TrackMyLocation(true)) } @Test fun `when permission denied is displayed user can open the settings`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setShowLocationView( aShowLocationState( - permissionDialog = ShowLocationState.Dialog.PermissionDenied, + constraintsDialogState = LocationConstraintsDialogState.PermissionDenied, eventSink = eventsRecorder ), onBackClick = EnsureNeverCalled(), ) rule.clickOn(CommonStrings.action_continue) - eventsRecorder.assertSingle(ShowLocationEvents.OpenAppSettings) + eventsRecorder.assertSingle(ShowLocationEvent.OpenAppSettings) } @Test fun `when permission denied is displayed user can close the dialog`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setShowLocationView( aShowLocationState( - permissionDialog = ShowLocationState.Dialog.PermissionDenied, + constraintsDialogState = LocationConstraintsDialogState.PermissionDenied, eventSink = eventsRecorder ), onBackClick = EnsureNeverCalled(), ) rule.clickOn(CommonStrings.action_cancel) - eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog) + eventsRecorder.assertSingle(ShowLocationEvent.DismissDialog) } @Test fun `when permission rationale is displayed user can request permissions`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setShowLocationView( aShowLocationState( - permissionDialog = ShowLocationState.Dialog.PermissionRationale, + constraintsDialogState = LocationConstraintsDialogState.PermissionRationale, eventSink = eventsRecorder ), onBackClick = EnsureNeverCalled(), ) rule.clickOn(CommonStrings.action_continue) - eventsRecorder.assertSingle(ShowLocationEvents.RequestPermissions) + eventsRecorder.assertSingle(ShowLocationEvent.RequestPermissions) } @Test fun `when permission rationale is displayed user can close the dialog`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setShowLocationView( aShowLocationState( - permissionDialog = ShowLocationState.Dialog.PermissionRationale, + constraintsDialogState = LocationConstraintsDialogState.PermissionRationale, eventSink = eventsRecorder ), onBackClick = EnsureNeverCalled(), ) rule.clickOn(CommonStrings.action_cancel) - eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog) + eventsRecorder.assertSingle(ShowLocationEvent.DismissDialog) } } diff --git a/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeSendLocationEntryPoint.kt b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeShareLocationEntryPoint.kt similarity index 83% rename from features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeSendLocationEntryPoint.kt rename to features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeShareLocationEntryPoint.kt index 2a1741e6c8..9fbbf2a7a2 100644 --- a/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeSendLocationEntryPoint.kt +++ b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeShareLocationEntryPoint.kt @@ -10,11 +10,11 @@ package io.element.android.features.location.test import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node -import io.element.android.features.location.api.SendLocationEntryPoint +import io.element.android.features.location.api.ShareLocationEntryPoint import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.tests.testutils.lambda.lambdaError -class FakeSendLocationEntryPoint : SendLocationEntryPoint { +class FakeShareLocationEntryPoint : ShareLocationEntryPoint { override fun createNode( parentNode: Node, buildContext: BuildContext, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 8342184b12..38d0504258 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -28,10 +28,10 @@ import io.element.android.features.call.api.CallType import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint -import io.element.android.features.location.api.Location import io.element.android.features.location.api.LocationService -import io.element.android.features.location.api.SendLocationEntryPoint +import io.element.android.features.location.api.ShareLocationEntryPoint import io.element.android.features.location.api.ShowLocationEntryPoint +import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode @@ -102,7 +102,7 @@ class MessagesFlowNode( @Assisted plugins: List, private val roomListService: RoomListService, private val sessionId: SessionId, - private val sendLocationEntryPoint: SendLocationEntryPoint, + private val shareLocationEntryPoint: ShareLocationEntryPoint, private val showLocationEntryPoint: ShowLocationEntryPoint, private val createPollEntryPoint: CreatePollEntryPoint, private val elementCallEntryPoint: ElementCallEntryPoint, @@ -148,7 +148,7 @@ class MessagesFlowNode( data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment, val inReplyToEventId: EventId?) : NavTarget @Parcelize - data class LocationViewer(val location: Location, val description: String?) : NavTarget + data class LocationViewer(val mode: ShowLocationMode) : NavTarget @Parcelize data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget @@ -336,7 +336,7 @@ class MessagesFlowNode( createNode(buildContext, listOf(inputs)) } is NavTarget.LocationViewer -> { - val inputs = ShowLocationEntryPoint.Inputs(navTarget.location, navTarget.description) + val inputs = ShowLocationEntryPoint.Inputs(navTarget.mode) showLocationEntryPoint.createNode( parentNode = this, buildContext = buildContext, @@ -374,7 +374,7 @@ class MessagesFlowNode( createNode(buildContext, listOf(inputs)) } is NavTarget.SendLocation -> { - sendLocationEntryPoint.createNode( + shareLocationEntryPoint.createNode( parentNode = this, buildContext = buildContext, timelineMode = navTarget.timelineMode, @@ -558,9 +558,16 @@ class MessagesFlowNode( ) } is TimelineItemLocationContent -> { - NavTarget.LocationViewer( + val mode = ShowLocationMode.Static( location = event.content.location, - description = event.content.description, + senderName = event.safeSenderName, + senderId = event.senderId, + senderAvatarUrl = event.senderAvatar.url, + timestamp = event.sentTimeMillis, + assetType = event.content.assetType, + ) + NavTarget.LocationViewer( + mode = mode ).takeIf { locationService.isServiceAvailable() } } else -> null diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index adfaa93cce..184acf1386 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -39,7 +39,7 @@ import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugIn import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails -import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady +import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -166,7 +166,7 @@ internal fun aTimelineItemEvent( isMine = isMine, isEditable = isEditable, canBeRepliedTo = canBeRepliedTo, - senderProfile = aProfileTimelineDetailsReady( + senderProfile = aProfileDetailsReady( displayName = senderDisplayName, displayNameAmbiguous = displayNameAmbiguous, ), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt index 9ebe35a51b..592b95a337 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt @@ -8,10 +8,8 @@ package io.element.android.features.messages.impl.timeline.components.event -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter @@ -21,31 +19,22 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.Text @Composable fun TimelineItemLocationView( content: TimelineItemLocationContent, modifier: Modifier = Modifier, ) { - Column(modifier = modifier.fillMaxWidth()) { - content.description?.let { - Text( - text = it, - modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp), - ) - } - - StaticMapView( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 188.dp), - lat = content.location.lat, - lon = content.location.lon, - zoom = 15.0, - contentDescription = content.body - ) - } + StaticMapView( + modifier = modifier + .fillMaxWidth() + .heightIn(max = 188.dp), + pinVariant = content.pinVariant, + lat = content.location.lat, + lon = content.location.lon, + zoom = 15.0, + contentDescription = content.body + ) } @PreviewsDayNight diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt index 31cf689ef8..2b5c0fa98a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt @@ -9,8 +9,10 @@ package io.element.android.features.messages.impl.timeline.factories.event import dev.zacsweers.metro.Inject +import io.element.android.features.location.api.Location import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.libraries.matrix.api.core.EventId @@ -22,6 +24,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent @@ -70,10 +73,10 @@ class TimelineItemContentFactory( is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent) is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent) is MessageContent -> { - val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender) messageFactory.create( + senderId = sender, + senderProfile = senderProfile, content = itemContent, - senderDisambiguatedDisplayName = senderDisambiguatedDisplayName, eventId = eventId, ) } @@ -96,6 +99,24 @@ class TimelineItemContentFactory( is UnableToDecryptContent -> utdFactory.create(itemContent) is CallNotifyContent -> TimelineItemRtcNotificationContent() is UnknownContent -> TimelineItemUnknownContent + is LiveLocationContent -> { + val lastKnownLocation = itemContent.locations.mapNotNull { beacon -> + Location.fromGeoUri(beacon.geoUri) + }.lastOrNull() + if (lastKnownLocation != null) { + TimelineItemLocationContent( + body = itemContent.body.trimEnd(), + description = itemContent.description?.trimEnd(), + assetType = itemContent.assetType, + senderId = sender, + senderProfile = senderProfile, + location = lastKnownLocation, + mode = TimelineItemLocationContent.Mode.Live(isActive = itemContent.isLive) + ) + } else { + TimelineItemUnknownContent + } + } } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index 1db6ad304c..723ab6feac 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -30,6 +30,7 @@ import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.androidutils.text.safeLinkify import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType @@ -39,10 +40,12 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessa import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName import io.element.android.libraries.matrix.ui.messages.toHtmlDocument import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor import kotlinx.collections.immutable.persistentListOf @@ -65,11 +68,13 @@ class TimelineItemContentMessageFactory( ) { fun create( content: MessageContent, - senderDisambiguatedDisplayName: String, + senderId: UserId, + senderProfile: ProfileDetails, eventId: EventId?, ): TimelineItemEventContent { return when (val messageType = content.type) { is EmoteMessageType -> { + val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(senderId) val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}" val dom = messageType.formatted?.toHtmlDocument( permalinkParser = permalinkParser, @@ -135,8 +140,8 @@ class TimelineItemContentMessageFactory( } is LocationMessageType -> { val location = Location.fromGeoUri(messageType.geoUri) + val body = messageType.body.trimEnd() if (location == null) { - val body = messageType.body.trimEnd() TimelineItemTextContent( body = body, htmlDocument = null, @@ -145,9 +150,13 @@ class TimelineItemContentMessageFactory( ) } else { TimelineItemLocationContent( - body = messageType.body.trimEnd(), + body = body, location = location, - description = messageType.description + description = messageType.description, + senderId = senderId, + senderProfile = senderProfile, + assetType = messageType.assetType, + mode = TimelineItemLocationContent.Mode.Static ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt index 12c457175f..6f369417dd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt @@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyCon import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent @@ -81,7 +82,8 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean { RedactedContent, is StickerContent, is PollContent, - is UnableToDecryptContent -> true + is UnableToDecryptContent, + is LiveLocationContent -> true // Can't be grouped is FailedToParseStateContent, is ProfileChangeContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index ac93a0ac4f..f3d70f44e7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -28,14 +28,14 @@ class TimelineItemEventContentProvider : PreviewParameterProvider { + if (mode.isActive) { + PinVariant.UserLocation(avatarData = senderAvatar(), isLive = true) + } else { + PinVariant.StaleLocation + } + } + Mode.Static -> { + when (assetType) { + AssetType.PIN -> PinVariant.PinnedLocation + AssetType.SENDER, + AssetType.UNKNOWN, + null -> PinVariant.UserLocation(avatarData = senderAvatar(), isLive = false) + } + } + } + + private fun senderAvatar() = AvatarData( + senderId.value, + name = senderProfile.getDisplayName(), + url = senderProfile.getAvatarUrl(), + size = AvatarSize.LocationPin + ) + + sealed interface Mode { + data object Static : Mode + data class Live(val isActive: Boolean) : Mode + } + override val type: String = "TimelineItemLocationContent" } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt index 0fd3f5f41b..362e9b4cda 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt @@ -10,21 +10,32 @@ package io.element.android.features.messages.impl.timeline.model.event import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.location.api.Location +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady open class TimelineItemLocationContentProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aTimelineItemLocationContent(), - aTimelineItemLocationContent("This is a description!"), + aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)), + aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = false)), ) } -fun aTimelineItemLocationContent(description: String? = null) = TimelineItemLocationContent( - body = "User location geo:52.2445,0.7186;u=5000", +fun aTimelineItemLocationContent( + body: String = "", + senderId: UserId = UserId("@sender:matrix.org"), + senderProfile: ProfileDetails = aProfileDetailsReady(), + mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static, +) = TimelineItemLocationContent( + body = body, location = Location( lat = 52.2445, lon = 0.7186, accuracy = 5000f, ), - description = description, + senderId = senderId, + senderProfile = senderProfile, + mode = mode ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt index 90c31b911a..a1db09dfda 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt @@ -17,7 +17,7 @@ import io.element.android.features.call.test.FakeElementCallEntryPoint import io.element.android.features.forward.test.FakeForwardEntryPoint import io.element.android.features.knockrequests.test.FakeKnockRequestsListEntryPoint import io.element.android.features.location.test.FakeLocationService -import io.element.android.features.location.test.FakeSendLocationEntryPoint +import io.element.android.features.location.test.FakeShareLocationEntryPoint import io.element.android.features.location.test.FakeShowLocationEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.impl.pinned.banner.createPinnedEventsTimelineProvider @@ -62,7 +62,7 @@ class DefaultMessagesEntryPointTest { plugins = plugins, roomListService = FakeRoomListService(), sessionId = A_SESSION_ID, - sendLocationEntryPoint = FakeSendLocationEntryPoint(), + shareLocationEntryPoint = FakeShareLocationEntryPoint(), showLocationEntryPoint = FakeShowLocationEntryPoint(), createPollEntryPoint = FakeCreatePollEntryPoint(), elementCallEntryPoint = FakeElementCallEntryPoint(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt index 516cd9ea77..1b9394f2ee 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt @@ -31,7 +31,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.core.FakeSendHandle import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails -import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady +import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady import kotlinx.collections.immutable.toImmutableList internal fun aMessageEvent( @@ -52,7 +52,7 @@ internal fun aMessageEvent( eventId = eventId, transactionId = transactionId, senderId = A_USER_ID, - senderProfile = aProfileTimelineDetailsReady(displayName = A_USER_NAME), + senderProfile = aProfileDetailsReady(displayName = A_USER_NAME), senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME, size = AvatarSize.TimelineSender), content = content, sentTime = "", diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index 9f23388ecb..957b01d1ed 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -41,6 +41,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.ThumbnailInfo import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType @@ -59,8 +60,10 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.media.aMediaSource import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.timeline.aProfileDetails import io.element.android.libraries.matrix.test.timeline.aStickerContent import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation @@ -83,7 +86,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = OtherMessageType(msgType = "a_type", body = "body")), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemTextContent( @@ -98,15 +102,21 @@ class TimelineItemContentMessageFactoryTest { @Test fun `test create LocationMessageType not null`() = runTest { val sut = createTimelineItemContentMessageFactory() + val assetType = AssetType.SENDER val result = sut.create( - content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description")), - senderDisambiguatedDisplayName = "Bob", + content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description", assetType)), + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemLocationContent( body = "body", - location = Location(lat = 1.0, lon = 2.0, accuracy = 0.0F), + location = Location(lat = 1.0, lon = 2.0, accuracy = null), description = "description", + assetType = assetType, + mode = TimelineItemLocationContent.Mode.Static, + senderId = A_USER_ID, + senderProfile = aProfileDetails(), ) assertThat(result).isEqualTo(expected) } @@ -115,8 +125,9 @@ class TimelineItemContentMessageFactoryTest { fun `test create LocationMessageType null`() = runTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( - content = createMessageContent(type = LocationMessageType("body", "", null)), - senderDisambiguatedDisplayName = "Bob", + content = createMessageContent(type = LocationMessageType("body", "", null, null)), + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemTextContent( @@ -133,7 +144,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = TextMessageType("body", null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemTextContent( @@ -150,7 +162,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = TextMessageType("https://www.example.org", null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) as TimelineItemTextContent val expected = TimelineItemTextContent( @@ -197,7 +210,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.HTML, expected.toString()) ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expected) @@ -215,7 +229,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.UNKNOWN, "formatted") ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("body")) @@ -226,7 +241,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = VideoMessageType("filename", null, null, MediaSource("url"), null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemVideoContent( @@ -279,7 +295,8 @@ class TimelineItemContentMessageFactoryTest { ), isEdited = true, ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemVideoContent( @@ -309,7 +326,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = AudioMessageType("filename", null, null, MediaSource("url"), null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemAudioContent( @@ -345,7 +363,8 @@ class TimelineItemContentMessageFactoryTest { ), isEdited = true, ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemAudioContent( @@ -368,7 +387,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = VoiceMessageType("filename", null, null, MediaSource("url"), null, null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemVoiceContent( @@ -410,7 +430,8 @@ class TimelineItemContentMessageFactoryTest { ), isEdited = true, ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemVoiceContent( @@ -435,7 +456,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = ImageMessageType("filename", "body", null, MediaSource("url"), null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemImageContent( @@ -515,7 +537,8 @@ class TimelineItemContentMessageFactoryTest { ), isEdited = true, ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemImageContent( @@ -544,7 +567,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = FileMessageType("filename", null, null, MediaSource("url"), null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemFileContent( @@ -586,7 +610,8 @@ class TimelineItemContentMessageFactoryTest { ), isEdited = true, ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemFileContent( @@ -609,7 +634,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = NoticeMessageType("body", null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemNoticeContent( @@ -631,7 +657,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.HTML, "formatted") ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) (result as TimelineItemNoticeContent).formattedBody.assertSpannedEquals(SpannedString("formatted")) @@ -642,7 +669,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = EmoteMessageType("body", null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails("Bob"), eventId = AN_EVENT_ID, ) val expected = TimelineItemEmoteContent( @@ -664,7 +692,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.HTML, "formatted") ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails("Bob"), eventId = AN_EVENT_ID, ) @@ -690,7 +719,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.HTML, "Test me@matrix.org") ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) (result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned) @@ -715,7 +745,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org") ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) (result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned) @@ -741,7 +772,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org") ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt index 7a7d4cdfd4..726646f5e9 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt @@ -23,7 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSen import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.core.FakeSendHandle -import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady +import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady import kotlinx.collections.immutable.toImmutableList import org.junit.Test @@ -34,7 +34,7 @@ class TimelineItemGrouperTest { id = UniqueId("0"), senderId = A_USER_ID, senderAvatar = anAvatarData(), - senderProfile = aProfileTimelineDetailsReady(displayName = ""), + senderProfile = aProfileDetailsReady(displayName = ""), content = TimelineItemStateEventContent(body = "a state event"), reactionsState = aTimelineItemReactions(count = 0), readReceiptState = TimelineItemReadReceipts(emptyList().toImmutableList()), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9ac60a7965..da8eda2c9e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -209,6 +209,7 @@ telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = " statemachine = "com.freeletics.flowredux:compose:1.2.2" maplibre = "org.maplibre.gl:android-sdk:13.0.1" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2" +maplibre_compose = "org.maplibre.compose:maplibre-compose:0.12.1" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" opusencoder = "io.element.android:opusencoder:1.2.0" zxing_cpp = "io.github.zxing-cpp:android:3.0.2" diff --git a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt index 8ecf01c343..ac297d10e9 100644 --- a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt +++ b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt @@ -11,6 +11,19 @@ package io.element.android.libraries.dateformatter.api import java.util.Locale import kotlin.time.Duration +/** + * Formats a duration in a localized, human-readable way. + * Uses the largest appropriate unit (hours, minutes, or seconds). + * + * Examples (in English): + * - 2 hours 30 minutes → "3 hours" (rounded) + * - 45 minutes → "45 minutes" + * - 30 seconds → "30 seconds" + */ +interface DurationFormatter { + fun format(duration: Duration): String +} + /** * Convert milliseconds to human readable duration. * Hours in 1 digit or more. diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatter.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatter.kt new file mode 100644 index 0000000000..41a4c66481 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatter.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 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.libraries.dateformatter.impl + +import android.icu.text.MeasureFormat +import android.icu.text.MeasureFormat.FormatWidth +import android.icu.util.Measure +import android.icu.util.MeasureUnit +import android.text.format.DateUtils +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import dev.zacsweers.metro.binding +import io.element.android.libraries.dateformatter.api.DurationFormatter +import java.util.Locale +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +/** + * Formats durations in a localized, human-readable way using Android's MeasureFormat. + * + * Uses WIDE format for readability (e.g., "5 hours", "3 minutes", "10 seconds"). + * Rounds to the nearest unit for cleaner display. + */ +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class, binding = binding()) +class DefaultDurationFormatter( + localeChangeObserver: LocaleChangeObserver, + locale: Locale, +) : DurationFormatter, LocaleChangeListener { + init { + localeChangeObserver.addListener(this) + } + + // Cache formatter, recreate only on locale change + private var formatter: MeasureFormat = MeasureFormat.getInstance(locale, FormatWidth.WIDE) + + override fun onLocaleChange() { + formatter = MeasureFormat.getInstance(Locale.getDefault(), FormatWidth.WIDE) + } + + override fun format(duration: Duration): String { + val millis = duration.inWholeMilliseconds + + return when { + duration >= 1.hours -> { + // Round to nearest hour (add 30 minutes before dividing) + val hours = ((millis + 30 * DateUtils.MINUTE_IN_MILLIS) / DateUtils.HOUR_IN_MILLIS).toInt() + formatter.format(Measure(hours, MeasureUnit.HOUR)) + } + duration >= 1.minutes -> { + // Round to nearest minute (add 30 seconds before dividing) + val minutes = ((millis + 30 * DateUtils.SECOND_IN_MILLIS) / DateUtils.MINUTE_IN_MILLIS).toInt() + formatter.format(Measure(minutes, MeasureUnit.MINUTE)) + } + else -> { + // Round to nearest second (add 500ms before dividing) + val seconds = ((millis + 500) / DateUtils.SECOND_IN_MILLIS).toInt() + formatter.format(Measure(seconds, MeasureUnit.SECOND)) + } + } + } +} diff --git a/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatterTest.kt b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatterTest.kt new file mode 100644 index 0000000000..3fee5dbb0d --- /dev/null +++ b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatterTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 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.libraries.dateformatter.impl + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.util.Locale +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@RunWith(AndroidJUnit4::class) +@Config(qualifiers = "en", sdk = [Build.VERSION_CODES.TIRAMISU]) +class DefaultDurationFormatterTest { + private fun createDurationFormatter(): DefaultDurationFormatter { + return DefaultDurationFormatter( + localeChangeObserver = {}, + locale = Locale.US, + ) + } + + @Test + fun `test zero duration`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(0.seconds)).isEqualTo("0 seconds") + } + + @Test + fun `test 1 second`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(1.seconds)).isEqualTo("1 second") + } + + @Test + fun `test 30 seconds`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(30.seconds)).isEqualTo("30 seconds") + } + + @Test + fun `test 59 seconds`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(59.seconds)).isEqualTo("59 seconds") + } + + @Test + fun `test 1 minute`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(1.minutes)).isEqualTo("1 minute") + } + + @Test + fun `test 1 minute 29 seconds rounds to 1 minute`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(1.minutes + 29.seconds)).isEqualTo("1 minute") + } + + @Test + fun `test 1 minute 30 seconds rounds to 2 minutes`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(1.minutes + 30.seconds)).isEqualTo("2 minutes") + } + + @Test + fun `test 45 minutes`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(45.minutes)).isEqualTo("45 minutes") + } + + @Test + fun `test 59 minutes`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(59.minutes)).isEqualTo("59 minutes") + } + + @Test + fun `test 1 hour`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(1.hours)).isEqualTo("1 hour") + } + + @Test + fun `test 1 hour 29 minutes rounds to 1 hour`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(1.hours + 29.minutes)).isEqualTo("1 hour") + } + + @Test + fun `test 1 hour 30 minutes rounds to 2 hours`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(1.hours + 30.minutes)).isEqualTo("2 hours") + } + + @Test + fun `test 2 hours 30 minutes rounds to 3 hours`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(2.hours + 30.minutes)).isEqualTo("3 hours") + } + + @Test + fun `test 5 hours`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(5.hours)).isEqualTo("5 hours") + } + + @Test + fun `test 24 hours`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(24.hours)).isEqualTo("24 hours") + } + + @Test + fun `test rounding at seconds threshold - 499ms rounds to 0 seconds`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(499.milliseconds)).isEqualTo("0 seconds") + } + + @Test + fun `test rounding at seconds threshold - 500ms rounds to 1 second`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(500.milliseconds)).isEqualTo("1 second") + } +} diff --git a/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeDurationFormatter.kt b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeDurationFormatter.kt new file mode 100644 index 0000000000..7b5cf038ce --- /dev/null +++ b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeDurationFormatter.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 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.libraries.dateformatter.test + +import io.element.android.libraries.dateformatter.api.DurationFormatter +import kotlin.time.Duration + +class FakeDurationFormatter( + private val formatLambda: (Duration) -> String = { it.toString() }, +) : DurationFormatter { + override fun format(duration: Duration): String { + return formatLambda(duration) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt new file mode 100644 index 0000000000..af8e29d518 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt @@ -0,0 +1,419 @@ +/* + * 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.libraries.designsystem.components + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.graphics.RectF +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.core.graphics.createBitmap +import androidx.core.graphics.withSave +import coil3.Image +import coil3.ImageLoader +import coil3.SingletonImageLoader +import coil3.asImage +import coil3.memory.MemoryCache +import coil3.request.ImageRequest +import coil3.request.allowHardware +import coil3.toBitmap +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.colors.AvatarColorsProvider +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +private val PIN_WIDTH = 42.dp +private val PIN_HEIGHT = PIN_WIDTH * 1.2f +private val AVATAR_SIZE = PIN_WIDTH - 10.dp +private val CONTENT_OFFSET = 5.dp +private val DOT_RADIUS = 6.dp +private val STROKE_WIDTH = 1.dp + +/** + * Variants of location pin markers. + */ +@Immutable +sealed interface PinVariant { + data class UserLocation( + val avatarData: AvatarData, + val isLive: Boolean, + ) : PinVariant + + data object PinnedLocation : PinVariant + data object StaleLocation : PinVariant +} + +/** + * A location pin composable that supports multiple variants. + * + * Based on Figma design: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=4665-2890&m=dev + */ +@Composable +fun LocationPin( + variant: PinVariant, + modifier: Modifier = Modifier, +) { + val image = rememberLocationPinBitmap(variant) + Canvas(modifier = modifier.size(PIN_WIDTH, PIN_HEIGHT)) { + if (image != null) { + drawImage(image) + } + } +} + +/** + * Renders a location pin to an [ImageBitmap] using Canvas operations. + * @param variant The pin variant to render + * @return The rendered [ImageBitmap], or null if still loading + */ +@Composable +fun rememberLocationPinBitmap(variant: PinVariant): ImageBitmap? { + val context = LocalContext.current + val density = LocalDensity.current + val imageLoader = SingletonImageLoader.get(context) + val colors = pinColors(variant) + val cacheKey = rememberCacheKey(variant) + return produceState(initialValue = null, cacheKey) { + val memoryCacheKey = MemoryCache.Key(cacheKey) + val cached = imageLoader.memoryCache?.get(memoryCacheKey) + if (cached != null) { + value = cached.image.toBitmap().asImageBitmap() + } else { + val dimensions = PinDimensions(density) + val bitmap = LocationPinRenderer.renderPin(variant, colors, dimensions, context, imageLoader) + imageLoader.memoryCache?.set(memoryCacheKey, MemoryCache.Value(bitmap.asImage())) + value = bitmap.asImageBitmap() + } + }.value +} + +@Composable +private fun pinColors(variant: PinVariant): PinColors { + return when (variant) { + is PinVariant.UserLocation -> { + val avatarColors = AvatarColorsProvider.provide(variant.avatarData.id) + if (variant.isLive) { + PinColors( + fill = ElementTheme.colors.iconAccentPrimary, + stroke = Color.Transparent, + dot = Color.Transparent, + avatarStroke = ElementTheme.colors.bgCanvasDefault, + avatarBackground = avatarColors.background, + avatarForeground = avatarColors.foreground, + ) + } else { + PinColors( + fill = ElementTheme.colors.bgCanvasDefault, + stroke = ElementTheme.colors.iconQuaternaryAlpha, + dot = Color.Transparent, + avatarStroke = ElementTheme.colors.iconQuaternaryAlpha, + avatarBackground = avatarColors.background, + avatarForeground = avatarColors.foreground, + ) + } + } + PinVariant.PinnedLocation -> PinColors( + fill = ElementTheme.colors.bgCanvasDefault, + stroke = ElementTheme.colors.iconSecondaryAlpha, + avatarStroke = Color.Transparent, + avatarBackground = Color.Transparent, + avatarForeground = Color.Transparent, + dot = ElementTheme.colors.iconPrimary, + ) + PinVariant.StaleLocation -> PinColors( + fill = ElementTheme.colors.bgSubtleSecondary, + stroke = ElementTheme.colors.iconDisabled, + avatarStroke = Color.Transparent, + avatarBackground = Color.Transparent, + avatarForeground = Color.Transparent, + dot = ElementTheme.colors.iconDisabled, + ) + } +} + +/** + * Color configuration for rendering a location pin. + */ +private data class PinColors( + val fill: Color, + val stroke: Color, + val dot: Color, + val avatarStroke: Color, + val avatarBackground: Color, + val avatarForeground: Color, +) + +/** + * Pre-calculated pixel dimensions for rendering a location pin. + */ +private class PinDimensions(density: Density) { + val pinWidth = with(density) { PIN_WIDTH.toPx() } + val pinHeight = with(density) { PIN_HEIGHT.toPx() } + val avatarSize: Float = with(density) { AVATAR_SIZE.toPx() } + val avatarOffset: Float = with(density) { CONTENT_OFFSET.toPx() } + val dotRadius: Float = with(density) { DOT_RADIUS.toPx() } + val strokeWidth: Float = with(density) { STROKE_WIDTH.toPx() } +} + +/** + * Renders location pins to bitmaps using Canvas operations. + * Uses Coil for avatar loading. + * Paint objects are shared across all renders. + */ +private object LocationPinRenderer { + // Shared Paint objects to avoid allocations + private val fillPaint = Paint().apply { + style = Paint.Style.FILL + isAntiAlias = true + } + private val strokePaint = Paint().apply { + style = Paint.Style.STROKE + isAntiAlias = true + } + private val textPaint = Paint().apply { + textAlign = Paint.Align.CENTER + isAntiAlias = true + isFakeBoldText = true + } + + /** + * Renders a pin variant to bitmap. Suspending for async avatar loading. + */ + suspend fun renderPin( + variant: PinVariant, + colors: PinColors, + dimensions: PinDimensions, + context: Context, + imageLoader: ImageLoader, + ): Bitmap { + val bitmap = createBitmap(dimensions.pinWidth.toInt(), dimensions.pinHeight.toInt()) + val canvas = Canvas(bitmap) + canvas.drawPinShape(colors.fill, colors.stroke, dimensions) + when (variant) { + is PinVariant.UserLocation -> { + val avatarImage = loadAvatarImage(variant.avatarData, context, imageLoader) + canvas.drawAvatar( + avatarImage = avatarImage, + avatarData = variant.avatarData, + borderColor = colors.avatarStroke, + backgroundColor = colors.avatarBackground, + foregroundColor = colors.avatarForeground, + dimensions = dimensions, + ) + } + PinVariant.PinnedLocation, + PinVariant.StaleLocation -> canvas.drawDot(colors.dot, dimensions) + } + return bitmap + } + + private fun Canvas.drawPinShape(fillColor: Color, strokeColor: Color, dimensions: PinDimensions) { + val path = createPinPath(dimensions) + fillPaint.color = fillColor.toArgb() + drawPath(path, fillPaint) + strokePaint.color = strokeColor.toArgb() + strokePaint.strokeWidth = dimensions.strokeWidth + drawPath(path, strokePaint) + } + + /** + * Updates the teardrop-shaped pin path to match dimensions. + * Based on SVG path with dimensions 40x48 (ratio 1:1.2). + */ + private fun createPinPath(dimensions: PinDimensions): Path { + val svgWidth = 40f + val svgHeight = 48f + val inset = dimensions.strokeWidth / 2 + val scaleX = (dimensions.pinWidth - dimensions.strokeWidth) / svgWidth + val scaleY = (dimensions.pinHeight - dimensions.strokeWidth) / svgHeight + + val path = Path().apply { + moveTo(20f, 48f) + cubicTo(19.4167f, 48f, 18.8333f, 47.8965f, 18.25f, 47.6895f) + cubicTo(17.6667f, 47.4825f, 17.1458f, 47.1721f, 16.6875f, 46.7581f) + cubicTo(13.9792f, 44.2743f, 11.5833f, 41.8525f, 9.5f, 39.4929f) + cubicTo(7.41667f, 37.1332f, 5.67708f, 34.8461f, 4.28125f, 32.6313f) + cubicTo(2.88542f, 30.4166f, 1.82292f, 28.2846f, 1.09375f, 26.2354f) + cubicTo(0.364583f, 24.1863f, 0f, 22.2303f, 0f, 20.3674f) + cubicTo(0f, 14.1578f, 2.01042f, 9.21087f, 6.03125f, 5.52652f) + cubicTo(10.0521f, 1.84217f, 14.7083f, 0f, 20f, 0f) + cubicTo(25.2917f, 0f, 29.9479f, 1.84217f, 33.9688f, 5.52652f) + cubicTo(37.9896f, 9.21087f, 40f, 14.1578f, 40f, 20.3674f) + cubicTo(40f, 22.2303f, 39.6354f, 24.1863f, 38.9062f, 26.2354f) + cubicTo(38.1771f, 28.2846f, 37.1146f, 30.4166f, 35.7188f, 32.6313f) + cubicTo(34.3229f, 34.8461f, 32.5833f, 37.1332f, 30.5f, 39.4929f) + cubicTo(28.4167f, 41.8525f, 26.0208f, 44.2743f, 23.3125f, 46.7581f) + cubicTo(22.8542f, 47.1721f, 22.3333f, 47.4825f, 21.75f, 47.6895f) + cubicTo(21.1667f, 47.8965f, 20.5833f, 48f, 20f, 48f) + close() + } + val matrix = Matrix().apply { + setScale(scaleX, scaleY) + postTranslate(inset, inset) + } + path.transform(matrix) + return path + } + + private suspend fun loadAvatarImage( + avatarData: AvatarData, + context: Context, + imageLoader: ImageLoader, + ): Image? { + val request = ImageRequest.Builder(context) + .data(avatarData) + // Disable hardware rendering for Canvas + .allowHardware(false) + .build() + return imageLoader.execute(request).image + } + + private fun Canvas.drawAvatar( + avatarImage: Image?, + avatarData: AvatarData, + borderColor: Color, + backgroundColor: Color, + foregroundColor: Color, + dimensions: PinDimensions, + ) { + val centerX = dimensions.pinWidth / 2 + val avatarY = dimensions.avatarOffset + val avatarRadius = dimensions.avatarSize / 2 + + withSave { + if (avatarImage != null) { + val bitmap = avatarImage.toBitmap() + // Calculate centered square crop (ContentScale.Crop behavior) + val srcSize = minOf(bitmap.width, bitmap.height) + val srcX = (bitmap.width - srcSize) / 2 + val srcY = (bitmap.height - srcSize) / 2 + val srcRect = Rect(srcX, srcY, srcX + srcSize, srcY + srcSize) + val destRect = RectF( + centerX - avatarRadius, + avatarY, + centerX + avatarRadius, + avatarY + dimensions.avatarSize + ) + val clipPath = Path().apply { + addCircle(centerX, avatarY + avatarRadius, avatarRadius, Path.Direction.CW) + } + clipPath(clipPath) + drawBitmap(bitmap, srcRect, destRect, null) + } else { + drawInitialLetterAvatar( + initialLetter = avatarData.initialLetter, + centerX = centerX, + centerY = avatarY + avatarRadius, + radius = avatarRadius, + foreground = foregroundColor.toArgb(), + background = backgroundColor.toArgb() + ) + } + } + strokePaint.color = borderColor.toArgb() + strokePaint.strokeWidth = dimensions.strokeWidth + drawCircle(centerX, avatarY + avatarRadius, avatarRadius, strokePaint) + } + + private fun Canvas.drawInitialLetterAvatar( + initialLetter: String, + centerX: Float, + centerY: Float, + radius: Float, + foreground: Int, + background: Int, + ) { + fillPaint.color = background + drawCircle(centerX, centerY, radius, fillPaint) + textPaint.color = foreground + textPaint.textSize = radius * 1.2f + val textBounds = Rect() + textPaint.getTextBounds(initialLetter, 0, 1, textBounds) + val textY = centerY + textBounds.height() / 2f + drawText(initialLetter, centerX, textY, textPaint) + } + + private fun Canvas.drawDot(dotColor: Color, dimensions: PinDimensions) { + if (dotColor == Color.Transparent) return + val centerX = dimensions.pinWidth / 2 + val centerY = dimensions.avatarOffset + dimensions.avatarSize / 2 + fillPaint.color = dotColor.toArgb() + drawCircle(centerX, centerY, dimensions.dotRadius, fillPaint) + } +} + +@PreviewsDayNight +@Composable +internal fun LocationPinPreview() = ElementPreview { + val sampleAvatarData = AvatarData( + id = "@alice:matrix.org", + name = "Alice", + url = null, + size = AvatarSize.SelectedUser + ) + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + LocationPin( + variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = false), + ) + LocationPin( + variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = true), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + LocationPin( + variant = PinVariant.PinnedLocation, + ) + LocationPin( + variant = PinVariant.StaleLocation, + ) + } + } +} + +@Composable +private fun rememberCacheKey(variant: PinVariant): String { + val isLightTheme = ElementTheme.isLightTheme + val density = LocalDensity.current.density + return remember(isLightTheme, density, variant) { + val pinVariant = when (variant) { + PinVariant.PinnedLocation -> "pin_pinned" + PinVariant.StaleLocation -> "pin_stale" + is PinVariant.UserLocation -> "pin_user_${variant.avatarData.id}_${variant.isLive}" + } + "${pinVariant}_{$isLightTheme}_{$density}" + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PinIcon.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PinIcon.kt deleted file mode 100644 index 88287ef44b..0000000000 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PinIcon.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector 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.libraries.designsystem.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.width -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.designsystem.R -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.Icon - -@Composable -fun PinIcon( - modifier: Modifier = Modifier -) { - Box( - modifier = modifier - .background(ElementTheme.colors.bgSubtlePrimary) - ) { - Icon( - modifier = Modifier - .align(Alignment.Center) - .width(22.dp), - resourceId = R.drawable.pin, - contentDescription = null, - tint = Color.Unspecified, - ) - } -} - -@PreviewsDayNight -@Composable -internal fun PinIconPreview() = ElementPreview { - PinIcon() -} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index 8407445394..cd29773a5b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -75,6 +75,6 @@ enum class AvatarSize(val dp: Dp) { SpaceMember(24.dp), LeaveSpaceRoom(32.dp), SelectParentSpace(32.dp), - AccountItem(32.dp), + LocationPin(32.dp) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt index ce1afae93d..91c058a0e4 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt @@ -42,6 +42,7 @@ fun ListDialog( enabled: Boolean = true, applyPaddingToContents: Boolean = true, destructiveSubmit: Boolean = false, + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(16.dp), listItems: LazyListScope.() -> Unit, ) { val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let { @@ -67,6 +68,7 @@ fun ListDialog( listItems = listItems, applyPaddingToContents = applyPaddingToContents, destructiveSubmit = destructiveSubmit, + verticalArrangement = verticalArrangement, ) } } @@ -82,6 +84,7 @@ private fun ListDialogContent( enabled: Boolean, applyPaddingToContents: Boolean, destructiveSubmit: Boolean, + verticalArrangement: Arrangement.Vertical, subtitle: @Composable (() -> Unit)? = null, ) { SimpleAlertDialogContent( @@ -99,7 +102,7 @@ private fun ListDialogContent( val horizontalPadding = if (applyPaddingToContents) 0.dp else 8.dp LazyColumn( modifier = Modifier.padding(horizontal = horizontalPadding), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = verticalArrangement, ) { listItems() } } } @@ -126,6 +129,7 @@ internal fun ListDialogContentPreview() { enabled = true, destructiveSubmit = false, applyPaddingToContents = true, + verticalArrangement = Arrangement.spacedBy(16.dp), ) } } diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt index 009547f9eb..68dd4cd332 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt @@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParse import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageType @@ -115,6 +116,10 @@ class DefaultRoomLatestEventFormatter( val message = sp.getString(CommonStrings.common_unsupported_event) message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) } + is LiveLocationContent -> { + val message = sp.getString(CommonStrings.common_shared_location) + message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) + } is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call) is CallNotifyContent -> sp.getString(CommonStrings.common_call_started) }?.take(DEFAULT_SAFE_LENGTH) diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt index 9657f87bd0..ff5cce7a59 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent @@ -69,6 +70,7 @@ class DefaultTimelineEventFormatter( is MessageContent, is FailedToParseMessageLikeContent, is FailedToParseStateContent, + is LiveLocationContent, is UnknownContent -> { if (buildMeta.isDebuggable) { error("You should not use this formatter for this event content: $content") diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt index b58bbb4b25..e91bed409e 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt @@ -145,7 +145,7 @@ class DefaultPinnedMessagesBannerFormatterTest { ImageMessageType(body, null, null, MediaSource("url"), null), StickerMessageType(body, null, null, MediaSource("url"), null), FileMessageType(body, null, null, MediaSource("url"), null), - LocationMessageType(body, "geo:1,2", null), + LocationMessageType(body, "geo:1,2", null, null), NoticeMessageType(body, null), EmoteMessageType(body, null), OtherMessageType(msgType = "a_type", body = body), diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt index 0da3134098..2345af8a33 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt @@ -190,7 +190,7 @@ class DefaultRoomLatestEventFormatterTest { ImageMessageType(body, null, null, MediaSource("url"), null), StickerMessageType(body, null, null, MediaSource("url"), null), FileMessageType(body, null, null, MediaSource("url"), null), - LocationMessageType(body, "geo:1,2", null), + LocationMessageType(body, "geo:1,2", null, null), NoticeMessageType(body, null), EmoteMessageType(body, null), OtherMessageType(msgType = "a_type", body = body), diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 4f1fe30d10..6cd9dec60c 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -147,6 +147,13 @@ enum class FeatureFlags( defaultValue = { false }, isFinished = false, ), + LiveLocationSharing( + key = "feature.liveLocationSharing", + title = "Live location sharing", + description = "Allow sharing live location in rooms.", + defaultValue = { false }, + isFinished = false, + ), ValidateNetworkWhenSchedulingNotificationFetching( key = "feature.validate_network_when_scheduling_notification_fetching", title = "validate internet connectivity when scheduling notification fetching", diff --git a/libraries/maplibre-compose/build.gradle.kts b/libraries/maplibre-compose/build.gradle.kts deleted file mode 100644 index 5552aae37f..0000000000 --- a/libraries/maplibre-compose/build.gradle.kts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2022-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -plugins { - id("io.element.android-compose-library") - id("kotlin-parcelize") -} - -android { - namespace = "io.element.android.libraries.maplibre.compose" - - kotlin { - compilerOptions { - explicitApi() - } - } -} - -dependencies { - api(libs.maplibre) - api(libs.maplibre.ktx) - api(libs.maplibre.annotation) -} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt deleted file mode 100644 index 8ef02f5bbc..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import androidx.compose.runtime.Immutable -import org.maplibre.android.location.modes.CameraMode as InternalCameraMode - -@Immutable -public enum class CameraMode { - NONE, - NONE_COMPASS, - NONE_GPS, - TRACKING, - TRACKING_COMPASS, - TRACKING_GPS, - TRACKING_GPS_NORTH; - - @InternalCameraMode.Mode - internal fun toInternal(): Int = when (this) { - NONE -> InternalCameraMode.NONE - NONE_COMPASS -> InternalCameraMode.NONE_COMPASS - NONE_GPS -> InternalCameraMode.NONE_GPS - TRACKING -> InternalCameraMode.TRACKING - TRACKING_COMPASS -> InternalCameraMode.TRACKING_COMPASS - TRACKING_GPS -> InternalCameraMode.TRACKING_GPS - TRACKING_GPS_NORTH -> InternalCameraMode.TRACKING_GPS_NORTH - } - - internal companion object { - fun fromInternal(@InternalCameraMode.Mode mode: Int): CameraMode = when (mode) { - InternalCameraMode.NONE -> NONE - InternalCameraMode.NONE_COMPASS -> NONE_COMPASS - InternalCameraMode.NONE_GPS -> NONE_GPS - InternalCameraMode.TRACKING -> TRACKING - InternalCameraMode.TRACKING_COMPASS -> TRACKING_COMPASS - InternalCameraMode.TRACKING_GPS -> TRACKING_GPS - InternalCameraMode.TRACKING_GPS_NORTH -> TRACKING_GPS_NORTH - else -> error("Unknown camera mode: $mode") - } - } -} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt deleted file mode 100644 index 2683de1655..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import androidx.compose.runtime.Immutable -import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_ANIMATION -import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE -import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_DEVELOPER_ANIMATION - -/** - * Enumerates the different reasons why the map camera started to move. - * - * Based on enum values from https://docs.maptiler.com/maplibre-gl-native-android/org.maplibre.android.maps/#oncameramovestartedlistener. - * - * [NO_MOVEMENT_YET] is used as the initial state before any map movement has been observed. - * - * [UNKNOWN] is used to represent when an unsupported integer value is provided to [fromInt] - this - * may be a new constant value from the Maps SDK that isn't supported by maps-compose yet, in which - * case this library should be updated to include a new enum value for that constant. - */ -@Immutable -public enum class CameraMoveStartedReason(public val value: Int) { - UNKNOWN(-2), - NO_MOVEMENT_YET(-1), - GESTURE(REASON_API_GESTURE), - API_ANIMATION(REASON_API_ANIMATION), - DEVELOPER_ANIMATION(REASON_DEVELOPER_ANIMATION); - - public companion object { - /** - * Converts from the Maps SDK [org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener] - * constants to [CameraMoveStartedReason], or returns [UNKNOWN] if there is no such - * [CameraMoveStartedReason] for the given [value]. - * - * See https://docs.maptiler.com/maplibre-gl-native-android/org.maplibre.android.maps/#oncameramovestartedlistener. - */ - public fun fromInt(value: Int): CameraMoveStartedReason { - return values().firstOrNull { it.value == value } ?: return UNKNOWN - } - } -} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt deleted file mode 100644 index 1999526718..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import android.location.Location -import android.os.Parcelable -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.staticCompositionLocalOf -import kotlinx.parcelize.Parcelize -import org.maplibre.android.camera.CameraPosition -import org.maplibre.android.camera.CameraUpdateFactory -import org.maplibre.android.maps.MapLibreMap -import org.maplibre.android.maps.Projection - -/** - * Create and [rememberSaveable] a [CameraPositionState] using [CameraPositionState.Saver]. - * [init] will be called when the [CameraPositionState] is first created to configure its - * initial state. - */ -@Composable -public inline fun rememberCameraPositionState( - crossinline init: CameraPositionState.() -> Unit = {} -): CameraPositionState = rememberSaveable(saver = CameraPositionState.Saver) { - CameraPositionState().apply(init) -} - -/** - * A state object that can be hoisted to control and observe the map's camera state. - * A [CameraPositionState] may only be used by a single [MapLibreMap] composable at a time - * as it reflects instance state for a single view of a map. - * - * @param position the initial camera position - * @param cameraMode the initial camera mode - */ -public class CameraPositionState( - position: CameraPosition = CameraPosition.Builder().build(), - cameraMode: CameraMode = CameraMode.NONE, -) { - /** - * Whether the camera is currently moving or not. This includes any kind of movement: - * panning, zooming, or rotation. - */ - public var isMoving: Boolean by mutableStateOf(false) - internal set - - /** - * The reason for the start of the most recent camera moment, or - * [CameraMoveStartedReason.NO_MOVEMENT_YET] if the camera hasn't moved yet or - * [CameraMoveStartedReason.UNKNOWN] if an unknown constant is received from the Maps SDK. - */ - public var cameraMoveStartedReason: CameraMoveStartedReason by mutableStateOf( - CameraMoveStartedReason.NO_MOVEMENT_YET - ) - internal set - - /** - * Returns the current [Projection] to be used for converting between screen - * coordinates and lat/lng. - */ - public val projection: Projection? - get() = map?.projection - - /** - * Local source of truth for the current camera position. - * While [map] is non-null this reflects the current position of [map] as it changes. - * While [map] is null it reflects the last known map position, or the last value set by - * explicitly setting [position]. - */ - internal var rawPosition by mutableStateOf(position) - - /** - * Current position of the camera on the map. - */ - public var position: CameraPosition - get() = rawPosition - set(value) { - synchronized(lock) { - val map = map - if (map == null) { - rawPosition = value - } else { - map.moveCamera(CameraUpdateFactory.newCameraPosition(value)) - } - } - } - - /** - * Local source of truth for the current camera mode. - * While [map] is non-null this reflects the current camera mode as it changes. - * While [map] is null it reflects the last known camera mode, or the last value set by - * explicitly setting [cameraMode]. - */ - internal var rawCameraMode by mutableStateOf(cameraMode) - - /** - * Current tracking mode of the camera. - */ - public var cameraMode: CameraMode - get() = rawCameraMode - set(value) { - synchronized(lock) { - val map = map - if (map == null) { - rawCameraMode = value - } else { - map.locationComponent.cameraMode = value.toInternal() - } - } - } - - /** - * The user's last available location. - */ - public var location: Location? by mutableStateOf(null) - internal set - - // Used to perform side effects thread-safely. - // Guards all mutable properties that are not `by mutableStateOf`. - private val lock = Unit - - // The map currently associated with this CameraPositionState. - // Guarded by `lock`. - private var map: MapLibreMap? by mutableStateOf(null) - - // The current map is set and cleared by side effect. - // There can be only one associated at a time. - internal fun setMap(map: MapLibreMap?) { - synchronized(lock) { - if (this.map == null && map == null) return - if (this.map != null && map != null) { - error("CameraPositionState may only be associated with one MapLibreMap at a time") - } - this.map = map - if (map == null) { - isMoving = false - } else { - map.moveCamera(CameraUpdateFactory.newCameraPosition(position)) - map.locationComponent.cameraMode = cameraMode.toInternal() - } - } - } - - public companion object { - /** - * The default saver implementation for [CameraPositionState]. - */ - public val Saver: Saver = Saver( - save = { SaveableCameraPositionData(it.position, it.cameraMode.toInternal()) }, - restore = { CameraPositionState(it.position, CameraMode.fromInternal(it.cameraMode)) } - ) - } -} - -/** Provides the [CameraPositionState] used by the map. */ -internal val LocalCameraPositionState = staticCompositionLocalOf { CameraPositionState() } - -/** The current [CameraPositionState] used by the map. */ -public val currentCameraPositionState: CameraPositionState - @[MapLibreMapComposable ReadOnlyComposable Composable] - get() = LocalCameraPositionState.current - -@Parcelize -public data class SaveableCameraPositionData( - val position: CameraPosition, - val cameraMode: Int -) : Parcelable diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt deleted file mode 100644 index e46dcdcf65..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import androidx.compose.runtime.Immutable -import org.maplibre.android.style.layers.Property - -@Immutable -public enum class IconAnchor { - CENTER, - LEFT, - RIGHT, - TOP, - BOTTOM, - TOP_LEFT, - TOP_RIGHT, - BOTTOM_LEFT, - BOTTOM_RIGHT; - - @Property.ICON_ANCHOR - internal fun toInternal(): String = when (this) { - CENTER -> Property.ICON_ANCHOR_CENTER - LEFT -> Property.ICON_ANCHOR_LEFT - RIGHT -> Property.ICON_ANCHOR_RIGHT - TOP -> Property.ICON_ANCHOR_TOP - BOTTOM -> Property.ICON_ANCHOR_BOTTOM - TOP_LEFT -> Property.ICON_ANCHOR_TOP_LEFT - TOP_RIGHT -> Property.ICON_ANCHOR_TOP_RIGHT - BOTTOM_LEFT -> Property.ICON_ANCHOR_BOTTOM_LEFT - BOTTOM_RIGHT -> Property.ICON_ANCHOR_BOTTOM_RIGHT - } -} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt deleted file mode 100644 index f8fd64c537..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import androidx.compose.runtime.AbstractApplier -import org.maplibre.android.maps.MapLibreMap -import org.maplibre.android.maps.Style -import org.maplibre.android.plugins.annotation.SymbolManager - -internal interface MapNode { - fun onAttached() {} - fun onRemoved() {} - fun onCleared() {} -} - -private object MapNodeRoot : MapNode - -internal class MapApplier( - val map: MapLibreMap, - val style: Style, - val symbolManager: SymbolManager, -) : AbstractApplier(MapNodeRoot) { - private val decorations = mutableListOf() - - override fun onClear() { - symbolManager.deleteAll() - decorations.forEach { it.onCleared() } - decorations.clear() - } - - override fun insertBottomUp(index: Int, instance: MapNode) { - decorations.add(index, instance) - instance.onAttached() - } - - override fun insertTopDown(index: Int, instance: MapNode) { - // insertBottomUp is preferred - } - - override fun move(from: Int, to: Int, count: Int) { - decorations.move(from, to, count) - } - - override fun remove(index: Int, count: Int) { - repeat(count) { - decorations[index + it].onRemoved() - } - decorations.remove(index, count) - } -} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMap.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMap.kt deleted file mode 100644 index 62c29fbd04..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMap.kt +++ /dev/null @@ -1,247 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import android.content.ComponentCallbacks2 -import android.content.Context -import android.content.res.Configuration -import android.os.Bundle -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Composition -import androidx.compose.runtime.CompositionContext -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCompositionContext -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import kotlinx.collections.immutable.ImmutableMap -import kotlinx.collections.immutable.persistentMapOf -import kotlinx.coroutines.awaitCancellation -import org.maplibre.android.MapLibre -import org.maplibre.android.maps.MapLibreMap -import org.maplibre.android.maps.MapView -import org.maplibre.android.maps.Style -import org.maplibre.android.plugins.annotation.SymbolManager -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine - -/** - * A compose container for a MapLibre [MapView]. - * - * Heavily inspired by https://github.com/googlemaps/android-maps-compose - * - * @param styleUri a URI where to asynchronously fetch a style for the map - * @param modifier Modifier to be applied to the MapLibreMap - * @param images images added to the map's style to be later used with [Symbol] - * @param cameraPositionState the [CameraPositionState] to be used to control or observe the map's - * camera state - * @param uiSettings the [MapUiSettings] to be used for UI-specific settings on the map - * @param symbolManagerSettings the [MapSymbolManagerSettings] to be used for symbol manager settings - * @param locationSettings the [MapLocationSettings] to be used for location settings - * @param content the content of the map - */ -@Composable -public fun MapLibreMap( - styleUri: String, - modifier: Modifier = Modifier, - images: ImmutableMap = persistentMapOf(), - cameraPositionState: CameraPositionState = rememberCameraPositionState(), - uiSettings: MapUiSettings = DefaultMapUiSettings, - symbolManagerSettings: MapSymbolManagerSettings = DefaultMapSymbolManagerSettings, - locationSettings: MapLocationSettings = DefaultMapLocationSettings, - content: (@Composable @MapLibreMapComposable () -> Unit)? = null, -) { - // When in preview, early return a Box with the received modifier preserving layout - if (LocalInspectionMode.current) { - @Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return. - Box( - modifier = modifier.background(Color.DarkGray) - ) { - Text("[Map]", modifier = Modifier.align(Alignment.Center)) - } - return - } - - val context = LocalContext.current - val mapView = remember { - MapLibre.getInstance(context) - MapView(context) - } - - @Suppress("ModifierReused") - AndroidView(modifier = modifier, factory = { mapView }) - MapLifecycle(mapView) - - // rememberUpdatedState and friends are used here to make these values observable to - // the subcomposition without providing a new content function each recomposition - val currentCameraPositionState by rememberUpdatedState(cameraPositionState) - val currentUiSettings by rememberUpdatedState(uiSettings) - val currentMapLocationSettings by rememberUpdatedState(locationSettings) - val currentSymbolManagerSettings by rememberUpdatedState(symbolManagerSettings) - - val parentComposition = rememberCompositionContext() - val currentContent by rememberUpdatedState(content) - - LaunchedEffect(styleUri, images) { - disposingComposition { - parentComposition.newComposition( - context = context, - mapView = mapView, - styleUri = styleUri, - images = images, - ) { - MapUpdater( - cameraPositionState = currentCameraPositionState, - uiSettings = currentUiSettings, - locationSettings = currentMapLocationSettings, - symbolManagerSettings = currentSymbolManagerSettings, - ) - CompositionLocalProvider( - LocalCameraPositionState provides cameraPositionState, - ) { - currentContent?.invoke() - } - } - } - } -} - -private suspend inline fun disposingComposition(factory: () -> Composition) { - val composition = factory() - try { - awaitCancellation() - } finally { - composition.dispose() - } -} - -private suspend inline fun CompositionContext.newComposition( - context: Context, - mapView: MapView, - styleUri: String, - images: ImmutableMap, - noinline content: @Composable () -> Unit -): Composition { - val map = mapView.awaitMap() - val style = map.awaitStyle(context, styleUri, images) - val symbolManager = SymbolManager(mapView, map, style) - return Composition( - MapApplier(map, style, symbolManager), - this - ).apply { - setContent(content) - } -} - -private suspend inline fun MapView.awaitMap(): MapLibreMap = suspendCoroutine { continuation -> - getMapAsync { map -> - continuation.resume(map) - } -} - -private suspend inline fun MapLibreMap.awaitStyle( - context: Context, - styleUri: String, - images: ImmutableMap, -): Style = suspendCoroutine { continuation -> - setStyle( - Style.Builder().apply { - fromUri(styleUri) - images.forEach { (id, drawableRes) -> - withImage(id, checkNotNull(context.getDrawable(drawableRes)) { - "Drawable resource $drawableRes with id $id not found" - }) - } - } - ) { style -> - continuation.resume(style) - } -} - -/** - * Registers lifecycle observers to the local [MapView]. - */ -@Composable -private fun MapLifecycle(mapView: MapView) { - val context = LocalContext.current - val lifecycle = LocalLifecycleOwner.current.lifecycle - val previousState = remember { mutableStateOf(Lifecycle.Event.ON_CREATE) } - DisposableEffect(context, lifecycle, mapView) { - val mapLifecycleObserver = mapView.lifecycleObserver(previousState) - val callbacks = mapView.componentCallbacks() - - lifecycle.addObserver(mapLifecycleObserver) - context.registerComponentCallbacks(callbacks) - - onDispose { - lifecycle.removeObserver(mapLifecycleObserver) - context.unregisterComponentCallbacks(callbacks) - } - } - DisposableEffect(mapView) { - onDispose { - mapView.onDestroy() - mapView.removeAllViews() - } - } -} - -private fun MapView.lifecycleObserver(previousState: MutableState): LifecycleEventObserver = - LifecycleEventObserver { _, event -> - event.targetState - when (event) { - Lifecycle.Event.ON_CREATE -> { - // Skip calling mapView.onCreate if the lifecycle did not go through onDestroy - in - // this case the MapLibreMap composable also doesn't leave the composition. So, - // recreating the map does not restore state properly which must be avoided. - if (previousState.value != Lifecycle.Event.ON_STOP) { - this.onCreate(Bundle()) - } - } - Lifecycle.Event.ON_START -> this.onStart() - Lifecycle.Event.ON_RESUME -> this.onResume() - Lifecycle.Event.ON_PAUSE -> this.onPause() - Lifecycle.Event.ON_STOP -> this.onStop() - Lifecycle.Event.ON_DESTROY -> { - // handled in onDispose - } - Lifecycle.Event.ON_ANY -> error("ON_ANY should never be used") - } - previousState.value = event - } - -private fun MapView.componentCallbacks(): ComponentCallbacks2 = - object : ComponentCallbacks2 { - override fun onConfigurationChanged(config: Configuration) = Unit - - @Suppress("OVERRIDE_DEPRECATION") - override fun onLowMemory() = Unit - - override fun onTrimMemory(level: Int) { - // We call the `MapView.onLowMemory` method for any memory trim level - this@componentCallbacks.onLowMemory() - } - } diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMapComposable.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMapComposable.kt deleted file mode 100644 index c819dee711..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMapComposable.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import androidx.compose.runtime.ComposableTargetMarker - -/** - * An annotation that can be used to mark a composable function as being expected to be use in a - * composable function that is also marked or inferred to be marked as a [MapLibreMapComposable]. - * - * This will produce build warnings when [MapLibreMapComposable] composable functions are used outside - * of a [MapLibreMapComposable] content lambda, and vice versa. - */ -@Retention(AnnotationRetention.BINARY) -@ComposableTargetMarker(description = "MapLibre Map Composable") -@Target( - AnnotationTarget.FILE, - AnnotationTarget.FUNCTION, - AnnotationTarget.PROPERTY_GETTER, - AnnotationTarget.TYPE, - AnnotationTarget.TYPE_PARAMETER, -) -public annotation class MapLibreMapComposable diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLocationSettings.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLocationSettings.kt deleted file mode 100644 index 7fb777aeba..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLocationSettings.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import androidx.compose.ui.graphics.Color - -internal val DefaultMapLocationSettings = MapLocationSettings() - -/** - * Data class for UI-related settings on the map. - * - * Note: Should not be a data class if in need of maintaining binary compatibility - * on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/ - */ -public data class MapLocationSettings( - public val locationEnabled: Boolean = false, - public val backgroundTintColor: Color = Color.Unspecified, - public val foregroundTintColor: Color = Color.Unspecified, - public val backgroundStaleTintColor: Color = Color.Unspecified, - public val foregroundStaleTintColor: Color = Color.Unspecified, - public val accuracyColor: Color = Color.Unspecified, - public val pulseEnabled: Boolean = false, - public val pulseColor: Color = Color.Unspecified -) diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapSymbolManagerSettings.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapSymbolManagerSettings.kt deleted file mode 100644 index 93c7b2118b..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapSymbolManagerSettings.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -internal val DefaultMapSymbolManagerSettings = MapSymbolManagerSettings() - -/** - * Data class for UI-related settings on the map. - * - * Note: Should not be a data class if in need of maintaining binary compatibility - * on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/ - */ -public data class MapSymbolManagerSettings( - public val iconAllowOverlap: Boolean = false, -) diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUiSettings.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUiSettings.kt deleted file mode 100644 index edee9b4dc5..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUiSettings.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import android.view.Gravity -import androidx.compose.ui.graphics.Color - -internal val DefaultMapUiSettings = MapUiSettings() - -/** - * Data class for UI-related settings on the map. - * - * Note: Should not be a data class if in need of maintaining binary compatibility - * on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/ - */ -public data class MapUiSettings( - public val compassEnabled: Boolean = true, - public val rotationGesturesEnabled: Boolean = true, - public val scrollGesturesEnabled: Boolean = true, - public val tiltGesturesEnabled: Boolean = true, - public val zoomGesturesEnabled: Boolean = true, - public val logoGravity: Int = Gravity.BOTTOM, - public val attributionGravity: Int = Gravity.BOTTOM, - public val attributionTintColor: Color = Color.Unspecified, -) diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt deleted file mode 100644 index a07a596fe3..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ -@file:Suppress("MatchingDeclarationName") - -package io.element.android.libraries.maplibre.compose - -import android.annotation.SuppressLint -import android.content.Context -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ComposeNode -import androidx.compose.runtime.currentComposer -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import org.maplibre.android.location.LocationComponentActivationOptions -import org.maplibre.android.location.LocationComponentOptions -import org.maplibre.android.location.OnCameraTrackingChangedListener -import org.maplibre.android.location.engine.LocationEngineRequest -import org.maplibre.android.maps.MapLibreMap -import org.maplibre.android.maps.Style - -private const val LOCATION_REQUEST_INTERVAL = 750L - -internal class MapPropertiesNode( - val map: MapLibreMap, - style: Style, - context: Context, - cameraPositionState: CameraPositionState, - locationSettings: MapLocationSettings, -) : MapNode { - init { - map.locationComponent.activateLocationComponent( - LocationComponentActivationOptions.Builder(context, style) - .locationComponentOptions( - LocationComponentOptions.builder(context) - .backgroundTintColor(locationSettings.backgroundTintColor.toArgb()) - .foregroundTintColor(locationSettings.foregroundTintColor.toArgb()) - .backgroundStaleTintColor(locationSettings.backgroundStaleTintColor.toArgb()) - .foregroundStaleTintColor(locationSettings.foregroundStaleTintColor.toArgb()) - .accuracyColor(locationSettings.accuracyColor.toArgb()) - .pulseEnabled(locationSettings.pulseEnabled) - .pulseColor(locationSettings.pulseColor.toArgb()) - .build() - ) - .locationEngineRequest( - LocationEngineRequest.Builder(LOCATION_REQUEST_INTERVAL) - .setPriority(LocationEngineRequest.PRIORITY_HIGH_ACCURACY) - .setFastestInterval(LOCATION_REQUEST_INTERVAL) - .build() - ) - .build() - ) - cameraPositionState.setMap(map) - } - - var cameraPositionState = cameraPositionState - set(value) { - if (value == field) return - field.setMap(null) - field = value - value.setMap(map) - } - - override fun onAttached() { - map.addOnCameraIdleListener { - cameraPositionState.isMoving = false - // addOnCameraIdleListener is only invoked when the camera position - // is changed via .animate(). To handle updating state when .move() - // is used, it's necessary to set the camera's position here as well - cameraPositionState.rawPosition = map.cameraPosition - // Updating user location on every camera move due to lack of a better location updates API. - cameraPositionState.location = map.locationComponent.lastKnownLocation - } - map.addOnCameraMoveCancelListener { - cameraPositionState.isMoving = false - } - map.addOnCameraMoveStartedListener { - cameraPositionState.cameraMoveStartedReason = CameraMoveStartedReason.fromInt(it) - cameraPositionState.isMoving = true - } - map.addOnCameraMoveListener { - cameraPositionState.rawPosition = map.cameraPosition - // Updating user location on every camera move due to lack of a better location updates API. - cameraPositionState.location = map.locationComponent.lastKnownLocation - } - map.locationComponent.addOnCameraTrackingChangedListener(object : OnCameraTrackingChangedListener { - override fun onCameraTrackingDismissed() {} - - override fun onCameraTrackingChanged(currentMode: Int) { - cameraPositionState.rawCameraMode = CameraMode.fromInternal(currentMode) - } - }) - } - - override fun onRemoved() { - cameraPositionState.setMap(null) - } - - override fun onCleared() { - cameraPositionState.setMap(null) - } -} - -/** - * Used to keep the primary map properties up to date. This should never leave the map composition. - */ -@SuppressLint("MissingPermission") -@Suppress("NOTHING_TO_INLINE") -@Composable -internal inline fun MapUpdater( - cameraPositionState: CameraPositionState, - locationSettings: MapLocationSettings, - uiSettings: MapUiSettings, - symbolManagerSettings: MapSymbolManagerSettings, -) { - val mapApplier = currentComposer.applier as MapApplier - val map = mapApplier.map - val style = mapApplier.style - val symbolManager = mapApplier.symbolManager - val context = LocalContext.current - ComposeNode( - factory = { - MapPropertiesNode( - map = map, - style = style, - context = context, - cameraPositionState = cameraPositionState, - locationSettings = locationSettings, - ) - }, - update = { - set(locationSettings.locationEnabled) { map.locationComponent.isLocationComponentEnabled = it } - - set(uiSettings.compassEnabled) { map.uiSettings.isCompassEnabled = it } - set(uiSettings.rotationGesturesEnabled) { map.uiSettings.isRotateGesturesEnabled = it } - set(uiSettings.scrollGesturesEnabled) { map.uiSettings.isScrollGesturesEnabled = it } - set(uiSettings.tiltGesturesEnabled) { map.uiSettings.isTiltGesturesEnabled = it } - set(uiSettings.zoomGesturesEnabled) { map.uiSettings.isZoomGesturesEnabled = it } - set(uiSettings.logoGravity) { map.uiSettings.logoGravity = it } - set(uiSettings.attributionGravity) { map.uiSettings.attributionGravity = it } - set(uiSettings.attributionTintColor) { map.uiSettings.setAttributionTintColor(it.toArgb()) } - - set(symbolManagerSettings.iconAllowOverlap) { symbolManager.iconAllowOverlap = it } - - update(cameraPositionState) { this.cameraPositionState = it } - } - ) -} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt deleted file mode 100644 index e6a5c3f632..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ComposeNode -import androidx.compose.runtime.currentComposer -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import org.maplibre.android.geometry.LatLng -import org.maplibre.android.plugins.annotation.Symbol -import org.maplibre.android.plugins.annotation.SymbolManager -import org.maplibre.android.plugins.annotation.SymbolOptions - -internal class SymbolNode( - val symbolManager: SymbolManager, - val symbol: Symbol, -) : MapNode { - override fun onRemoved() { - symbolManager.delete(symbol) - } - - override fun onCleared() { - symbolManager.delete(symbol) - } -} - -/** - * A state object that can be hoisted to control and observe the symbol state. - * - * @param position the initial symbol position - */ -public class SymbolState( - position: LatLng -) { - /** - * Current position of the symbol. - */ - public var position: LatLng by mutableStateOf(position) - - public companion object { - /** - * The default saver implementation for [SymbolState]. - */ - public val Saver: Saver = Saver( - save = { it.position }, - restore = { SymbolState(it) } - ) - } -} - -@Composable -public fun rememberSymbolState( - position: LatLng = LatLng(0.0, 0.0) -): SymbolState = rememberSaveable(saver = SymbolState.Saver) { - SymbolState(position) -} - -/** - * A composable for a symbol on the map. - * - * @param iconId an id of an image from the current [Style] - * @param state the [SymbolState] to be used to control or observe the symbol - * state such as its position and info window - * @param iconAnchor the anchor for the symbol image - */ -@Composable -@MapLibreMapComposable -public fun Symbol( - iconId: String, - state: SymbolState = rememberSymbolState(), - iconAnchor: IconAnchor? = null, -) { - val mapApplier = currentComposer.applier as MapApplier - val symbolManager = mapApplier.symbolManager - ComposeNode( - factory = { - SymbolNode( - symbolManager = symbolManager, - symbol = symbolManager.create( - SymbolOptions().apply { - withLatLng(state.position) - withIconImage(iconId) - iconAnchor?.let { withIconAnchor(it.toInternal()) } - } - ), - ) - }, - update = { - update(state.position) { - this.symbol.latLng = it - symbolManager.update(this.symbol) - } - update(iconId) { - this.symbol.iconImage = it - symbolManager.update(this.symbol) - } - update(iconAnchor) { - this.symbol.iconAnchor = it?.toInternal() - symbolManager.update(this.symbol) - } - } - ) -} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt index b804bdf46d..808f37c7c9 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt @@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.knock.KnockRequest +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility @@ -182,4 +183,30 @@ interface JoinedRoom : BaseRoom { * Subscribe to a [Flow] of [SendQueueUpdate] related to this room. */ fun subscribeToSendQueueUpdates(): Flow + + /** + * Subscribe to live location shares in this room. + * @return Flow of list of active live location shares. + */ + fun subscribeToLiveLocationShares(): Flow> + + /** + * Start sharing live location in this room. + * @param durationMillis How long to share location (in milliseconds). + * @return Result indicating success or failure. + */ + suspend fun startLiveLocationShare(durationMillis: Long): Result + + /** + * Stop sharing live location in this room. + * @return Result indicating success or failure. + */ + suspend fun stopLiveLocationShare(): Result + + /** + * Send a live location update while a live location share is active. + * @param geoUri The geo URI (e.g., "geo:51.5074,-0.1278"). + * @return Result indicating success or failure. + */ + suspend fun sendLiveLocation(geoUri: String): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/AssetType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/AssetType.kt index 42d384fd74..ea65d61bdf 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/AssetType.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/AssetType.kt @@ -10,5 +10,6 @@ package io.element.android.libraries.matrix.api.room.location enum class AssetType { SENDER, - PIN + PIN, + UNKNOWN } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationInfo.kt new file mode 100644 index 0000000000..a04ef2dfb9 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationInfo.kt @@ -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.libraries.matrix.api.room.location + +data class LiveLocationInfo( + val description: String?, + val geoUri: String, + val timestamp: Long, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt new file mode 100644 index 0000000000..7e841639bd --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 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.libraries.matrix.api.room.location + +import io.element.android.libraries.matrix.api.core.UserId + +/** + * Represents a live location share from a user in a room. + */ +data class LiveLocationShare( + /** The user who is sharing their location. */ + val userId: UserId, + /** The last known geo URI (e.g., "geo:51.5074,-0.1278"). */ + val lastGeoUri: String, + /** The timestamp of the last location update. */ + val lastTimestamp: Long, + /** Whether the live location share is still active. */ + val isLive: Boolean, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index b6ed7dc602..95d4327c07 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -14,6 +14,8 @@ import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.poll.PollAnswer import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.room.location.LiveLocationInfo import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -102,6 +104,15 @@ data class FailedToParseStateContent( val error: String ) : EventContent +data class LiveLocationContent( + val body: String, + val isLive: Boolean, + val description: String?, + val timeout: Long, + val assetType: AssetType?, + val locations: List, +) : EventContent + data object LegacyCallInviteContent : EventContent data object CallNotifyContent : EventContent diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt index 6de2876f61..0cc5ca0ff7 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt @@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.matrix.api.room.location.AssetType @Immutable sealed interface MessageType @@ -55,6 +56,7 @@ data class LocationMessageType( val body: String, val geoUri: String, val description: String?, + val assetType: AssetType?, ) : MessageType data class AudioMessageType( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index 8cd31185e5..644c5aefc2 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.room.SendQueueUpdate import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.knock.KnockRequest +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange import io.element.android.libraries.matrix.api.room.roomNotificationSettings @@ -42,6 +43,7 @@ import io.element.android.libraries.matrix.impl.mapper.map import io.element.android.libraries.matrix.impl.room.history.map import io.element.android.libraries.matrix.impl.room.join.map import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest +import io.element.android.libraries.matrix.impl.room.location.map import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher import io.element.android.libraries.matrix.impl.roomdirectory.map import io.element.android.libraries.matrix.impl.timeline.RustTimeline @@ -66,6 +68,7 @@ import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.DateDividerMode import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener import org.matrix.rustcomponents.sdk.KnockRequestsListener +import org.matrix.rustcomponents.sdk.LiveLocationShareListener import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate import org.matrix.rustcomponents.sdk.SendQueueListener @@ -500,6 +503,34 @@ class JoinedRustRoom( } } + override fun subscribeToLiveLocationShares(): Flow> { + return mxCallbackFlow { + innerRoom.subscribeToLiveLocationShares(object : LiveLocationShareListener { + override fun call(liveLocationShares: List) { + trySend(liveLocationShares.map { it.map() }) + } + }) + } + } + + override suspend fun startLiveLocationShare(durationMillis: Long): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.startLiveLocationShare(durationMillis.toULong()) + } + } + + override suspend fun stopLiveLocationShare(): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.stopLiveLocationShare() + } + } + + override suspend fun sendLiveLocation(geoUri: String): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.sendLiveLocation(geoUri) + } + } + override fun close() = destroy() override fun destroy() { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt index c7c2c88fcc..5f8fa70f59 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt @@ -9,8 +9,16 @@ package io.element.android.libraries.matrix.impl.room.location import io.element.android.libraries.matrix.api.room.location.AssetType +import org.matrix.rustcomponents.sdk.AssetType as RustAssetType -fun AssetType.toInner(): org.matrix.rustcomponents.sdk.AssetType = when (this) { - AssetType.SENDER -> org.matrix.rustcomponents.sdk.AssetType.SENDER - AssetType.PIN -> org.matrix.rustcomponents.sdk.AssetType.PIN +fun AssetType.into(): RustAssetType = when (this) { + AssetType.SENDER -> RustAssetType.SENDER + AssetType.PIN -> RustAssetType.PIN + AssetType.UNKNOWN -> RustAssetType.UNKNOWN +} + +fun RustAssetType.into(): AssetType = when (this) { + RustAssetType.SENDER -> AssetType.SENDER + RustAssetType.PIN -> AssetType.PIN + RustAssetType.UNKNOWN -> AssetType.UNKNOWN } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationShareMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationShareMapper.kt new file mode 100644 index 0000000000..3b80c1c61f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationShareMapper.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 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.libraries.matrix.impl.room.location + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare +import org.matrix.rustcomponents.sdk.LiveLocationShare as RustLiveLocationShare + +fun RustLiveLocationShare.map(): LiveLocationShare { + return LiveLocationShare( + userId = UserId(userId), + lastGeoUri = lastLocation.location.geoUri, + lastTimestamp = lastLocation.ts.toLong(), + isLive = isLive, + ) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index 0ee7239933..3996155871 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -32,7 +32,7 @@ import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl import io.element.android.libraries.matrix.impl.media.map import io.element.android.libraries.matrix.impl.poll.toInner import io.element.android.libraries.matrix.impl.room.RoomContentForwarder -import io.element.android.libraries.matrix.impl.room.location.toInner +import io.element.android.libraries.matrix.impl.room.location.into import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper @@ -478,7 +478,7 @@ class RustTimeline( geoUri = geoUri, description = description, zoomLevel = zoomLevel?.toUByte(), - assetType = assetType?.toInner(), + assetType = assetType?.into(), repliedToEventId = inReplyToEventId?.value, ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt index 813bf0ec11..e382bce8db 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt @@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.impl.media.map +import io.element.android.libraries.matrix.impl.room.location.into import io.element.android.libraries.matrix.impl.timeline.reply.InReplyToMapper import org.matrix.rustcomponents.sdk.InReplyToDetails import org.matrix.rustcomponents.sdk.MessageType @@ -112,7 +113,12 @@ class EventMessageMapper { ) } is RustMessageType.Location -> { - LocationMessageType(type.content.body, type.content.geoUri, type.content.description) + LocationMessageType( + body = type.content.body, + geoUri = type.content.geoUri, + description = type.content.description, + assetType = type.content.asset.into() + ) } is MessageType.Other -> { OtherMessageType(type.msgtype, type.body) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetTypeKtTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetTypeKtTest.kt index 9b12d12a04..f20ae940cf 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetTypeKtTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetTypeKtTest.kt @@ -15,7 +15,7 @@ import org.junit.Test class AssetTypeKtTest { @Test fun toInner() { - assertThat(AssetType.SENDER.toInner()).isEqualTo(org.matrix.rustcomponents.sdk.AssetType.SENDER) - assertThat(AssetType.PIN.toInner()).isEqualTo(org.matrix.rustcomponents.sdk.AssetType.PIN) + assertThat(AssetType.SENDER.into()).isEqualTo(org.matrix.rustcomponents.sdk.AssetType.SENDER) + assertThat(AssetType.PIN.into()).isEqualTo(org.matrix.rustcomponents.sdk.AssetType.PIN) } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt index 9c97e7787b..a4580334e4 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt @@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.room.SendQueueUpdate import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.knock.KnockRequest +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility @@ -84,6 +85,10 @@ class FakeJoinedRoom( private val enableEncryptionResult: () -> Result = { lambdaError() }, private val updateJoinRuleResult: (JoinRule) -> Result = { lambdaError() }, private val setSendQueueEnabledResult: (Boolean) -> Unit = { _: Boolean -> }, + private val liveLocationSharesFlow: Flow> = MutableStateFlow(emptyList()), + private val startLiveLocationShareResult: (Long) -> Result = { lambdaError() }, + private val stopLiveLocationShareResult: () -> Result = { lambdaError() }, + private val sendLiveLocationResult: (String) -> Result = { lambdaError() }, ) : JoinedRoom, BaseRoom by baseRoom { private val sendQueueUpdates = MutableSharedFlow(extraBufferCapacity = 10) @@ -227,6 +232,22 @@ class FakeJoinedRoom( return sendQueueUpdates } + override fun subscribeToLiveLocationShares(): Flow> { + return liveLocationSharesFlow + } + + override suspend fun startLiveLocationShare(durationMillis: Long): Result = simulateLongTask { + startLiveLocationShareResult(durationMillis) + } + + override suspend fun stopLiveLocationShare(): Result = simulateLongTask { + stopLiveLocationShareResult() + } + + override suspend fun sendLiveLocation(geoUri: String): Result = simulateLongTask { + sendLiveLocationResult(geoUri) + } + private suspend fun simulateSendMediaProgress(progressCallback: ProgressCallback?) { progressCallbackValues.forEach { (current, total) -> progressCallback?.onProgress(current, total) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt index 6430c8f346..ebc8790a8a 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt @@ -11,7 +11,6 @@ package io.element.android.libraries.matrix.ui.components import android.os.Parcelable import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme @@ -24,7 +23,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.libraries.designsystem.components.PinIcon import io.element.android.libraries.designsystem.components.blurhash.BlurHashAsyncImage import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -97,16 +95,10 @@ fun AttachmentThumbnail( ) } AttachmentThumbnailType.Location -> { - PinIcon( - modifier = Modifier.fillMaxSize() - ) - /* - // For coherency across the app, we should us this instead. Waiting for design decision. Icon( - resourceId = R.drawable.ic_september_location, + imageVector = CompoundIcons.LocationPin(), contentDescription = info.textContent, ) - */ } AttachmentThumbnailType.Poll -> { Icon( diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt index 0727b0b7ec..5ddf57b723 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt @@ -71,7 +71,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider ), aMessageContent( body = "Location", - type = LocationMessageType("Location", "geo:1,2", null), + type = LocationMessageType("Location", "geo:1,2", null, assetType = null), ), aMessageContent( body = "Notice", @@ -152,13 +152,13 @@ private fun aInReplyToDetails( eventId = EventId("\$event"), eventContent = eventContent, senderId = UserId("@Sender:domain"), - senderProfile = aProfileTimelineDetailsReady( + senderProfile = aProfileDetailsReady( displayNameAmbiguous = displayNameAmbiguous, ), textContent = (eventContent as? MessageContent)?.body.orEmpty(), ) -fun aProfileTimelineDetailsReady( +fun aProfileDetailsReady( displayName: String? = "Sender", displayNameAmbiguous: Boolean = false, avatarUrl: String? = null, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt index 4d0a0f045b..9e5e468cd9 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt @@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParse import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent @@ -32,6 +33,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToMetadata.Text +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToMetadata.Thumbnail import io.element.android.libraries.ui.strings.CommonStrings @Immutable @@ -60,7 +63,7 @@ internal sealed interface InReplyToMetadata { @Composable internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetadata? = when (eventContent) { is MessageContent -> when (val type = eventContent.type) { - is ImageMessageType -> InReplyToMetadata.Thumbnail( + is ImageMessageType -> Thumbnail( AttachmentThumbnailInfo( thumbnailSource = (type.info?.thumbnailSource ?: type.source).takeUnless { hideImage }, textContent = eventContent.body, @@ -68,7 +71,7 @@ internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetad blurHash = type.info?.blurhash, ) ) - is VideoMessageType -> InReplyToMetadata.Thumbnail( + is VideoMessageType -> Thumbnail( AttachmentThumbnailInfo( thumbnailSource = type.info?.thumbnailSource?.takeUnless { hideImage }, textContent = eventContent.body, @@ -76,34 +79,34 @@ internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetad blurHash = type.info?.blurhash, ) ) - is FileMessageType -> InReplyToMetadata.Thumbnail( + is FileMessageType -> Thumbnail( AttachmentThumbnailInfo( thumbnailSource = type.info?.thumbnailSource?.takeUnless { hideImage }, textContent = eventContent.body, type = AttachmentThumbnailType.File, ) ) - is LocationMessageType -> InReplyToMetadata.Thumbnail( + is LocationMessageType -> Thumbnail( AttachmentThumbnailInfo( textContent = stringResource(CommonStrings.common_shared_location), type = AttachmentThumbnailType.Location, ) ) - is AudioMessageType -> InReplyToMetadata.Thumbnail( + is AudioMessageType -> Thumbnail( AttachmentThumbnailInfo( textContent = eventContent.body, type = AttachmentThumbnailType.Audio, ) ) - is VoiceMessageType -> InReplyToMetadata.Thumbnail( + is VoiceMessageType -> Thumbnail( AttachmentThumbnailInfo( textContent = stringResource(CommonStrings.common_voice_message), type = AttachmentThumbnailType.Voice, ) ) - else -> InReplyToMetadata.Text(textContent ?: eventContent.body) + else -> Text(textContent ?: eventContent.body) } - is StickerContent -> InReplyToMetadata.Thumbnail( + is StickerContent -> Thumbnail( AttachmentThumbnailInfo( thumbnailSource = eventContent.source.takeUnless { hideImage }, textContent = eventContent.body, @@ -111,7 +114,7 @@ internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetad blurHash = eventContent.info.blurhash, ) ) - is PollContent -> InReplyToMetadata.Thumbnail( + is PollContent -> Thumbnail( AttachmentThumbnailInfo( textContent = eventContent.question, type = AttachmentThumbnailType.Poll, @@ -127,5 +130,6 @@ internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetad UnknownContent, is LegacyCallInviteContent, is CallNotifyContent, + is LiveLocationContent, null -> null } diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt index 004e622211..5c7fdfaac8 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt @@ -355,6 +355,7 @@ class InReplyToMetadataKtTest { body = "body", geoUri = "geo:3.0,4.0;u=5.0", description = null, + assetType = null ) ) ).metadata(hideImage = false) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt index edbf9dc51e..67b73d616d 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParse import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType @@ -75,6 +76,7 @@ class EventItemFactory( is StateContent, is StickerContent, is UnableToDecryptContent, + is LiveLocationContent, UnknownContent -> { Timber.w("Should not happen: ${content.javaClass.simpleName}") null diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt index c70d658418..6c88f1c33f 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt @@ -107,7 +107,7 @@ class DefaultEventItemFactoryTest { EmoteMessageType("", null), NoticeMessageType("", null), OtherMessageType("", ""), - LocationMessageType("", "", null), + LocationMessageType("", "", null, null), TextMessageType("", null) ) messageTypes.forEach { diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt index a25e5782ba..63b903a3f7 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt @@ -339,7 +339,7 @@ class DefaultNotifiableEventResolverTest { AN_EVENT_ID to Result.success(aNotificationData( content = NotificationContent.MessageLike.RoomMessage( senderId = A_USER_ID_2, - messageType = LocationMessageType("Location", "geo:1,2", null), + messageType = LocationMessageType("Location", "geo:1,2", null, null), ), )) ) diff --git a/libraries/ui-strings/src/main/res/values-fi/translations.xml b/libraries/ui-strings/src/main/res/values-fi/translations.xml index e7606fce3e..182f403140 100644 --- a/libraries/ui-strings/src/main/res/values-fi/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fi/translations.xml @@ -496,7 +496,6 @@ Haluatko varmasti jatkaa?" "Viestiä ladataan…" "Näytä kaikki" "Keskustelu" - "Valitse, kuinka kauan haluat jakaa reaaliaikaisen sijaintisi." "Jaa sijainti" "Jaa sijaintini" "Avaa Apple Mapsissa" diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index fdc682a67b..1dad21ff65 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -496,7 +496,6 @@ Raison : %1$s." "Chargement du message…" "Voir tout" "Discussion" - "Choisissez la durée pendant laquelle vous partagerez votre position en direct." "Partage de position" "Partager ma position" "Ouvrir dans Apple Maps" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index c5258a23c9..98a8aa1862 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -496,7 +496,6 @@ Are you sure you want to continue?" "Loading message…" "View All" "Chat" - "Choose how long to share your live location." "Share location" "Share my location" "Open in Apple Maps" diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png new file mode 100644 index 0000000000..e48b3cd8ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2550638ee12b4181cea31caff0b5838a9cdb3a180c01d1188bc7c2726051b863 +size 16578 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png new file mode 100644 index 0000000000..0f17f6d6a1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c880e4d01495868b3f0689d20d3cbf2050d6261be936421343bc1ac210aabeec +size 15959 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_0_en.png deleted file mode 100644 index 7b7a97ff45..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:95a8d94f223cdb1f45fb43406688c7ae103a0e6c8cead84c7726795c553a3b54 -size 19773 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_1_en.png deleted file mode 100644 index 3a820c29a3..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0cf81beb22fca6641de9e08d69da0b6f2ed1e2593296987ac7c052c89010ee75 -size 35449 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_2_en.png deleted file mode 100644 index 6bb166ec80..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0281adefc90c5ec6a9788a142bca2bc486f1957e943791bb61c1e20e707ab0a7 -size 33926 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_3_en.png deleted file mode 100644 index 7b7a97ff45..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:95a8d94f223cdb1f45fb43406688c7ae103a0e6c8cead84c7726795c553a3b54 -size 19773 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_4_en.png deleted file mode 100644 index 07827e9681..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_4_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d6f357222c421d6f02414f3dd9d39c13cfceb36f3f4c6740b91cf18c75eace6e -size 18894 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_0_en.png deleted file mode 100644 index f1e4389d7a..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ece0a2814e93bf971c2bfd5309cd239d98795d8ae72041c0a48f0f304250a017 -size 19325 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_1_en.png deleted file mode 100644 index 11d4c7dd65..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:982df0a262833543c2d4cd56059e48e9020f3bf5b00843acec5b92066dd6ffb3 -size 33522 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_2_en.png deleted file mode 100644 index 97d474cf5a..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6d05adff48c9c6dff7178c556d746f8729e45cb100d074f3bb6a2d783211612a -size 32108 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_3_en.png deleted file mode 100644 index f1e4389d7a..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ece0a2814e93bf971c2bfd5309cd239d98795d8ae72041c0a48f0f304250a017 -size 19325 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_4_en.png deleted file mode 100644 index 2ccb40dc97..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_4_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0d7578366c6b02b2afe5899195138318bcdda1c408f61740b5fba9084f618a2a -size 18491 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_0_en.png new file mode 100644 index 0000000000..898a010ddf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db105243ef507c209c756015cf41a23150259cbe7ab3be6d233590fa9257b0e5 +size 20528 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_1_en.png new file mode 100644 index 0000000000..8957a012f2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d950639a9cc9961210b449e5c975b6f6bc890a47f4977eeaaa19b837458f66e +size 36475 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_2_en.png new file mode 100644 index 0000000000..bf25a07679 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a52d39cb27572c49ab64b1534ead353687d83870b5d5cfe7158859925b4717f3 +size 34926 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_3_en.png new file mode 100644 index 0000000000..3f99f935c9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ccb0deee31506379cfa76f8d39d37639b6b1f03d7eb2e7d5e8c6ceff300b1978 +size 30397 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_4_en.png new file mode 100644 index 0000000000..898a010ddf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db105243ef507c209c756015cf41a23150259cbe7ab3be6d233590fa9257b0e5 +size 20528 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_5_en.png new file mode 100644 index 0000000000..69aea3df2a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ba1418b5d42a56db47e7cc574cedb886c75d9cf22828341bd954a4f1845670e +size 17925 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_6_en.png new file mode 100644 index 0000000000..cce7a48382 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a97492422d54a6d6666c1ade693dc9b63bc9ca07c17d6c1f787c081984c09f68 +size 42470 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_0_en.png new file mode 100644 index 0000000000..dbd4836467 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3260d344a065f1710aff1450dbdaaa0bd1fbd963d5304167124d845c46be433 +size 19903 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_1_en.png new file mode 100644 index 0000000000..bf0e1594e1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:001bcd1755e837c0c05e493f964ee5c71fba81e811bdaf91effba257743bdacb +size 34836 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_2_en.png new file mode 100644 index 0000000000..11be8ba550 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38d253d8b3aa1c4599aa6b6f5982b7ef0c87ea7346aba13054a7891197b7c000 +size 33268 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_3_en.png new file mode 100644 index 0000000000..2c8c53dc1e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8901e7adb15ff01e9c696729f1abc52c5fc8666bca030f449f8caf5a22615f53 +size 29196 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_4_en.png new file mode 100644 index 0000000000..dbd4836467 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3260d344a065f1710aff1450dbdaaa0bd1fbd963d5304167124d845c46be433 +size 19903 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_5_en.png new file mode 100644 index 0000000000..060f25819e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d786937d790e13b53a5de8ae3321e5aa8744a979a3a99ecc36def6b7dbf6cf60 +size 17237 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_6_en.png new file mode 100644 index 0000000000..541b2a97e1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:038e12f3caeef6ac8d5389b7cdc68138e089dcac335d0a5904adc55c9bcb7b1c +size 40642 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_0_en.png index a7e98b868f..46226555db 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2c0d1a04c5a3096363f636da1d3226c4242936c2430f58a8d08af1b02c5d6808 -size 10890 +oid sha256:cdf7b194a075902ab9434e272865293535ef39370fbb9cb172b3cf8774850c73 +size 19104 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_1_en.png index d1664bb47d..ff0295d9ac 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:83a49b16c632b680d5c6cd09043c4ca85fdbe8844d519d59377015f4d5657f1f -size 29457 +oid sha256:37ccae030071cc4801538dc5c753a6148ce7e465442edcc89877353b7f5675cb +size 37572 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_2_en.png index 02b18b4992..6f440d71d6 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c25ef1a184356bb6f7b751bb32abfa6963bf2a1bb4f5f9cdc090d1e573ce04e6 -size 27985 +oid sha256:5f57485d56fd4d02731f797762d931c4d738c8693539da612e33403693cd4b08 +size 35976 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_3_en.png index a7e98b868f..964ad077b5 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2c0d1a04c5a3096363f636da1d3226c4242936c2430f58a8d08af1b02c5d6808 -size 10890 +oid sha256:c882f3f9ed18a64ecfa253284bf1dbdad5d38f524258b6463521d5185c1c32a7 +size 31530 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_4_en.png index c8400f7ca7..46226555db 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:651aa76c0a9b6845037c827728d6faf38864135d5a1a421f1acbd29b66aa4122 -size 11019 +oid sha256:cdf7b194a075902ab9434e272865293535ef39370fbb9cb172b3cf8774850c73 +size 19104 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_5_en.png index c74f159222..ceb1513af6 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fbab6f0cf6e268e16a631183b05b0c463c22d1ebff2358085f12661f98893c02 -size 14598 +oid sha256:8988c700db517eef71d7f42c8e21ac819f51c92fb88c0c25cb400be7a5326c22 +size 19228 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_6_en.png deleted file mode 100644 index 8338af3af2..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_6_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bb05d4d4481b1177542a7d5f26a0a9a98e718e20848f08132786073594c19d1a -size 23068 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_7_en.png deleted file mode 100644 index 014429e6b9..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_7_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:804cd2bee1d48ec05a248744de906b659e2a6c62c4d3cf4a90ee6cd2689f2081 -size 25758 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_0_en.png index 953e1db0f9..eed60f472d 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9d73cdc2be6545d09165a27d77de3204f1a491c04fc6651913ef3b76b2060339 -size 10548 +oid sha256:da601c01dd487f9c66f78ada91954398e1dcdf699f1ba4f6d8f7661f7b8cc4b7 +size 18715 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_1_en.png index 81fc808eaf..6c424a1ffe 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b9b8967a596dcae62399e953af359caa96a0252ba4d4d63b3eaa5c0afb7f61dd -size 27750 +oid sha256:6a62d7b4716f97f73dddd22fc3ecad30ef159da186ff2f2029772f4574a4f474 +size 36084 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_2_en.png index 4c0ec571a5..72196c0b11 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a8a950a9808be5828a98a0c84a89a6d5fd08a57e22c50f09de8f48f3cb50309d -size 26337 +oid sha256:10d95b146c51895a0e9e816cf56aa216dfbf77e74ae3da10f9c3fa94468ba9ed +size 34500 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_3_en.png index 953e1db0f9..da90a76ab1 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9d73cdc2be6545d09165a27d77de3204f1a491c04fc6651913ef3b76b2060339 -size 10548 +oid sha256:64236eda401891b7a04c9240ed2b9b077b8c08b182b76f454cf0a4376daa740a +size 30345 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_4_en.png index fd49e1b5a8..eed60f472d 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f258316f364bdf365dd71caa913baa22dce9db599233e02ddca73b9ac4bbc6c7 -size 10666 +oid sha256:da601c01dd487f9c66f78ada91954398e1dcdf699f1ba4f6d8f7661f7b8cc4b7 +size 18715 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_5_en.png index 1cb2452f75..d3ee3b9e22 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ac7478f8dbb8195bce194d50008fc9d7dc80d9f40b1d9b2b22e8ddd517c2be5 -size 13941 +oid sha256:ca86fd5eea8c05a52fee801e1ca61c2e4e205ad9874e06d69b1d1f674585f87b +size 18842 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_6_en.png deleted file mode 100644 index 4bd7b317bd..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_6_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:29c49cbcaf41b41b7924a57e6d6652e7dd5e5bf90b39815f839c9611cb6237eb -size 22205 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_7_en.png deleted file mode 100644 index 12c581dd09..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_7_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bb1255327a2ae18b6dc4117e13b2d1e516eec023ef679034c079cddc9c5a4636 -size 24775 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png index 9d11f9e61c..57b0b89912 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0731766e54bdee0fcf7b7ca6c5066fc7a2d17f0c18afad3a5b64d5fea9ac95cc -size 148332 +oid sha256:b2dea1019d3de891dd47d0d5e3b1deefeb0938be82afb72b0dc77c1f14596553 +size 144841 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png new file mode 100644 index 0000000000..57b0b89912 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2dea1019d3de891dd47d0d5e3b1deefeb0938be82afb72b0dc77c1f14596553 +size 144841 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png index 4d766b1ee2..74add72a23 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2411192232cdd0f359e446768f052cd1675495afdf1d82526343fc8fa42880f9 -size 62469 +oid sha256:9305cafe7cca68c9307577533f3b489566c669cbd4fc08c724c8f492bbd75573 +size 58482 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png new file mode 100644 index 0000000000..74add72a23 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9305cafe7cca68c9307577533f3b489566c669cbd4fc08c724c8f492bbd75573 +size 58482 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png index 84cabe4409..bf6cbaadcb 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:29e50867fd6e64234317766463d3428187a5171215fb5786f6b16d288cc97692 -size 364448 +oid sha256:6d7b628cc474a421869207c8ffd86e46c193da3e1fe8ea115fb12574528da933 +size 364172 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png index 4195637aa5..38499a5838 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:91646548d795c159b94d909276a80ee44ba83b0c95b9d76b2669b6cb4a9f8880 -size 362696 +oid sha256:8b9a9264b6637a35096b2f552a2dbe9f6fa6a078bb631c17bf1d207e10928f07 +size 362463 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_10_en.png index c8bbd3dcd1..ce251301d9 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ffe8ceb923b63331d4b49bcb4d69793f6ce4f82be6e54b7d3470952b35a2da73 -size 332417 +oid sha256:877e252ee1e0c35efef1854bf6c94783add9138148a9e910bc7908ee7228209b +size 88375 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_11_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_11_en.png index ce251301d9..3907bdf959 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:877e252ee1e0c35efef1854bf6c94783add9138148a9e910bc7908ee7228209b -size 88375 +oid sha256:6e157c0f0f115133f36dee9c1f1647fb8f00813a12783b18fa83c6972e92e037 +size 51116 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_12_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_12_en.png index 3907bdf959..3ea50dcbe1 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6e157c0f0f115133f36dee9c1f1647fb8f00813a12783b18fa83c6972e92e037 -size 51116 +oid sha256:00be45a05243c429344206d132d0fdbc6322c25c3c71dcab61f939070dd015d3 +size 63156 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_13_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_13_en.png index 3ea50dcbe1..b9da99e5c2 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:00be45a05243c429344206d132d0fdbc6322c25c3c71dcab61f939070dd015d3 -size 63156 +oid sha256:8a19d67b394c682e329bc232b0a36c5015dd73d09931b7e401c5d306f922f026 +size 47404 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_14_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_14_en.png index b9da99e5c2..bd22123444 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_14_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_14_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a19d67b394c682e329bc232b0a36c5015dd73d09931b7e401c5d306f922f026 -size 47404 +oid sha256:205d1785959ccc932b6f307bbe8fc86466eeab2be83ce80b265cb54bf75cdfe3 +size 64392 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_15_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_15_en.png index bd22123444..cdd4607e18 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_15_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_15_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:205d1785959ccc932b6f307bbe8fc86466eeab2be83ce80b265cb54bf75cdfe3 -size 64392 +oid sha256:4384708192ccd7ef69601ba4a37b491d1959f8ec84c3e70ceba148d716347952 +size 54368 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_16_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_16_en.png index cdd4607e18..32a9dd93d2 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_16_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_16_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4384708192ccd7ef69601ba4a37b491d1959f8ec84c3e70ceba148d716347952 -size 54368 +oid sha256:8c3ebe62e38381e25e85c0be666ab6380811d2fbbea58461b917a0af4c6cb4d8 +size 64322 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png index 32a9dd93d2..b3255ab5b5 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8c3ebe62e38381e25e85c0be666ab6380811d2fbbea58461b917a0af4c6cb4d8 -size 64322 +oid sha256:8a87e01d7a14fa40b73c76af0e80c4f2b43ec8cd2f40cb82ef3e321d54f6f341 +size 374820 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_10_en.png index 99d8ed3cd9..80dcc6a601 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9a69c582e4e05d2555ffe5e5aaa190eb216a48aa408623a518b0238dcf70a456 -size 148001 +oid sha256:0e5dddb25a8a201e5334566c90a252111e0d8a65c9949e4f0b7d3d66b3a4103c +size 83890 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_11_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_11_en.png index 80dcc6a601..7f114a43f2 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0e5dddb25a8a201e5334566c90a252111e0d8a65c9949e4f0b7d3d66b3a4103c -size 83890 +oid sha256:f225e76a780b6a3eccc66c720f710564b82774250a3f229367b12cdb390928a4 +size 50876 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_12_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_12_en.png index 7f114a43f2..161bc26f01 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f225e76a780b6a3eccc66c720f710564b82774250a3f229367b12cdb390928a4 -size 50876 +oid sha256:ed0df44ce25f2a9f7e75d49c48a805f62bbf61f1f63ecee7f29e34f9bcae0e70 +size 61888 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_13_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_13_en.png index 161bc26f01..d74c89c6aa 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ed0df44ce25f2a9f7e75d49c48a805f62bbf61f1f63ecee7f29e34f9bcae0e70 -size 61888 +oid sha256:4eeddbe4e53cdf4453db6a7bd0257a52d46cfcac762da73319cf8735aedce481 +size 47422 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_14_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_14_en.png index d74c89c6aa..6ddd6f5648 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_14_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_14_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4eeddbe4e53cdf4453db6a7bd0257a52d46cfcac762da73319cf8735aedce481 -size 47422 +oid sha256:143ae0c737a60e42370c10cdb39c577b91a151fa69079843e740b9edc7c5fc67 +size 63288 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_15_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_15_en.png index 6ddd6f5648..1cff467cbe 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_15_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_15_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:143ae0c737a60e42370c10cdb39c577b91a151fa69079843e740b9edc7c5fc67 -size 63288 +oid sha256:4474b2c524768d2493b7ffd6c05b43f486c595c23f1ea641ba7704fad7181d0f +size 53792 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_16_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_16_en.png index 1cff467cbe..ba24c369e1 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_16_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_16_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4474b2c524768d2493b7ffd6c05b43f486c595c23f1ea641ba7704fad7181d0f -size 53792 +oid sha256:f7e7905c6765db72a06c0dba835e34556377fb014476f0ebb2a68a6b93d05314 +size 64889 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png index ba24c369e1..fc5e6d8e98 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f7e7905c6765db72a06c0dba835e34556377fb014476f0ebb2a68a6b93d05314 -size 64889 +oid sha256:3ac7741cfc4ec1d8fd9c8c5bab7dc3dcc0ba79932b5a1ab325573b7dd3622c4c +size 153022 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_LocationPin_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_LocationPin_Day_0_en.png new file mode 100644 index 0000000000..8dda833412 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_LocationPin_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f3df9c9de000b15692b982c7d69f85ce3d03cab468c342773c88eff3b4fca65 +size 9082 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_LocationPin_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_LocationPin_Night_0_en.png new file mode 100644 index 0000000000..dac3588ed9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_LocationPin_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9767298a096be7fc78210245e6b806a72a6c1ad16d7335e0f89728bbcf08ebd4 +size 15884 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_PinIcon_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_PinIcon_Day_0_en.png deleted file mode 100644 index f758a309b4..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_PinIcon_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d3ca357675ca4328041237cb4b9d8a5725c2a37007e242395742fc8f65b4bec9 -size 4732 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_PinIcon_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_PinIcon_Night_0_en.png deleted file mode 100644 index b174bdbfdd..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_PinIcon_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:25bb33b880f2ce4eafcb2a34ac1a6cf806da8820caa95c46883a525b50c784fe -size 4731 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_AttachmentThumbnail_Day_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_AttachmentThumbnail_Day_6_en.png index 28bf34a5a4..54ad34decf 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_AttachmentThumbnail_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_AttachmentThumbnail_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c5a90fc9dc608cc080c473c127289aba3a75c1b04b7353cdf053a625ab45aaf -size 4957 +oid sha256:9a0f68b963d169fead1b012792aeaa890cfa1631370b6f276e39e5c23febebda +size 4749 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_AttachmentThumbnail_Night_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_AttachmentThumbnail_Night_6_en.png index 9316c24d4c..1a2ae3b794 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_AttachmentThumbnail_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_AttachmentThumbnail_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a56fa04f2d2e98a0a9147fae158e7921ee533290feb6e08b3111fdb4c6d21c43 -size 4939 +oid sha256:6e787d1d35de4d2473ae5b59a8c77c129bb6d8baed05e8dd243edaafb7038639 +size 4795 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_InReplyToView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_InReplyToView_Day_8_en.png index c0a170e9de..789e6ad754 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_InReplyToView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_InReplyToView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e34ddf667db8e18e9554f476cf120766c3882ea8155818ffae3a313c4b88d433 -size 9048 +oid sha256:936f352e9c70919e2f5d325dee2eb3048b625e77bbdc0ac1685d023c131a7cb2 +size 8977 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_InReplyToView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_InReplyToView_Night_8_en.png index 2840f32904..8220e6b24a 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_InReplyToView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_InReplyToView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:afcaa0043c17025cbacaf6964ba681a11949ae1241da30aa6a6ab8ebcf3cd19e -size 8839 +oid sha256:8799ed0f6c481130b4f1e3b5ff2c47148e84d5d26aa94559643b82f6db5a3521 +size 8756 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png index 63fee25873..f7398ae396 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:331388711afc99265e651897c04df501f2de777fd0c8e4b084f67fce2a2a1c94 -size 66937 +oid sha256:15256a6b4d1fe8e52080b9726cde401944cc6941f7527db1d18484c86ec88023 +size 66699 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png index 8b189dd1c7..9bfed4fc61 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2bd8f39629ae22d9226f9d759b73a1db9d1d53a80923c835bef6bd403e45e737 -size 64060 +oid sha256:d7c95c91b7c6b04f2851df6a64de1b10c7338a4d46921a90037fe50cd7d49262 +size 63957 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png index fd37f7ae83..8e397b024e 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:de8ffda35666fc34ba5521673af2318ba162a442574005b5062afc17172f4bf5 -size 67414 +oid sha256:27c823be03e6721a4034cdc4827a4d3dd34ac3e9d567651a8ce125cef435b57e +size 67151 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png index 203dcc6280..0654617bd7 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0514f7ceb74b446203f36cf6af6b1da3e8f0a55f7e636a0341b2703201d6390e -size 64529 +oid sha256:947ddd34efb97e8baad91a1c2865105190a7be599327a271db0466f4a86bf092 +size 64326 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index d9c5e088e1..d3e44b7c08 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -409,6 +409,13 @@ "screen\\.security_and_privacy\\..*", "screen\\.manage_authorized_spaces\\..*" ] + }, + { + "name" : ":features:location:impl", + "includeRegex" : [ + "screen\\.share_location\\..*", + "screen\\.view_location\\..*" + ] } ] }