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..984c3fc985 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt @@ -0,0 +1,192 @@ +/* + * 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.graphics.ColorFilter +import android.graphics.ColorMatrixColorFilter +import android.graphics.drawable.BitmapDrawable +import android.view.MotionEvent +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +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.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.vitorpamplona.amethyst.ui.theme.isLight +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 + +/** + * Night-mode filter for the (always-light) MAPNIK tiles so the map follows the + * app theme. This is a lightness invert composed with a 180° hue rotation: it + * darkens the map (light land → near-black) while *preserving* hue, so forests + * stay green and water stays blue and the surfaces remain distinguishable — + * unlike a plain colour invert (forests → magenta) or a desaturating invert + * (everything → black). + */ +private val NIGHT_TILE_FILTER: ColorFilter = + ColorMatrixColorFilter( + floatArrayOf( + 0.574f, + -1.430f, + -0.144f, + 0f, + 255f, + -0.426f, + -0.430f, + -0.144f, + 0f, + 255f, + -0.426f, + -1.430f, + 0.856f, + 0f, + 255f, + 0f, + 0f, + 0f, + 1f, + 0f, + ), + ) + +/** + * 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. + * + * When [pinColor] and [pinEmoji] are supplied the marker becomes a colored + * teardrop with the category emoji on it (see [roadEventPinBitmap]); otherwise + * osmdroid's default pin is used. [pinAlpha] fades the marker to signal + * freshness (e.g. an event close to its effective expiry). + * + * 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, + pinColor: Color? = null, + pinEmoji: String? = null, + pinAlpha: Float = 1f, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val darkTheme = !MaterialTheme.colorScheme.isLight + + val markerIcon = + remember(pinColor, pinEmoji) { + if (pinColor != null && pinEmoji != null) { + val bitmap = roadEventPinBitmap(pinEmoji, pinColor.toArgb(), context.resources.displayMetrics.density) + BitmapDrawable(context.resources, bitmap) + } else { + null + } + } + + 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().aspectRatio(1f), + factory = { mapView }, + update = { map -> + val point = GeoPoint(latitude, longitude) + map.controller.setZoom(zoom) + map.controller.setCenter(point) + + // Follow the app theme: dim the bright MAPNIK tiles in dark mode. + map.overlayManager.tilesOverlay.setColorFilter(if (darkTheme) NIGHT_TILE_FILTER else null) + + map.overlays.removeAll { it is Marker } + val marker = + Marker(map).apply { + position = point + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + setInfoWindow(null) + markerIcon?.let { icon = it } + alpha = pinAlpha + } + map.overlays.add(marker) + map.invalidate() + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt new file mode 100644 index 0000000000..8e9f422ccc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt @@ -0,0 +1,102 @@ +/* + * 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.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import kotlin.math.roundToInt + +/** + * Draws a Roadstr-style map pin: a colored teardrop in [colorArgb] with [emoji] + * centered on its head, a white ring, and a soft drop shadow. + * + * The pin's tip sits at the bottom-center of the returned bitmap, so the + * osmdroid marker should be anchored at (0.5, 1.0) — the geographic point then + * lands exactly under the tip. + * + * Matches the visual language of the roadstr reference clients + * (): the same per-category color is used so + * a pin reads the same across implementations. + */ +fun roadEventPinBitmap( + emoji: String, + colorArgb: Int, + density: Float, +): Bitmap { + fun dp(value: Float) = value * density + + val head = dp(34f) // head diameter + val radius = head / 2f + val tail = dp(12f) // pointer height below the head + val pad = dp(5f) // room for the drop shadow + val ringWidth = dp(2.5f) + + val width = (head + pad * 2).roundToInt() + val height = (head + tail + pad * 2).roundToInt() + val cx = width / 2f + val cy = pad + radius + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + + // Head circle + pointer drawn as a single path so they share one shadow + // instead of seaming where the triangle meets the circle. + val body = + Path().apply { + addCircle(cx, cy, radius, Path.Direction.CW) + val mouth = radius * 0.7f + moveTo(cx - mouth, cy + radius * 0.55f) + lineTo(cx + mouth, cy + radius * 0.55f) + lineTo(cx, pad + head + tail) + close() + } + + val fill = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = colorArgb + style = Paint.Style.FILL + setShadowLayer(dp(3f), 0f, dp(2f), 0x80000000.toInt()) + } + canvas.drawPath(body, fill) + + val ring = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = ringWidth + } + canvas.drawCircle(cx, cy, radius - ringWidth / 2f, ring) + + // Color emoji fonts ignore the paint color, so the glyph keeps its own hue. + val text = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + textSize = head * 0.58f + } + val metrics = text.fontMetrics + val baseline = cy - (metrics.ascent + metrics.descent) / 2f + canvas.drawText(emoji, cx, baseline, text) + + return bitmap +} 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..3594b98919 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,39 +20,50 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth 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.Surface 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.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight 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 +import com.vitorpamplona.quartz.utils.TimeUtils -/** Emoji marker for a road event category — keeps the card icon-light and locale-independent. */ +/** + * Emoji marker for a road event category. Mirrors the icon set of the roadstr + * reference clients () so a report reads the + * same across implementations; keeps the card icon-light and locale-independent. + */ private fun RoadEventType.emoji(): String = when (this) { RoadEventType.POLICE -> "👮" RoadEventType.SPEED_CAMERA -> "📷" RoadEventType.TRAFFIC_JAM -> "🚗" RoadEventType.ACCIDENT -> "💥" - RoadEventType.ROAD_CLOSURE -> "⛔" + RoadEventType.ROAD_CLOSURE -> "🚫" RoadEventType.CONSTRUCTION -> "🚧" RoadEventType.HAZARD -> "⚠️" RoadEventType.ROAD_CONDITION -> "🛣️" @@ -60,7 +71,29 @@ private fun RoadEventType.emoji(): String = RoadEventType.FOG -> "🌫️" RoadEventType.ICE -> "🧊" RoadEventType.ANIMAL -> "🦌" - RoadEventType.OTHER -> "📍" + RoadEventType.OTHER -> "ℹ️" + } + +/** + * Per-category pin color. Uses the exact palette from the roadstr reference + * clients () so a pin's color carries the + * same meaning across the network. + */ +private fun RoadEventType.color(): Color = + when (this) { + RoadEventType.POLICE -> Color(0xFF0000FF) + RoadEventType.SPEED_CAMERA -> Color(0xFF800080) + RoadEventType.TRAFFIC_JAM -> Color(0xFFFF8C00) + RoadEventType.ACCIDENT -> Color(0xFFFF0000) + RoadEventType.ROAD_CLOSURE -> Color(0xFF8B0000) + RoadEventType.CONSTRUCTION -> Color(0xFFFFD700) + RoadEventType.HAZARD -> Color(0xFFFF4500) + RoadEventType.ROAD_CONDITION -> Color(0xFF4682B4) + RoadEventType.POTHOLE -> Color(0xFF795548) + RoadEventType.FOG -> Color(0xFF9E9E9E) + RoadEventType.ICE -> Color(0xFF00CED1) + RoadEventType.ANIMAL -> Color(0xFF4CAF50) + RoadEventType.OTHER -> Color(0xFF808080) } private fun RoadEventType.labelRes(): Int = @@ -83,10 +116,10 @@ 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 - * [RenderBirdex]. + * A map hero pinned at the event's coordinates, with a floating category pill + * (colored per [RoadEventType.color]) and the optional free-text comment below. + * Identical in the feed and the opened thread view, so it takes no `makeItShort` + * flag — mirroring [RenderBirdex]. */ @Composable fun RenderRoadEventReport(baseNote: Note) { @@ -94,93 +127,175 @@ 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() } + val freshness = remember(noteEvent) { noteEvent.freshnessAlpha() } - Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { - val title = - if (type != null) { - "${type.emoji()} ${stringResource(type.labelRes())}" - } else { - stringResource(R.string.road_event_unknown) - } + val color = type?.color() ?: MaterialTheme.colorScheme.outline + val emoji = type?.emoji() ?: "📍" + val label = if (type != null) stringResource(type.labelRes()) else stringResource(R.string.road_event_unknown) - Text(text = title, style = MaterialTheme.typography.titleMedium) - - if (comment.isNotEmpty()) { - Spacer(Modifier.height(6.dp)) - Text( - text = comment, - style = MaterialTheme.typography.bodyMedium, - maxLines = 4, - overflow = TextOverflow.Ellipsis, - ) - } - - if (geohash != null) { - Spacer(Modifier.height(6.dp)) - RoadEventLocation(geohash) - } - } + RoadEventCard( + chipColor = color, + chipEmoji = emoji, + chipLabel = label, + point = point, + pinAlpha = freshness, + comment = comment, + ) } /** * Self-contained card for a Roadstr confirmation/denial (kind 1316). * - * Shows whether the referenced report was confirmed (still there) or denied - * (no longer there), plus a one-line description of the action. + * Same hero/pill layout as a report, with a green ✅ "Still there" or red ❌ + * "No longer there" pill and a one-line description of the action. */ @Composable 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 "✅" - val titleRes = - if (status == RoadEventStatus.NO_LONGER_THERE) { - R.string.road_event_denied - } else { - R.string.road_event_confirmed - } + val denied = status == RoadEventStatus.NO_LONGER_THERE + val emoji = if (denied) "❌" else "✅" + val color = if (denied) Color(0xFFF44336) else Color(0xFF4CAF50) - Text(text = "$emoji ${stringResource(titleRes)}", style = MaterialTheme.typography.titleMedium) - - Spacer(Modifier.height(4.dp)) - Text( - text = - stringResource( - if (status == RoadEventStatus.NO_LONGER_THERE) { - R.string.road_event_denies_report - } else { - R.string.road_event_confirms_report - }, - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.placeholderText, - ) - - if (geohash != null) { - Spacer(Modifier.height(6.dp)) - RoadEventLocation(geohash) - } - } + RoadEventCard( + chipColor = color, + chipEmoji = emoji, + chipLabel = stringResource(if (denied) R.string.road_event_denied else R.string.road_event_confirmed), + point = point, + subtitle = stringResource(if (denied) R.string.road_event_denies_report else R.string.road_event_confirms_report), + ) } -/** A single "📍 City" line that reverse-geocodes [geohash], falling back to the raw geohash. */ +/** + * Shared road event card chrome: a full-bleed [LocationPreviewMap] hero with a + * floating [CategoryPill] overlaid on its top-start, and an optional + * [subtitle] + [comment] block below. When [point] is null the pill stands on + * its own so the card still reads. + */ @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 RoadEventCard( + chipColor: Color, + chipEmoji: String, + chipLabel: String, + point: Pair?, + pinAlpha: Float = 1f, + comment: String = "", + subtitle: String? = null, +) { + Column(MaterialTheme.colorScheme.replyModifier) { + if (point != null) { + Box(Modifier.fillMaxWidth()) { + LocationPreviewMap( + latitude = point.first, + longitude = point.second, + pinColor = chipColor, + pinEmoji = chipEmoji, + pinAlpha = pinAlpha, + ) + CategoryPill( + color = chipColor, + emoji = chipEmoji, + label = chipLabel, + modifier = Modifier.align(Alignment.TopStart).padding(10.dp), + ) + } + } else { + CategoryPill( + color = chipColor, + emoji = chipEmoji, + label = chipLabel, + modifier = Modifier.padding(12.dp), ) } + + if (subtitle != null || comment.isNotEmpty()) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + if (comment.isNotEmpty()) { + if (subtitle != null) Spacer(Modifier.height(4.dp)) + Text( + text = comment, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +/** A floating rounded pill: [emoji] + [label] on a [color] background, text auto-contrasted. */ +@Composable +private fun CategoryPill( + color: Color, + emoji: String, + label: String, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + color = color, + contentColor = if (color.luminance() > 0.55f) Color.Black else Color.White, + shape = RoundedCornerShape(50), + shadowElevation = 3.dp, + ) { + Text( + text = "$emoji $label", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } +} + +/** + * 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 +} + +/** + * Marker opacity by freshness, matching the roadstr clients: full while the + * report has plenty of life left, dimmed to 0.6 once under 25% of its effective + * TTL remains, and faded to 0.4 once effectively expired. Returns 1f when the + * type (and therefore the TTL) is unknown. + */ +private fun RoadEventReportEvent.freshnessAlpha(now: Long = TimeUtils.now()): Float { + val expiryAt = effectiveExpirationAt() ?: return 1f + val total = expiryAt - createdAt + if (total <= 0L) return 1f + + val fraction = (expiryAt - now).toFloat() / total + return when { + fraction <= 0f -> 0.4f + fraction < 0.25f -> 0.6f + else -> 1f } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt index 52f726d396..81b4c333fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt @@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -86,7 +88,9 @@ class GeoHashFeedFilter( event is MusicTrackEvent || event is MusicPlaylistEvent || event is PodcastEpisodeEvent || - event is PodcastMetadataEvent + event is PodcastMetadataEvent || + event is RoadEventReportEvent || + event is RoadEventConfirmationEvent ) && event.isTaggedGeoHash(geohash) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt index 0fa9ec6fc3..86e8f49fa0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -57,6 +59,8 @@ val PostsByGeohashKinds = PodcastEpisodeEvent.KIND, PodcastMetadataEvent.KIND, WikiNoteEvent.KIND, + RoadEventReportEvent.KIND, + RoadEventConfirmationEvent.KIND, ) fun filterPostsByGeohash( 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" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt index 9259df9d45..746fe9200b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt @@ -89,7 +89,14 @@ class RoadEventConfirmationEvent( companion object { const val KIND = 1316 - const val ALT_DESCRIPTION = "Roadstr: event confirmation" + + /** NIP-31 fallback for a confirmation (`still_there`), per the roadstr spec. */ + const val ALT_CONFIRMED = "Roadstr: event confirmed" + + /** NIP-31 fallback for a denial (`no_longer_there`), per the roadstr spec. */ + const val ALT_DENIED = "Roadstr: event denied" + + fun altDescription(status: RoadEventStatus) = if (status == RoadEventStatus.NO_LONGER_THERE) ALT_DENIED else ALT_CONFIRMED fun build( reportId: HexKey, @@ -106,7 +113,7 @@ class RoadEventConfirmationEvent( coordinates(latitude, longitude) } expiration(createdAt + RoadEventReportEvent.RELAY_TTL_SECONDS) - alt(ALT_DESCRIPTION) + alt(altDescription(status)) initializer() } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventTest.kt index 16a7f82706..fd66eeb1d2 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventTest.kt @@ -69,7 +69,7 @@ class RoadEventTest { arrayOf("e", reportId), arrayOf("status", "no_longer_there"), arrayOf("expiration", "1701213200"), - arrayOf("alt", "Roadstr: event confirmation"), + arrayOf("alt", "Roadstr: event denied"), ), content = "", sig = "00".repeat(64), @@ -129,7 +129,7 @@ class RoadEventTest { assertEquals(RoadEventStatus.NO_LONGER_THERE, event.status()) assertTrue(event.isDenial()) assertTrue(!event.isConfirmation()) - assertEquals("Roadstr: event confirmation", event.alt()) + assertEquals("Roadstr: event denied", event.alt()) } @Test @@ -161,7 +161,7 @@ class RoadEventTest { assertEquals(reportId, template.tags.first { it[0] == "e" }[1]) assertEquals("still_there", template.tags.first { it[0] == "status" }[1]) - assertEquals("Roadstr: event confirmation", template.tags.first { it[0] == "alt" }[1]) + assertEquals("Roadstr: event confirmed", template.tags.first { it[0] == "alt" }[1]) assertTrue(template.tags.none { it[0] == "g" || it[0] == "lat" || it[0] == "lon" }) }