Send My Location (#770)

- https://github.com/vector-im/element-meta/issues/1682
This commit is contained in:
Marco Romano 2023-07-19 11:58:13 +02:00 committed by GitHub
parent 68c2aa8822
commit 3c45a5ece4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 1351 additions and 767 deletions

View file

@ -37,4 +37,8 @@ data class Location(
)
}
}
fun toGeoUri(): String {
return "geo:$lat,$lon;u=$accuracy"
}
}

View file

@ -107,6 +107,7 @@ fun StaticMapView(
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.align { size, space, _ ->
// Center bottom edge of pin (i.e. its arrow) to center of screen
IntOffset(
x = (space.width - size.width) / 2,
y = (space.height / 2) - size.height,

View file

@ -17,7 +17,11 @@
package io.element.android.features.location.api.internal
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import io.element.android.features.location.api.R
import io.element.android.libraries.theme.ElementTheme
/**
* Provides the URL to an image that contains a statically-generated map of the given location.
@ -34,10 +38,25 @@ fun staticMapUrl(
return "${baseUrl(darkMode)}/static/${lon},${lat},${zoom}/${width}x${height}@2x.webp?key=${context.apiKey}&attribution=bottomleft"
}
/**
* Utility function to remember the tile server URL based on the current theme.
*/
@Composable
fun rememberTileStyleUrl(): String {
val context = LocalContext.current
val darkMode = !ElementTheme.isLightTheme
return remember(darkMode) {
tileStyleUrl(
context = context,
darkMode = darkMode
)
}
}
/**
* Provides the URL to a MapLibre style document, used for rendering dynamic maps.
*/
fun tileStyleUrl(
private fun tileStyleUrl(
context: Context,
darkMode: Boolean,
): String {

View file

@ -76,4 +76,9 @@ internal class LocationKtTest {
))
}
@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")
}
}

View file

@ -30,18 +30,22 @@ anvil {
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.maplibre)
implementation(libs.maplibre.annotation)
implementation(libs.accompanist.permission)
implementation(projects.libraries.uiStrings)
implementation(libs.dagger)
implementation(projects.anvilannotations)
implementation(projects.services.toolbox.api)
anvil(projects.anvilcodegen)
ksp(libs.showkase.processor)
@ -52,4 +56,6 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.analytics.test)
testImplementation(projects.features.messages.test)
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.location.impl.show
package io.element.android.features.location.impl
import android.content.Context
import android.content.Intent
@ -22,6 +22,8 @@ import android.net.Uri
import androidx.annotation.VisibleForTesting
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.show.LocationActions
import io.element.android.libraries.androidutils.system.openAppSettingsPage
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import timber.log.Timber
@ -29,24 +31,25 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class)
class AndroidLocationActions @Inject constructor(
@ApplicationContext private val appContext: Context
@ApplicationContext private val context: Context
) : LocationActions {
private var activityContext: Context? = null
override fun share(location: Location, label: String?) {
runCatching {
val uri = Uri.parse(buildUrl(location, label))
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
val chooserIntent = Intent.createChooser(showMapsIntent, null)
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
appContext.startActivity(chooserIntent)
context.startActivity(chooserIntent)
}.onSuccess {
Timber.v("Open location succeed")
}.onFailure {
Timber.e(it, "Open location failed")
}
}
override fun openSettings() {
context.openAppSettingsPage()
}
}
@VisibleForTesting

View file

