From a9ce0df8f24521307a0e8420804a986fcaae34d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 21:36:56 +0000 Subject: [PATCH] feat: render road event (kind 1315/1316) location on an OSM map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd --- amethyst/build.gradle.kts | 3 + .../creators/location/LocationPreviewMap.kt | 128 ++++++++++++++++++ .../amethyst/ui/note/types/RoadEvent.kt | 70 ++++++---- gradle/libs.versions.toml | 2 + 4 files changed, 175 insertions(+), 28 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index c44598b810..c11ed91602 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -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" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt new file mode 100644 index 0000000000..2918045f30 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt @@ -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() + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt index d560d3d173..0c71ef351c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt @@ -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) { + 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? = resolveRoadEventPoint(latitude(), longitude(), geohashes()) + +private fun RoadEventConfirmationEvent.roadEventPoint(): Pair? = resolveRoadEventPoint(latitude(), longitude(), geohashes()) + +private fun resolveRoadEventPoint( + latitude: Double?, + longitude: Double?, + geohashes: List, +): Pair? { + 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 } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index be11853836..60600193f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }