Send My Location (#770)
- https://github.com/vector-im/element-meta/issues/1682
This commit is contained in:
parent
68c2aa8822
commit
3c45a5ece4
55 changed files with 1351 additions and 767 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dcd8ccab99efd822f085614edb7296e5d73f3c1ae8d84ec0ccf930ead56bfa13
|
||||
size 7803
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e6040743cf442f6e3069eb77f562d6803eb9243edc959c0db7b5631ad00c583b
|
||||
size 7103
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e45014ab4619ebeb90de5c69320b3ec4be51b6f1dbf8d6c8da9fec44ec0e8a2f
|
||||
size 20840
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4c0a503af0cbcf66a71fd93fdba8cc710a5f6a3ad847bc5af36b68b2c2e7eef3
|
||||
size 34482
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9b90f17d03c0200600356dc2f694c92703ad34dcf3721fcfb3e4f37fcfbb6e55
|
||||
size 33552
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e45014ab4619ebeb90de5c69320b3ec4be51b6f1dbf8d6c8da9fec44ec0e8a2f
|
||||
size 20840
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:db911f98b01b92cff861787a2cdc2a01fa6d9fc0281a19075b2bf2ebc9f8f109
|
||||
size 20971
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:90000e70e71b582c32f1699c1a968725e7b6a281af4b5b2e3e64eed33d5f7916
|
||||
size 19358
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:890f5dfb312fdbc3f942b7e719e2e1f12759d1348917b80d57b500332eb0bf4c
|
||||
size 32109
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:718b3c631a73d60950f1c945e8c0c31891eb9734223a8aecbcd7407baaa3bd5f
|
||||
size 31290
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:90000e70e71b582c32f1699c1a968725e7b6a281af4b5b2e3e64eed33d5f7916
|
||||
size 19358
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f7cc4fb19532e0c88107f0adabfef4859d4ab190ace800523490a81687ca674e
|
||||
size 19435
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:56ef973e3f944265aac38f15a1e5f339e5b8e6923a1db783fda20a846aeea149
|
||||
size 10521
|
||||
oid sha256:364f107ffaa4844d0141361642ce3a494a187588f27be50b5fd27d44be21fa64
|
||||
size 8887
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5760854d139072805765e75856e19947df525c8ab1e93d85b73a4db82e778d66
|
||||
size 13825
|
||||
oid sha256:f19878925f3b5b377a91885540fb15d29a5b78d8be2282d64e8809af0bbf5ff4
|
||||
size 12195
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:aba8f92a7d7924e98efeb17b5d0c3a83e555edb94809df6446a28e2ea38a3fb2
|
||||
size 23287
|
||||
oid sha256:c936d2d804bc9e98fcc49430f11ddaa572b05fc8d3a0df93ad6521ee8e78f708
|
||||
size 21806
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8f1203a54cc36ceb6bfd50ddb1db9e55efdfa2fe09ad03eaca685bb306ad5c64
|
||||
size 10506
|
||||
oid sha256:ac01bc1992e3fa27950c7071cd3e8a06b94a608238d55972816fa2a1a3175e7c
|
||||
size 9448
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b35436aff6308e353bf52f837f0d267e39b73a529523cef8c6df937792b61be8
|
||||
size 14330
|
||||
oid sha256:065d2a09680e35540b870862ec0ba5c54182e016017938ef64e510b6132333c9
|
||||
size 13389
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f68bec570d74cbf375688b40ec27ac2b6a1048c315cb54472db78260edcc112f
|
||||
size 25297
|
||||
oid sha256:0bb88c64bc68b10b1fb709135f445de5a5e4d78623448d0fef97504e025d5f6d
|
||||
size 24551
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c67fe85270b99a4a23a63b65554c73c95ad4761cb8e4acaad6239817b22c5de7
|
||||
size 18034
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8b6e542d637b348b22982d528c93ff9a3e70f2f833342b1d0941b9f265a5c2aa
|
||||
size 18798
|
||||
Loading…
Add table
Add a link
Reference in a new issue