@ -0,0 +1,63 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl
import android.view.Gravity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import com.mapbox.mapboxsdk.camera.CameraPosition
import com.mapbox.mapboxsdk.geometry.LatLng
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 io.element.android.libraries.theme.ElementTheme
/**
* 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,
)
val centerCameraPosition = CameraPosition.Builder()
.target(LatLng(49.843, 9.902056))
.zoom(2.7)
.build()
const val DEFAULT_ZOOM = 15.0
}

View file

@ -1,71 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl
import androidx.compose.runtime.Composable
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 io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.location.AssetType
import kotlinx.coroutines.launch
import javax.inject.Inject
class SendLocationPresenter @Inject constructor(
private val room: MatrixRoom,
) : Presenter<SendLocationState> {
@Composable
override fun present(): SendLocationState {
val scope = rememberCoroutineScope()
var mode by remember {
mutableStateOf<SendLocationState.Mode>(SendLocationState.Mode.ALocation)
}
fun handleEvents(event: SendLocationEvents) {
when (event) {
is SendLocationEvents.ShareLocation -> scope.launch {
shareLocation(event)
}
is SendLocationEvents.SwitchMode -> {
mode = event.mode
}
}
}
return SendLocationState(
mode = mode,
eventSink = ::handleEvents,
)
}
private suspend fun shareLocation(
event: SendLocationEvents.ShareLocation
) {
room.sendLocation(
body = "Location at latitude: ${event.lat}, longitude: ${event.lng}",
geoUri = "geo:${event.lat},${event.lng}",
description = null,
zoomLevel = 15, // Send default zoom level for now.
assetType = AssetType.PIN,
)
}
}

View file

@ -1,148 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
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.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.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import io.element.android.features.location.impl.map.MapView
import io.element.android.features.location.impl.map.rememberMapState
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
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.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.designsystem.R as DesignSystemR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun SendLocationView(
state: SendLocationState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
val mapState = rememberMapState()
BottomSheetScaffold(
sheetContent = {
Spacer(modifier = Modifier.height(16.dp))
ListItem(
headlineContent = {
Text(stringResource(CommonStrings.screen_share_this_location_action))
},
modifier = Modifier.clickable {
state.eventSink(
SendLocationEvents.ShareLocation(
lat = mapState.position.lat,
lng = mapState.position.lon
)
)
onBackPressed()
},
leadingContent = {
Icon(Icons.Default.LocationOn, null)
},
)
Spacer(modifier = Modifier.height(16.dp))
},
modifier = modifier,
scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded),
),
sheetDragHandle = {},
sheetSwipeEnabled = false,
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(CommonStrings.screen_share_location_title),
style = ElementTheme.typography.aliasScreenTitle,
)
},
navigationIcon = {
BackButton(onClick = onBackPressed)
},
)
},
) {
Box(
modifier = Modifier
.padding(it)
.consumeWindowInsets(it),
contentAlignment = Alignment.Center
) {
MapView(
modifier = Modifier.fillMaxSize(),
mapState = mapState,
)
Icon(
resourceId = DesignSystemR.drawable.pin,
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.align { size, space, _ ->
IntOffset(
x = (space.width - size.width) / 2,
y = (space.height / 2) - size.height,
)
}
)
}
}
}
@Preview
@Composable
internal fun SendLocationViewLightPreview(@PreviewParameter(SendLocationStateProvider::class) state: SendLocationState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun SendLocationViewDarkPreview(@PreviewParameter(SendLocationStateProvider::class) state: SendLocationState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: SendLocationState) {
SendLocationView(
state = state,
onBackPressed = {},
)
}

View file

@ -1,96 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.location
import android.Manifest
import android.content.Context
import android.location.LocationManager
import androidx.annotation.RequiresPermission
import androidx.core.content.getSystemService
import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat
import io.element.android.features.location.api.Location
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
/**
* Returns a cold [Flow] that, once collected, emits [Location] updates every second.
*/
@RequiresPermission(
anyOf = [
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
]
)
fun locationUpdatesFlow(
context: Context,
coroutineDispatchers: CoroutineDispatchers,
): Flow<Location> = callbackFlow {
val locationManager: LocationManager = checkNotNull(context.getSystemService())
val provider = locationManager.bestAvailableProvider()
// Try to eagerly emit the last known location as fast as possible
locationManager.getLastKnownLocation(provider)?.let { location ->
trySendBlocking(
Location(
lat = location.latitude,
lon = location.longitude,
accuracy = location.accuracy
)
)
}
val locationListener = LocationListenerCompat { location ->
trySendBlocking(
Location(
lat = location.latitude,
lon = location.longitude,
accuracy = location.accuracy
)
)
}
LocationManagerCompat.requestLocationUpdates(
locationManager,
provider,
buildLocationRequest(),
coroutineDispatchers.io.asExecutor(),
locationListener,
)
awaitClose {
LocationManagerCompat.removeUpdates(locationManager, locationListener)
}
}
private fun LocationManager.bestAvailableProvider(): String =
checkNotNull(getProviders(true).maxByOrNull { providerPriority(it) }) {
"No location provider available"
}
private fun providerPriority(provider: String): Int = when (provider) {
LocationManager.FUSED_PROVIDER -> 4
LocationManager.GPS_PROVIDER -> 3
LocationManager.NETWORK_PROVIDER -> 2
LocationManager.PASSIVE_PROVIDER -> 1
else -> 0
}
private fun buildLocationRequest() = LocationRequestCompat.Builder(1_000).apply {
setMinUpdateIntervalMillis(1_000)
}.build()

View file

