feat: render road event (kind 1315/1316) location on an OSM map

Replace the reverse-geocoded "📍 City" line on the Roadstr road event
report and confirmation cards with an osmdroid OpenStreetMap preview
pinned at the event's coordinates.

- Add osmdroid-android 6.1.20 (Apache-2.0) to the version catalog and
  the amethyst module.
- New LocationPreviewMap composable: an AndroidView-wrapped MapView with
  MAPNIK tiles, a single marker, lifecycle-aware onResume/onPause/onDetach,
  and nested-scroll-friendly touch handling so panning the map doesn't
  fight the feed scroll. Sets the OSM User-Agent to the package name
  (required or OSM returns 403).
- RoadEvent cards resolve a point from the explicit lat/lon tags, falling
  back to the center of the finest published geohash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd
This commit is contained in:
Claude
2026-06-18 21:36:56 +00:00
parent 84976b0a3e
commit a9ce0df8f2
4 changed files with 175 additions and 28 deletions
+3
View File
@@ -408,6 +408,9 @@ dependencies {
implementation(libs.zxing)
implementation(libs.zxing.embedded)
// OpenStreetMap tiles for road event location maps (kind 1315/1316)
implementation(libs.osmdroid.android)
// Markdown
// implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
// implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.creators.location
import android.view.MotionEvent
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
/** Default close-up zoom for a single pinned location (street-level). */
private const val DEFAULT_ZOOM = 16.0
/**
* A small OpenStreetMap (osmdroid) preview centered on [latitude]/[longitude]
* with a single pin at that point.
*
* Used by the Roadstr road event cards (kind 1315/1316) in place of a
* reverse-geocoded city name. Tiles are fetched from the public OSM tile
* servers, which requires the app's package name as the User-Agent (set on
* [Configuration] below) — without it OSM returns HTTP 403.
*
* Pan/zoom stay enabled, but a touch listener asks the parent to stop
* intercepting gestures while the finger is on the map, so dragging the map
* pans it instead of scrolling the surrounding feed.
*/
@Composable
fun LocationPreviewMap(
latitude: Double,
longitude: Double,
modifier: Modifier = Modifier,
zoom: Double = DEFAULT_ZOOM,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val mapView =
remember(context) {
// Must be set before the MapView is created so OSM tile requests
// carry a valid User-Agent (the default "osmdroid" is rejected).
Configuration.getInstance().userAgentValue = context.packageName
MapView(context).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true)
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
setOnTouchListener { view, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> view.parent?.requestDisallowInterceptTouchEvent(true)
MotionEvent.ACTION_UP -> view.parent?.requestDisallowInterceptTouchEvent(false)
}
// Returning false lets the MapView still handle the pan/zoom.
false
}
}
}
// osmdroid's MapView is a plain Android View with its own lifecycle: it
// needs onResume/onPause to (re)start its tile threads, and onDetach to
// free the tile cache when the composable leaves the tree.
DisposableEffect(lifecycleOwner, mapView) {
val observer =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> mapView.onResume()
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
mapView.onDetach()
}
}
AndroidView(
modifier = modifier.fillMaxWidth().height(180.dp),
factory = { mapView },
update = { map ->
val point = GeoPoint(latitude, longitude)
map.controller.setZoom(zoom)
map.controller.setCenter(point)
map.overlays.removeAll { it is Marker }
val marker =
Marker(map).apply {
position = point
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
setInfoWindow(null)
}
map.overlays.add(marker)
map.invalidate()
},
)
}
@@ -20,30 +20,30 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.note.creators.location.LocationPreviewMap
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatus
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventType
import com.vitorpamplona.quartz.nip01Core.tags.geohash.toGeoHash
/** Emoji marker for a road event category — keeps the card icon-light and locale-independent. */
private fun RoadEventType.emoji(): String =
@@ -84,8 +84,8 @@ private fun RoadEventType.labelRes(): Int =
* Self-contained card for a Roadstr road event report (kind 1315).
*
* Shows the category (emoji + localized label), the optional free-text comment,
* and the location resolved from the finest published geohash. Identical in the
* feed and the opened thread view, so it takes no `makeItShort` flag — mirroring
* and a map pinned at the event's coordinates. Identical in the feed and the
* opened thread view, so it takes no `makeItShort` flag — mirroring
* [RenderBirdex].
*/
@Composable
@@ -94,7 +94,7 @@ fun RenderRoadEventReport(baseNote: Note) {
val type = remember(noteEvent) { noteEvent.roadEventType() }
val comment = remember(noteEvent) { noteEvent.content.trim() }
val geohash = remember(noteEvent) { noteEvent.geohashes().maxByOrNull { it.length } }
val point = remember(noteEvent) { noteEvent.roadEventPoint() }
Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) {
val title =
@@ -116,9 +116,9 @@ fun RenderRoadEventReport(baseNote: Note) {
)
}
if (geohash != null) {
Spacer(Modifier.height(6.dp))
RoadEventLocation(geohash)
if (point != null) {
Spacer(Modifier.height(8.dp))
RoadEventMap(point)
}
}
}
@@ -134,7 +134,7 @@ fun RenderRoadEventConfirmation(baseNote: Note) {
val noteEvent = baseNote.event as? RoadEventConfirmationEvent ?: return
val status = remember(noteEvent) { noteEvent.status() }
val geohash = remember(noteEvent) { noteEvent.geohashes().maxByOrNull { it.length } }
val point = remember(noteEvent) { noteEvent.roadEventPoint() }
Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) {
val emoji = if (status == RoadEventStatus.NO_LONGER_THERE) "❌" else "✅"
@@ -161,26 +161,40 @@ fun RenderRoadEventConfirmation(baseNote: Note) {
color = MaterialTheme.colorScheme.placeholderText,
)
if (geohash != null) {
Spacer(Modifier.height(6.dp))
RoadEventLocation(geohash)
if (point != null) {
Spacer(Modifier.height(8.dp))
RoadEventMap(point)
}
}
}
/** A single "📍 City" line that reverse-geocodes [geohash], falling back to the raw geohash. */
/** A rounded OpenStreetMap preview with a pin at the road event's [point]. */
@Composable
private fun RoadEventLocation(geohash: String) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = "📍", style = MaterialTheme.typography.bodyMedium)
LoadCityName(geohashStr = geohash) { cityName ->
Text(
text = cityName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
private fun RoadEventMap(point: Pair<Double, Double>) {
LocationPreviewMap(
latitude = point.first,
longitude = point.second,
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
)
}
/**
* Best available coordinate for the card map: the event's explicit `lat`/`lon`
* tags when present, otherwise the center of the finest published geohash.
* Returns null when the event carries no location at all.
*/
private fun RoadEventReportEvent.roadEventPoint(): Pair<Double, Double>? = resolveRoadEventPoint(latitude(), longitude(), geohashes())
private fun RoadEventConfirmationEvent.roadEventPoint(): Pair<Double, Double>? = resolveRoadEventPoint(latitude(), longitude(), geohashes())
private fun resolveRoadEventPoint(
latitude: Double?,
longitude: Double?,
geohashes: List<String>,
): Pair<Double, Double>? {
if (latitude != null && longitude != null) return latitude to longitude
val finest = geohashes.maxByOrNull { it.length } ?: return null
val decoded = runCatching { finest.toGeoHash() }.getOrNull() ?: return null
return decoded.centerLat to decoded.centerLon
}
+2
View File
@@ -55,6 +55,7 @@ negentropyKmp = "v1.0.2"
netUrlencoderLibVersion = "1.6.0"
navigationCompose = "2.9.8"
okhttp = "5.4.0"
osmdroid = "6.1.20"
runner = "1.7.0"
secp256k1KmpJniAndroid = "0.23.0"
schnorr256k1Kmp = "1.0.5"
@@ -193,6 +194,7 @@ negentropy-kmp = { module = "com.vitorpamplona.negentropy:kmp-negentropy", versi
net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" }
osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" }
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }