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