@ -1,299 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.map
import android.annotation.SuppressLint
import android.view.Gravity
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.mapbox.mapboxsdk.Mapbox
import com.mapbox.mapboxsdk.camera.CameraPosition
import com.mapbox.mapboxsdk.camera.CameraUpdateFactory
import com.mapbox.mapboxsdk.geometry.LatLng
import com.mapbox.mapboxsdk.maps.MapView
import com.mapbox.mapboxsdk.maps.MapboxMap
import com.mapbox.mapboxsdk.maps.Style
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
import com.mapbox.mapboxsdk.style.layers.Property.ICON_ANCHOR_BOTTOM
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.internal.tileStyleUrl
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import timber.log.Timber
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import io.element.android.libraries.designsystem.R as DesignSystemR
/**
* Composable wrapper around MapLibre's [MapView].
*/
@SuppressLint("MissingPermission")
@Composable
fun MapView(
modifier: Modifier = Modifier,
mapState: MapState = rememberMapState(),
darkMode: Boolean = !ElementTheme.isLightTheme,
) {
// When in preview, early return a Box with the received modifier preserving layout
if (LocalInspectionMode.current) {
@Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return.
Box(
modifier = modifier.background(Color.DarkGray)
) {
Text("[MapView]", modifier = Modifier.align(Alignment.Center))
}
return
}
val context = LocalContext.current
val mapView = remember {
Mapbox.getInstance(context)
MapView(context)
}
var mapRefs by remember { mutableStateOf<MapRefs?>(null) }
val attributionColour = ElementTheme.colors.iconPrimary
// Build map
LaunchedEffect(darkMode) {
mapView.awaitMap().let { map ->
map.uiSettings.apply {
attributionGravity = Gravity.TOP
setAttributionTintColor(attributionColour.toArgb())
logoGravity = Gravity.TOP
isCompassEnabled = false
isRotateGesturesEnabled = false
}
map.setStyle(tileStyleUrl(context, darkMode)) { style ->
mapRefs = MapRefs(
map = map,
symbolManager = SymbolManager(mapView, map, style).apply {
iconAllowOverlap = true
},
style = style
)
}
}
}
// Update state position when moving map
DisposableEffect(mapRefs) {
var listener: MapboxMap.OnCameraIdleListener? = null
mapRefs?.let { mapRefs ->
listener = MapboxMap.OnCameraIdleListener {
mapRefs.map.cameraPosition.target?.let { target ->
val position = MapState.CameraPosition(
lat = target.latitude,
lon = target.longitude,
zoom = mapRefs.map.cameraPosition.zoom
)
mapState.position = position
Timber.d("Camera moved to: $position")
}
}.apply {
mapRefs.map.addOnCameraIdleListener(this)
Timber.d("Added OnCameraIdleListener $this")
}
}
onDispose {
mapRefs?.let { mapRefs ->
listener?.let {
mapRefs.map.removeOnCameraIdleListener(it).apply {
Timber.d("Removed OnCameraIdleListener $it")
}
}
}
}
}
// Move map to given position when state has changed
LaunchedEffect(mapRefs, mapState.position) {
mapRefs?.map?.moveCamera(
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder()
.target(LatLng(mapState.position.lat, mapState.position.lon))
.zoom(mapState.position.zoom).build()
)
)
Timber.d("Camera position updated to: ${mapState.position}")
}
// Draw pin
LaunchedEffect(mapRefs, mapState.location) {
mapRefs?.let { mapRefs ->
mapState.location?.let { location ->
context.getDrawable(DesignSystemR.drawable.pin)?.let { mapRefs.style.addImage("pin", it) }
mapRefs.symbolManager.create(
SymbolOptions()
.withLatLng(LatLng(location.lat, location.lon))
.withIconImage("pin")
.withIconSize(1.3f)
.withIconAnchor(ICON_ANCHOR_BOTTOM)
)
Timber.d("Shown pin at location: $location")
}
}
}
// Draw markers
LaunchedEffect(mapRefs, mapState.markers) {
mapRefs?.let { mapRefs ->
mapState.markers.forEachIndexed { index, marker ->
context.getDrawable(marker.drawable)?.let { mapRefs.style.addImage("marker_$index", it) }
mapRefs.symbolManager.create(
SymbolOptions()
.withLatLng(LatLng(marker.lat, marker.lon))
.withIconImage("marker_$index")
.withIconSize(1.0f)
)
Timber.d("Shown marker at location: $marker")
}
}
}
@Suppress("ModifierReused")
AndroidView(
factory = { mapView },
modifier = modifier
)
}
@Composable
fun rememberMapState(
position: MapState.CameraPosition = MapState.CameraPosition(lat = 0.0, lon = 0.0, zoom = 0.0),
location: Location? = null,
markers: ImmutableList<MapState.Marker> = emptyList<MapState.Marker>().toImmutableList(),
): MapState = remember {
MapState(
position = position,
location = location,
markers = markers,
)
} // TODO(Use remember saveable with Parcelable custom saver)
@Stable
class MapState(
position: CameraPosition, // The position of the camera, it's what will be shared
location: Location? = null, // The location retrieved by the location subsystem, if any.
markers: ImmutableList<Marker> = emptyList<Marker>().toImmutableList(), // The pin's location, if any.
) {
var position: CameraPosition by mutableStateOf(position)
var location: Location? by mutableStateOf(location)
var markers: ImmutableList<Marker> by mutableStateOf(markers)
override fun toString(): String {
return "MapState(position=$position, location=$location, markers=$markers)"
}
@Stable
data class CameraPosition(
val lat: Double,
val lon: Double,
val zoom: Double,
)
@Stable
data class Marker(
@DrawableRes val drawable: Int,
val lat: Double,
val lon: Double,
)
}
private class MapRefs(
val map: MapboxMap,
val symbolManager: SymbolManager,
val style: Style
)
/**
* A suspending function that provides an instance of [MapboxMap] from this [MapView]. This is
* an alternative to [MapView.getMapAsync] by using coroutines to obtain the [MapboxMap].
*
* Inspired from [com.google.maps.android.ktx.awaitMap]
*
* @return the [MapboxMap] instance
*/
private suspend inline fun MapView.awaitMap(): MapboxMap =
suspendCoroutine { continuation ->
getMapAsync {
continuation.resume(it)
}
}
@Preview
@Composable
fun MapViewLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun MapViewDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
MapView(
modifier = Modifier.size(400.dp),
mapState = rememberMapState(
position = MapState.CameraPosition(
lat = 0.0,
lon = 0.0,
zoom = 0.0,
),
location = Location(
lat = 0.0,
lon = 0.0,
accuracy = 0.0f,
),
markers = listOf(
MapState.Marker(
drawable = DesignSystemR.drawable.pin,
lat = 0.0,
lon = 0.0,
)
).toImmutableList()
),
)
}

View file

