Show location events in timeline
Not in scope: interacting with the timeline items, reply formatting. These will be implemented separately. Closes #689
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
private const val GEO_URI_REGEX = """geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitude>-?\d+(?:\.\d+)?)(?:;u=(?<uncertainty>\d+(?:\.\d+)?))?"""
|
||||
|
||||
fun parseGeoUri(geoUri: String): Location? {
|
||||
val result = Regex(GEO_URI_REGEX).matchEntire(geoUri) ?: return null
|
||||
return Location (
|
||||
lat = result.groups["latitude"]?.value?.toDoubleOrNull() ?: return null,
|
||||
lon = result.groups["longitude"]?.value?.toDoubleOrNull() ?: return null,
|
||||
accuracy = result.groups["uncertainty"]?.value?.toFloatOrNull() ?: 0f,
|
||||
)
|
||||
}
|
||||
@@ -34,10 +34,12 @@ import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
import io.element.android.features.location.api.internal.AttributionPlacement
|
||||
import io.element.android.features.location.api.internal.StaticMapPlaceholder
|
||||
import io.element.android.features.location.api.internal.buildStaticMapsApiUrl
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import timber.log.Timber
|
||||
@@ -73,9 +75,13 @@ fun StaticMapView(
|
||||
lat = lat,
|
||||
lon = lon,
|
||||
desiredZoom = zoom,
|
||||
desiredWidth = constraints.maxWidth,
|
||||
desiredHeight = constraints.maxHeight,
|
||||
darkMode = darkMode,
|
||||
attributionPlacement = AttributionPlacement.BottomLeft,
|
||||
// Size the map based on DP rather than pixels, as otherwise the features and attribution
|
||||
// end up being illegibly tiny on high density displays.
|
||||
desiredWidth = constraints.maxWidth.toDp().value.toInt(),
|
||||
desiredHeight = constraints.maxHeight.toDp().value.toInt(),
|
||||
doubleScale = true,
|
||||
)
|
||||
)
|
||||
.size(width = constraints.maxWidth, height = constraints.maxHeight)
|
||||
|
||||
@@ -23,7 +23,7 @@ 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_SCALE_2X = "@2x"
|
||||
private const val STATIC_MAP_MAX_WIDTH_HEIGHT = 2048
|
||||
private const val STATIC_MAP_MAX_ZOOM = 22.0
|
||||
|
||||
@@ -35,6 +35,14 @@ internal fun buildTileServerUrl(
|
||||
"$BASE_URL/maps/$DARK_MAP_ID/style.json?key=$API_KEY"
|
||||
}
|
||||
|
||||
internal enum class AttributionPlacement(val value: String) {
|
||||
BottomRight("bottomright"),
|
||||
BottomLeft("bottomleft"),
|
||||
TopLeft("topleft"),
|
||||
TopRight("topright"),
|
||||
Hidden("false"),
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a valid URL for maptiler.com static map api based on the given params.
|
||||
*
|
||||
@@ -50,7 +58,9 @@ internal fun buildStaticMapsApiUrl(
|
||||
desiredZoom: Double,
|
||||
desiredWidth: Int,
|
||||
desiredHeight: Int,
|
||||
darkMode: Boolean
|
||||
darkMode: Boolean,
|
||||
doubleScale: Boolean,
|
||||
attributionPlacement: AttributionPlacement,
|
||||
): String {
|
||||
require(desiredWidth > 0 && desiredHeight > 0) {
|
||||
"Width ($desiredHeight) and height ($desiredHeight) must be > 0"
|
||||
@@ -72,9 +82,10 @@ internal fun buildStaticMapsApiUrl(
|
||||
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"
|
||||
}
|
||||
|
||||
val mapId = if (darkMode) DARK_MAP_ID else LIGHT_MAP_ID
|
||||
val scaleSuffix = if (doubleScale) STATIC_MAP_SCALE_2X else ""
|
||||
|
||||
return "$BASE_URL/maps/$mapId/static/${lon},${lat},${zoom}/${width}x${height}${scaleSuffix}.$STATIC_MAP_FORMAT" +
|
||||
"?key=$API_KEY&attribution=${attributionPlacement.value}"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
internal class GeoUrisKtTest {
|
||||
|
||||
@Test
|
||||
fun `parseGeoUri - returns null for invalid urls`() {
|
||||
assertThat(parseGeoUri("")).isNull()
|
||||
assertThat(parseGeoUri("http://example.com/")).isNull()
|
||||
assertThat(parseGeoUri("geo:")).isNull()
|
||||
assertThat(parseGeoUri("geo:1.234")).isNull()
|
||||
assertThat(parseGeoUri("geo:1.234,")).isNull()
|
||||
assertThat(parseGeoUri("geo:,1.234")).isNull()
|
||||
assertThat(parseGeoUri("notgeo:1.234,5.678")).isNull()
|
||||
assertThat(parseGeoUri("geo:+1.234,5.678")).isNull()
|
||||
assertThat(parseGeoUri("geo:+1.234,*5.678")).isNull()
|
||||
assertThat(parseGeoUri("geo:not,good")).isNull()
|
||||
assertThat(parseGeoUri("geo:1.234,5.678;u=wrong")).isNull()
|
||||
assertThat(parseGeoUri("geo:1.234,5.678trailing")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseGeoUri - returns location for valid urls`() {
|
||||
assertThat(parseGeoUri("geo:1.234,5.678")).isEqualTo(Location(
|
||||
lat = 1.234,
|
||||
lon = 5.678,
|
||||
accuracy = 0f,
|
||||
))
|
||||
|
||||
assertThat(parseGeoUri("geo:1,5")).isEqualTo(Location(
|
||||
lat = 1.0,
|
||||
lon = 5.0,
|
||||
accuracy = 0f,
|
||||
))
|
||||
|
||||
assertThat(parseGeoUri("geo:1.234,5.678;u=3000")).isEqualTo(Location(
|
||||
lat = 1.234,
|
||||
lon = 5.678,
|
||||
accuracy = 3000f,
|
||||
))
|
||||
|
||||
assertThat(parseGeoUri("geo:1,5;u=3000")).isEqualTo(Location(
|
||||
lat = 1.0,
|
||||
lon = 5.0,
|
||||
accuracy = 3000f,
|
||||
))
|
||||
|
||||
assertThat(parseGeoUri("geo:-1.234,-5.678;u=9.10")).isEqualTo(Location(
|
||||
lat = -1.234,
|
||||
lon = -5.678,
|
||||
accuracy = 9.10f,
|
||||
))
|
||||
|
||||
assertThat(parseGeoUri("geo:-1,-5;u=9.10")).isEqualTo(Location(
|
||||
lat = -1.0,
|
||||
lon = -5.0,
|
||||
accuracy = 9.10f,
|
||||
))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,7 +17,6 @@
|
||||
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 {
|
||||
@@ -30,10 +29,13 @@ class BuildStaticMapsApiUrlTest {
|
||||
desiredZoom = 1.2,
|
||||
desiredWidth = 100,
|
||||
desiredHeight = 200,
|
||||
darkMode = false
|
||||
darkMode = false,
|
||||
doubleScale = false,
|
||||
attributionPlacement = AttributionPlacement.BottomLeft,
|
||||
)
|
||||
).isEqualTo(
|
||||
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200.webp?key=fU3vlMsMn4Jb6dnEIFsx"
|
||||
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200.webp" +
|
||||
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,10 +48,51 @@ class BuildStaticMapsApiUrlTest {
|
||||
desiredZoom = 1.2,
|
||||
desiredWidth = 100,
|
||||
desiredHeight = 200,
|
||||
darkMode = true
|
||||
darkMode = true,
|
||||
doubleScale = false,
|
||||
attributionPlacement = AttributionPlacement.BottomLeft,
|
||||
)
|
||||
).isEqualTo(
|
||||
"https://api.maptiler.com/maps/dea61faf-292b-4774-9660-58fcef89a7f3/static/5.678,1.234,1.2/100x200.webp?key=fU3vlMsMn4Jb6dnEIFsx"
|
||||
"https://api.maptiler.com/maps/dea61faf-292b-4774-9660-58fcef89a7f3/static/5.678,1.234,1.2/100x200.webp" +
|
||||
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildStaticMapsApiUrl builds double scale mode url`() {
|
||||
assertThat(
|
||||
buildStaticMapsApiUrl(
|
||||
lat = 1.234,
|
||||
lon = 5.678,
|
||||
desiredZoom = 1.2,
|
||||
desiredWidth = 100,
|
||||
desiredHeight = 200,
|
||||
darkMode = false,
|
||||
doubleScale = true,
|
||||
attributionPlacement = AttributionPlacement.BottomLeft,
|
||||
)
|
||||
).isEqualTo(
|
||||
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200@2x.webp" +
|
||||
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildStaticMapsApiUrl builds no attribution url`() {
|
||||
assertThat(
|
||||
buildStaticMapsApiUrl(
|
||||
lat = 1.234,
|
||||
lon = 5.678,
|
||||
desiredZoom = 1.2,
|
||||
desiredWidth = 100,
|
||||
desiredHeight = 200,
|
||||
darkMode = false,
|
||||
doubleScale = false,
|
||||
attributionPlacement = AttributionPlacement.Hidden,
|
||||
)
|
||||
).isEqualTo(
|
||||
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200.webp" +
|
||||
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=false"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,10 +105,13 @@ class BuildStaticMapsApiUrlTest {
|
||||
desiredZoom = 100.0,
|
||||
desiredWidth = 8192,
|
||||
desiredHeight = 4096,
|
||||
darkMode = false
|
||||
darkMode = false,
|
||||
doubleScale = false,
|
||||
attributionPlacement = AttributionPlacement.BottomLeft,
|
||||
)
|
||||
).isEqualTo(
|
||||
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,22.0/2048x1024.webp?key=fU3vlMsMn4Jb6dnEIFsx"
|
||||
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,22.0/2048x1024.webp" +
|
||||
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user