First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
/*
* 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.
*/
import config.BuildTimeConfig
import extension.buildConfigFieldStr
import extension.readLocalProperty
import extension.testCommonDependencies
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.location.api"
buildFeatures {
buildConfig = true
}
defaultConfig {
buildConfigFieldStr(
name = "MAPTILER_BASE_URL",
value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL ?: "https://api.maptiler.com/maps"
)
buildConfigFieldStr(
name = "MAPTILER_API_KEY",
value = if (isEnterpriseBuild) {
BuildTimeConfig.SERVICES_MAPTILER_APIKEY
} else {
System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY")
?: readLocalProperty("services.maptiler.apikey")
}
?: ""
)
buildConfigFieldStr(
name = "MAPTILER_LIGHT_MAP_ID",
value = if (isEnterpriseBuild) {
BuildTimeConfig.SERVICES_MAPTILER_LIGHT_MAPID
} else {
System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID")
?: readLocalProperty("services.maptiler.lightMapId")
}
// fall back to maptiler's default light map.
?: "basic-v2"
)
buildConfigFieldStr(
name = "MAPTILER_DARK_MAP_ID",
value = if (isEnterpriseBuild) {
BuildTimeConfig.SERVICES_MAPTILER_DARK_MAPID
} else {
System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID")
?: readLocalProperty("services.maptiler.darkMapId")
}
// fall back to maptiler's default dark map.
?: "basic-v2-dark"
)
}
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
implementation(libs.coil.compose)
testCommonDependencies(libs)
}
@@ -0,0 +1,38 @@
/*
* 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.api
import android.annotation.SuppressLint
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
private const val GEO_URI_REGEX = """geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitude>-?\d+(?:\.\d+)?)(?:;u=(?<uncertainty>\d+(?:\.\d+)?))?"""
@SuppressLint("NewApi")
@Parcelize
data class Location(
val lat: Double,
val lon: Double,
val accuracy: Float,
) : Parcelable {
companion object {
fun fromGeoUri(geoUri: String): Location? {
val result = Regex(GEO_URI_REGEX).matchEntire(geoUri) ?: return null
return Location(
lat = result.groups["latitude"]?.value?.toDoubleOrNull() ?: return null,
lon = result.groups["longitude"]?.value?.toDoubleOrNull() ?: return null,
accuracy = result.groups["uncertainty"]?.value?.toFloatOrNull() ?: 0f,
)
}
}
fun toGeoUri(): String {
return "geo:$lat,$lon;u=$accuracy"
}
}
@@ -0,0 +1,13 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.api
interface LocationService {
fun isServiceAvailable(): Boolean
}
@@ -0,0 +1,27 @@
/*
* 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.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.timeline.Timeline
/**
* The "Send location" screen.
*
* Allows a user to share a location message within a room.
*/
interface SendLocationEntryPoint : FeatureEntryPoint {
fun createNode(
parentNode: Node,
buildContext: BuildContext,
timelineMode: Timeline.Mode,
): Node
}
@@ -0,0 +1,27 @@
/*
* 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.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
interface ShowLocationEntryPoint : FeatureEntryPoint {
data class Inputs(
val location: Location,
val description: String?,
) : NodeInputs
fun createNode(
parentNode: Node,
buildContext: BuildContext,
inputs: Inputs,
): Node
}
@@ -0,0 +1,132 @@
/*
* 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.api
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import coil3.Extras
import coil3.compose.AsyncImagePainter
import coil3.compose.rememberAsyncImagePainter
import coil3.request.ImageRequest
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.api.internal.StaticMapPlaceholder
import io.element.android.features.location.api.internal.StaticMapUrlBuilder
import io.element.android.features.location.api.internal.centerBottomEdge
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.utils.CommonDrawables
/**
* Shows a static map image downloaded via a third party service's static maps API.
*/
@Composable
fun StaticMapView(
lat: Double,
lon: Double,
zoom: Double,
contentDescription: String?,
modifier: Modifier = Modifier,
darkMode: Boolean = !ElementTheme.isLightTheme,
) {
// Using BoxWithConstraints to:
// 1) Size the inner Image to the same Dp size of the outer BoxWithConstraints.
// 2) Request the static map image of the exact required size in Px to fill the AsyncImage.
BoxWithConstraints(
modifier = modifier,
contentAlignment = Alignment.Center
) {
val context = LocalContext.current
var retryHash by remember { mutableIntStateOf(0) }
val builder = remember { StaticMapUrlBuilder() }
val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) {
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).
null
} else {
ImageRequest.Builder(context)
.data(
builder.build(
lat = lat,
lon = lon,
zoom = zoom,
darkMode = darkMode,
width = constraints.maxWidth,
height = constraints.maxHeight,
density = LocalDensity.current.density,
)
)
.size(width = constraints.maxWidth, height = constraints.maxHeight)
.apply {
extras.set(Extras.Key("retry_hash"), retryHash).build()
}
.build()
}
)
val collectedState = painter.state.collectAsState()
if (collectedState.value is AsyncImagePainter.State.Success) {
Image(
painter = painter,
contentDescription = contentDescription,
modifier = Modifier.size(width = maxWidth, height = maxHeight),
// The returned image can be smaller than the requested size due to the static maps API having
// a max width and height of 2048 px. See buildStaticMapsApiUrl() for more details.
// We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case.
contentScale = ContentScale.Fit,
)
Icon(
resourceId = CommonDrawables.pin,
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.centerBottomEdge(this),
)
} else {
StaticMapPlaceholder(
showProgress = collectedState.value.isLoading(),
canReload = builder.isServiceAvailable(),
contentDescription = contentDescription,
width = maxWidth,
height = maxHeight,
onLoadMapClick = { retryHash++ }
)
}
}
}
private fun AsyncImagePainter.State.isLoading(): Boolean {
return this is AsyncImagePainter.State.Empty ||
this is AsyncImagePainter.State.Loading
}
@PreviewsDayNight
@Composable
internal fun StaticMapViewPreview() = ElementPreview {
StaticMapView(
lat = 0.0,
lon = 0.0,
zoom = 0.0,
contentDescription = null,
modifier = Modifier.size(400.dp),
)
}
@@ -0,0 +1,88 @@
/*
* 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.api.internal
import io.element.android.features.location.api.BuildConfig
import kotlin.math.roundToInt
/**
* Builds an URL for MapTiler's Static Maps API.
*
* https://docs.maptiler.com/cloud/api/static-maps/
*/
internal class MapTilerStaticMapUrlBuilder(
private val baseUrl: String,
private val apiKey: String,
private val lightMapId: String,
private val darkMapId: String,
) : StaticMapUrlBuilder {
constructor() : this(
baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"),
apiKey = BuildConfig.MAPTILER_API_KEY,
lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
)
override fun build(
lat: Double,
lon: Double,
zoom: Double,
darkMode: Boolean,
width: Int,
height: Int,
density: Float
): String {
val mapId = if (darkMode) darkMapId else lightMapId
val finalZoom = zoom.coerceIn(zoomRange)
// Request @2x density for xhdpi and above (xhdpi == 320dpi == 2x density).
val is2x = density >= 2
// Scale requested width/height according to the reported display density.
val (finalWidth, finalHeight) = coerceWidthAndHeight(
width = (width / density).roundToInt(),
height = (height / density).roundToInt(),
is2x = is2x,
)
val scale = if (is2x) "@2x" else ""
// Since Maptiler doesn't support arbitrary dpi scaling, we stick to 2x sized
// images even on displays with density higher than 2x, thereby yielding an
// image smaller than the available space in pixels.
// The resulting image will have to be scaled to fit the available space in order
// to keep the perceived content size constant at the expense of sharpness.
return "$baseUrl/$mapId/static/$lon,$lat,$finalZoom/${finalWidth}x${finalHeight}$scale.webp?key=$apiKey&attribution=bottomleft"
}
override fun isServiceAvailable() = apiKey.isNotEmpty()
}
private fun coerceWidthAndHeight(width: Int, height: Int, is2x: Boolean): Pair<Int, Int> {
if (width <= 0 || height <= 0) {
// This effectively yields an URL which asks for a 0x0 image which will result in an HTTP error,
// but it's better than e.g. asking for a 1x1 image which would be unreadable and increase usage costs.
return 0 to 0
}
val aspectRatio = width.toDouble() / height.toDouble()
val range = if (is2x) widthHeightRange2x else widthHeightRange
return if (width >= height) {
width.coerceIn(range).let { coercedWidth ->
coercedWidth to (coercedWidth / aspectRatio).roundToInt()
}
} else {
height.coerceIn(range).let { coercedHeight ->
(coercedHeight * aspectRatio).roundToInt() to coercedHeight
}
}
}
private val widthHeightRange = 1..2048 // API will error if outside 1-2048 range @1x.
private val widthHeightRange2x = 1..1024 // API will error if outside 1-1024 range @2x.
private val zoomRange = 0.0..22.0 // API will error if outside 0-22 range.
@@ -0,0 +1,32 @@
/*
* 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:JvmName("TileServerStyleUriBuilderKt")
package io.element.android.features.location.api.internal
import io.element.android.features.location.api.BuildConfig
internal class MapTilerTileServerStyleUriBuilder(
private val baseUrl: String,
private val apiKey: String,
private val lightMapId: String,
private val darkMapId: String,
) : TileServerStyleUriBuilder {
constructor() : this(
baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"),
apiKey = BuildConfig.MAPTILER_API_KEY,
lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
)
override fun build(darkMode: Boolean): String {
val mapId = if (darkMode) darkMapId else lightMapId
return "$baseUrl/$mapId/style.json?key=$apiKey"
}
}
@@ -0,0 +1,28 @@
/*
* 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.api.internal
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
/**
* Horizontally aligns the content to the center of the space.
* Vertically aligns the bottom edge of the content to the center of the space.
*/
fun Modifier.centerBottomEdge(scope: BoxScope): Modifier = this.then(
with(scope) {
Modifier.align { size, space, _ ->
IntOffset(
x = (space.width - size.width) / 2,
y = space.height / 2 - size.height,
)
}
}
)
@@ -0,0 +1,95 @@
/*
* 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.api.internal
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.location.api.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.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun StaticMapPlaceholder(
showProgress: Boolean,
canReload: Boolean,
contentDescription: String?,
width: Dp,
height: Dp,
onLoadMapClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.size(width = width, height = height)
.then(if (showProgress) Modifier else Modifier.clickable(onClick = onLoadMapClick))
) {
Image(
painter = painterResource(id = R.drawable.blurred_map),
contentDescription = contentDescription,
contentScale = ContentScale.FillBounds,
modifier = Modifier.size(width = width, height = height)
)
if (showProgress) {
CircularProgressIndicator()
} else if (canReload) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = CompoundIcons.Restart(),
contentDescription = null
)
Text(text = stringResource(id = CommonStrings.action_static_map_load))
}
}
}
}
@PreviewsDayNight
@Composable
internal fun StaticMapPlaceholderPreview() = ElementPreview {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf(
true to false,
false to true,
false to false,
).forEach { (showProgress, canReload) ->
StaticMapPlaceholder(
showProgress = showProgress,
canReload = canReload,
contentDescription = null,
width = 400.dp,
height = 200.dp,
onLoadMapClick = {},
)
}
}
}
@@ -0,0 +1,28 @@
/*
* 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.api.internal
/**
* Builds an URL for a 3rd party service provider static maps API.
*/
interface StaticMapUrlBuilder {
fun build(
lat: Double,
lon: Double,
zoom: Double,
darkMode: Boolean,
width: Int,
height: Int,
density: Float,
): String
fun isServiceAvailable(): Boolean
}
fun StaticMapUrlBuilder(): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder()
@@ -0,0 +1,39 @@
/*
* 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.api.internal
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import io.element.android.compound.theme.ElementTheme
/**
* Builds a style URI for a MapLibre compatible tile server.
*
* Used for rendering dynamic maps.
*/
interface TileServerStyleUriBuilder {
fun build(
darkMode: Boolean,
): String
}
fun TileServerStyleUriBuilder(): TileServerStyleUriBuilder = MapTilerTileServerStyleUriBuilder()
/**
* Provides and remembers a style URI for a MapLibre compatible tile server.
*
* Used for rendering dynamic maps.
*/
@Composable
fun rememberTileStyleUrl(): String {
val darkMode = !ElementTheme.isLightTheme
return remember(darkMode) {
TileServerStyleUriBuilder().build(darkMode)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

@@ -0,0 +1,75 @@
/*
* 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.api
import com.google.common.truth.Truth.assertThat
import org.junit.Test
internal class LocationKtTest {
@Test
fun `parseGeoUri - returns null for invalid urls`() {
assertThat(Location.fromGeoUri("")).isNull()
assertThat(Location.fromGeoUri("http://example.com/")).isNull()
assertThat(Location.fromGeoUri("geo:")).isNull()
assertThat(Location.fromGeoUri("geo:1.234")).isNull()
assertThat(Location.fromGeoUri("geo:1.234,")).isNull()
assertThat(Location.fromGeoUri("geo:,1.234")).isNull()
assertThat(Location.fromGeoUri("notgeo:1.234,5.678")).isNull()
assertThat(Location.fromGeoUri("geo:+1.234,5.678")).isNull()
assertThat(Location.fromGeoUri("geo:+1.234,*5.678")).isNull()
assertThat(Location.fromGeoUri("geo:not,good")).isNull()
assertThat(Location.fromGeoUri("geo:1.234,5.678;u=wrong")).isNull()
assertThat(Location.fromGeoUri("geo:1.234,5.678trailing")).isNull()
}
@Test
fun `parseGeoUri - returns location for valid urls`() {
assertThat(Location.fromGeoUri("geo:1.234,5.678")).isEqualTo(Location(
lat = 1.234,
lon = 5.678,
accuracy = 0f,
))
assertThat(Location.fromGeoUri("geo:1,5")).isEqualTo(Location(
lat = 1.0,
lon = 5.0,
accuracy = 0f,
))
assertThat(Location.fromGeoUri("geo:1.234,5.678;u=3000")).isEqualTo(Location(
lat = 1.234,
lon = 5.678,
accuracy = 3000f,
))
assertThat(Location.fromGeoUri("geo:1,5;u=3000")).isEqualTo(Location(
lat = 1.0,
lon = 5.0,
accuracy = 3000f,
))
assertThat(Location.fromGeoUri("geo:-1.234,-5.678;u=9.10")).isEqualTo(Location(
lat = -1.234,
lon = -5.678,
accuracy = 9.10f,
))
assertThat(Location.fromGeoUri("geo:-1,-5;u=9.10")).isEqualTo(Location(
lat = -1.0,
lon = -5.0,
accuracy = 9.10f,
))
}
@Test
fun `encode geoUri - returns geoUri from a Location`() {
assertThat(Location(1.0, 2.0, 3.0f).toGeoUri())
.isEqualTo("geo:1.0,2.0;u=3.0")
}
}
@@ -0,0 +1,199 @@
/*
* 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.api.internal
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class MapTilerStaticMapUrlBuilderTest {
private val builder = MapTilerStaticMapUrlBuilder(
baseUrl = "https://base.url",
apiKey = "anApiKey",
lightMapId = "aLightMapId",
darkMapId = "aDarkMapId",
)
@Test
fun `isServiceAvailable returns true if api key is not empty`() {
assertThat(builder.isServiceAvailable()).isTrue()
}
@Test
fun `isServiceAvailable returns false if api key is empty`() {
val builderWithoutKey = MapTilerStaticMapUrlBuilder(
baseUrl = "https://base.url",
apiKey = "",
lightMapId = "aLightMapId",
darkMapId = "aDarkMapId",
)
assertThat(builderWithoutKey.isServiceAvailable()).isFalse()
}
@Test
fun `static map 1x density`() {
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 800,
height = 600,
density = 1f,
)
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
}
@Test
fun `static map 1,5x density`() {
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 1200,
height = 900,
density = 1.5f,
)
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
}
@Test
fun `static map 2x density`() {
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 1600,
height = 1200,
density = 2f,
)
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
}
@Test
fun `static map 3x density`() {
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 2400,
height = 1800,
density = 3f,
)
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
}
@Test
fun `too big image is coerced keeping aspect ratio`() {
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 4096,
height = 2048,
density = 1f,
)
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/2048x1024.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 2048,
height = 4096,
density = 1f,
)
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x2048.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 4096,
height = 2048,
density = 2f,
)
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x512@2x.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 2048,
height = 4096,
density = 2f,
)
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/512x1024@2x.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = Int.MAX_VALUE,
height = Int.MAX_VALUE,
density = 2f,
)
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x1024@2x.webp?key=anApiKey&attribution=bottomleft")
}
@Test
fun `too small image is coerced to 0x0`() {
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 0,
height = 0,
density = 1f,
)
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 0,
height = 0,
density = 2f,
)
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0@2x.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = Int.MIN_VALUE,
height = Int.MIN_VALUE,
density = 1f,
)
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
}
}
@@ -0,0 +1,35 @@
/*
* 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.api.internal
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class MapTilerTileServerStyleUriBuilderTest {
private val builder = MapTilerTileServerStyleUriBuilder(
baseUrl = "https://base.url",
apiKey = "anApiKey",
lightMapId = "aLightMapId",
darkMapId = "aDarkMapId",
)
@Test
fun `light map uri`() {
assertThat(
builder.build(darkMode = false)
).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey")
}
@Test
fun `dark map uri`() {
assertThat(
builder.build(darkMode = true)
).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey")
}
}
+48
View File
@@ -0,0 +1,48 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2024 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")
}
android {
namespace = "io.element.android.features.location.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupDependencyInjection()
dependencies {
api(projects.features.location.api)
implementation(projects.features.messages.api)
implementation(projects.libraries.maplibreCompose)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.di)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.androidutils)
implementation(projects.services.analytics.api)
implementation(libs.accompanist.permission)
implementation(projects.libraries.uiStrings)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.testtags)
testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test)
}
@@ -0,0 +1,12 @@
<!--
~ Copyright (c) 2025 Element Creations Ltd.
~ Copyright 2023 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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
</manifest>
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.location.api.BuildConfig
import io.element.android.features.location.api.LocationService
@ContributesBinding(AppScope::class)
class DefaultLocationService : LocationService {
override fun isServiceAvailable(): Boolean {
return BuildConfig.MAPTILER_API_KEY.isNotEmpty()
}
}
@@ -0,0 +1,66 @@
/*
* 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 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
/**
* 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 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()
const val DEFAULT_ZOOM = 15.0
val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
}
@@ -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.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),
)
}
@@ -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.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),
)
}
@@ -0,0 +1,58 @@
/*
* 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.actions
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.annotation.VisibleForTesting
import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.location.api.Location
import io.element.android.libraries.androidutils.system.openAppSettingsPage
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.ApplicationContext
import timber.log.Timber
import java.util.Locale
@ContributesBinding(AppScope::class)
class AndroidLocationActions(
@ApplicationContext private val context: Context
) : LocationActions {
override fun share(location: Location, label: String?) {
runCatchingExceptions {
val uri = buildUrl(location, label).toUri()
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
val chooserIntent = Intent.createChooser(showMapsIntent, null)
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(chooserIntent)
}.onSuccess {
Timber.v("Open location succeed")
}.onFailure {
Timber.e(it, "Open location failed")
}
}
override fun openSettings() {
context.openAppSettingsPage()
}
}
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap
@VisibleForTesting
internal fun buildUrl(
location: Location,
label: String?,
urlEncoder: (String) -> String = Uri::encode
): String {
// This is needed so the coordinates are formatted with a dot as decimal separator
val locale = Locale.ENGLISH
return "geo:0,0?q=%.6f,%.6f (%s)".format(locale, location.lat, location.lon, urlEncoder(label.orEmpty()))
}
@@ -0,0 +1,16 @@
/*
* 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.actions
import io.element.android.features.location.api.Location
interface LocationActions {
fun share(location: Location, label: String?)
fun openSettings()
}
@@ -0,0 +1,53 @@
/*
* 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.permissions
import androidx.compose.runtime.Composable
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesBinding
@Suppress("unused")
@AssistedInject
class DefaultPermissionsPresenter(
@Assisted private val permissions: List<String>
) : PermissionsPresenter {
@AssistedFactory
@ContributesBinding(AppScope::class)
interface Factory : PermissionsPresenter.Factory {
override fun create(permissions: List<String>): DefaultPermissionsPresenter
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
override fun present(): PermissionsState {
val multiplePermissionsState = rememberMultiplePermissionsState(permissions = permissions)
fun handleEvent(event: PermissionsEvents) {
when (event) {
PermissionsEvents.RequestPermissions -> multiplePermissionsState.launchMultiplePermissionRequest()
}
}
return PermissionsState(
permissions = when {
multiplePermissionsState.allPermissionsGranted -> PermissionsState.Permissions.AllGranted
multiplePermissionsState.permissions.any { it.status.isGranted } -> PermissionsState.Permissions.SomeGranted
else -> PermissionsState.Permissions.NoneGranted
},
shouldShowRationale = multiplePermissionsState.shouldShowRationale,
eventSink = ::handleEvent,
)
}
}
@@ -0,0 +1,13 @@
/*
* 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.permissions
sealed interface PermissionsEvents {
data object RequestPermissions : PermissionsEvents
}
@@ -0,0 +1,17 @@
/*
* 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.permissions
import io.element.android.libraries.architecture.Presenter
interface PermissionsPresenter : Presenter<PermissionsState> {
fun interface Factory {
fun create(permissions: List<String>): PermissionsPresenter
}
}
@@ -0,0 +1,24 @@
/*
* 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.permissions
data class PermissionsState(
val permissions: Permissions,
val shouldShowRationale: Boolean,
val eventSink: (PermissionsEvents) -> Unit,
) {
sealed interface Permissions {
data object AllGranted : Permissions
data object SomeGranted : Permissions
data object NoneGranted : Permissions
}
val isAnyGranted: Boolean
get() = permissions is Permissions.SomeGranted || permissions is Permissions.AllGranted
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.ui
import androidx.compose.foundation.layout.size
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Ref: See design in https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=3426-141111
*/
@Composable
internal fun LocationFloatingActionButton(
isMapCenteredOnUser: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
FloatingActionButton(
shape = FloatingActionButtonDefaults.smallShape,
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),
) {
val iconImage = if (isMapCenteredOnUser) {
CompoundIcons.LocationNavigatorCentred()
} else {
CompoundIcons.LocationNavigator()
}
Icon(
imageVector = iconImage,
contentDescription = stringResource(CommonStrings.a11y_move_the_map_to_my_location),
)
}
}
@@ -0,0 +1,31 @@
/*
* 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 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.libraries.architecture.createNode
import io.element.android.libraries.matrix.api.timeline.Timeline
@ContributesBinding(AppScope::class)
class DefaultSendLocationEntryPoint : SendLocationEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
timelineMode: Timeline.Mode,
): Node {
return parentNode.createNode<SendLocationNode>(
buildContext = buildContext,
plugins = listOf(SendLocationNode.Inputs(timelineMode))
)
}
}
@@ -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.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
}
@@ -0,0 +1,57 @@
/*
* 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.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class)
@AssistedInject
class SendLocationNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: SendLocationPresenter.Factory,
analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val timelineMode: Timeline.Mode,
) : NodeInputs
private val presenter = presenterFactory.create(inputs<Inputs>().timelineMode)
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.LocationSend))
}
)
}
@Composable
override fun View(modifier: Modifier) {
SendLocationView(
state = presenter.present(),
modifier = modifier,
navigateUp = ::navigateUp,
)
}
}
@@ -0,0 +1,175 @@
/*
* 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<SendLocationState> {
@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<Timeline> {
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"
@@ -0,0 +1,28 @@
/*
* 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
}
}
@@ -0,0 +1,58 @@
/*
* 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<SendLocationState> {
override val values: Sequence<SendLocationState>
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 = {}
)
}
@@ -0,0 +1,212 @@
/*
* 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 = {},
)
}
@@ -0,0 +1,27 @@
/*
* 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 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.ShowLocationEntryPoint
import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
class DefaultShowLocationEntryPoint : ShowLocationEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
inputs: ShowLocationEntryPoint.Inputs,
): Node {
return parentNode.createNode<ShowLocationNode>(buildContext, listOf(inputs))
}
}
@@ -0,0 +1,17 @@
/*
* 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
}
@@ -0,0 +1,53 @@
/*
* 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 androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class)
@AssistedInject
class ShowLocationNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: ShowLocationPresenter.Factory,
analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.LocationView))
}
)
}
private val inputs: ShowLocationEntryPoint.Inputs = inputs()
private val presenter = presenterFactory.create(inputs.location, inputs.description)
@Composable
override fun View(modifier: Modifier) {
ShowLocationView(
state = presenter.present(),
modifier = modifier,
onBackClick = ::navigateUp
)
}
}
@@ -0,0 +1,93 @@
/*
* 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 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.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.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.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@AssistedInject
class ShowLocationPresenter(
@Assisted private val location: Location,
@Assisted private val description: String?,
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val locationActions: LocationActions,
private val buildMeta: BuildMeta,
) : Presenter<ShowLocationState> {
@AssistedFactory
fun interface Factory {
fun create(location: Location, description: String?): ShowLocationPresenter
}
private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
@Composable
override fun present(): ShowLocationState {
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)
}
LaunchedEffect(permissionsState.permissions) {
if (permissionsState.isAnyGranted) {
permissionDialog = ShowLocationState.Dialog.None
}
}
fun handleEvent(event: ShowLocationEvents) {
when (event) {
ShowLocationEvents.Share -> locationActions.share(location, description)
is ShowLocationEvents.TrackMyLocation -> {
if (event.enabled) {
when {
permissionsState.isAnyGranted -> isTrackMyLocation = true
permissionsState.shouldShowRationale -> permissionDialog = ShowLocationState.Dialog.PermissionRationale
else -> permissionDialog = ShowLocationState.Dialog.PermissionDenied
}
} else {
isTrackMyLocation = false
}
}
ShowLocationEvents.DismissDialog -> permissionDialog = ShowLocationState.Dialog.None
ShowLocationEvents.OpenAppSettings -> {
locationActions.openSettings()
permissionDialog = ShowLocationState.Dialog.None
}
ShowLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
}
}
return ShowLocationState(
permissionDialog = permissionDialog,
location = location,
description = description,
hasLocationPermission = permissionsState.isAnyGranted,
isTrackMyLocation = isTrackMyLocation,
appName = appName,
eventSink = ::handleEvent,
)
}
}
@@ -0,0 +1,27 @@
/*
* 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
data class ShowLocationState(
val permissionDialog: Dialog,
val location: Location,
val description: String?,
val hasLocationPermission: Boolean,
val isTrackMyLocation: Boolean,
val appName: String,
val eventSink: (ShowLocationEvents) -> Unit,
) {
sealed interface Dialog {
data object None : Dialog
data object PermissionRationale : Dialog
data object PermissionDenied : Dialog
}
}
@@ -0,0 +1,62 @@
/*
* 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.api.Location
private const val APP_NAME = "ApplicationName"
class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
override val values: Sequence<ShowLocationState>
get() = sequenceOf(
aShowLocationState(),
aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionDenied,
),
aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionRationale,
),
aShowLocationState(
hasLocationPermission = true,
),
aShowLocationState(
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!",
),
)
}
fun aShowLocationState(
permissionDialog: ShowLocationState.Dialog = ShowLocationState.Dialog.None,
location: Location = Location(1.23, 2.34, 4f),
description: String? = null,
hasLocationPermission: Boolean = false,
isTrackMyLocation: Boolean = false,
appName: String = APP_NAME,
eventSink: (ShowLocationEvents) -> Unit = {},
) = ShowLocationState(
permissionDialog = permissionDialog,
location = location,
description = description,
hasLocationPermission = hasLocationPermission,
isTrackMyLocation = isTrackMyLocation,
appName = appName,
eventSink = eventSink,
)
@@ -0,0 +1,179 @@
/*
* 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 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.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.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.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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShowLocationView(
state: ShowLocationState,
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,
)
}
val cameraPositionState = rememberCameraPositionState {
position = CameraPosition.Builder()
.target(LatLng(state.location.lat, state.location.lon))
.zoom(MapDefaults.DEFAULT_ZOOM)
.build()
}
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
}
}
}
LaunchedEffect(cameraPositionState.isMoving) {
if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) {
state.eventSink(ShowLocationEvents.TrackMyLocation(false))
}
}
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
titleStr = stringResource(CommonStrings.screen_view_location_title),
navigationIcon = {
BackButton(
onClick = onBackClick,
)
},
actions = {
IconButton(
onClick = { state.eventSink(ShowLocationEvents.Share) }
) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = stringResource(CommonStrings.action_share),
)
}
}
)
},
floatingActionButton = {
LocationFloatingActionButton(
isMapCenteredOnUser = state.isTrackMyLocation,
onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) },
)
},
) { 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
@Composable
internal fun ShowLocationViewPreview(@PreviewParameter(ShowLocationStateProvider::class) state: ShowLocationState) = ElementPreview {
ShowLocationView(
state = state,
onBackClick = {},
)
}
private const val PIN_ID = "pin"
@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="28dp"
android:viewportWidth="26"
android:viewportHeight="28">
<path
android:pathData="M12.962,28L9.819,24.889L16.105,24.889L12.962,28Z"
android:fillColor="#EBEEF2"/>
<path
android:pathData="M12.963,12.963m-12.963,0a12.963,12.963 0,1 1,25.926 0a12.963,12.963 0,1 1,-25.926 0"
android:fillColor="#EBEEF2"/>
<group>
<clip-path
android:pathData="M6.74,6.74h12.444v12.444h-12.444z"/>
<path
android:pathData="M12.962,6.74C10.554,6.74 8.606,8.741 8.606,11.215C8.606,13.88 11.357,17.555 12.489,18.955C12.738,19.262 13.192,19.262 13.441,18.955C14.567,17.555 17.318,13.88 17.318,11.215C17.318,8.741 15.37,6.74 12.962,6.74ZM12.962,12.813C12.103,12.813 11.406,12.097 11.406,11.215C11.406,10.333 12.103,9.617 12.962,9.617C13.821,9.617 14.518,10.333 14.518,11.215C14.518,12.097 13.821,12.813 12.962,12.813Z"
android:fillColor="#101317"/>
</group>
</vector>
@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="28dp"
android:viewportWidth="26"
android:viewportHeight="28">
<path
android:pathData="M12.962,28L9.819,24.889L16.105,24.889L12.962,28Z"
android:fillColor="#1B1D22"/>
<path
android:pathData="M12.963,12.963m-12.963,0a12.963,12.963 0,1 1,25.926 0a12.963,12.963 0,1 1,-25.926 0"
android:fillColor="#1B1D22"/>
<group>
<clip-path
android:pathData="M6.74,6.741h12.444v12.444h-12.444z"/>
<path
android:pathData="M12.962,6.741C10.554,6.741 8.606,8.741 8.606,11.215C8.606,13.88 11.357,17.555 12.489,18.955C12.738,19.262 13.192,19.262 13.441,18.955C14.567,17.555 17.318,13.88 17.318,11.215C17.318,8.741 15.37,6.741 12.962,6.741ZM12.962,12.813C12.103,12.813 11.406,12.097 11.406,11.215C11.406,10.333 12.103,9.617 12.962,9.617C13.821,9.617 14.518,10.333 14.518,11.215C14.518,12.097 13.821,12.813 12.962,12.813Z"
android:fillColor="#ffffff"/>
</group>
</vector>
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.BuildConfig
import org.junit.Test
class DefaultLocationServiceTest {
@Test
fun `isServiceAvailable should return value depending on BuildConfig MAPTILER_API_KEY`() {
val locationService = DefaultLocationService()
assertThat(locationService.isServiceAvailable()).isEqualTo(
BuildConfig.MAPTILER_API_KEY.isNotEmpty()
)
}
}
@@ -0,0 +1,22 @@
/*
* 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
import io.element.android.features.location.impl.common.permissions.PermissionsState
fun aPermissionsState(
permissions: PermissionsState.Permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale: Boolean = false,
): PermissionsState {
return PermissionsState(
permissions = permissions,
shouldShowRationale = shouldShowRationale,
eventSink = {},
)
}
@@ -0,0 +1,79 @@
/*
* 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.actions
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import org.junit.Test
import java.net.URLEncoder
import java.util.Locale
internal class AndroidLocationActionsTest {
// We use an Android-native encoder in the actual app, switch to an equivalent JVM one for the tests
private fun urlEncoder(input: String) = URLEncoder.encode(input, "US-ASCII")
@Test
fun `buildUrl - truncates excessive decimals to 6dp`() {
val location = Location(
lat = 1.234567890123,
lon = 123.456789012345,
accuracy = 0f
)
val actual = buildUrl(location, null, ::urlEncoder)
val expected = "geo:0,0?q=1.234568,123.456789 ()"
assertThat(actual).isEqualTo(expected)
}
@Test
fun `buildUrl - appends label if set`() {
val location = Location(
lat = 1.000001,
lon = 2.000001,
accuracy = 0f
)
val actual = buildUrl(location, "point", ::urlEncoder)
val expected = "geo:0,0?q=1.000001,2.000001 (point)"
assertThat(actual).isEqualTo(expected)
}
@Test
fun `buildUrl - URL encodes label`() {
val location = Location(
lat = 1.000001,
lon = 2.000001,
accuracy = 0f
)
val actual = buildUrl(location, "(weird/stuff here)", ::urlEncoder)
val expected = "geo:0,0?q=1.000001,2.000001 (%28weird%2Fstuff+here%29)"
assertThat(actual).isEqualTo(expected)
}
@Test
fun `buildUrl - URL encodes coordinates in locale with comma decimal separator`() {
val location = Location(
lat = 1.000001,
lon = 2.000001,
accuracy = 0f
)
// Set a locale with comma as decimal separator
@Suppress("DEPRECATION")
Locale.setDefault(Locale.Category.FORMAT, Locale("pt", "BR"))
val actual = buildUrl(location, "(weird/stuff here)", ::urlEncoder)
val expected = "geo:0,0?q=1.000001,2.000001 (%28weird%2Fstuff+here%29)"
assertThat(actual).isEqualTo(expected)
}
}
@@ -0,0 +1,31 @@
/*
* 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.actions
import io.element.android.features.location.api.Location
class FakeLocationActions : LocationActions {
var sharedLocation: Location? = null
private set
var sharedLabel: String? = null
private set
var openSettingsInvocationsCount = 0
private set
override fun share(location: Location, label: String?) {
sharedLocation = location
sharedLabel = label
}
override fun openSettings() {
openSettingsInvocationsCount++
}
}
@@ -0,0 +1,35 @@
/*
* 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.permissions
import androidx.compose.runtime.Composable
class FakePermissionsPresenter : PermissionsPresenter {
val events = mutableListOf<PermissionsEvents>()
private fun handleEvent(event: PermissionsEvents) {
events += event
}
private var state = PermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
eventSink = ::handleEvent,
)
set(value) {
field = value.copy(eventSink = ::handleEvent)
}
fun givenState(state: PermissionsState) {
this.state = state
}
@Composable
override fun present(): PermissionsState = state
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.timeline.Timeline
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
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultSendLocationEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultSendLocationEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
SendLocationNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { timelineMode: Timeline.Mode ->
SendLocationPresenter(
permissionsPresenterFactory = { FakePermissionsPresenter() },
room = FakeJoinedRoom(),
timelineMode = timelineMode,
analyticsService = FakeAnalyticsService(),
messageComposerContext = FakeMessageComposerContext(),
locationActions = FakeLocationActions(),
buildMeta = aBuildMeta(),
)
},
analyticsService = FakeAnalyticsService(),
)
}
val timelineMode = Timeline.Mode.Live
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
timelineMode = timelineMode,
)
assertThat(result).isInstanceOf(SendLocationNode::class.java)
assertThat(result.plugins).contains(SendLocationNode.Inputs(timelineMode))
}
}
@@ -0,0 +1,496 @@
/*
* 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<String>): 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<String, String, String?, Int?, AssetType?, EventId?, Result<Unit>> { _, _, _, _, _, _ ->
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<String, String, String?, Int?, AssetType?, EventId?, Result<Unit>> { _, _, _, _, _, _ ->
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<String, String, String?, Int?, AssetType?, EventId?, Result<Unit>> { _, _, _, _, _, _ ->
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")
}
}
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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 androidx.arch.core.executor.testing.InstantTaskExecutorRule
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.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultShowLocationEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultShowLocationEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
ShowLocationNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { location: Location, description: String? ->
ShowLocationPresenter(
permissionsPresenterFactory = { FakePermissionsPresenter() },
locationActions = FakeLocationActions(),
buildMeta = aBuildMeta(),
location = location,
description = description,
)
},
analyticsService = FakeAnalyticsService(),
)
}
val inputs = ShowLocationEntryPoint.Inputs(
location = Location(37.4219983, -122.084, 10f),
description = "My location",
)
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
inputs = inputs,
)
assertThat(result).isInstanceOf(ShowLocationNode::class.java)
assertThat(result.plugins).contains(inputs)
}
}
@@ -0,0 +1,288 @@
/*
* 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 app.cash.molecule.RecompositionMode
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.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.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ShowLocationPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private val fakePermissionsPresenter = FakePermissionsPresenter()
private val fakeLocationActions = FakeLocationActions()
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
private val location = Location(1.23, 4.56, 7.8f)
private val presenter = ShowLocationPresenter(
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permissions: List<String>): PermissionsPresenter = fakePermissionsPresenter
},
locationActions = fakeLocationActions,
buildMeta = fakeBuildMeta,
location = location,
description = A_DESCRIPTION,
)
@Test
fun `emits initial state with no location permission`() = runTest {
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.location).isEqualTo(location)
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.isTrackMyLocation).isFalse()
}
}
@Test
fun `emits initial state location permission denied once`() = runTest {
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.location).isEqualTo(location)
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.isTrackMyLocation).isFalse()
}
}
@Test
fun `emits initial state with location permission`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.location).isEqualTo(location)
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.isTrackMyLocation).isFalse()
}
}
@Test
fun `emits initial state with partial location permission`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.location).isEqualTo(location)
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.isTrackMyLocation).isFalse()
}
}
@Test
fun `uses action to share location`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(ShowLocationEvents.Share)
assertThat(fakeLocationActions.sharedLocation).isEqualTo(location)
assertThat(fakeLocationActions.sharedLabel).isEqualTo(A_DESCRIPTION)
}
}
@Test
fun `centers on user location`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.isTrackMyLocation).isFalse()
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
val trackMyLocationState = awaitItem()
delay(1)
assertThat(trackMyLocationState.hasLocationPermission).isTrue()
assertThat(trackMyLocationState.isTrackMyLocation).isTrue()
// Swipe the map to switch mode
initialState.eventSink(ShowLocationEvents.TrackMyLocation(false))
val trackLocationDisabledState = awaitItem()
assertThat(trackLocationDisabledState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None)
assertThat(trackLocationDisabledState.isTrackMyLocation).isFalse()
assertThat(trackLocationDisabledState.hasLocationPermission).isTrue()
}
}
@Test
fun `rationale dialog dismiss`() = runTest {
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip initial state
val initialState = awaitItem()
// Click on the button to switch mode
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
val trackLocationState = awaitItem()
assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionRationale)
assertThat(trackLocationState.isTrackMyLocation).isFalse()
assertThat(trackLocationState.hasLocationPermission).isFalse()
// Dismiss the dialog
initialState.eventSink(ShowLocationEvents.DismissDialog)
val dialogDismissedState = awaitItem()
assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None)
assertThat(dialogDismissedState.isTrackMyLocation).isFalse()
assertThat(dialogDismissedState.hasLocationPermission).isFalse()
}
}
@Test
fun `rationale dialog continue`() = runTest {
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip initial state
val initialState = awaitItem()
// Click on the button to switch mode
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
val trackLocationState = awaitItem()
assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionRationale)
assertThat(trackLocationState.isTrackMyLocation).isFalse()
assertThat(trackLocationState.hasLocationPermission).isFalse()
// Continue the dialog sends permission request to the permissions presenter
trackLocationState.eventSink(ShowLocationEvents.RequestPermissions)
assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions)
}
}
@Test
fun `permission denied dialog dismiss`() = runTest {
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip initial state
val initialState = awaitItem()
// Click on the button to switch mode
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
val trackLocationState = awaitItem()
assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionDenied)
assertThat(trackLocationState.isTrackMyLocation).isFalse()
assertThat(trackLocationState.hasLocationPermission).isFalse()
// Dismiss the dialog
initialState.eventSink(ShowLocationEvents.DismissDialog)
val dialogDismissedState = awaitItem()
assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None)
assertThat(dialogDismissedState.isTrackMyLocation).isFalse()
assertThat(dialogDismissedState.hasLocationPermission).isFalse()
}
}
@Test
fun `open settings activity`() = runTest {
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip initial state
val initialState = awaitItem()
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
val dialogShownState = awaitItem()
// Open settings
dialogShownState.eventSink(ShowLocationEvents.OpenAppSettings)
val settingsOpenedState = awaitItem()
assertThat(settingsOpenedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None)
assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1)
}
}
@Test
fun `application name is in state`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.appName).isEqualTo("app name")
}
}
companion object {
private const val A_DESCRIPTION = "My happy place"
}
}
@@ -0,0 +1,147 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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 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.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
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 ShowLocationViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `test back action`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setShowLocationView(
state = aShowLocationState(
eventSink = eventsRecorder
),
onBackClick = callback,
)
rule.pressBack()
}
}
@Test
fun `test share action`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
rule.setShowLocationView(
aShowLocationState(
eventSink = eventsRecorder
),
onBackClick = EnsureNeverCalled(),
)
val shareContentDescription = rule.activity.getString(CommonStrings.action_share)
rule.onNodeWithContentDescription(shareContentDescription).performClick()
eventsRecorder.assertSingle(ShowLocationEvents.Share)
}
@Test
fun `test fab click`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
rule.setShowLocationView(
aShowLocationState(
eventSink = eventsRecorder
),
onBackClick = EnsureNeverCalled(),
)
rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick()
eventsRecorder.assertSingle(ShowLocationEvents.TrackMyLocation(true))
}
@Test
fun `when permission denied is displayed user can open the settings`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
rule.setShowLocationView(
aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionDenied,
eventSink = eventsRecorder
),
onBackClick = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShowLocationEvents.OpenAppSettings)
}
@Test
fun `when permission denied is displayed user can close the dialog`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
rule.setShowLocationView(
aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionDenied,
eventSink = eventsRecorder
),
onBackClick = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog)
}
@Test
fun `when permission rationale is displayed user can request permissions`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
rule.setShowLocationView(
aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionRationale,
eventSink = eventsRecorder
),
onBackClick = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShowLocationEvents.RequestPermissions)
}
@Test
fun `when permission rationale is displayed user can close the dialog`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
rule.setShowLocationView(
aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionRationale,
eventSink = eventsRecorder
),
onBackClick = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setShowLocationView(
state: ShowLocationState,
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
// Simulate a LocalInspectionMode for MapLibreMap
CompositionLocalProvider(LocalInspectionMode provides true) {
ShowLocationView(
state = state,
onBackClick = onBackClick,
)
}
}
}
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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-library")
}
android {
namespace = "io.element.android.features.location.test"
}
dependencies {
api(projects.features.location.api)
implementation(projects.libraries.matrix.api)
implementation(libs.appyx.core)
implementation(projects.tests.testutils)
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.test
import io.element.android.features.location.api.LocationService
class FakeLocationService(
private val isServiceAvailable: Boolean = false,
) : LocationService {
override fun isServiceAvailable() = isServiceAvailable
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.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.libraries.matrix.api.timeline.Timeline
import io.element.android.tests.testutils.lambda.lambdaError
class FakeSendLocationEntryPoint : SendLocationEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
timelineMode: Timeline.Mode,
): Node = lambdaError()
}
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.test
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
class FakeShowLocationEntryPoint : ShowLocationEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
inputs: ShowLocationEntryPoint.Inputs,
): Node = lambdaError()
}