@ -14,9 +14,8 @@
* limitations under the License.
*/
package io.element.android.features.location.impl
package io.element.android.features.location.impl.permissions
sealed interface SendLocationEvents {
data class ShareLocation(val lat: Double, val lng: Double) : SendLocationEvents
data class SwitchMode(val mode: SendLocationState.Mode) : SendLocationEvents
sealed interface PermissionsEvents {
object RequestPermissions : PermissionsEvents
}

View file

@ -14,14 +14,12 @@
* limitations under the License.
*/
package io.element.android.features.location.impl
package io.element.android.features.location.impl.permissions
data class SendLocationState(
val mode: Mode = Mode.ALocation,
val eventSink: (SendLocationEvents) -> Unit = {},
) {
sealed interface Mode {
object MyLocation : Mode
object ALocation : Mode
import io.element.android.libraries.architecture.Presenter
interface PermissionsPresenter : Presenter<PermissionsState> {
interface Factory {
fun create(permissions: List<String>): PermissionsPresenter
}
}

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.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 com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.di.AppScope
class PermissionsPresenterImpl @AssistedInject constructor(
@Assisted private val permissions: List<String>
) : PermissionsPresenter {
@AssistedFactory
@ContributesBinding(AppScope::class)
interface Factory : PermissionsPresenter.Factory {
override fun create(permissions: List<String>): PermissionsPresenterImpl
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
override fun present(): PermissionsState {
val multiplePermissionsState = rememberMultiplePermissionsState(permissions = permissions)
fun handleEvents(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 = ::handleEvents,
)
}
}

View file

@ -14,22 +14,19 @@
* limitations under the License.
*/
package io.element.android.features.location.impl.location
package io.element.android.features.location.impl.permissions
import io.element.android.features.location.api.Location
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
fun fakeLocationUpdatesFlow(): Flow<Location> = flow {
while (true) {
delay(1_000)
emit(aLocation())
data class PermissionsState(
val permissions: Permissions = Permissions.NoneGranted,
val shouldShowRationale: Boolean = false,
val eventSink: (PermissionsEvents) -> Unit = {},
) {
sealed interface Permissions {
object AllGranted : Permissions
object SomeGranted : Permissions
object NoneGranted : Permissions
}
}
private fun aLocation() = Location(
lat = 51.49404,
lon = -0.25484,
accuracy = 5f
)
val isAnyGranted: Boolean
get() = permissions is Permissions.SomeGranted || permissions is Permissions.AllGranted
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.location.impl
package io.element.android.features.location.impl.send
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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,
)
}
object SwitchToMyLocationMode : SendLocationEvents
object SwitchToPinLocationMode : SendLocationEvents
object DismissDialog : SendLocationEvents
object RequestPermissions : SendLocationEvents
object OpenAppSettings : SendLocationEvents
}

View file

@ -14,30 +14,43 @@
* limitations under the License.
*/
package io.element.android.features.location.impl
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 dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class)
class SendLocationNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: SendLocationPresenter,
analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.LocationSend))
}
)
}
@Composable
override fun View(modifier: Modifier) {
SendLocationView(
state = presenter.present(),
modifier = modifier,
onBackPressed = ::navigateUp,
navigateUp = ::navigateUp,
)
}
}

View file

@ -0,0 +1,168 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.send
import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.Manifest.permission.ACCESS_FINE_LOCATION
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 im.vector.app.features.analytics.plan.Composer
import io.element.android.features.location.impl.MapDefaults
import io.element.android.features.location.impl.permissions.PermissionsEvents
import io.element.android.features.location.impl.permissions.PermissionsPresenter
import io.element.android.features.location.impl.permissions.PermissionsState
import io.element.android.features.location.impl.show.LocationActions
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import javax.inject.Inject
class SendLocationPresenter @Inject constructor(
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val room: MatrixRoom,
private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContext,
private val locationActions: LocationActions,
private val systemClock: SystemClock,
private val buildMeta: BuildMeta,
) : Presenter<SendLocationState> {
private val permissionsPresenter = permissionsPresenterFactory.create(
listOf(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION)
)
@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 handleEvents(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 = ::handleEvents,
)
}
private suspend fun sendLocation(
event: SendLocationEvents.SendLocation,
mode: SendLocationState.Mode,
) {
when (mode) {
SendLocationState.Mode.PinLocation -> {
val geoUri = event.cameraPosition.toGeoUri()
room.sendLocation(
body = generateBody(geoUri, systemClock.epochMillis()),
geoUri = geoUri,
description = null,
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
assetType = AssetType.PIN
)
analyticsService.capture(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
isLocation = true,
isReply = messageComposerContext.composerMode.isReply,
locationType = Composer.LocationType.PinDrop,
)
)
}
SendLocationState.Mode.SenderLocation -> {
val geoUri = event.toGeoUri()
room.sendLocation(
body = generateBody(geoUri, systemClock.epochMillis()),
geoUri = geoUri,
description = null,
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
assetType = AssetType.SENDER
)
analyticsService.capture(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
isLocation = true,
isReply = messageComposerContext.composerMode.isReply,
locationType = Composer.LocationType.MyLocation,
)
)
}
}
}
}
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, epochMillis: Long): String {
val timestamp = ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT)
return "Location was shared at $uri as of $timestamp"
}

View file

@ -14,13 +14,23 @@
* limitations under the License.
*/
package io.element.android.features.location.impl
package io.element.android.features.location.impl.send
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
data class SendLocationState(
val permissionDialog: Dialog = Dialog.None,
val mode: Mode = Mode.PinLocation,
val hasLocationPermission: Boolean = false,
val appName: String = "AppName",
val eventSink: (SendLocationEvents) -> Unit = {},
) {
sealed interface Mode {
object SenderLocation : Mode
object PinLocation : Mode
}
class SendLocationStateProvider : PreviewParameterProvider<SendLocationState> {
override val values: Sequence<SendLocationState>
get() = sequenceOf(
SendLocationState(),
)
sealed interface Dialog {
object None : Dialog
object PermissionRationale : Dialog
object PermissionDenied : Dialog
}
}

View file

@ -0,0 +1,57 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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(
SendLocationState(
permissionDialog = SendLocationState.Dialog.None,
mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = false,
appName = APP_NAME,
),
SendLocationState(
permissionDialog = SendLocationState.Dialog.PermissionDenied,
mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = false,
appName = APP_NAME,
),
SendLocationState(
permissionDialog = SendLocationState.Dialog.PermissionRationale,
mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = false,
appName = APP_NAME,
),
SendLocationState(
permissionDialog = SendLocationState.Dialog.None,
mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = true,
appName = APP_NAME,
),
SendLocationState(
permissionDialog = SendLocationState.Dialog.None,
mode = SendLocationState.Mode.SenderLocation,
hasLocationPermission = true,
appName = APP_NAME,
),
)
}

View file

@ -0,0 +1,257 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.send
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.LocationSearching
import androidx.compose.material.icons.filled.MyLocation
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.IntOffset
import androidx.compose.ui.unit.dp
import com.mapbox.mapboxsdk.camera.CameraPosition
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.MapDefaults
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
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.maplibre.compose.CameraMode
import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason
import io.element.android.libraries.maplibre.compose.MapboxMap
import io.element.android.libraries.maplibre.compose.rememberCameraPositionState
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.designsystem.R as DesignSystemR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun SendLocationView(
state: SendLocationState,
modifier: Modifier = Modifier,
navigateUp: () -> Unit = {},
) {
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(
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 {
state.eventSink(
SendLocationEvents.SendLocation(
cameraPosition = SendLocationEvents.SendLocation.CameraPosition(
lat = cameraPositionState.position.target!!.latitude,
lon = cameraPositionState.position.target!!.longitude,
zoom = cameraPositionState.position.zoom,
),
cameraPositionState.location?.let {
Location(
lat = it.latitude,
lon = it.longitude,
accuracy = it.accuracy,
)
}
)
)
navigateUp()
},
leadingContent = {
Icon(Icons.Default.LocationOn, null)
},
)
Spacer(modifier = Modifier.height(28.dp))
},
modifier = modifier,
scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded),
),
sheetDragHandle = {},
sheetSwipeEnabled = false,
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(CommonStrings.screen_share_location_title),
style = ElementTheme.typography.aliasScreenTitle,
)
},
navigationIcon = {
BackButton(onClick = navigateUp)
},
)
},
) {
Box(
modifier = Modifier
.padding(it)
.consumeWindowInsets(it),
contentAlignment = Alignment.Center
) {
MapboxMap(
styleUri = rememberTileStyleUrl(),
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
uiSettings = MapDefaults.uiSettings,
symbolManagerSettings = MapDefaults.symbolManagerSettings,
locationSettings = MapDefaults.locationSettings.copy(
locationEnabled = state.hasLocationPermission,
),
)
Icon(
resourceId = DesignSystemR.drawable.pin,
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.align { size, space, _ ->
// Center bottom edge of pin (i.e. its arrow) to center of screen
IntOffset(
x = (space.width - size.width) / 2,
y = (space.height / 2) - size.height,
)
}
)
FloatingActionButton(
onClick = { state.eventSink(SendLocationEvents.SwitchToMyLocationMode) },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 72.dp),
) {
when (state.mode) {
SendLocationState.Mode.PinLocation -> Icon(imageVector = Icons.Default.LocationSearching, contentDescription = null)
SendLocationState.Mode.SenderLocation -> Icon(imageVector = Icons.Default.MyLocation, contentDescription = null)
}
}
}
}
}
@DayNightPreviews
@Composable
fun SendLocationViewPreview(
@PreviewParameter(SendLocationStateProvider::class) state: SendLocationState
) = ElementPreview {
SendLocationView(
state = state,
navigateUp = {},
)
}
@Composable
private fun PermissionRationaleDialog(
onContinue: () -> Unit,
onDismiss: () -> Unit,
appName: String,
) {
ConfirmationDialog(
content = stringResource(CommonStrings.error_missing_location_rationale_android, appName),
onSubmitClicked = onContinue,
onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue),
cancelText = stringResource(CommonStrings.action_cancel),
)
}
@Composable
private fun PermissionDeniedDialog(
onContinue: () -> Unit,
onDismiss: () -> Unit,
appName: String,
) {
ConfirmationDialog(
content = stringResource(CommonStrings.error_missing_location_auth_android, appName),
onSubmitClicked = onContinue,
onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue),
cancelText = stringResource(CommonStrings.action_cancel),
)
}

View file

@ -20,4 +20,5 @@ import io.element.android.features.location.api.Location
interface LocationActions {
fun share(location: Location, label: String?)
fun openSettings()
}

View file

