Send My Location (#770)
- https://github.com/vector-im/element-meta/issues/1682
This commit is contained in:
parent
6520419c4b
commit
278f8ae4c6
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,
|
contentDescription = null,
|
||||||
tint = Color.Unspecified,
|
tint = Color.Unspecified,
|
||||||
modifier = Modifier.align { size, space, _ ->
|
modifier = Modifier.align { size, space, _ ->
|
||||||
|
// Center bottom edge of pin (i.e. its arrow) to center of screen
|
||||||
IntOffset(
|
IntOffset(
|
||||||
x = (space.width - size.width) / 2,
|
x = (space.width - size.width) / 2,
|
||||||
y = (space.height / 2) - size.height,
|
y = (space.height / 2) - size.height,
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,11 @@
|
||||||
package io.element.android.features.location.api.internal
|
package io.element.android.features.location.api.internal
|
||||||
|
|
||||||
import android.content.Context
|
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.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.
|
* 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"
|
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.
|
* Provides the URL to a MapLibre style document, used for rendering dynamic maps.
|
||||||
*/
|
*/
|
||||||
fun tileStyleUrl(
|
private fun tileStyleUrl(
|
||||||
context: Context,
|
context: Context,
|
||||||
darkMode: Boolean,
|
darkMode: Boolean,
|
||||||
): String {
|
): 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 {
|
dependencies {
|
||||||
api(projects.features.location.api)
|
api(projects.features.location.api)
|
||||||
|
implementation(projects.features.messages.api)
|
||||||
|
implementation(projects.libraries.maplibreCompose)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
implementation(projects.libraries.di)
|
implementation(projects.libraries.di)
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
implementation(libs.maplibre)
|
implementation(libs.accompanist.permission)
|
||||||
implementation(libs.maplibre.annotation)
|
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(libs.dagger)
|
implementation(libs.dagger)
|
||||||
implementation(projects.anvilannotations)
|
implementation(projects.anvilannotations)
|
||||||
|
implementation(projects.services.toolbox.api)
|
||||||
anvil(projects.anvilcodegen)
|
anvil(projects.anvilcodegen)
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
||||||
|
|
@ -52,4 +56,6 @@ dependencies {
|
||||||
testImplementation(libs.test.turbine)
|
testImplementation(libs.test.turbine)
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
|
testImplementation(projects.features.analytics.test)
|
||||||
|
testImplementation(projects.features.messages.test)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
|
@ -22,6 +22,8 @@ import android.net.Uri
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.features.location.api.Location
|
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.AppScope
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -29,24 +31,25 @@ import javax.inject.Inject
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class AndroidLocationActions @Inject constructor(
|
class AndroidLocationActions @Inject constructor(
|
||||||
@ApplicationContext private val appContext: Context
|
@ApplicationContext private val context: Context
|
||||||
) : LocationActions {
|
) : LocationActions {
|
||||||
|
|
||||||
private var activityContext: Context? = null
|
|
||||||
|
|
||||||
override fun share(location: Location, label: String?) {
|
override fun share(location: Location, label: String?) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val uri = Uri.parse(buildUrl(location, label))
|
val uri = Uri.parse(buildUrl(location, label))
|
||||||
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
|
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
|
||||||
val chooserIntent = Intent.createChooser(showMapsIntent, null)
|
val chooserIntent = Intent.createChooser(showMapsIntent, null)
|
||||||
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
appContext.startActivity(chooserIntent)
|
context.startActivity(chooserIntent)
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
Timber.v("Open location succeed")
|
Timber.v("Open location succeed")
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
Timber.e(it, "Open location failed")
|
Timber.e(it, "Open location failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun openSettings() {
|
||||||
|
context.openAppSettingsPage()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@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.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.location.impl
|
package io.element.android.features.location.impl.permissions
|
||||||
|
|
||||||
sealed interface SendLocationEvents {
|
sealed interface PermissionsEvents {
|
||||||
data class ShareLocation(val lat: Double, val lng: Double) : SendLocationEvents
|
object RequestPermissions : PermissionsEvents
|
||||||
data class SwitchMode(val mode: SendLocationState.Mode) : SendLocationEvents
|
|
||||||
}
|
}
|
||||||
|
|
@ -14,14 +14,12 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.location.impl
|
package io.element.android.features.location.impl.permissions
|
||||||
|
|
||||||
data class SendLocationState(
|
import io.element.android.libraries.architecture.Presenter
|
||||||
val mode: Mode = Mode.ALocation,
|
|
||||||
val eventSink: (SendLocationEvents) -> Unit = {},
|
interface PermissionsPresenter : Presenter<PermissionsState> {
|
||||||
) {
|
interface Factory {
|
||||||
sealed interface Mode {
|
fun create(permissions: List<String>): PermissionsPresenter
|
||||||
object MyLocation : Mode
|
|
||||||
object ALocation : Mode
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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.
|
* 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
|
data class PermissionsState(
|
||||||
import kotlinx.coroutines.delay
|
val permissions: Permissions = Permissions.NoneGranted,
|
||||||
import kotlinx.coroutines.flow.Flow
|
val shouldShowRationale: Boolean = false,
|
||||||
import kotlinx.coroutines.flow.flow
|
val eventSink: (PermissionsEvents) -> Unit = {},
|
||||||
|
) {
|
||||||
fun fakeLocationUpdatesFlow(): Flow<Location> = flow {
|
sealed interface Permissions {
|
||||||
while (true) {
|
object AllGranted : Permissions
|
||||||
delay(1_000)
|
object SomeGranted : Permissions
|
||||||
emit(aLocation())
|
object NoneGranted : Permissions
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun aLocation() = Location(
|
val isAnyGranted: Boolean
|
||||||
lat = 51.49404,
|
get() = permissions is Permissions.SomeGranted || permissions is Permissions.AllGranted
|
||||||
lon = -0.25484,
|
}
|
||||||
accuracy = 5f
|
|
||||||
)
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
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.
|
* 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.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.lifecycle.subscribe
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
import im.vector.app.features.analytics.plan.MobileScreen
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
|
||||||
@ContributesNode(RoomScope::class)
|
@ContributesNode(RoomScope::class)
|
||||||
class SendLocationNode @AssistedInject constructor(
|
class SendLocationNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val presenter: SendLocationPresenter,
|
private val presenter: SendLocationPresenter,
|
||||||
|
analyticsService: AnalyticsService,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
lifecycle.subscribe(
|
||||||
|
onResume = {
|
||||||
|
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.LocationSend))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
SendLocationView(
|
SendLocationView(
|
||||||
state = presenter.present(),
|
state = presenter.present(),
|
||||||
modifier = modifier,
|
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.
|
* 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> {
|
sealed interface Dialog {
|
||||||
override val values: Sequence<SendLocationState>
|
object None : Dialog
|
||||||
get() = sequenceOf(
|
object PermissionRationale : Dialog
|
||||||
SendLocationState(),
|
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 {
|
interface LocationActions {
|
||||||
fun share(location: Location, label: String?)
|
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.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.location.impl.map.MapState
|
import com.mapbox.mapboxsdk.camera.CameraPosition
|
||||||
import io.element.android.features.location.impl.map.MapView
|
import com.mapbox.mapboxsdk.geometry.LatLng
|
||||||
import io.element.android.features.location.impl.map.rememberMapState
|
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.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
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.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
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.ElementTheme
|
||||||
import io.element.android.libraries.theme.compound.generated.TypographyTokens
|
import io.element.android.libraries.theme.compound.generated.TypographyTokens
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
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)
|
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -56,11 +64,6 @@ fun ShowLocationView(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onBackPressed: () -> Unit = {},
|
onBackPressed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val mapState = rememberMapState(
|
|
||||||
location = state.location,
|
|
||||||
position = MapState.CameraPosition(state.location.lat, state.location.lon, 15.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
Scaffold(modifier,
|
Scaffold(modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|
@ -100,10 +103,27 @@ fun ShowLocationView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
MapView(
|
MapboxMap(
|
||||||
mapState = mapState,
|
styleUri = rememberTileStyleUrl(),
|
||||||
modifier = Modifier.fillMaxSize(),
|
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 = {},
|
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 com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.features.location.impl.buildUrl
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,15 @@ class FakeLocationActions : LocationActions {
|
||||||
var sharedLabel: String? = null
|
var sharedLabel: String? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
var openSettingsInvocationsCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
override fun share(location: Location, label: String?) {
|
override fun share(location: Location, label: String?) {
|
||||||
sharedLocation = location
|
sharedLocation = location
|
||||||
sharedLabel = label
|
sharedLabel = label
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun openSettings() {
|
||||||
|
openSettingsInvocationsCount++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,8 +97,8 @@ class FakeMatrixRoom(
|
||||||
var reportedContentCount: Int = 0
|
var reportedContentCount: Int = 0
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var sendLocationCount: Int = 0
|
private val _sentLocations = mutableListOf<SendLocationInvocation>()
|
||||||
private set
|
val sentLocations: List<SendLocationInvocation> = _sentLocations
|
||||||
|
|
||||||
|
|
||||||
var invitedUserId: UserId? = null
|
var invitedUserId: UserId? = null
|
||||||
|
|
@ -279,7 +279,7 @@ class FakeMatrixRoom(
|
||||||
zoomLevel: Int?,
|
zoomLevel: Int?,
|
||||||
assetType: AssetType?,
|
assetType: AssetType?,
|
||||||
): Result<Unit> = simulateLongTask {
|
): Result<Unit> = simulateLongTask {
|
||||||
sendLocationCount++
|
_sentLocations.add(SendLocationInvocation(body, geoUri, description, zoomLevel, assetType))
|
||||||
return sendLocationResult
|
return sendLocationResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -381,3 +381,11 @@ class FakeMatrixRoom(
|
||||||
progressCallbackValues = values
|
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(
|
Image(
|
||||||
modifier = Modifier.size(12.5f.dp),
|
modifier = Modifier.size(12.5f.dp),
|
||||||
painter = painterResource(R.drawable.ic_add_attachment),
|
painter = painterResource(R.drawable.ic_add_attachment),
|
||||||
contentDescription = null,
|
contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment),
|
||||||
contentScale = ContentScale.Inside,
|
contentScale = ContentScale.Inside,
|
||||||
colorFilter = ColorFilter.tint(
|
colorFilter = ColorFilter.tint(
|
||||||
LocalContentColor.current
|
LocalContentColor.current
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<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_bullet_list">"Toggle bullet list"</string>
|
||||||
<string name="rich_text_editor_code_block">"Toggle code block"</string>
|
<string name="rich_text_editor_code_block">"Toggle code block"</string>
|
||||||
<string name="rich_text_editor_composer_placeholder">"Message…"</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_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_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_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_some_messages_have_not_been_sent">"Niektoré správy neboli odoslané"</string>
|
||||||
<string name="error_unknown">"Prepáčte, vyskytla sa chyba"</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>
|
<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_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_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_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_some_messages_have_not_been_sent">"Some messages have not been sent"</string>
|
||||||
<string name="error_unknown">"Sorry, an error occurred"</string>
|
<string name="error_unknown">"Sorry, an error occurred"</string>
|
||||||
<string name="invite_friends_rich_title">"🔐️ Join me on %1$s"</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
|
package io.element.android.services.toolbox.api.systemclock
|
||||||
|
|
||||||
interface SystemClock {
|
fun interface SystemClock {
|
||||||
fun epochMillis(): Long
|
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
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:56ef973e3f944265aac38f15a1e5f339e5b8e6923a1db783fda20a846aeea149
|
oid sha256:364f107ffaa4844d0141361642ce3a494a187588f27be50b5fd27d44be21fa64
|
||||||
size 10521
|
size 8887
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:5760854d139072805765e75856e19947df525c8ab1e93d85b73a4db82e778d66
|
oid sha256:f19878925f3b5b377a91885540fb15d29a5b78d8be2282d64e8809af0bbf5ff4
|
||||||
size 13825
|
size 12195
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:aba8f92a7d7924e98efeb17b5d0c3a83e555edb94809df6446a28e2ea38a3fb2
|
oid sha256:c936d2d804bc9e98fcc49430f11ddaa572b05fc8d3a0df93ad6521ee8e78f708
|
||||||
size 23287
|
size 21806
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8f1203a54cc36ceb6bfd50ddb1db9e55efdfa2fe09ad03eaca685bb306ad5c64
|
oid sha256:ac01bc1992e3fa27950c7071cd3e8a06b94a608238d55972816fa2a1a3175e7c
|
||||||
size 10506
|
size 9448
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b35436aff6308e353bf52f837f0d267e39b73a529523cef8c6df937792b61be8
|
oid sha256:065d2a09680e35540b870862ec0ba5c54182e016017938ef64e510b6132333c9
|
||||||
size 14330
|
size 13389
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f68bec570d74cbf375688b40ec27ac2b6a1048c315cb54472db78260edcc112f
|
oid sha256:0bb88c64bc68b10b1fb709135f445de5a5e4d78623448d0fef97504e025d5f6d
|
||||||
size 25297
|
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