Send My Location (#770)
- https://github.com/vector-im/element-meta/issues/1682
This commit is contained in:
@@ -37,4 +37,8 @@ data class Location(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toGeoUri(): String {
|
||||
return "geo:$lat,$lon;u=$accuracy"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -20,4 +20,5 @@ import io.element.android.features.location.api.Location
|
||||
|
||||
interface LocationActions {
|
||||
fun share(location: Location, label: String?)
|
||||
fun openSettings()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -16,6 +16,6 @@
|
||||
|
||||
package io.element.android.services.toolbox.api.systemclock
|
||||
|
||||
interface SystemClock {
|
||||
fun interface SystemClock {
|
||||
fun epochMillis(): Long
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user