@ -33,9 +33,10 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.location.impl.map.MapState
import io.element.android.features.location.impl.map.MapView
import io.element.android.features.location.impl.map.rememberMapState
import com.mapbox.mapboxsdk.camera.CameraPosition
import com.mapbox.mapboxsdk.geometry.LatLng
import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.MapDefaults
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -45,9 +46,16 @@ 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.maplibre.compose.IconAnchor
import io.element.android.libraries.maplibre.compose.MapboxMap
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.theme.ElementTheme
import io.element.android.libraries.theme.compound.generated.TypographyTokens
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableMap
import io.element.android.libraries.designsystem.R as DesignSystemR
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
@ -56,11 +64,6 @@ fun ShowLocationView(
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
val mapState = rememberMapState(
location = state.location,
position = MapState.CameraPosition(state.location.lat, state.location.lon, 15.0),
)
Scaffold(modifier,
topBar = {
TopAppBar(
@ -100,10 +103,27 @@ fun ShowLocationView(
)
}
MapView(
mapState = mapState,
MapboxMap(
styleUri = rememberTileStyleUrl(),
modifier = Modifier.fillMaxSize(),
)
images = mapOf(PIN_ID to DesignSystemR.drawable.pin).toImmutableMap(),
cameraPositionState = rememberCameraPositionState {
position = CameraPosition.Builder()
.target(LatLng(state.location.lat, state.location.lon))
.zoom(MapDefaults.DEFAULT_ZOOM)
.build()
},
uiSettings = MapDefaults.uiSettings,
symbolManagerSettings = MapDefaults.symbolManagerSettings,
) {
Symbol(
iconId = PIN_ID,
state = rememberSymbolState(
position = LatLng(state.location.lat, state.location.lon)
),
iconAnchor = IconAnchor.BOTTOM,
)
}
}
}
}
@ -125,3 +145,6 @@ private fun ContentToPreview(state: ShowLocationState) {
onBackPressed = {},
)
}
private const val PIN_ID = "pin"

View file

@ -1,64 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Test
class SendLocationPresenterTest {
private val room = FakeMatrixRoom()
private val presenter = SendLocationPresenter(room)
@Test
fun `emits initial state`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
Truth.assertThat(awaitItem().mode).isEqualTo(SendLocationState.Mode.ALocation)
}
}
@Test
fun `share location event shares a location`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(SendLocationEvents.ShareLocation(1.0, 2.0))
delay(1)
Truth.assertThat(room.sendLocationCount).isEqualTo(1)
}
}
@Test
fun `switches mode`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(SendLocationEvents.SwitchMode(SendLocationState.Mode.MyLocation))
Truth.assertThat(awaitItem().mode).isEqualTo(SendLocationState.Mode.MyLocation)
}
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.permissions
import androidx.compose.runtime.Composable
class PermissionsPresenterFake : PermissionsPresenter {
val events = mutableListOf<PermissionsEvents>()
private fun handleEvent(event: PermissionsEvents) {
events += event
}
private var state = PermissionsState(eventSink = ::handleEvent)
set(value) {
field = value.copy(eventSink = ::handleEvent)
}
fun givenState(state: PermissionsState) {
this.state = state
}
@Composable
override fun present(): PermissionsState = state
}

View file

