diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index ff7f498420..d82d2c221a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -75,6 +75,8 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +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.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent @@ -3870,6 +3872,14 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } + is RoadEventReportEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + + is RoadEventConfirmationEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + is RelayDiscoveryEvent -> { consumeBaseReplaceable(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt index dd5325011e..1e83a7dc03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe -import com.vitorpamplona.amethyst.service.location.GeoHash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash fun compute50kmLine(geoHash: GeoHash): List { val hashes = mutableListOf() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationGeoHash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationGeoHash.kt new file mode 100644 index 0000000000..4540a537c1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationGeoHash.kt @@ -0,0 +1,37 @@ +/* + * 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.service.location + +import android.location.Location +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash + +/** + * Android `Location` glue for the platform-agnostic [GeoHash] that now lives in + * Quartz. The encode/decode/neighbor logic is shared in `commonMain`; only these + * conversions to/from the Android `Location` type stay here. + */ +fun Location.toGeoHash(charsCount: Int = GeoHash.MAX_CHAR_PRECISION): GeoHash = GeoHash.encode(latitude, longitude, charsCount) + +fun GeoHash.toLocation(): Location = + Location("").also { + it.latitude = centerLat + it.longitude = centerLon + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt index 46b719eccf..298799e119 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.location import android.content.Context +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index e41f4ae5e9..5202720733 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -174,6 +174,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayLeaveRequest import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember import com.vitorpamplona.amethyst.ui.note.types.RenderReport +import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventConfirmation +import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventReport import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset @@ -235,6 +237,8 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAss import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +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.tags.geohash.geoHashOrScope import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent @@ -1285,6 +1289,14 @@ private fun RenderNoteRow( RenderBirdex(baseNote) } + is RoadEventReportEvent -> { + RenderRoadEventReport(baseNote) + } + + is RoadEventConfirmationEvent -> { + RenderRoadEventConfirmation(baseNote) + } + is HighlightEvent -> { RenderHighlight( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt index 05cce5516b..2fd6d60d36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt @@ -30,7 +30,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations -import com.vitorpamplona.amethyst.service.location.toGeoHash +import com.vitorpamplona.amethyst.service.location.toLocation +import com.vitorpamplona.quartz.nip01Core.tags.geohash.toGeoHash import kotlinx.coroutines.delay import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume 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 new file mode 100644 index 0000000000..d560d3d173 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt @@ -0,0 +1,186 @@ +/* + * 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.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.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.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.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 + +/** Emoji marker for a road event category — 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.CONSTRUCTION -> "🚧" + RoadEventType.HAZARD -> "⚠️" + RoadEventType.ROAD_CONDITION -> "🛣️" + RoadEventType.POTHOLE -> "🕳️" + RoadEventType.FOG -> "🌫️" + RoadEventType.ICE -> "🧊" + RoadEventType.ANIMAL -> "🦌" + RoadEventType.OTHER -> "📍" + } + +private fun RoadEventType.labelRes(): Int = + when (this) { + RoadEventType.POLICE -> R.string.road_event_police + RoadEventType.SPEED_CAMERA -> R.string.road_event_speed_camera + RoadEventType.TRAFFIC_JAM -> R.string.road_event_traffic_jam + RoadEventType.ACCIDENT -> R.string.road_event_accident + RoadEventType.ROAD_CLOSURE -> R.string.road_event_road_closure + RoadEventType.CONSTRUCTION -> R.string.road_event_construction + RoadEventType.HAZARD -> R.string.road_event_hazard + RoadEventType.ROAD_CONDITION -> R.string.road_event_road_condition + RoadEventType.POTHOLE -> R.string.road_event_pothole + RoadEventType.FOG -> R.string.road_event_fog + RoadEventType.ICE -> R.string.road_event_ice + RoadEventType.ANIMAL -> R.string.road_event_animal + RoadEventType.OTHER -> R.string.road_event_other + } + +/** + * 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]. + */ +@Composable +fun RenderRoadEventReport(baseNote: Note) { + val noteEvent = baseNote.event as? RoadEventReportEvent ?: return + + val type = remember(noteEvent) { noteEvent.roadEventType() } + val comment = remember(noteEvent) { noteEvent.content.trim() } + val geohash = remember(noteEvent) { noteEvent.geohashes().maxByOrNull { it.length } } + + Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { + val title = + if (type != null) { + "${type.emoji()} ${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) + } + } +} + +/** + * 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. + */ +@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 } } + + 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 + } + + 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) + } + } +} + +/** A single "📍 City" line that reverse-geocodes [geohash], falling back to the raw geohash. */ +@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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 55cd5886a1..2820260a0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -192,6 +192,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayJoinRequest import com.vitorpamplona.amethyst.ui.note.types.RenderRelayLeaveRequest import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember +import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventConfirmation +import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventReport import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset @@ -250,6 +252,8 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.Softw import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +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.tags.geohash.geoHashOrScope @@ -814,6 +818,10 @@ private fun FullBleedNoteCompose( RenderFundraiser(baseNote, makeItShort = false, accountViewModel, nav) } else if (noteEvent is BirdexEvent) { RenderBirdex(baseNote) + } else if (noteEvent is RoadEventReportEvent) { + RenderRoadEventReport(baseNote) + } else if (noteEvent is RoadEventConfirmationEvent) { + RenderRoadEventConfirmation(baseNote) } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { RenderRepost(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav) } else if (noteEvent is RelayDiscoveryEvent) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 07da4a218b..c768b6b0aa 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3441,6 +3441,25 @@ %1$s +%2$d more %1$s +%2$d more + + Police + Speed camera + Traffic jam + Accident + Road closure + Construction + Hazard + Road condition + Pothole + Fog + Ice + Animal on the road + Road event + Road event + Still there + No longer there + Confirms a road report + Reports a road event cleared Goal amount (sats) 100000 Describe your goal 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 new file mode 100644 index 0000000000..9259df9d45 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt @@ -0,0 +1,125 @@ +/* + * 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.quartz.experimental.roadstr.confirmation + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatus +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadReportTag +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent +import com.vitorpamplona.quartz.experimental.roadstr.tags.coordinates +import com.vitorpamplona.quartz.experimental.roadstr.tags.latitude +import com.vitorpamplona.quartz.experimental.roadstr.tags.longitude +import com.vitorpamplona.quartz.experimental.roadstr.tags.roadGeohashes +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Roadstr road event confirmation/denial (kind 1316). + * + * Validates or refutes a previously published [RoadEventReportEvent]. See + * . + * + * Tags (canonical order): + * - `e` — the referenced report event id ([RoadReportTag]). + * - `status` — [RoadEventStatus.STILL_THERE] (confirm) or [RoadEventStatus.NO_LONGER_THERE] (deny). + * - `g` / `lat` / `lon` — optional copy of the report's location, so confirmations + * are reachable through the same `#g` geohash queries as reports. + * - `expiration` — NIP-40 relay garbage-collection window (`created_at + 14 days`). + * - `alt` — NIP-31 human-readable fallback. + */ +@Immutable +class RoadEventConfirmationEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider { + override fun eventHints() = tags.mapNotNull(RoadReportTag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(RoadReportTag::parseId) + + /** The referenced report event id from the first `e` tag. */ + fun reportId() = tags.roadReport()?.eventId + + fun status() = tags.roadEventStatus() + + fun isConfirmation() = status() == RoadEventStatus.STILL_THERE + + fun isDenial() = status() == RoadEventStatus.NO_LONGER_THERE + + fun geohashes() = tags.geohashes() + + fun latitude() = tags.latitude() + + fun longitude() = tags.longitude() + + fun alt() = tags.firstNotNullOfOrNull(AltTag::parse) + + fun relayExpiration() = tags.expiration() + + companion object { + const val KIND = 1316 + const val ALT_DESCRIPTION = "Roadstr: event confirmation" + + fun build( + reportId: HexKey, + status: RoadEventStatus, + latitude: Double? = null, + longitude: Double? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + report(reportId) + status(status) + if (latitude != null && longitude != null) { + roadGeohashes(latitude, longitude) + coordinates(latitude, longitude) + } + expiration(createdAt + RoadEventReportEvent.RELAY_TTL_SECONDS) + alt(ALT_DESCRIPTION) + initializer() + } + + fun build( + report: EventHintBundle, + status: RoadEventStatus, + latitude: Double? = null, + longitude: Double? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(report.event.id, status, latitude, longitude, createdAt) { + report(report) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..8d5a0debd7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayBuilderExt.kt @@ -0,0 +1,35 @@ +/* + * 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.quartz.experimental.roadstr.confirmation + +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatus +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatusTag +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadReportTag +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle + +fun TagArrayBuilder.report(reportId: HexKey) = addUnique(RoadReportTag.assemble(reportId, null, null)) + +fun TagArrayBuilder.report(reportHint: EventHintBundle) = addUnique(RoadReportTag.assemble(reportHint)) + +fun TagArrayBuilder.status(status: RoadEventStatus) = addUnique(RoadEventStatusTag.assemble(status)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayExt.kt new file mode 100644 index 0000000000..6b413829f6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayExt.kt @@ -0,0 +1,29 @@ +/* + * 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.quartz.experimental.roadstr.confirmation + +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatusTag +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadReportTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.roadReport() = firstNotNullOfOrNull(RoadReportTag::parse) + +fun TagArray.roadEventStatus() = firstNotNullOfOrNull(RoadEventStatusTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadEventStatusTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadEventStatusTag.kt new file mode 100644 index 0000000000..87ca3f0f3e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadEventStatusTag.kt @@ -0,0 +1,54 @@ +/* + * 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.quartz.experimental.roadstr.confirmation.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Confirmation status carried in the `status` tag of a Roadstr confirmation (kind 1316). */ +enum class RoadEventStatus( + val code: String, +) { + STILL_THERE("still_there"), + NO_LONGER_THERE("no_longer_there"), + ; + + companion object { + fun fromCode(code: String?): RoadEventStatus? = entries.firstOrNull { it.code == code } + } +} + +/** The `status` tag of a Roadstr confirmation (kind 1316): `still_there` or `no_longer_there`. */ +class RoadEventStatusTag { + companion object { + const val TAG_NAME = "status" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): RoadEventStatus? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return RoadEventStatus.fromCode(tag[1]) + } + + fun assemble(status: RoadEventStatus) = arrayOf(TAG_NAME, status.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadReportTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadReportTag.kt new file mode 100644 index 0000000000..f4c3bd5597 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadReportTag.kt @@ -0,0 +1,96 @@ +/* + * 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.quartz.experimental.roadstr.confirmation.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +/** The `e` tag of a Roadstr confirmation (kind 1316): the referenced report event. */ +@Immutable +data class RoadReportTag( + override val eventId: HexKey, +) : GenericETag { + override var relay: NormalizedRelayUrl? = null + override var author: HexKey? = null + + constructor(eventId: HexKey, relayHint: NormalizedRelayUrl? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + this.relay = relayHint + this.author = authorPubKeyHex + } + + fun toNEvent(): String = NEvent.create(eventId, author, RoadEventReportEvent.KIND, relay) + + override fun toTagArray() = assemble(eventId, relay, author) + + companion object { + const val TAG_NAME = "e" + + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun parse(tag: Array): RoadReportTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return RoadReportTag(tag[1], hint, tag.getOrNull(3)) + } + + fun parseId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + fun parseAsHint(tag: Array): EventIdHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + + ensure(hint != null) { return null } + + return EventIdHint(tag[1], hint) + } + + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl?, + author: HexKey?, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author) + + fun assemble(eventHint: EventHintBundle) = assemble(eventHint.event.id, eventHint.relay, eventHint.event.pubKey) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/RoadEventReportEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/RoadEventReportEvent.kt new file mode 100644 index 0000000000..1d7eb8747c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/RoadEventReportEvent.kt @@ -0,0 +1,113 @@ +/* + * 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.quartz.experimental.roadstr.report + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventType +import com.vitorpamplona.quartz.experimental.roadstr.tags.coordinates +import com.vitorpamplona.quartz.experimental.roadstr.tags.latitude +import com.vitorpamplona.quartz.experimental.roadstr.tags.longitude +import com.vitorpamplona.quartz.experimental.roadstr.tags.roadGeohashes +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Roadstr road event report (kind 1315). + * + * A driver-published report of a notable road condition (police, accident, + * hazard, …) at a specific location. See . + * + * Tags (canonical order): + * - `t` — the [RoadEventType] code. + * - `g` — geohash at precision 4, 5 and 6 (for multi-resolution spatial queries). + * - `lat` / `lon` — coordinates as decimal strings with 7 fractional digits. + * - `expiration` — NIP-40 relay garbage-collection window, always `created_at + 14 days`. + * - `alt` — NIP-31 human-readable fallback. + * + * The relay-side `expiration` is a fixed 14-day window; clients should instead use + * [effectiveExpirationAt] (derived from the event type's TTL) to decide when to + * stop displaying a report. The `content` field holds an optional free-text comment. + */ +@Immutable +class RoadEventReportEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The parsed [RoadEventType], or null when the `t` tag is missing/unknown. */ + fun roadEventType() = tags.roadEventType() + + /** The raw event-type code from the first `t` tag (may be outside [RoadEventType]). */ + fun roadEventTypeCode() = tags.roadEventTypeCode() + + /** All `g` geohash tags, coarse-to-fine as published. */ + fun geohashes() = tags.geohashes() + + fun latitude() = tags.latitude() + + fun longitude() = tags.longitude() + + fun alt() = tags.firstNotNullOfOrNull(AltTag::parse) + + fun relayExpiration() = tags.expiration() + + /** Client-side effective expiry (`created_at + ` the type's TTL), or null if the type is unknown. */ + fun effectiveExpirationAt(): Long? = roadEventType()?.let { createdAt + it.effectiveTtlSeconds } + + /** Whether the report should no longer be displayed at [now], per its client-side TTL. */ + fun isEffectivelyExpired(now: Long = TimeUtils.now()): Boolean = effectiveExpirationAt()?.let { now > it } ?: false + + companion object { + const val KIND = 1315 + const val ALT_PREFIX = "Roadstr: " + + /** Fixed relay-side NIP-40 window (14 days) for both report and confirmation events. */ + const val RELAY_TTL_SECONDS = 1_209_600L + + fun altDescription(type: RoadEventType) = "$ALT_PREFIX${type.code} report" + + fun build( + type: RoadEventType, + latitude: Double, + longitude: Double, + comment: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, comment, createdAt) { + roadEventType(type) + roadGeohashes(latitude, longitude) + coordinates(latitude, longitude) + expiration(createdAt + RELAY_TTL_SECONDS) + alt(altDescription(type)) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..ff4713a50a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayBuilderExt.kt @@ -0,0 +1,27 @@ +/* + * 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.quartz.experimental.roadstr.report + +import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventType +import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventTypeTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.roadEventType(type: RoadEventType) = add(RoadEventTypeTag.assemble(type)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayExt.kt new file mode 100644 index 0000000000..7f6f40ab1f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayExt.kt @@ -0,0 +1,28 @@ +/* + * 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.quartz.experimental.roadstr.report + +import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventTypeTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.roadEventType() = firstNotNullOfOrNull(RoadEventTypeTag::parse) + +fun TagArray.roadEventTypeCode() = firstNotNullOfOrNull(RoadEventTypeTag::parseCode) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/tags/RoadEventTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/tags/RoadEventTypeTag.kt new file mode 100644 index 0000000000..48188fe5ca --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/tags/RoadEventTypeTag.kt @@ -0,0 +1,82 @@ +/* + * 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.quartz.experimental.roadstr.report.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Road event categories carried in the `t` tag of a Roadstr report (kind 1315). + * + * Each type also defines a client-side *effective* TTL: the relay-side NIP-40 + * `expiration` tag is always a fixed 14-day garbage-collection window, but + * clients should stop showing a report after [effectiveTtlSeconds] from its + * `created_at` (subject to confirmations). + */ +enum class RoadEventType( + val code: String, + val effectiveTtlSeconds: Long, +) { + POLICE("police", 7_200L), // 2 hours + SPEED_CAMERA("speed_camera", 2_592_000L), // 30 days + TRAFFIC_JAM("traffic_jam", 3_600L), // 1 hour + ACCIDENT("accident", 10_800L), // 3 hours + ROAD_CLOSURE("road_closure", 604_800L), // 7 days + CONSTRUCTION("construction", 604_800L), // 7 days + HAZARD("hazard", 14_400L), // 4 hours + ROAD_CONDITION("road_condition", 21_600L), // 6 hours + POTHOLE("pothole", 604_800L), // 7 days + FOG("fog", 10_800L), // 3 hours + ICE("ice", 21_600L), // 6 hours + ANIMAL("animal", 3_600L), // 1 hour + OTHER("other", 7_200L), // 2 hours + ; + + companion object { + fun fromCode(code: String?): RoadEventType? = entries.firstOrNull { it.code == code } + } +} + +/** The `t` tag of a Roadstr report (kind 1315): the [RoadEventType] code. */ +class RoadEventTypeTag { + companion object { + const val TAG_NAME = "t" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + /** Parses the tag into a known [RoadEventType], or null when missing/unknown. */ + fun parse(tag: Array): RoadEventType? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return RoadEventType.fromCode(tag[1]) + } + + /** Returns the raw `t` value even when it is not a known [RoadEventType]. */ + fun parseCode(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(type: RoadEventType) = arrayOf(TAG_NAME, type.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LatitudeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LatitudeTag.kt new file mode 100644 index 0000000000..d3c8ab167c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LatitudeTag.kt @@ -0,0 +1,41 @@ +/* + * 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.quartz.experimental.roadstr.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** The `lat` tag of a Roadstr event: latitude as a decimal string with 7 fractional digits. */ +class LatitudeTag { + companion object { + const val TAG_NAME = "lat" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Double? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].toDoubleOrNull() + } + + fun assemble(latitude: Double) = arrayOf(TAG_NAME, RoadCoordinate.format(latitude)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LongitudeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LongitudeTag.kt new file mode 100644 index 0000000000..18880cfebc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LongitudeTag.kt @@ -0,0 +1,41 @@ +/* + * 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.quartz.experimental.roadstr.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** The `lon` tag of a Roadstr event: longitude as a decimal string with 7 fractional digits. */ +class LongitudeTag { + companion object { + const val TAG_NAME = "lon" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Double? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].toDoubleOrNull() + } + + fun assemble(longitude: Double) = arrayOf(TAG_NAME, RoadCoordinate.format(longitude)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/RoadCoordinate.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/RoadCoordinate.kt new file mode 100644 index 0000000000..193b34e122 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/RoadCoordinate.kt @@ -0,0 +1,43 @@ +/* + * 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.quartz.experimental.roadstr.tags + +import kotlin.math.abs +import kotlin.math.round + +/** + * Formatting for the `lat`/`lon` tag values of Roadstr events. The spec requires + * a decimal string with exactly 7 fractional digits (e.g. `48.8566140`), which is + * locale-independent here (unlike `String.format`, which is unavailable in + * `commonMain` anyway). + */ +object RoadCoordinate { + private const val SCALE = 10_000_000L // 7 decimal places + + fun format(value: Double): String { + val scaled = round(value * SCALE).toLong() + val negative = scaled < 0L + val absValue = abs(scaled) + val intPart = absValue / SCALE + val fracPart = (absValue % SCALE).toString().padStart(7, '0') + return (if (negative) "-" else "") + intPart + "." + fracPart + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..a124f24cb4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayBuilderExt.kt @@ -0,0 +1,46 @@ +/* + * 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.quartz.experimental.roadstr.tags + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag + +/** Adds `lat`/`lon` tags (7-decimal strings) shared by Roadstr reports and confirmations. */ +fun TagArrayBuilder.coordinates( + latitude: Double, + longitude: Double, +) = apply { + add(LatitudeTag.assemble(latitude)) + add(LongitudeTag.assemble(longitude)) +} + +/** Adds the three `g` geohash tags (precision 4, 5 and 6) derived from [latitude]/[longitude]. */ +fun TagArrayBuilder.roadGeohashes( + latitude: Double, + longitude: Double, +) = apply { + val geohash6 = GeoHash.encode(latitude, longitude, 6).toString() + add(GeoHashTag.assembleSingle(geohash6.substring(0, 4))) + add(GeoHashTag.assembleSingle(geohash6.substring(0, 5))) + add(GeoHashTag.assembleSingle(geohash6)) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayExt.kt new file mode 100644 index 0000000000..1368797d18 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayExt.kt @@ -0,0 +1,27 @@ +/* + * 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.quartz.experimental.roadstr.tags + +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.latitude() = firstNotNullOfOrNull(LatitudeTag::parse) + +fun TagArray.longitude() = firstNotNullOfOrNull(LongitudeTag::parse) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/GeoHash.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHash.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/GeoHash.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHash.kt index 4741b104a5..ac2b320078 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/GeoHash.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHash.kt @@ -18,13 +18,17 @@ * 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.service.location - -import android.location.Location +package com.vitorpamplona.quartz.nip01Core.tags.geohash /** * GeoHash encoding/decoding based on Gustavo Niemeyer's algorithm (2008). * + * Pure-Kotlin and platform-agnostic (lives in `commonMain`), so any Nostr + * protocol code can derive `g` tags from coordinates and walk neighboring cells + * without depending on a platform location API. Callers that work with a + * platform `Location` type add thin conversion helpers in their own module + * (e.g. `amethyst`'s `Location.toGeoHash()` / `GeoHash.toLocation()`). + * * Bits are interleaved: even-indexed bits (0, 2, 4, ...) = longitude, * odd-indexed bits (1, 3, 5, ...) = latitude. */ @@ -237,12 +241,6 @@ class GeoHash private constructor( val easternNeighbour: GeoHash get() = fromBitArrays(latBits, numLat, mask(lonBits + 1L, numLon), numLon) val westernNeighbour: GeoHash get() = fromBitArrays(latBits, numLat, mask(lonBits - 1L, numLon), numLon) - fun toLocation(): Location = - Location("").also { - it.latitude = centerLat - it.longitude = centerLon - } - override fun toString(): String { val charCount = significantBits / BITS_PER_CHAR val sb = StringBuilder(charCount) @@ -263,6 +261,4 @@ class GeoHash private constructor( override fun hashCode(): Int = 31 * bits.hashCode() + significantBits } -fun Location.toGeoHash(charsCount: Int = GeoHash.MAX_CHAR_PRECISION): GeoHash = GeoHash.encode(latitude, longitude, charsCount) - fun String.toGeoHash(): GeoHash? = GeoHash.decode(this) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 6752dcd986..cdf987c70f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -53,6 +53,8 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +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.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent @@ -579,6 +581,8 @@ class EventFactory { ReleaseArtifactSetEvent.KIND -> ReleaseArtifactSetEvent(id, pubKey, createdAt, tags, content, sig) RelaySetEvent.KIND -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig) ReportEvent.KIND -> ReportEvent(id, pubKey, createdAt, tags, content, sig) + RoadEventConfirmationEvent.KIND -> RoadEventConfirmationEvent(id, pubKey, createdAt, tags, content, sig) + RoadEventReportEvent.KIND -> RoadEventReportEvent(id, pubKey, createdAt, tags, content, sig) RootSiteEvent.KIND -> RootSiteEvent(id, pubKey, createdAt, tags, content, sig) RepostEvent.KIND -> RepostEvent(id, pubKey, createdAt, tags, content, sig) RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig) 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 new file mode 100644 index 0000000000..16a7f82706 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventTest.kt @@ -0,0 +1,207 @@ +/* + * 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.quartz.experimental.roadstr + +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.experimental.roadstr.tags.RoadCoordinate +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RoadEventTest { + private val reportId = "fcbfc866b657a2fe514b579bcf7ff7700a195731eb6f306efb01529a683f86db" + + private fun sampleReport(): Event = + EventFactory.create( + id = "a099d4db563041bb289d3704f983fc148fc805860303a4f479a8264dc6a2d7cc", + pubKey = "932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d", + createdAt = 1_700_000_000L, + kind = RoadEventReportEvent.KIND, + tags = + arrayOf( + arrayOf("t", "police"), + arrayOf("g", "u2ed"), + arrayOf("g", "u2edc"), + arrayOf("g", "u2edcg"), + arrayOf("lat", "48.8566140"), + arrayOf("lon", "2.3522219"), + arrayOf("expiration", "1701209600"), + arrayOf("alt", "Roadstr: police report"), + ), + content = "", + sig = "00".repeat(64), + ) + + private fun sampleConfirmation(): Event = + EventFactory.create( + id = "b199d4db563041bb289d3704f983fc148fc805860303a4f479a8264dc6a2d7cc", + pubKey = "932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d", + createdAt = 1_700_003_600L, + kind = RoadEventConfirmationEvent.KIND, + tags = + arrayOf( + arrayOf("e", reportId), + arrayOf("status", "no_longer_there"), + arrayOf("expiration", "1701213200"), + arrayOf("alt", "Roadstr: event confirmation"), + ), + content = "", + sig = "00".repeat(64), + ) + + @Test + fun factoryParsesReportAndConfirmation() { + assertIs(sampleReport()) + assertIs(sampleConfirmation()) + } + + @Test + fun kindsAreKnown() { + assertTrue(EventFactory.isKnownKind(RoadEventReportEvent.KIND)) + assertTrue(EventFactory.isKnownKind(RoadEventConfirmationEvent.KIND)) + } + + @Test + fun parsesReportFields() { + val event = sampleReport() + assertIs(event) + + assertEquals(RoadEventType.POLICE, event.roadEventType()) + assertEquals(listOf("u2ed", "u2edc", "u2edcg"), event.geohashes()) + assertEquals(48.8566140, event.latitude()) + assertEquals(2.3522219, event.longitude()) + assertEquals("Roadstr: police report", event.alt()) + assertEquals(1701209600L, event.relayExpiration()) + // police TTL is 2h → created_at + 7200 + assertEquals(1_700_000_000L + 7_200L, event.effectiveExpirationAt()) + } + + @Test + fun unknownTypeYieldsNullType() { + val event = + EventFactory.create( + id = "00".repeat(32), + pubKey = "00".repeat(32), + createdAt = 1_700_000_000L, + kind = RoadEventReportEvent.KIND, + tags = arrayOf(arrayOf("t", "spaceship")), + content = "", + sig = "00".repeat(64), + ) + assertIs(event) + assertEquals("spaceship", event.roadEventTypeCode()) + assertNull(event.roadEventType()) + assertNull(event.effectiveExpirationAt()) + } + + @Test + fun parsesConfirmationFields() { + val event = sampleConfirmation() + assertIs(event) + + assertEquals(reportId, event.reportId()) + assertEquals(RoadEventStatus.NO_LONGER_THERE, event.status()) + assertTrue(event.isDenial()) + assertTrue(!event.isConfirmation()) + assertEquals("Roadstr: event confirmation", event.alt()) + } + + @Test + fun buildReportProducesCanonicalTags() { + val lat = 48.8566140 + val lon = 2.3522219 + val createdAt = 1_700_000_000L + val template = RoadEventReportEvent.build(RoadEventType.SPEED_CAMERA, lat, lon, createdAt = createdAt) + + assertEquals(RoadEventReportEvent.KIND, template.kind) + + val expectedG6 = GeoHash.encode(lat, lon, 6).toString() + val geohashes = template.tags.filter { it[0] == "g" }.map { it[1] } + assertEquals( + listOf(expectedG6.substring(0, 4), expectedG6.substring(0, 5), expectedG6), + geohashes, + ) + + assertEquals("speed_camera", template.tags.first { it[0] == "t" }[1]) + assertEquals("48.8566140", template.tags.first { it[0] == "lat" }[1]) + assertEquals("2.3522219", template.tags.first { it[0] == "lon" }[1]) + assertEquals((createdAt + RoadEventReportEvent.RELAY_TTL_SECONDS).toString(), template.tags.first { it[0] == "expiration" }[1]) + assertEquals("Roadstr: speed_camera report", template.tags.first { it[0] == "alt" }[1]) + } + + @Test + fun buildConfirmationOmitsLocationWhenAbsent() { + val template = RoadEventConfirmationEvent.build(reportId, RoadEventStatus.STILL_THERE, createdAt = 1_700_000_000L) + + 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]) + assertTrue(template.tags.none { it[0] == "g" || it[0] == "lat" || it[0] == "lon" }) + } + + @Test + fun buildConfirmationIncludesLocationWhenProvided() { + val lat = 48.8566140 + val lon = 2.3522219 + val template = RoadEventConfirmationEvent.build(reportId, RoadEventStatus.STILL_THERE, lat, lon) + + val expectedG6 = GeoHash.encode(lat, lon, 6).toString() + assertEquals(expectedG6, template.tags.filter { it[0] == "g" }.last()[1]) + assertEquals("48.8566140", template.tags.first { it[0] == "lat" }[1]) + } + + @Test + fun roadEventTypeTtls() { + assertEquals(7_200L, RoadEventType.POLICE.effectiveTtlSeconds) + assertEquals(2_592_000L, RoadEventType.SPEED_CAMERA.effectiveTtlSeconds) + assertEquals(3_600L, RoadEventType.TRAFFIC_JAM.effectiveTtlSeconds) + assertEquals(604_800L, RoadEventType.POTHOLE.effectiveTtlSeconds) + assertEquals(RoadEventType.TRAFFIC_JAM, RoadEventType.fromCode("traffic_jam")) + assertNull(RoadEventType.fromCode("nope")) + } + + @Test + fun coordinateFormattingHasSevenDecimals() { + assertEquals("48.8566140", RoadCoordinate.format(48.856614)) + assertEquals("2.3522219", RoadCoordinate.format(2.3522219)) + assertEquals("-0.0000010", RoadCoordinate.format(-0.000001)) + assertEquals("0.0000000", RoadCoordinate.format(0.0)) + assertEquals("-33.8688000", RoadCoordinate.format(-33.8688)) + } + + @Test + fun geohashEncodeProducesPrefixHierarchy() { + val g6 = GeoHash.encode(48.8566140, 2.3522219, 6).toString() + val g5 = GeoHash.encode(48.8566140, 2.3522219, 5).toString() + assertEquals(6, g6.length) + assertEquals(g6.substring(0, 5), g5) + // decode round-trips back to a hash with the same prefix + assertEquals(g6, GeoHash.decode(g6).toString()) + } +}