Merge pull request #3272 from vitorpamplona/claude/stoic-lovelace-qv7vy8

Add Roadstr road event support (kinds 1315/1316)
This commit is contained in:
Vitor Pamplona
2026-06-18 17:05:08 -04:00
committed by GitHub
26 changed files with 1281 additions and 13 deletions
@@ -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)
}
@@ -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<String> {
val hashes = mutableListOf<String>()
@@ -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
}
@@ -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
@@ -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,
@@ -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
@@ -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,
)
}
}
}
@@ -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) {
+19
View File
@@ -3441,6 +3441,25 @@
<item quantity="one">%1$s +%2$d more</item>
<item quantity="other">%1$s +%2$d more</item>
</plurals>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="road_event_police">Police</string>
<string name="road_event_speed_camera">Speed camera</string>
<string name="road_event_traffic_jam">Traffic jam</string>
<string name="road_event_accident">Accident</string>
<string name="road_event_road_closure">Road closure</string>
<string name="road_event_construction">Construction</string>
<string name="road_event_hazard">Hazard</string>
<string name="road_event_road_condition">Road condition</string>
<string name="road_event_pothole">Pothole</string>
<string name="road_event_fog">Fog</string>
<string name="road_event_ice">Ice</string>
<string name="road_event_animal">Animal on the road</string>
<string name="road_event_other">Road event</string>
<string name="road_event_unknown">Road event</string>
<string name="road_event_confirmed">Still there</string>
<string name="road_event_denied">No longer there</string>
<string name="road_event_confirms_report">Confirms a road report</string>
<string name="road_event_denies_report">Reports a road event cleared</string>
<string name="goal_amount_label">Goal amount (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Describe your goal</string>
@@ -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
* <https://github.com/jooray/roadstr>.
*
* 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<Array<String>>,
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<RoadEventConfirmationEvent>.() -> 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<RoadEventReportEvent>,
status: RoadEventStatus,
latitude: Double? = null,
longitude: Double? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RoadEventConfirmationEvent>.() -> Unit = {},
) = build(report.event.id, status, latitude, longitude, createdAt) {
report(report)
initializer()
}
}
}
@@ -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<RoadEventConfirmationEvent>.report(reportId: HexKey) = addUnique(RoadReportTag.assemble(reportId, null, null))
fun TagArrayBuilder<RoadEventConfirmationEvent>.report(reportHint: EventHintBundle<RoadEventReportEvent>) = addUnique(RoadReportTag.assemble(reportHint))
fun TagArrayBuilder<RoadEventConfirmationEvent>.status(status: RoadEventStatus) = addUnique(RoadEventStatusTag.assemble(status))
@@ -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)
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): 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)
}
}
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
fun parse(tag: Array<String>): 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>): 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<String>): 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<RoadEventReportEvent>) = assemble(eventHint.event.id, eventHint.relay, eventHint.event.pubKey)
}
}
@@ -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 <https://github.com/jooray/roadstr>.
*
* 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<Array<String>>,
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<RoadEventReportEvent>.() -> Unit = {},
) = eventTemplate(KIND, comment, createdAt) {
roadEventType(type)
roadGeohashes(latitude, longitude)
coordinates(latitude, longitude)
expiration(createdAt + RELAY_TTL_SECONDS)
alt(altDescription(type))
initializer()
}
}
}
@@ -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<RoadEventReportEvent>.roadEventType(type: RoadEventType) = add(RoadEventTypeTag.assemble(type))
@@ -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)
@@ -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<String>) = 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<String>): 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>): 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)
}
}
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): 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))
}
}
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): 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))
}
}
@@ -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
}
}
@@ -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 <T : Event> TagArrayBuilder<T>.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 <T : Event> TagArrayBuilder<T>.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))
}
@@ -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)
@@ -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)
@@ -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)
@@ -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<RoadEventReportEvent>(sampleReport())
assertIs<RoadEventConfirmationEvent>(sampleConfirmation())
}
@Test
fun kindsAreKnown() {
assertTrue(EventFactory.isKnownKind(RoadEventReportEvent.KIND))
assertTrue(EventFactory.isKnownKind(RoadEventConfirmationEvent.KIND))
}
@Test
fun parsesReportFields() {
val event = sampleReport()
assertIs<RoadEventReportEvent>(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<Event>(
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<RoadEventReportEvent>(event)
assertEquals("spaceship", event.roadEventTypeCode())
assertNull(event.roadEventType())
assertNull(event.effectiveExpirationAt())
}
@Test
fun parsesConfirmationFields() {
val event = sampleConfirmation()
assertIs<RoadEventConfirmationEvent>(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())
}
}