@ -0,0 +1,461 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.send
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.permissions.PermissionsEvents
import io.element.android.features.location.impl.permissions.PermissionsPresenter
import io.element.android.features.location.impl.permissions.PermissionsPresenterFake
import io.element.android.features.location.impl.permissions.PermissionsState
import io.element.android.features.location.impl.show.FakeLocationActions
import io.element.android.features.messages.test.MessageComposerContextFake
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.SendLocationInvocation
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Test
class SendLocationPresenterTest {
private val permissionsPresenterFake = PermissionsPresenterFake()
private val fakeMatrixRoom = FakeMatrixRoom()
private val fakeAnalyticsService = FakeAnalyticsService()
private val messageComposerContextFake = MessageComposerContextFake()
private val fakeLocationActions = FakeLocationActions()
private val fakeSystemClock = SystemClock { 0L }
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
private val sendLocationPresenter: SendLocationPresenter = SendLocationPresenter(
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permissions: List<String>): PermissionsPresenter = permissionsPresenterFake
},
room = fakeMatrixRoom,
analyticsService = fakeAnalyticsService,
messageComposerContext = messageComposerContextFake,
locationActions = fakeLocationActions,
systemClock = fakeSystemClock,
buildMeta = fakeBuildMeta,
)
@Test
fun `initial state with permissions granted`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
moleculeFlow(RecompositionClock.Immediate) {
sendLocationPresenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
Truth.assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.SenderLocation)
Truth.assertThat(initialState.hasLocationPermission).isEqualTo(true)
// Swipe the map to switch mode
initialState.eventSink(SendLocationEvents.SwitchToPinLocationMode)
val myLocationState = awaitItem()
Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(true)
}
}
@Test
fun `initial state with permissions partially granted`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
permissions = PermissionsState.Permissions.SomeGranted,
shouldShowRationale = false,
)
)
moleculeFlow(RecompositionClock.Immediate) {
sendLocationPresenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
Truth.assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.SenderLocation)
Truth.assertThat(initialState.hasLocationPermission).isEqualTo(true)
// Swipe the map to switch mode
initialState.eventSink(SendLocationEvents.SwitchToPinLocationMode)
val myLocationState = awaitItem()
Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(true)
}
}
@Test
fun `initial state with permissions denied`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
moleculeFlow(RecompositionClock.Immediate) {
sendLocationPresenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
Truth.assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
Truth.assertThat(initialState.hasLocationPermission).isEqualTo(false)
// Click on the button to switch mode
initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
val myLocationState = awaitItem()
Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionDenied)
Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(false)
}
}
@Test
fun `initial state with permissions denied once`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
)
moleculeFlow(RecompositionClock.Immediate) {
sendLocationPresenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
Truth.assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
Truth.assertThat(initialState.hasLocationPermission).isEqualTo(false)
// Click on the button to switch mode
initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
val myLocationState = awaitItem()
Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale)
Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(false)
}
}
@Test
fun `rationale dialog dismiss`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
)
moleculeFlow(RecompositionClock.Immediate) {
sendLocationPresenter.present()
}.test {
// Skip initial state
val initialState = awaitItem()
// Click on the button to switch mode
initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
val myLocationState = awaitItem()
Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale)
Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(false)
// Dismiss the dialog
myLocationState.eventSink(SendLocationEvents.DismissDialog)
val dialogDismissedState = awaitItem()
Truth.assertThat(dialogDismissedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
Truth.assertThat(dialogDismissedState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
Truth.assertThat(dialogDismissedState.hasLocationPermission).isEqualTo(false)
}
}
@Test
fun `rationale dialog continue`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
)
moleculeFlow(RecompositionClock.Immediate) {
sendLocationPresenter.present()
}.test {
// Skip initial state
val initialState = awaitItem()
// Click on the button to switch mode
initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
val myLocationState = awaitItem()
Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale)
Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(false)
// Continue the dialog sends permission request to the permissions presenter
myLocationState.eventSink(SendLocationEvents.RequestPermissions)
Truth.assertThat(permissionsPresenterFake.events.last()).isEqualTo(PermissionsEvents.RequestPermissions)
}
}
@Test
fun `permission denied dialog dismiss`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
moleculeFlow(RecompositionClock.Immediate) {
sendLocationPresenter.present()
}.test {
// Skip initial state
val initialState = awaitItem()
// Click on the button to switch mode
initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
val myLocationState = awaitItem()
Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionDenied)
Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(false)
// Dismiss the dialog
myLocationState.eventSink(SendLocationEvents.DismissDialog)
val dialogDismissedState = awaitItem()
Truth.assertThat(dialogDismissedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
Truth.assertThat(dialogDismissedState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
Truth.assertThat(dialogDismissedState.hasLocationPermission).isEqualTo(false)
}
}
@Test
fun `share sender location`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
moleculeFlow(RecompositionClock.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
Truth.assertThat(fakeMatrixRoom.sentLocations.size).isEqualTo(1)
Truth.assertThat(fakeMatrixRoom.sentLocations.last()).isEqualTo(
SendLocationInvocation(
body = "Location was shared at geo:3.0,4.0;u=5.0 as of 1970-01-01T00:00:00Z",
geoUri = "geo:3.0,4.0;u=5.0",
description = null,
zoomLevel = 15,
assetType = AssetType.SENDER
)
)
Truth.assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
Truth.assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
Composer(
inThread = false,
isEditing = false,
isLocation = true,
isReply = false,
locationType = Composer.LocationType.MyLocation,
)
)
}
}
@Test
fun `share pin location`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
moleculeFlow(RecompositionClock.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
Truth.assertThat(fakeMatrixRoom.sentLocations.size).isEqualTo(1)
Truth.assertThat(fakeMatrixRoom.sentLocations.last()).isEqualTo(
SendLocationInvocation(
body = "Location was shared at geo:0.0,1.0 as of 1970-01-01T00:00:00Z",
geoUri = "geo:0.0,1.0",
description = null,
zoomLevel = 15,
assetType = AssetType.PIN
)
)
Truth.assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
Truth.assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
Composer(
inThread = false,
isEditing = false,
isLocation = true,
isReply = false,
locationType = Composer.LocationType.PinDrop,
)
)
}
}
@Test
fun `composer context passes through analytics`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
messageComposerContextFake.apply {
composerMode = MessageComposerMode.Edit(
eventId = null, defaultContent = "", transactionId = null
)
}
moleculeFlow(RecompositionClock.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
Truth.assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
Truth.assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
Composer(
inThread = false,
isEditing = true,
isLocation = true,
isReply = false,
locationType = Composer.LocationType.PinDrop,
)
)
}
}
@Test
fun `open settings activity`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
messageComposerContextFake.apply {
composerMode = MessageComposerMode.Edit(
eventId = null, defaultContent = "", transactionId = null
)
}
moleculeFlow(RecompositionClock.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()
Truth.assertThat(settingsOpenedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
Truth.assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1)
}
}
@Test
fun `application name is in state`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
sendLocationPresenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.appName).isEqualTo("app name")
}
}
}

View file

@ -18,6 +18,7 @@ package io.element.android.features.location.impl.show
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.buildUrl
import org.junit.Test
import java.net.URLEncoder

View file

@ -26,8 +26,15 @@ class FakeLocationActions : LocationActions {
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++
}
}

View file

@ -97,8 +97,8 @@ class FakeMatrixRoom(
var reportedContentCount: Int = 0
private set
var sendLocationCount: Int = 0
private set
private val _sentLocations = mutableListOf<SendLocationInvocation>()
val sentLocations: List<SendLocationInvocation> = _sentLocations
var invitedUserId: UserId? = null
@ -279,7 +279,7 @@ class FakeMatrixRoom(
zoomLevel: Int?,
assetType: AssetType?,
): Result<Unit> = simulateLongTask {
sendLocationCount++
_sentLocations.add(SendLocationInvocation(body, geoUri, description, zoomLevel, assetType))
return sendLocationResult
}
@ -381,3 +381,11 @@ class FakeMatrixRoom(
progressCallbackValues = values
}
}
data class SendLocationInvocation(
val body: String,
val geoUri: String,
val description: String?,
val zoomLevel: Int?,
val assetType: AssetType?,
)

View file

