diff --git a/build.gradle.kts b/build.gradle.kts index 240af6c9ae..b0bcde29a8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -247,6 +247,7 @@ koverMerged { excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*" excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState" excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState" + excludes += "io.element.android.features.location.api.MapState" } bound { minValue = 90 diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts new file mode 100644 index 0000000000..a2f73e7def --- /dev/null +++ b/features/location/api/build.gradle.kts @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.features.location.api" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.network) + implementation(projects.libraries.core) + implementation(projects.libraries.uiStrings) + implementation(libs.maplibre) + implementation(libs.network.retrofit) + implementation(libs.maplibre.annotation) + implementation(libs.coil.compose) + implementation(libs.serialization.json) + implementation(libs.accompanist.permission) + ksp(libs.showkase.processor) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.matrix.test) +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt new file mode 100644 index 0000000000..596bc4d1a0 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt @@ -0,0 +1,26 @@ +/* + * 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.api + +/** + * Represents a location sample emitted by the device's location subsystem. + */ +data class Location( + val lat: Double, + val lon: Double, + val accuracy: Float, +) diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/MapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/MapView.kt new file mode 100644 index 0000000000..464422d713 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/MapView.kt @@ -0,0 +1,297 @@ +/* + * 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.api + +import android.annotation.SuppressLint +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +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.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 io.element.android.features.location.api.internal.buildTileServerUrl +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.FloatingActionButton +import io.element.android.libraries.designsystem.theme.components.Icon +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import timber.log.Timber +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** + * Composable wrapper around MapLibre's [MapView]. + */ +@SuppressLint("MissingPermission") +@Composable +fun MapView( + modifier: Modifier = Modifier, + mapState: MapState = rememberMapState(), + darkMode: Boolean = !ElementTheme.colors.isLight, + onLocationClick: () -> Unit, +) { + // 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) + return + } + + val context = LocalContext.current + val mapView = remember { + Mapbox.getInstance(context) + MapView(context) + } + var mapRefs by remember { mutableStateOf(null) } + + // Build map + LaunchedEffect(darkMode) { + mapView.awaitMap().let { map -> + map.uiSettings.apply { + isCompassEnabled = false + } + map.setStyle(buildTileServerUrl(darkMode = 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(R.drawable.pin)?.let { mapRefs.style.addImage("pin", it) } + mapRefs.symbolManager.create( + SymbolOptions() + .withLatLng(LatLng(location.lat, location.lon)) + .withIconImage("pin") + .withIconSize(1.3f) + ) + 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") + Box(modifier = modifier) { + AndroidView(factory = { mapView }) + FloatingActionButton( + onClick = onLocationClick, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + ) { + Icon( + imageVector = Icons.Filled.LocationOn, + contentDescription = null, // TODO + ) + } + } +} + +@Composable +fun rememberMapState( + position: MapState.CameraPosition = MapState.CameraPosition(lat = 0.0, lon = 0.0, zoom = 0.0), + location: Location? = null, + markers: ImmutableList = emptyList().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 = emptyList().toImmutableList(), // The pin's location, if any. +) { + var position: CameraPosition by mutableStateOf(position) + var location: Location? by mutableStateOf(location) + var markers: ImmutableList 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 = R.drawable.pin, + lat = 0.0, + lon = 0.0, + ) + ).toImmutableList() + ), + onLocationClick = {}, + ) +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt new file mode 100644 index 0000000000..e68c42368f --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -0,0 +1,135 @@ +/* + * 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.api + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImagePainter +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.features.location.api.internal.StaticMapPlaceholder +import io.element.android.features.location.api.internal.buildStaticMapsApiUrl +import timber.log.Timber + +/** + * Shows a static map image downloaded via a third party service's static maps API. + */ +@Composable +fun StaticMapView( + lat: Double, + lon: Double, + zoom: Double, + contentDescription: String?, + modifier: Modifier = Modifier, + darkMode: Boolean = !ElementTheme.colors.isLight, +) { + // Using BoxWithConstraints to: + // 1) Size the inner Image to the same Dp size of the outer BoxWithConstraints. + // 2) Request the static map image of the exact required size in Px to fill the AsyncImage. + BoxWithConstraints( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + var retryHash by remember { mutableStateOf(0) } + val painter = rememberAsyncImagePainter( + model = if (constraints.isZero) { + // Avoid building a URL if any of the size constraints is zero (else it will thrown an exception). + null + } else { + ImageRequest.Builder(LocalContext.current) + .data( + buildStaticMapsApiUrl( + lat = lat, + lon = lon, + desiredZoom = zoom, + desiredWidth = constraints.maxWidth, + desiredHeight = constraints.maxHeight, + darkMode = darkMode, + ) + ) + .size(width = constraints.maxWidth, height = constraints.maxHeight) + .setParameter("retry_hash", retryHash, memoryCacheKey = null) + .build() + }.apply { + Timber.d("Static map image request: ${this?.data}") + } + ) + + if (painter.state is AsyncImagePainter.State.Success) { + Image( + painter = painter, + contentDescription = contentDescription, + modifier = Modifier.size(width = maxWidth, height = maxHeight), + // The returned image can be smaller than the requested size due to the static maps API having + // a max width and height of 2048 px. See buildStaticMapsApiUrl() for more details. + // We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case. + contentScale = ContentScale.Fit, + ) + Icon( + resourceId = R.drawable.pin, + contentDescription = null, + tint = Color.Unspecified + ) + } else { + StaticMapPlaceholder( + showProgress = painter.state is AsyncImagePainter.State.Loading, + contentDescription = contentDescription, + modifier = Modifier.size(width = maxWidth, height = maxHeight), + darkMode = darkMode, + onLoadMapClick = { retryHash++ } + ) + } + } +} + +@Preview +@Composable +fun StaticMapViewLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +fun StaticMapViewDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + StaticMapView( + lat = 0.0, + lon = 0.0, + zoom = 0.0, + contentDescription = null, + modifier = Modifier.size(400.dp), + ) +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapsUtils.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapsUtils.kt new file mode 100644 index 0000000000..f5a15e46c6 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapsUtils.kt @@ -0,0 +1,80 @@ +/* + * 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.api.internal + +import kotlin.math.roundToInt + +private const val API_KEY = "fU3vlMsMn4Jb6dnEIFsx" +private const val BASE_URL = "https://api.maptiler.com" +private const val LIGHT_MAP_ID = "9bc819c8-e627-474a-a348-ec144fe3d810" +private const val DARK_MAP_ID = "dea61faf-292b-4774-9660-58fcef89a7f3" +private const val STATIC_MAP_FORMAT = "webp" +private const val STATIC_MAP_SCALE = "" // Either "" (empty string) for normal image or "@2x" for retina images. +private const val STATIC_MAP_MAX_WIDTH_HEIGHT = 2048 +private const val STATIC_MAP_MAX_ZOOM = 22.0 + +internal fun buildTileServerUrl( + darkMode: Boolean +): String = if (!darkMode) { + "$BASE_URL/maps/$LIGHT_MAP_ID/style.json?key=$API_KEY" +} else { + "$BASE_URL/maps/$DARK_MAP_ID/style.json?key=$API_KEY" +} + +/** + * Builds a valid URL for maptiler.com static map api based on the given params. + * + * Coerces width and height to the API maximum of 2048 keeping the requested aspect ratio. + * Coerces zoom to the API maximum of 22. + * + * NB: This will throw if either width or height are <= 0. You need to handle this case upstream + * (hint: views can't have negative width or height but can have 0 width or height sometimes). + */ +internal fun buildStaticMapsApiUrl( + lat: Double, + lon: Double, + desiredZoom: Double, + desiredWidth: Int, + desiredHeight: Int, + darkMode: Boolean +): String { + require(desiredWidth > 0 && desiredHeight > 0) { + "Width ($desiredHeight) and height ($desiredHeight) must be > 0" + } + require(desiredZoom >= 0) { "Zoom ($desiredZoom) must be >= 0" } + val zoom = desiredZoom.coerceAtMost(STATIC_MAP_MAX_ZOOM) // API will error if outside 0-22 range. + val width: Int + val height: Int + if (desiredWidth <= STATIC_MAP_MAX_WIDTH_HEIGHT && desiredHeight <= STATIC_MAP_MAX_WIDTH_HEIGHT) { + width = desiredWidth + height = desiredHeight + } else { + val aspectRatio = desiredWidth.toDouble() / desiredHeight.toDouble() + if (desiredWidth >= desiredHeight) { + width = desiredWidth.coerceAtMost(STATIC_MAP_MAX_WIDTH_HEIGHT) + height = (width / aspectRatio).roundToInt() + } else { + height = desiredHeight.coerceAtMost(STATIC_MAP_MAX_WIDTH_HEIGHT) + width = (height * aspectRatio).roundToInt() + } + } + return if (!darkMode) { + "$BASE_URL/maps/$LIGHT_MAP_ID/static/${lon},${lat},${zoom}/${width}x${height}$STATIC_MAP_SCALE.$STATIC_MAP_FORMAT?key=$API_KEY" + } else { + "$BASE_URL/maps/$DARK_MAP_ID/static/${lon},${lat},${zoom}/${width}x${height}$STATIC_MAP_SCALE.$STATIC_MAP_FORMAT?key=$API_KEY" + } +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt new file mode 100644 index 0000000000..d39bfd7d15 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt @@ -0,0 +1,111 @@ +/* + * 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.api.internal + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.features.location.api.R +import io.element.android.libraries.ui.strings.R as StringsR + +@Composable +internal fun StaticMapPlaceholder( + showProgress: Boolean, + contentDescription: String?, + modifier: Modifier = Modifier, + darkMode: Boolean = !ElementTheme.colors.isLight, + onLoadMapClick: () -> Unit, +) { + Box( + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource( + id = if (darkMode) R.drawable.blurred_map_dark + else R.drawable.blurred_map_light + ), + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.FillBounds, + ) + if (showProgress) { + CircularProgressIndicator() + } else { + Box( + modifier = modifier.clickable(onClick = onLoadMapClick), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null + ) + Text(text = stringResource(id = StringsR.string.action_static_map_load)) + } + } + } + } +} + +@Preview +@Composable +fun StaticMapPlaceholderLightPreview( + @PreviewParameter(BooleanParameterProvider::class) values: Boolean +) = ElementPreviewLight { ContentToPreview(values) } + +@Preview +@Composable +fun StaticMapPlaceholderDarkPreview( + @PreviewParameter(BooleanParameterProvider::class) values: Boolean +) = ElementPreviewDark { ContentToPreview(values) } + +@Composable +private fun ContentToPreview(showProgress: Boolean) { + StaticMapPlaceholder( + showProgress = showProgress, + contentDescription = null, + modifier = Modifier.size(400.dp), + onLoadMapClick = {}, + ) +} + +internal class BooleanParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf(true, false) +} diff --git a/features/location/api/src/main/res/drawable/blurred_map_dark.png b/features/location/api/src/main/res/drawable/blurred_map_dark.png new file mode 100644 index 0000000000..7e90d568f1 Binary files /dev/null and b/features/location/api/src/main/res/drawable/blurred_map_dark.png differ diff --git a/features/location/api/src/main/res/drawable/blurred_map_light.png b/features/location/api/src/main/res/drawable/blurred_map_light.png new file mode 100644 index 0000000000..365cf96786 Binary files /dev/null and b/features/location/api/src/main/res/drawable/blurred_map_light.png differ diff --git a/features/location/api/src/main/res/drawable/pin.xml b/features/location/api/src/main/res/drawable/pin.xml new file mode 100644 index 0000000000..9f47b9024f --- /dev/null +++ b/features/location/api/src/main/res/drawable/pin.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/BuildStaticMapsApiUrlTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/BuildStaticMapsApiUrlTest.kt new file mode 100644 index 0000000000..023c7be365 --- /dev/null +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/BuildStaticMapsApiUrlTest.kt @@ -0,0 +1,71 @@ +/* + * 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.api.internal + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.location.api.internal.buildStaticMapsApiUrl +import org.junit.Test + +class BuildStaticMapsApiUrlTest { + @Test + fun `buildStaticMapsApiUrl builds light mode url`() { + assertThat( + buildStaticMapsApiUrl( + lat = 1.234, + lon = 5.678, + desiredZoom = 1.2, + desiredWidth = 100, + desiredHeight = 200, + darkMode = false + ) + ).isEqualTo( + "https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200.webp?key=fU3vlMsMn4Jb6dnEIFsx" + ) + } + + @Test + fun `buildStaticMapsApiUrl builds dark mode url`() { + assertThat( + buildStaticMapsApiUrl( + lat = 1.234, + lon = 5.678, + desiredZoom = 1.2, + desiredWidth = 100, + desiredHeight = 200, + darkMode = true + ) + ).isEqualTo( + "https://api.maptiler.com/maps/dea61faf-292b-4774-9660-58fcef89a7f3/static/5.678,1.234,1.2/100x200.webp?key=fU3vlMsMn4Jb6dnEIFsx" + ) + } + + @Test + fun `buildStaticMapsApiUrl coerces zoom at 22 and width and height at max 2048 keeping aspect ratio`() { + assertThat( + buildStaticMapsApiUrl( + lat = 1.234, + lon = 5.678, + desiredZoom = 100.0, + desiredWidth = 8192, + desiredHeight = 4096, + darkMode = false + ) + ).isEqualTo( + "https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,22.0/2048x1024.webp?key=fU3vlMsMn4Jb6dnEIFsx" + ) + } +} diff --git a/features/location/fake/build.gradle.kts b/features/location/fake/build.gradle.kts new file mode 100644 index 0000000000..cceab3f2b7 --- /dev/null +++ b/features/location/fake/build.gradle.kts @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "io.element.android.features.location.fake" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + api(projects.features.location.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.di) + implementation(projects.libraries.network) + implementation(projects.libraries.core) + implementation(libs.maplibre) + implementation(libs.network.retrofit) + implementation(libs.maplibre.annotation) + implementation(libs.coil.compose) + implementation(libs.serialization.json) + implementation(libs.accompanist.permission) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.matrix.test) +} diff --git a/features/location/fake/src/main/kotlin/io/element/android/features/location/fake/LocationUpdatesFlowFake.kt b/features/location/fake/src/main/kotlin/io/element/android/features/location/fake/LocationUpdatesFlowFake.kt new file mode 100644 index 0000000000..c3f070acbb --- /dev/null +++ b/features/location/fake/src/main/kotlin/io/element/android/features/location/fake/LocationUpdatesFlowFake.kt @@ -0,0 +1,35 @@ +/* + * 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.fake + +import io.element.android.features.location.api.Location +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +fun fakeLocationUpdatesFlow(): Flow = flow { + while (true) { + delay(1_000) + emit(aLocation()) + } +} + +private fun aLocation() = Location( + lat = 51.49404, + lon = -0.25484, + accuracy = 5f +) diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts new file mode 100644 index 0000000000..66d29fd6bb --- /dev/null +++ b/features/location/impl/build.gradle.kts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.features.location.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + api(projects.features.location.api) + implementation(projects.libraries.di) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.network) + implementation(projects.libraries.core) + implementation(libs.maplibre) + implementation(libs.network.retrofit) + implementation(libs.maplibre.annotation) + implementation(libs.coil.compose) + implementation(libs.serialization.json) + implementation(libs.accompanist.permission) + ksp(libs.showkase.processor) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.matrix.test) +} diff --git a/features/location/impl/src/main/AndroidManifest.xml b/features/location/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..b4f5d8f271 --- /dev/null +++ b/features/location/impl/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/LocationUpdatesFlowImpl.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/LocationUpdatesFlowImpl.kt new file mode 100644 index 0000000000..11b1e2a02d --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/LocationUpdatesFlowImpl.kt @@ -0,0 +1,96 @@ +/* + * 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.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.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.features.location.api.Location +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 = 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() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9a4d549f77..2bf327ed58 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -155,6 +155,8 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0" vanniktech_emoji = "com.vanniktech:emoji-google:0.16.0" telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } statemachine = "com.freeletics.flowredux:compose:1.1.0" +maplibre = "org.maplibre.gl:android-sdk:10.2.0" +maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:1.0.0" # Analytics posthog = "com.posthog.android:posthog:2.0.3" @@ -186,6 +188,7 @@ android_application = { id = "com.android.application", version.ref = "android_g android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" } kotlin_android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin_jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin_serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } anvil = { id = "com.squareup.anvil", version.ref = "anvil" } diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..04f26d7d81 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:407a17fca1405575861b7c8861222a5d529778eeaeb904c3058fe19ff9f809fc +size 143328 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6aebd55728 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95068257a39fc8a693adce87c54b605be395de5fd639ee00b7d34dde5bce568a +size 147055 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..97787cadcd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:819c585286d2ef5c7064766d537b9da62be54b67340a5a7a44e94dcf53b1caf4 +size 277318 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7efba0b598 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c0e68e68e1cf4f735671b5abcfb4e0c936ca1a00ead817e8f010d39f5b753252 +size 280801 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..78ba79757e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d34ac54f8c46bc3752366adeefb0952b23f052f9359b48864a6a43455334a6d +size 4965 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..04f26d7d81 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:407a17fca1405575861b7c8861222a5d529778eeaeb904c3058fe19ff9f809fc +size 143328 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..97787cadcd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:819c585286d2ef5c7064766d537b9da62be54b67340a5a7a44e94dcf53b1caf4 +size 277318