@ -359,7 +359,7 @@ private fun AttachmentButton(
Image(
modifier = Modifier.size(12.5f.dp),
painter = painterResource(R.drawable.ic_add_attachment),
contentDescription = null,
contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment),
contentScale = ContentScale.Inside,
colorFilter = ColorFilter.tint(
LocalContentColor.current

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Add attachment"</string>
<string name="rich_text_editor_bullet_list">"Toggle bullet list"</string>
<string name="rich_text_editor_code_block">"Toggle code block"</string>
<string name="rich_text_editor_composer_placeholder">"Message…"</string>

View file

@ -143,7 +143,6 @@
<string name="error_failed_loading_map">"%1$s nedokázal načítať mapu. Skúste to prosím neskôr."</string>
<string name="error_failed_loading_messages">"Načítanie správ zlyhalo"</string>
<string name="error_failed_locating_user">"%1$s nemohol získať prístup k vašej polohe. Skúste to prosím neskôr."</string>
<string name="error_missing_location_auth">"%1$s nemá povolenie na prístup k vašej polohe. Prístup môžete povoliť v Nastavenia > Poloha"</string>
<string name="error_some_messages_have_not_been_sent">"Niektoré správy neboli odoslané"</string>
<string name="error_unknown">"Prepáčte, vyskytla sa chyba"</string>
<string name="invite_friends_rich_title">"🔐️ Pripojte sa ku mne na %1$s"</string>

View file

@ -143,7 +143,8 @@
<string name="error_failed_loading_map">"%1$s could not load the map. Please try again later."</string>
<string name="error_failed_loading_messages">"Failed loading messages"</string>
<string name="error_failed_locating_user">"%1$s could not access your location. Please try again later."</string>
<string name="error_missing_location_auth">"%1$s does not have permission to access your location. You can enable access in Settings > Location"</string>
<string name="error_missing_location_auth_android">"To send a location, allow %1$s to access your location from its settings screen."</string>
<string name="error_missing_location_rationale_android">"To send a location, allow %1$s to access your location in the next dialog."</string>
<string name="error_some_messages_have_not_been_sent">"Some messages have not been sent"</string>
<string name="error_unknown">"Sorry, an error occurred"</string>
<string name="invite_friends_rich_title">"🔐️ Join me on %1$s"</string>

View file

@ -16,6 +16,6 @@
package io.element.android.services.toolbox.api.systemclock
interface SystemClock {
fun interface SystemClock {
fun epochMillis(): Long
}

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dcd8ccab99efd822f085614edb7296e5d73f3c1ae8d84ec0ccf930ead56bfa13
size 7803

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e6040743cf442f6e3069eb77f562d6803eb9243edc959c0db7b5631ad00c583b
size 7103

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e45014ab4619ebeb90de5c69320b3ec4be51b6f1dbf8d6c8da9fec44ec0e8a2f
size 20840

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c0a503af0cbcf66a71fd93fdba8cc710a5f6a3ad847bc5af36b68b2c2e7eef3
size 34482

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9b90f17d03c0200600356dc2f694c92703ad34dcf3721fcfb3e4f37fcfbb6e55
size 33552

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e45014ab4619ebeb90de5c69320b3ec4be51b6f1dbf8d6c8da9fec44ec0e8a2f
size 20840

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:db911f98b01b92cff861787a2cdc2a01fa6d9fc0281a19075b2bf2ebc9f8f109
size 20971

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:90000e70e71b582c32f1699c1a968725e7b6a281af4b5b2e3e64eed33d5f7916
size 19358

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:890f5dfb312fdbc3f942b7e719e2e1f12759d1348917b80d57b500332eb0bf4c
size 32109

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:718b3c631a73d60950f1c945e8c0c31891eb9734223a8aecbcd7407baaa3bd5f
size 31290

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:90000e70e71b582c32f1699c1a968725e7b6a281af4b5b2e3e64eed33d5f7916
size 19358

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f7cc4fb19532e0c88107f0adabfef4859d4ab190ace800523490a81687ca674e
size 19435

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:56ef973e3f944265aac38f15a1e5f339e5b8e6923a1db783fda20a846aeea149
size 10521
oid sha256:364f107ffaa4844d0141361642ce3a494a187588f27be50b5fd27d44be21fa64
size 8887

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5760854d139072805765e75856e19947df525c8ab1e93d85b73a4db82e778d66
size 13825
oid sha256:f19878925f3b5b377a91885540fb15d29a5b78d8be2282d64e8809af0bbf5ff4
size 12195

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aba8f92a7d7924e98efeb17b5d0c3a83e555edb94809df6446a28e2ea38a3fb2
size 23287
oid sha256:c936d2d804bc9e98fcc49430f11ddaa572b05fc8d3a0df93ad6521ee8e78f708
size 21806

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8f1203a54cc36ceb6bfd50ddb1db9e55efdfa2fe09ad03eaca685bb306ad5c64
size 10506
oid sha256:ac01bc1992e3fa27950c7071cd3e8a06b94a608238d55972816fa2a1a3175e7c
size 9448

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b35436aff6308e353bf52f837f0d267e39b73a529523cef8c6df937792b61be8
size 14330
oid sha256:065d2a09680e35540b870862ec0ba5c54182e016017938ef64e510b6132333c9
size 13389

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f68bec570d74cbf375688b40ec27ac2b6a1048c315cb54472db78260edcc112f
size 25297
oid sha256:0bb88c64bc68b10b1fb709135f445de5a5e4d78623448d0fef97504e025d5f6d
size 24551

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c67fe85270b99a4a23a63b65554c73c95ad4761cb8e4acaad6239817b22c5de7
size 18034

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8b6e542d637b348b22982d528c93ff9a3e70f2f833342b1d0941b9f265a5c2aa
size 18798