Merge pull request #4162 from vitorpamplona/claude/push-notification-black-screens-heg0ze

Fix notification deep links and add inline reply feedback
This commit is contained in:
Vitor Pamplona
2026-09-22 08:42:06 -04:00
committed by GitHub
22 changed files with 1465 additions and 53 deletions
@@ -0,0 +1,180 @@
/*
* 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.notifications
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import androidx.core.app.Person
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
/**
* The long-lived shortcut that turns a MessagingStyle notification into an actual
* *conversation* as far as the system is concerned.
*
* MessagingStyle on its own is only half the contract. Since Android 11 the shade sorts a
* notification into the Conversations section — and offers the per-conversation controls
* that come with it (mark a person Priority, silence one room without silencing every DM) —
* only when the notification names a **long-lived shortcut** through `setShortcutId`.
* Without one it is ranked as an ordinary alerting notification, which is what every DM,
* group message and Buzz room got before this existed, despite the KDoc claiming otherwise.
*
* The same shortcut is the prerequisite for two things worth having later: bubbles
* (`setBubbleMetadata`) and Direct Share targets. Neither is wired yet.
*
* ### What gets published
*
* One shortcut per conversation, keyed by [NotificationUtils.Conversation.id] — which
* includes the account, so the same counterparty under two logins does not collapse into one
* launcher entry pointing at the wrong inbox. The intent is the same deep link the
* notification taps through ([NotificationRoutes]), so the launcher entry and the tap agree.
*
* ### Why this is gated on a setting
*
* A dynamic shortcut is visible in the launcher's long-press menu: publishing one puts a
* contact's name and avatar outside the app, where someone holding the phone can read it
* without unlocking anything of ours. That is the disclosure `showMessagesInNotifications`
* exists to control, so callers pass [NotificationUtils.Conversation] only when it is on.
*/
object ConversationShortcuts {
private const val TAG = "ConversationShortcuts"
/**
* Publishes (or refreshes) the conversation's shortcut and returns its id, or null when
* the system would not take it.
*
* Null matters: a `setShortcutId` pointing at a shortcut that does not exist is worse
* than none — the system logs it and still refuses the conversation treatment — so the
* caller must only stamp the notification when this succeeded.
*
* [ShortcutManagerCompat.pushDynamicShortcut] already handles the two things that bite
* here: it evicts the least-recently-used shortcut when the per-activity cap is reached,
* and it is rate-limit aware. It can still throw on a malformed shortcut, so the whole
* call is guarded — a conversation that cannot be published is a notification that
* renders normally, never a crash on the notification path.
*/
fun push(
context: Context,
conversation: NotificationUtils.Conversation,
uri: String,
person: Person,
icon: Bitmap?,
): String? =
try {
val target =
Intent(context, MainActivity::class.java).apply {
// A shortcut intent without an action is rejected outright.
action = Intent.ACTION_VIEW
data = uri.toUri()
}
val shortcut =
ShortcutInfoCompat
.Builder(context, conversation.id)
.setShortLabel(conversation.label)
.setLongLabel(conversation.label)
.setIntent(target)
// Without this the system drops the shortcut as soon as it leaves the
// dynamic list, and the conversation loses its history and its ranking.
.setLongLived(true)
.apply {
// A group is not a person. Attaching the speaker would make the
// launcher entry's face flip to whoever last spoke while the label
// stayed the group, and would rank the chat as that one contact.
if (!conversation.isGroup) setPerson(person)
// Plain, not adaptive: `icon` is already circle-cropped, and the
// adaptive mask would crop that circle a second time — zoomed, with
// the edges clipped off.
icon?.let { setIcon(IconCompat.createWithBitmap(it)) }
}.build()
if (ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)) {
conversation.id
} else {
Log.d(TAG) { "System declined the shortcut for ${conversation.label}" }
null
}
} catch (e: Exception) {
// Builder.build() throws on a malformed shortcut (an empty label, say) and it is
// inside the guard for that reason: this runs from postConversation, whose caller
// swallows exceptions, so a throw escaping here would silently drop the whole
// notification rather than just its conversation status.
Log.d(TAG) { "Could not publish a shortcut for ${conversation.label}: ${e.message}" }
null
}
/**
* Drops every conversation this account published into the launcher.
*
* Logging out has to take these with it. They are the one part of an account that lives
* outside the app's own storage — names and avatars of people it talked to, sitting in the
* launcher's long-press menu — so an account that is gone from Amethyst but still lists its
* contacts on the home screen would be a worse leak than not publishing them at all.
*
* Long-lived shortcuts are also removed from the system's cache, not just the dynamic list:
* the cache is what keeps a conversation alive after it falls out of the top slots, so
* dropping only the dynamic copy would leave it recoverable.
*/
fun removeForAccount(
context: Context,
accountNpub: String,
) {
val scope = ":$accountNpub:"
try {
// Every list the system keeps, not just the dynamic one. `pushDynamicShortcut`
// evicts the least-recently-used shortcut from the dynamic list when the cap is
// reached, and a long-lived shortcut that has been used in a notification is kept
// in the system *cache* rather than deleted — so the conversations most likely to
// have fallen out of the dynamic list are exactly the ones a dynamic-only sweep
// would leave behind, which is the leak this is here to close.
val mine =
ShortcutManagerCompat
.getShortcuts(
context,
ShortcutManagerCompat.FLAG_MATCH_DYNAMIC or
ShortcutManagerCompat.FLAG_MATCH_CACHED or
ShortcutManagerCompat.FLAG_MATCH_PINNED,
).map { it.id }
.filter { it.contains(scope) }
if (mine.isEmpty()) return
// A shortcut the user pinned to their home screen cannot be deleted by us, only
// disabled — so disable first, then remove. Without this a pinned conversation
// would survive the logout as a live tile.
ShortcutManagerCompat.disableShortcuts(
context,
mine,
stringRes(context, R.string.app_notification_shortcut_account_removed),
)
ShortcutManagerCompat.removeLongLivedShortcuts(context, mine)
} catch (e: Exception) {
Log.d(TAG) { "Could not clear shortcuts for $accountNpub: ${e.message}" }
}
}
}
@@ -0,0 +1,171 @@
/*
* 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.notifications
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
/**
* What became of a reply the user typed into the shade.
*
* The three outcomes are not two: a send that has neither returned nor thrown is its own
* thing, and saying so is the only honest option — see [Unconfirmed].
*/
sealed interface ReplyState {
/** On its way. [text] joins the thread so the user can see what they sent. */
data class Sending(
val text: String,
) : ReplyState
/** It went out — drop the marker and leave the thread as it now reads. */
data object Sent : ReplyState
/**
* Long enough that "Sending…" has stopped being true, without an answer either way.
*
* A broadcast receiver is killed around ten seconds in, and a NIP-17 send can exceed that
* on its own: an Amber round trip, a relay publish, proof-of-work mining. Whatever the
* shade is showing when the process dies is what it shows until the conversation is read,
* so it must not be a claim we cannot stand behind.
*
* Deliberately offers no Retry. The message may well have gone out — re-sending would
* deliver it twice, which is worse than leaving the user to open the app and look.
*/
data class Unconfirmed(
val text: String,
) : ReplyState
/**
* It threw. [retry] re-sends the same text on one tap, carrying the text itself because a
* RemoteInput cannot be pre-filled and re-typing it is the thing this prevents.
*/
data class Failed(
val text: String,
val retry: PendingIntent,
) : ReplyState
}
/**
* Re-renders the live notification for [notId] to say what happened to an inline reply.
*
* Until this existed a reply typed in the shade was posted into silence: the send either
* worked, and the notification vanished with no sign the message had gone, or it threw, and
* nothing at all happened — same notification, text gone, user believing it sent. A denied
* signature or a dead socket was indistinguishable from delivery.
*
* The live notification is the only place the reply can be shown, so it is rebuilt from
* itself rather than from scratch: [NotificationCompat.Builder] can recover a builder from a
* posted [Notification], and MessagingStyle can be extracted and extended. Callers that are
* not conversations (BigText notifications carrying an inline reply) have no thread to append
* to and get `setRemoteInputHistory`, which is the same idea in the shape that style supports.
*
* Returns false when nothing is posted under [notId] any more — the user swiped it away while
* the reply was in flight. Nothing is re-posted in that case: they are done with it.
*/
fun NotificationManager.renderReplyState(
applicationContext: Context,
notId: Int,
state: ReplyState,
): Boolean =
try {
renderReplyStateOrThrow(applicationContext, notId, state)
} catch (e: Exception) {
// Feedback must never break the thing it reports on. Reading the live notification
// and posting it back both go through the system, and both can throw (a revoked
// POST_NOTIFICATIONS between check and notify, say) — and this runs either side of
// the send, where an escape would cost the user the reply itself.
Log.w(TAG, "Could not render the reply state", e)
false
}
private const val TAG = "InlineReplyFeedback"
private fun NotificationManager.renderReplyStateOrThrow(
applicationContext: Context,
notId: Int,
state: ReplyState,
): Boolean {
val existing = activeNotifications.firstOrNull { it.id == notId }?.notification ?: return false
val builder =
NotificationCompat
.Builder(applicationContext, existing)
// The thread already alerted when the message arrived; an update about the user's
// own reply must not buzz again.
.setOnlyAlertOnce(true)
val style = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(existing)
when (state) {
is ReplyState.Sending -> {
// `null` attributes the message to the MessagingStyle's own user.
style?.addMessage(state.text, System.currentTimeMillis(), null as Person?)
?: builder.setRemoteInputHistory(arrayOf(state.text))
builder.setSubText(stringRes(applicationContext, R.string.app_notification_reply_sending))
}
ReplyState.Sent -> {
// Sending already put the text in the thread — appending here would double it.
builder.setSubText(null)
}
is ReplyState.Unconfirmed -> {
if (style == null) builder.setRemoteInputHistory(arrayOf(state.text))
builder.setSubText(stringRes(applicationContext, R.string.app_notification_reply_unconfirmed))
}
is ReplyState.Failed -> {
if (style == null) builder.setRemoteInputHistory(arrayOf(state.text))
builder.setSubText(stringRes(applicationContext, R.string.app_notification_reply_failed))
val retryLabel = stringRes(applicationContext, R.string.app_notification_reply_retry)
// Rebuilt from the posted notification, so its actions come with it — and a bare
// clearActions() would take Reply and Mark Read with them, leaving a failed reply
// with no way to write a different one. Keep them, drop any Retry left by an
// earlier attempt so repeated failures don't stack, then add this one.
val kept =
(0 until NotificationCompat.getActionCount(existing))
.mapNotNull { NotificationCompat.getAction(existing, it) }
.filter { it.title?.toString() != retryLabel }
builder.clearActions()
kept.forEach { builder.addAction(it) }
builder.addAction(
NotificationCompat.Action
.Builder(R.drawable.ic_action_reply, retryLabel, state.retry)
.build(),
)
}
}
style?.let { builder.setStyle(it) }
notify(notId, builder.build())
return true
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -46,11 +47,22 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
class NotificationReplyReceiver : BroadcastReceiver() {
companion object {
/**
* How long a send may run before the notification stops claiming it is sending. Set
* under the ten seconds a foreground broadcast gets, so the honest state is rendered
* while this process is still alive to render it.
*/
private const val UNCONFIRMED_AFTER_MS = 8_000L
}
override fun onReceive(
context: Context,
intent: Intent,
@@ -88,12 +100,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
}
NotificationUtils.REPLY_ACTION -> {
val replyText =
RemoteInput
.getResultsFromIntent(intent)
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
?.toString()
val replyText = replyTextFrom(intent)
if (replyText.isNullOrBlank()) return
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
@@ -102,35 +109,25 @@ class NotificationReplyReceiver : BroadcastReceiver() {
if (members.isEmpty()) return
runOnRelay(notificationManager, notificationId, eventId) {
runOnRelay(context, notificationManager, notificationId, eventId, replyText, intent) {
sendReply(accountNpub, members, replyText)
}
}
NotificationUtils.PUBLIC_REPLY_ACTION -> {
val replyText =
RemoteInput
.getResultsFromIntent(intent)
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
?.toString()
val replyText = replyTextFrom(intent)
if (replyText.isNullOrBlank()) return
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
val targetEventId = intent.getStringExtra(NotificationUtils.KEY_TARGET_EVENT_ID) ?: return
runOnRelay(notificationManager, notificationId, eventId) {
runOnRelay(context, notificationManager, notificationId, eventId, replyText, intent) {
sendPublicReply(accountNpub, targetEventId, replyText)
}
}
NotificationUtils.MARMOT_REPLY_ACTION -> {
val replyText =
RemoteInput
.getResultsFromIntent(intent)
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
?.toString()
val replyText = replyTextFrom(intent)
if (replyText.isNullOrBlank()) return
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
@@ -138,22 +135,51 @@ class NotificationReplyReceiver : BroadcastReceiver() {
val replyToInnerId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_ID)
val replyToInnerAuthor = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_AUTHOR)
runOnRelay(notificationManager, notificationId, eventId) {
runOnRelay(context, notificationManager, notificationId, eventId, replyText, intent) {
sendMarmotReply(accountNpub, nostrGroupId, replyToInnerId, replyToInnerAuthor, replyText)
}
}
}
}
/**
* The text of an inline reply: typed into the shade, or carried by a Retry re-sending one
* that failed. A RemoteInput cannot be pre-filled, so a retry has to bring its own copy.
*/
private fun replyTextFrom(intent: Intent): String? =
RemoteInput
.getResultsFromIntent(intent)
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
?.toString()
?: intent.getStringExtra(NotificationUtils.KEY_REPLY_TEXT)
/**
* Sends [block] and keeps the notification honest about how it went.
*
* The notification stays up rather than being cancelled on success. That is what every
* other messenger does — the reply appears in the thread you replied to — and it is what
* makes a failure visible at all: there is something left on screen to put the error on.
* It clears the usual way, when the conversation is read in the app.
*
* The dismissal guard is recorded on **both** outcomes. On success it stops the enrichment
* window resurrecting the pre-reply version seconds later; on failure it stops that same
* re-render overwriting the error and the Retry that carries the user's text. A nicer
* avatar is not worth losing an unsent message to.
*/
private fun runOnRelay(
context: Context,
notificationManager: NotificationManager,
notificationId: Int,
eventId: String?,
replyText: String,
source: Intent,
block: suspend () -> Unit,
) {
val pendingResult = goAsync()
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val appContext = context.applicationContext
scope.launch {
val collectionJob =
scope.launch {
@@ -161,13 +187,48 @@ class NotificationReplyReceiver : BroadcastReceiver() {
.collect()
}
// Stops "Sending…" becoming a lie the shade keeps telling. A broadcast receiver is
// killed around ten seconds in, and a NIP-17 send can outlive that on its own — an
// Amber round trip, a relay publish, proof-of-work mining — so whatever is on
// screen at that moment is what the user is left with.
//
// A watchdog, deliberately, not a `withTimeout`: timing the send out would *cancel*
// it, and a publish cancelled halfway is a worse outcome than a slow one. This only
// changes what the notification says. If the send does finish afterwards, the Sent
// or Failed render below overwrites it.
val watchdog =
scope.launch {
delay(UNCONFIRMED_AFTER_MS)
notificationManager.renderReplyState(
appContext,
notificationId,
ReplyState.Unconfirmed(replyText),
)
}
try {
// Inside the try: everything from here on must reach the `finally`, which is
// what calls pendingResult.finish() and releases the broadcast.
notificationManager.renderReplyState(appContext, notificationId, ReplyState.Sending(replyText))
block()
// Joined, not just cancelled: if the watchdog is already inside its render, the
// two notify() calls would land in an undefined order and the shade could keep
// "Still sending…" over a message that went out.
watchdog.cancelAndJoin()
eventId?.let { NotificationUtils.markDismissed(it) }
notificationManager.cancelAndPrune(notificationId)
notificationManager.renderReplyState(appContext, notificationId, ReplyState.Sent)
} catch (e: Exception) {
// Rethrown first: joining is a suspend call, which an already-cancelled
// coroutine cannot make. `scope.cancel()` below takes the watchdog with it.
if (e is CancellationException) throw e
watchdog.cancelAndJoin()
Log.e("NotificationReply") { "Failed to send reply: ${e.message}" }
eventId?.let { NotificationUtils.markDismissed(it) }
notificationManager.renderReplyState(
appContext,
notificationId,
ReplyState.Failed(replyText, retryIntent(appContext, notificationId, replyText, source)),
)
} finally {
pendingResult.finish()
collectionJob.cancel()
@@ -176,6 +237,37 @@ class NotificationReplyReceiver : BroadcastReceiver() {
}
}
/**
* A one-tap re-send of exactly what the user typed.
*
* A copy of [source] — which already carries the action and the account, room, group or
* target this reply was addressed to — plus the text. Keeping the original action is what
* makes this free: [onReceive] routes it back to the branch it came from with no alias to
* resolve, the dismissal guard already treats it as the reply action it is, and
* [replyTextFrom] already prefers a RemoteInput result and falls back to this extra.
*/
private fun retryIntent(
applicationContext: Context,
notificationId: Int,
replyText: String,
source: Intent,
): PendingIntent {
val intent =
Intent(source).apply {
setClass(applicationContext, NotificationReplyReceiver::class.java)
putExtra(NotificationUtils.KEY_REPLY_TEXT, replyText)
}
return PendingIntent.getBroadcast(
applicationContext,
// The other three request codes for this notification are notId, +1 (mark read)
// and +2 (dismiss).
notificationId + 3,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
private suspend fun sendReply(
accountNpub: String,
chatroomMembers: List<String>,
@@ -26,6 +26,7 @@ import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip19Bech32.toNpub
/** Deep-link URIs consumed by `MainActivity.uriToRoute` when a notification is tapped. */
@@ -38,11 +39,88 @@ object NotificationRoutes {
.hexToByteArray()
.toNpub()
/** Opens the note directly (used for replies, mentions, DMs, media, git). */
/**
* Opens the note directly (used for replies, mentions, media, git).
*
* A note delivered inside an envelope (a NIP-17 private note or reply) cannot be
* addressed this way — see [privateNoteUri].
*/
fun noteUri(
note: Note,
accountNpub: String,
): String = note.toNEvent() + ACCOUNT + accountNpub
): String =
if (note.rumorHost != null) {
privateNoteUri(note.idHex, accountNpub)
} else {
note.toNEvent() + ACCOUNT + accountNpub
}
/**
* Opens a NIP-17 private note or reply by the rumor's own id.
*
* [Note.toNEvent] cannot address one: for a rumor it encodes the kind-1059 gift wrap
* that delivered it, because the rumor id must never appear in anything that leaves
* for a relay (`RumorHostCitationTest` pins that, and [Note.isPrivateRumor] guards the
* publish paths). Two things then go wrong on a tap. The wrap is unfetchable — the
* general event finder asks the account's read relays for an id only the DM inbox
* relays ever held. And the wrap note emits exactly once, from `consumeRegularEvent`,
* *before* it is decrypted, so `routeFor` sees a null `innerEventId` and the redirect
* never fires again (the later `event = copyNoContent()` is a plain assignment).
*
* The rumor id has neither problem: its note emits when the rumor is consumed, and the
* always-on one-week gift-wrap tail re-delivers and re-unwraps the envelope on every
* cold start, so a push-recent message always comes back on its own. This URI is read
* only by `MainActivity.uriToRoute`, inside our own process, and the route it produces
* never puts the id in a REQ — see `Route.EventRedirect.isPrivate`.
*/
fun privateNoteUri(
rumorId: String,
accountNpub: String,
): String = "privatenote?id=$rumorId&account=$accountNpub"
/**
* Opens a NIP-17 / NIP-04 private chatroom (DM notifications).
*
* A DM must NOT deep-link through its own note: [Note.toNEvent] cites the kind-1059
* gift wrap that delivered the rumor (it has to — the rumor id is the private event's
* identity and resolves to nothing on a public relay, see `RumorHostCitationTest`).
* Resolving that nevent back to a room needs the wrap *and* seal notes to still be in
* the in-memory `LocalCache`. A push usually wakes a cold process, so by the time the
* user taps, the cache is empty — and no relay will ever serve that wrap again, so the
* tap landed on a `Route.EventRedirect` that could never resolve.
*
* The room key is derived locally and needs no event at all, so this survives the
* process dying. Same idea as [relayGroupUri], which routes a Buzz DM through the
* channel's naddr rather than the message.
*/
fun chatroomUri(
room: ChatroomKey,
accountNpub: String,
): String = "chatroom?id=${room.users.joinToString(",")}&account=$accountNpub"
// ---------------------------------------------------------------------
// Conversation shortcut ids
//
// Stable for the life of the chat and scoped to the account, so the same counterparty
// under two logins does not collapse into one launcher entry pointing at the wrong
// inbox. Members are sorted because a room key is a set — ordering must not create a
// second shortcut for a chat that already has one.
// ---------------------------------------------------------------------
fun chatroomShortcutId(
room: ChatroomKey,
accountNpub: String,
): String = "dm:$accountNpub:" + room.users.sorted().joinToString(",")
fun marmotShortcutId(
nostrGroupId: String,
accountNpub: String,
): String = "marmot:$accountNpub:$nostrGroupId"
fun relayGroupShortcutId(
channelNAddr: String,
accountNpub: String,
): String = "relaygroup:$accountNpub:$channelNAddr"
/** Opens the Notifications tab, scrolled to [scrollToId] (used for zaps, reactions, chess). */
fun notificationsUri(
@@ -50,7 +128,13 @@ object NotificationRoutes {
scrollToId: String,
): String = "notifications$ACCOUNT$accountNpub$SCROLL_TO$scrollToId"
/** Opens a Marmot group chatroom (welcome + group message). */
/**
* Opens a Marmot group chatroom (welcome + group message).
*
* Note the query here rides on an *opaque* URI (a scheme with no `//`), so its
* `?account=` can only be read by [String.findQueryParameterValue] — see the note
* there; `java.net.URI.rawQuery` is null for this shape.
*/
fun marmotUri(
nostrGroupId: String,
accountNpub: String,
@@ -37,6 +37,7 @@ import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.LocusIdCompat
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
@@ -80,6 +81,7 @@ object NotificationUtils {
* [KEY_TARGET_EVENT_ID], which is the note an inline reply is addressed to.
*/
const val KEY_EVENT_ID = "key_event_id"
const val KEY_ACCOUNT_NPUB = "key_account_npub"
const val KEY_CHATROOM_MEMBERS = "key_chatroom_members"
const val KEY_TARGET_EVENT_ID = "key_target_event_id"
@@ -166,6 +168,29 @@ object NotificationUtils {
) : ReplyAction
}
/**
* Identity of the chat a conversation notification belongs to, so the system can treat it
* as one — see [ConversationShortcuts] for what that buys and why it needs a shortcut.
*
* [id] must be stable for the life of the conversation and unique across accounts; [label]
* is what the conversation is called, which is not always the sender (a group message is
* from a person but belongs to the group).
*
* Callers pass this only when the account allows message content in notifications: the
* shortcut it publishes is readable from the launcher.
*/
data class Conversation(
val id: String,
val label: String,
/**
* The chat's own picture, when it has one. Null falls back to the message sender's
* avatar, which is the right face for a one-to-one DM and the wrong one for a group —
* hence [isGroup], which suppresses that fallback along with the sender `Person`.
*/
val iconUrl: String? = null,
val isGroup: Boolean = false,
)
/** A prior message rendered above the main one in a MessagingStyle notification (thread context). */
data class ParentMessage(
val senderName: String,
@@ -281,6 +306,7 @@ object NotificationUtils {
replyAction: ReplyAction? = null,
publicInlineReply: InlineReplyTarget? = null,
addMarkRead: Boolean = true,
conversation: Conversation? = null,
groupKey: String = category.group,
summaryId: Int = category.summaryId,
) {
@@ -330,6 +356,17 @@ object NotificationUtils {
val contentPendingIntent = contentIntent(applicationContext, notId, uri)
// Published before the notification that names it: a shortcutId the system cannot
// resolve is worse than none, so the id is only stamped below when this succeeded.
val shortcutId =
conversation?.let {
val conversationIcon =
it.iconUrl?.let { url -> loadBitmap(url, applicationContext)?.let { bmp -> circleCrop(bmp) } }
?: avatar.takeUnless { _ -> it.isGroup }
ConversationShortcuts.push(applicationContext, it, uri, sender, conversationIcon)
}
val builderPublic =
NotificationCompat
.Builder(applicationContext, channelId)
@@ -360,6 +397,15 @@ object NotificationUtils {
.setOnlyAlertOnce(true)
.setWhen(time * 1000)
// The three halves of the conversation contract: the shortcut the shade resolves, the
// locus that ties this notification to it, and the people it is with. All three have to
// be present or the notification is ranked as an ordinary alert.
if (shortcutId != null) {
builder.setShortcutId(shortcutId)
builder.setLocusId(LocusIdCompat(shortcutId))
}
builder.addPerson(sender)
when (replyAction) {
is ReplyAction.Dm -> builder.addAction(dmReplyAction(applicationContext, notId, id, replyAction))
is ReplyAction.Marmot -> builder.addAction(marmotReplyAction(applicationContext, notId, id, replyAction))
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.Conversation
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
@@ -82,10 +83,23 @@ object BuzzDmNotification {
val accountNpub = NotificationRoutes.accountNpub(account)
// The channel's kind-39000 naddr routes straight to the chatroom via the
// existing naddr → Route.RelayGroup path (no message load needed on tap).
val channelNAddr = channel.toNAddr()
val uri =
channel.toNAddr()?.let { NotificationRoutes.relayGroupUri(it, accountNpub) }
channelNAddr?.let { NotificationRoutes.relayGroupUri(it, accountNpub) }
?: NotificationRoutes.noteUri(note, accountNpub)
// Only the naddr identifies the room durably; without it there is no conversation to
// pin a shortcut to. The message-content gate is the early return at the top.
val conversation =
channelNAddr?.let {
Conversation(
id = NotificationRoutes.relayGroupShortcutId(it, accountNpub),
label = channel.toBestDisplayName(),
iconUrl = channel.profilePicture(),
isGroup = true,
)
}
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
@@ -107,6 +121,7 @@ object BuzzDmNotification {
applicationContext = context,
accountPictureUrl = account.userProfile().profilePicture(),
replyAction = null,
conversation = conversation,
)
}
}
@@ -21,12 +21,14 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.Conversation
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.ReplyAction
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation
import com.vitorpamplona.amethyst.service.notifications.notificationManager
@@ -96,7 +98,23 @@ object DirectMessageNotification {
}
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.noteUri(chatNote, accountNpub)
// The room, not the message: a rumor's nevent cites the gift wrap that delivered it,
// which is gone from LocalCache as soon as the process dies and is unfetchable from
// any relay. See [NotificationRoutes.chatroomUri].
val uri = NotificationRoutes.chatroomUri(chatRoom, accountNpub)
// Names the chat for the shade's Conversations section. Withheld when the account has
// turned message content off, because publishing it puts the counterparty's name and
// avatar in the launcher — see [ConversationShortcuts].
val conversation =
if (account.settings.showMessagesInNotifications.value) {
Conversation(
id = NotificationRoutes.chatroomShortcutId(chatRoom, accountNpub),
label = roomLabel(chatRoom, author),
)
} else {
null
}
val replyAction =
if (decrypt) {
null // NIP-04 is read-only in the tray
@@ -125,7 +143,22 @@ object DirectMessageNotification {
applicationContext = context,
accountPictureUrl = account.userProfile().profilePicture(),
replyAction = replyAction,
conversation = conversation,
)
}
}
/**
* What to call the chat. A one-to-one room is the other person, which is also the sender;
* a group room is everyone in it, so the sender's name alone would mislabel it.
*/
private fun roomLabel(
chatRoom: ChatroomKey,
author: User,
): String =
if (chatRoom.users.size <= 1) {
author.toBestDisplayName()
} else {
chatRoom.users.joinToString(", ") { LocalCache.getOrCreateUser(it).toBestDisplayName() }
}
}
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.Conversation
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.ReplyAction
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation
import com.vitorpamplona.amethyst.service.notifications.notificationManager
@@ -61,6 +62,20 @@ object GroupMessageNotification {
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.marmotUri(nostrGroupId, accountNpub)
// Withheld when the account has message content turned off — the shortcut it publishes
// names the group in the launcher. See [ConversationShortcuts].
val conversation =
if (account.settings.showMessagesInNotifications.value) {
// No iconUrl: a Marmot group's avatar is an encrypted Blossom blob, not a
// URL an image loader can take.
Conversation(
id = NotificationRoutes.marmotShortcutId(nostrGroupId, accountNpub),
label = groupName,
isGroup = true,
)
} else {
null
}
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
@@ -81,6 +96,7 @@ object GroupMessageNotification {
uri = uri,
applicationContext = context,
accountPictureUrl = account.userProfile().profilePicture(),
conversation = conversation,
replyAction =
ReplyAction.Marmot(
accountNpub = accountNpub,
@@ -59,6 +59,21 @@ fun observeNote(
return flow.collectAsStateWithLifecycle()
}
/**
* [observeNote] without the relay half: watches LocalCache and asks no relay for the note.
*
* For a NIP-17 rumor, which has no fetchable id — putting one in a REQ would tell relays the
* private event's identity, the leak [com.vitorpamplona.amethyst.commons.model.Note.isPrivateRumor]
* guards everywhere else. Nothing is lost by not asking: a rumor only ever reaches the cache by
* unwrapping the envelope that carried it, and the always-on gift-wrap tail re-fetches a week of
* those on every cold start, so this flow fires on its own once the envelope lands.
*/
@Composable
fun observeNoteLocally(note: Note): State<NoteState> {
val flow = remember(note) { note.flow().metadata.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
inline fun <reified T : Event> observeNoteEvent(
@@ -34,16 +34,20 @@ import com.vitorpamplona.amethyst.debugState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.notifications.NotificationRelayService
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
import com.vitorpamplona.amethyst.ui.navigation.findQueryParameterValue
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.routes.routeForPointer
import com.vitorpamplona.amethyst.ui.note.elements.NowProvider
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
@@ -172,6 +176,56 @@ fun fragmentHashtagOrNull(uri: String): String? {
fun isUrlRoute(uri: String) = uri.startsWith("url?id=") || uri.startsWith("nostr:url?id=")
/**
* A private chatroom, addressed by its participants rather than by a message.
* Posted by DM notifications — see [NotificationRoutes.chatroomUri] for why a DM
* cannot deep-link through its own note.
*/
fun isChatroomRoute(uri: String) = uri.startsWith("chatroom?id=") || uri.startsWith("nostr:chatroom?id=")
/**
* Note what this deliberately does *not* do: register the room on the account.
*
* `routeToMessage` would, and that is right for the in-app callers, but wrong here twice over.
* `uriToRoute` runs against whichever account is current, *before* `?account=` is read and the
* switch happens (`AppNavigation.NavigateIfIntentRequested`), so a DM notification for account B
* tapped while A is on screen would insert an empty conversation into **A**'s message list. And
* `nostr:` is an exported, browsable scheme, so any web page could post
* `nostr:chatroom?id=<hex>&account=…` and inject a room of its choosing.
*
* Nothing needs it: `ChatroomFeedFilter.chatroom()` calls `getOrCreatePrivateChatroom` when the
* screen actually opens, by which point the switch has happened and the room lands on the right
* account.
*
* The ids are still checked here — a room key is a set of pubkeys, so anything that isn't one is
* not a room, and a bad deep link should resolve to nothing rather than to an unopenable screen.
*/
fun chatroomRoute(uri: String): Route? {
val users =
uri
.findQueryParameterValue("id")
?.split(',')
?.map { it.trim() }
?.takeIf { it.isNotEmpty() && it.all(::isPubKeyHex) }
?.toSet() ?: return null
return Route.Room(ChatroomKey(users))
}
/** A bare 32-byte lowercase-hex pubkey, the only thing a chatroom key is made of. */
private fun isPubKeyHex(value: String) = value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' }
/**
* A NIP-17 private note or reply, addressed by the rumor's own id because its `nevent`
* names the undeliverable gift wrap instead — see [NotificationRoutes.privateNoteUri].
*/
fun isPrivateNoteRoute(uri: String) = uri.startsWith("privatenote?id=") || uri.startsWith("nostr:privatenote?id=")
fun privateNoteRoute(uri: String): Route? {
val id = uri.findQueryParameterValue("id") ?: return null
return Route.EventRedirect(id, isPrivate = true)
}
fun isConnectedAppRoute(uri: String) = uri.startsWith("connectedapp?coordinate=") || uri.startsWith("nostr:connectedapp?coordinate=")
/**
@@ -232,7 +286,7 @@ fun uriToRoute(
account: Account,
): Route? {
if (isNotificationRoute(uri)) {
val scrollTo = runCatching { java.net.URI(uri.removePrefix(NOSTR_URI_PREFIX)).findParameterValue("scrollTo") }.getOrNull()
val scrollTo = uri.findQueryParameterValue("scrollTo")
return Route.Notification(scrollToEventId = scrollTo)
}
if (isActiveSubscriptionsRoute(uri)) {
@@ -250,6 +304,12 @@ fun uriToRoute(
if (isUrlRoute(uri)) {
return urlRoute(uri)
}
if (isChatroomRoute(uri)) {
return chatroomRoute(uri)
}
if (isPrivateNoteRoute(uri)) {
return privateNoteRoute(uri)
}
if (isConnectedAppRoute(uri)) {
return connectedAppRoute(uri)
}
@@ -284,10 +344,15 @@ fun uriToRoute(
}
is NEvent -> {
routeFor(
note = LocalCache.getOrCreateNote(nip19.hex),
loggedIn = account,
) ?: Route.EventRedirect(nip19.hex)
val note = LocalCache.getOrCreateNote(nip19.hex)
// Only fall back to the pointer's own kind while the body is missing: once the
// event is cached it may belong somewhere the kind alone can't name (a channel,
// a chatroom), and routeFor knows that.
if (note.event == null) {
routeForPointer(nip19.kind, nip19.hex) ?: Route.EventRedirect(nip19.hex)
} else {
routeFor(note = note, loggedIn = account) ?: Route.EventRedirect(nip19.hex)
}
}
is NAddress -> {
@@ -886,7 +886,7 @@ fun BuildNavigation(
composableFromBottomArgs<Route.NewGroupDM> { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) }
composableFromBottomArgs<Route.ShareToDM> { ShareToDMScreen(it.message, it.attachment, accountViewModel, nav) }
composableArgs<Route.EventRedirect> { LoadRedirectScreen(it.id, accountViewModel, nav) }
composableArgs<Route.EventRedirect> { LoadRedirectScreen(it.id, it.isPrivate, accountViewModel, nav) }
composableFromBottomArgs<Route.GeoPost> {
GeoHashPostScreen(
@@ -1167,7 +1167,7 @@ private fun NavigateIfIntentRequested(
LaunchedEffect(intentNextPage) {
if (actionableNextPage != null) {
actionableNextPage?.let { nextRoute ->
val npub = runCatching { URI(intentNextPage.removePrefix("nostr:")).findParameterValue("account") }.getOrNull()
val npub = intentNextPage.findQueryParameterValue("account")
if (npub != null && accountSessionManager.currentAccountNPub() != npub) {
accountSessionManager.checkAndSwitchUserSync(npub) { account ->
uriToRoute(intentNextPage, account)
@@ -1254,7 +1254,7 @@ private fun NavigateIfIntentRequested(
if (newPage != null) {
scope.launch {
val npub = runCatching { URI(uri.removePrefix("nostr:")).findParameterValue("account") }.getOrNull()
val npub = uri.findQueryParameterValue("account")
if (npub != null && accountSessionManager.currentAccountNPub() != npub) {
accountSessionManager.checkAndSwitchUserSync(npub) { newAccount ->
uriToRoute(uri, newAccount)
@@ -1304,3 +1304,34 @@ fun URI.findParameterValue(parameterName: String): String? =
Pair(name, value)
}?.firstOrNull { it.first == parameterName }
?.second
/**
* The value of [parameterName] in this URI's query string, without going through
* [java.net.URI].
*
* Needed because `java.net.URI` only exposes `rawQuery` for *hierarchical* URIs. A URI
* with a scheme and no `//` is **opaque** — everything after the colon is one
* scheme-specific part — so `URI("marmot:<hex>?account=npub1…").rawQuery` is null. That
* is the shape [com.vitorpamplona.amethyst.service.notifications.NotificationRoutes.marmotUri]
* produces, so every Marmot group notification silently lost its `?account=` and opened
* the group under whichever account happened to be current instead of switching first.
*
* Splitting on the first `?` gets the same answer for both shapes, and returns null for a
* bare `nevent1…` with no query at all.
*
* [com.vitorpamplona.quartz.utils.UriParser] reads an opaque query correctly too, and is the
* right tool when a URI is already known to be well-formed. It is not this one: it builds a
* [java.net.URI], which *throws* on anything that is not a legal URI. What arrives here comes
* from an exported, browsable scheme, so it can be any string at all, and every caller on the
* deep-link path treats an unreadable uri as "no route" rather than as a crash.
*/
fun String.findQueryParameterValue(parameterName: String): String? {
val query = substringAfter('?', "")
if (query.isEmpty()) return null
return query
.split('&')
.firstOrNull { it.substringBefore('=') == parameterName }
?.substringAfter('=', "")
?.ifEmpty { null }
}
@@ -51,6 +51,10 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
@@ -60,9 +64,13 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip73ExternalIds.location.isGeohashedScoped
import com.vitorpamplona.quartz.nip73ExternalIds.topics.isHashtagScoped
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
@@ -98,6 +106,62 @@ fun minichatRouteFor(note: Note): Route? {
private fun Note.isInChatGatherer(): Boolean = inGatherers?.any { it is ConcordChannel || it is RelayGroupChannel || it is PublicChatChannel } == true
/**
* Kinds whose destination is `Route.Note(id)` — the generic thread view — no matter what the
* event body turns out to say. Mirrors the `else ->` branch of [routeForInner]: every kind here
* is a plain note that carries nothing (no channel id, no `a` tag, no chatroom key) that could
* send it somewhere more specific.
*
* **An addressable kind can never be listed here**, however plain it looks. [routeForInner]
* sends those to `Route.Note(addressTag())` through its `is AddressableEvent` branch, and the
* difference is not cosmetic: relays do not serve a replaceable by the id of one of its
* versions, so routing by id is a dead end. This bit the NIP-71 video kinds — 21/22 are regular
* but 34235/34236 extend `AddressableVideoEvent` — which is why the pair is split below and why
* `RouteForPointerTest` walks this list against [routeForInner] itself rather than trusting it.
*
* `internal` so that test can iterate the real list: a kind added here is covered by
* construction, not by someone remembering to add a case.
*/
internal val THREAD_VIEW_KINDS =
intArrayOf(
TextNoteEvent.KIND,
CommentEvent.KIND,
PollEvent.KIND,
PictureEvent.KIND,
// NIP-71 regular video only (21/22). The addressable pair, 34235/34236, is cited by
// `naddr` and routed by address — see the warning above.
VideoNormalEvent.KIND,
VideoShortEvent.KIND,
HighlightEvent.KIND,
GitIssueEvent.KIND,
GitPatchEvent.KIND,
GitPullRequestEvent.KIND,
GitPullRequestUpdateEvent.KIND,
)
/**
* Route for an event we hold only a NIP-19 pointer to — its id plus, when the pointer carries
* one, its [kind] — because the body has not reached [LocalCache] yet.
*
* Notification deep links are the reason this exists. A push normally wakes a *cold* process,
* so by the time the user taps the tray the cache is empty and [routeFor] can say nothing
* better than [Route.EventRedirect] — a bare "looking for event" screen the user sits on until
* the event is re-fetched. An `nevent` already states the kind, which for an ordinary note is
* the whole answer, so a reply or a mention can open its thread immediately and let the screen
* fill itself in.
*
* Returns null when the kind is absent or needs the body to place the event, leaving the
* caller's redirect in charge.
*/
fun routeForPointer(
kind: Int?,
id: HexKey,
): Route? {
if (kind == null) return null
if (kind !in THREAD_VIEW_KINDS) return null
return Route.Note(id)
}
fun routeFor(
note: Note,
loggedIn: Account,
@@ -1081,6 +1081,12 @@ sealed class Route {
@Serializable data class EventRedirect(
val id: String,
/**
* The event is a NIP-17 rumor, so [id] is a private event id that must never reach
* a relay: the screen watches LocalCache for it and issues no REQ. The envelope that
* carries it comes back on its own through the always-on gift-wrap tail.
*/
val isPrivate: Boolean = false,
) : Route()
@Serializable
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.service.notifications.ConversationShortcuts
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -398,6 +399,11 @@ class AccountSessionManager(
// `:napplet` call ProfileStore.deleteProfile(name) — and that must refuse a profile still in
// use by a live WebView. Not wired for this release; there is no existing hook that reaches
// the sandbox on account deletion.
// The launcher is the one place an account's contacts live outside our own
// storage, so the conversation shortcuts go before anything else — whether or not
// this is the account currently on screen. See [ConversationShortcuts].
ConversationShortcuts.removeForAccount(Amethyst.instance.appContext, accountInfo.npub)
if (accountInfo.npub == currentAccountNPub()) {
// Drop the Nest bridge ref before tearing down the
// current account so the audio-room activity can't
@@ -360,6 +360,18 @@ class GiftWrapEventHandler(
eventProcessor.consumeEvent(innerGift, innerGiftNote, publicNote)
}
}
// The wrap note already emitted once — from `consumeRegularEvent`, before any of this
// ran, when `innerEventId` was still null and the note said nothing about where it
// leads. Everything above is plain assignment, so without this the wrap's last word on
// itself is that first, useless emission: anything waiting on it (a `nostr:nevent…` for
// a wrap, a notification from a build that addressed DMs that way) waits forever while
// the message it wanted sits decrypted in the cache. Emitted last so an observer that
// wakes here can walk wrap → seal → rumor and find the whole chain linked.
//
// `flowSet?`, not `flow()`: a wrap nobody is watching — which is nearly all of them,
// a week's worth on every cold start — must not be given a flow set to hold.
eventNote.flowSet?.metadata?.invalidateData()
}
private suspend fun processExistingGiftWrap(
@@ -505,6 +517,11 @@ class SealedRumorEventHandler(
} else {
eventProcessor.consumeEvent(innerRumor, innerRumorNote, publicNote)
}
// Same as the wrap above: the seal's only emission came before it was opened, so
// re-emit now that it points at its rumor. This runs inside the wrap's own unwrap, so
// the wrap's emission lands after it and sees a fully linked chain.
eventNote.flowSet?.metadata?.invalidateData()
}
private suspend fun processExistingSealedRumor(
@@ -22,56 +22,118 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.looking_for_event
import com.vitorpamplona.amethyst.commons.resources.looking_for_event_title
import com.vitorpamplona.amethyst.commons.resources.looking_for_private_event
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteLocally
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* The waiting room for an event we can only name: hold here until it arrives, then replace
* this entry with wherever it actually belongs.
*
* It is a real screen, not a bare label. A notification tap on a cold process lands here
* (LocalCache is empty until the event is re-fetched), and with nothing but a line of text
* on `colorScheme.background` — black on the dark theme — the wait read as the app opening
* to a black screen, with no top bar and no way back other than the system gesture.
*/
@Composable
fun LoadRedirectScreen(
eventId: String?,
isPrivate: Boolean,
accountViewModel: AccountViewModel,
nav: Nav,
) {
if (eventId == null) return
LoadNote(eventId, accountViewModel) { note ->
note?.let {
LoadRedirectScreen(
baseNote = note,
accountViewModel = accountViewModel,
nav = nav,
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
TopBarExtensibleWithBackButton(
title = { Text(stringRes(Res.string.looking_for_event_title)) },
popBack = nav::popBack,
)
},
accountViewModel = accountViewModel,
) { padding ->
Column(
Modifier.fillMaxSize().padding(padding).padding(horizontal = 50.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
LoadNote(eventId, accountViewModel) { note ->
Text(
// A private id is not something to show a user, and it must not be
// copyable off the screen either.
text =
if (isPrivate) {
stringRes(Res.string.looking_for_private_event)
} else {
stringRes(Res.string.looking_for_event, note?.idHex ?: eventId)
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 20.dp),
)
note?.let {
WatchAndRedirect(
baseNote = it,
isPrivate = isPrivate,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
}
/**
* Renders nothing: it only watches [baseNote] and pops this entry for the real destination
* the moment the event lands.
*
* A public note is watched through [observeNote], which also holds a relay subscription open
* so the note is actually fetched. A private one is watched through [observeNoteLocally],
* which asks no relay: a rumor id must never appear in a REQ, and it does not need to — the
* envelope carrying it is re-fetched by the always-on gift-wrap tail, and unwrapping it fires
* this flow.
*/
@Composable
fun LoadRedirectScreen(
private fun WatchAndRedirect(
baseNote: Note,
isPrivate: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteState by observeNote(baseNote, accountViewModel)
val noteState by if (isPrivate) observeNoteLocally(baseNote) else observeNote(baseNote, accountViewModel)
LaunchedEffect(key1 = noteState) {
val event = noteState.note.event
@@ -83,12 +145,4 @@ fun LoadRedirectScreen(
}
}
}
Column(
Modifier.fillMaxHeight().fillMaxWidth().padding(horizontal = 50.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(stringRes(Res.string.looking_for_event, baseNote.idHex))
}
}
+5
View File
@@ -1002,6 +1002,11 @@
<string name="app_notification_reply_label">Reply</string>
<string name="app_notification_mark_read_label">Mark Read</string>
<string name="app_notification_reply_sending">Sending…</string>
<string name="app_notification_reply_unconfirmed">Still sending…</string>
<string name="app_notification_reply_failed">Not sent</string>
<string name="app_notification_reply_retry">Retry</string>
<string name="app_notification_shortcut_account_removed">This account was removed from Amethyst.</string>
<string name="app_notification_me">Me</string>
<string name="app_notification_new_message">New message</string>
<string name="app_notification_added_to_group">You\'ve been added to %1$s</string>
@@ -0,0 +1,109 @@
/*
* 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.notifications
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The identity a conversation is published under.
*
* A launcher shortcut is not a notification — it persists, it is what the system matches a
* notification against to grant it the Conversations treatment, and
* [ConversationShortcuts.removeForAccount] finds an account's shortcuts by pattern-matching
* these strings on logout. So the id has to be stable for the same chat, distinct for
* different ones, and readably scoped to its account.
*/
class ConversationShortcutIdTest {
private val npub = "npub1" + "q".repeat(58)
private val otherNpub = "npub1" + "p".repeat(58)
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
/**
* The one that would actually break. A [ChatroomKey] is a *set*, so two arrivals in the
* same group DM can iterate its members in different orders. Joining them unsorted would
* mint a second id for a chat that already has a shortcut — a duplicate entry in the
* launcher, and a notification whose `setShortcutId` points at whichever copy lost.
*/
@Test
fun aRoomHasOneIdRegardlessOfMemberOrder() {
val oneWay = NotificationRoutes.chatroomShortcutId(ChatroomKey(setOf(alice, bob)), npub)
val theOther = NotificationRoutes.chatroomShortcutId(ChatroomKey(setOf(bob, alice)), npub)
assertEquals(oneWay, theOther)
}
@Test
fun theSameChatUnderTwoAccountsIsTwoConversations() {
val mine = NotificationRoutes.chatroomShortcutId(ChatroomKey(setOf(alice)), npub)
val theirs = NotificationRoutes.chatroomShortcutId(ChatroomKey(setOf(alice)), otherNpub)
assertNotEquals(
"one launcher entry shared by two logins would open the wrong inbox",
mine,
theirs,
)
}
@Test
fun differentChatsUnderOneAccountStayApart() {
val withAlice = NotificationRoutes.chatroomShortcutId(ChatroomKey(setOf(alice)), npub)
val withBob = NotificationRoutes.chatroomShortcutId(ChatroomKey(setOf(bob)), npub)
val withBoth = NotificationRoutes.chatroomShortcutId(ChatroomKey(setOf(alice, bob)), npub)
assertEquals(3, setOf(withAlice, withBob, withBoth).size)
}
/** A DM, a Marmot group and a relay group named by the same hex are three rooms, not one. */
@Test
fun eachKindOfRoomHasItsOwnNamespace() {
val ids =
setOf(
NotificationRoutes.chatroomShortcutId(ChatroomKey(setOf(alice)), npub),
NotificationRoutes.marmotShortcutId(alice, npub),
NotificationRoutes.relayGroupShortcutId(alice, npub),
)
assertEquals(3, ids.size)
}
/**
* [ConversationShortcuts.removeForAccount] selects on `":$npub:"`, so every id has to carry
* the account delimited that way or a logout would leave that account's contacts in the
* launcher.
*/
@Test
fun everyIdIsFindableByTheAccountScopeLogoutSweepsOn() {
val scope = ":$npub:"
listOf(
NotificationRoutes.chatroomShortcutId(ChatroomKey(setOf(alice, bob)), npub),
NotificationRoutes.marmotShortcutId(alice, npub),
NotificationRoutes.relayGroupShortcutId("naddr1abc", npub),
).forEach {
assertTrue("'$it' is not sweepable on logout", it.contains(scope))
}
}
}
@@ -0,0 +1,217 @@
/*
* 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.notifications
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.chatroomRoute
import com.vitorpamplona.amethyst.ui.isChatroomRoute
import com.vitorpamplona.amethyst.ui.isPrivateNoteRoute
import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
import com.vitorpamplona.amethyst.ui.navigation.findQueryParameterValue
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.privateNoteRoute
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.net.URI
/**
* The deep links a tapped notification hands to `MainActivity.uriToRoute`, checked at the
* string level — these are what decide whether a tap lands on a real screen or on the
* "looking for event" redirect.
*/
class NotificationDeepLinkTest {
private val npub = "npub1" + "q".repeat(58)
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
@Test
fun chatroomUriCarriesBothParticipantsAndTheAccount() {
val uri = NotificationRoutes.chatroomUri(ChatroomKey(setOf(alice)), npub)
assertTrue(isChatroomRoute(uri))
assertEquals(alice, uri.findQueryParameterValue("id"))
assertEquals(npub, uri.findQueryParameterValue("account"))
}
@Test
fun chatroomUriKeepsEveryMemberOfAGroupDm() {
val uri = NotificationRoutes.chatroomUri(ChatroomKey(setOf(alice, bob)), npub)
assertEquals(setOf(alice, bob), uri.findQueryParameterValue("id")?.split(',')?.toSet())
assertEquals(npub, uri.findQueryParameterValue("account"))
}
/**
* The regression: `marmot:<hex>?account=…` is an *opaque* URI, so `java.net.URI` keeps the
* query inside the scheme-specific part and reports no query at all. Reading the account
* that way returned null on every Marmot notification, and the tap opened the group under
* whichever account happened to be current instead of switching to the addressee first.
*/
@Test
fun opaqueMarmotUriStillYieldsItsAccount() {
val uri = NotificationRoutes.marmotUri("d".repeat(64), npub)
assertNull(URI(uri).findParameterValue("account"))
assertEquals(npub, uri.findQueryParameterValue("account"))
}
@Test
fun hierarchicalNotificationUrisAreReadTheSameWay() {
val uri = NotificationRoutes.notificationsUri(npub, "c".repeat(64))
assertEquals(npub, uri.findQueryParameterValue("account"))
assertEquals("c".repeat(64), uri.findQueryParameterValue("scrollTo"))
}
@Test
fun aUriWithoutAQueryHasNoParameters() {
assertNull("nevent1qqsabcdef".findQueryParameterValue("account"))
assertNull("".findQueryParameterValue("account"))
}
@Test
fun anEmptyParameterValueReadsAsAbsent() {
assertNull("chatroom?id=&account=$npub".findQueryParameterValue("id"))
}
/**
* A rumor's `nevent` names the gift wrap that delivered it, never the rumor — that is the
* citation rule `RumorHostCitationTest` pins, and it is why a private note cannot be
* deep-linked the ordinary way: the wrap is unfetchable from the account's read relays and
* its note stops emitting before it is ever decrypted. So `noteUri` has to switch shapes on
* its own; every renderer calls it and none of them knows whether its note arrived sealed.
*/
@Test
fun aPrivateNoteIsAddressedByItsRumorIdNotItsEnvelope() {
val signer = NostrSignerSync(KeyPair())
val rumor = signer.sign(TextNoteEvent.build("psst"))
val wrap = GiftWrapEvent.create(rumor, signer.pubKey)
val note = Note(rumor.id)
note.event = rumor
note.recordRumorHost(wrap)
val uri = NotificationRoutes.noteUri(note, npub)
assertTrue(isPrivateNoteRoute(uri))
assertEquals(rumor.id, uri.findQueryParameterValue("id"))
assertEquals(npub, uri.findQueryParameterValue("account"))
assertFalse("the deep link must not name the wrap", uri.contains(wrap.id))
assertFalse("and must not be an nevent of it", uri.startsWith("nevent1"))
}
@Test
fun aPublicNoteKeepsItsNeventDeepLink() {
val signer = NostrSignerSync(KeyPair())
val event = signer.sign(TextNoteEvent.build("hello world"))
val note = Note(event.id)
note.event = event
val uri = NotificationRoutes.noteUri(note, npub)
assertEquals(NEvent.create(event.id, null, event.kind, null) + "?account=" + npub, uri)
}
/** The route a private link produces must be flagged, or its screen would REQ the rumor id. */
@Test
fun thePrivateRouteIsMarkedPrivate() {
val uri = NotificationRoutes.privateNoteUri(alice, npub)
assertEquals(Route.EventRedirect(alice, isPrivate = true), privateNoteRoute(uri))
}
@Test
fun aPrivateLinkWithoutAnIdIsNotARoute() {
assertNull(privateNoteRoute("privatenote?id=&account=$npub"))
}
/**
* The destination, not just the link: a DM opens the conversation it belongs to. A kind-14
* is a [com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable], so both ways into it agree —
* the room link a notification now carries, and `routeFor` on the event itself, which is how
* the wrap-shaped links still sitting in trays from an older build resolve once the envelope
* is opened. Neither is a thread: the thread view is where a *private note* goes, which is a
* NIP-17-wrapped kind 1 — the same envelope, but a note posted privately rather than a
* message in a room.
*/
@Test
fun aDmOpensItsConversationByEitherRoute() {
val me = NostrSignerSync(KeyPair())
val them = NostrSignerSync(KeyPair())
val account = mockk<Account>(relaxed = true)
every { account.userProfile().pubkeyHex } returns me.pubKey
val room = ChatroomKey(setOf(them.pubKey))
val expected = Route.Room(room)
assertEquals(expected, uriToRoute(NotificationRoutes.chatroomUri(room, npub), account))
val dm = them.sign(ChatMessageEvent.build("hi", listOf(PTag(me.pubKey, null))))
assertEquals(expected, routeFor(dm, account))
}
/**
* A room link resolves without touching the account.
*
* It must not: `uriToRoute` runs against whichever account is current, *before* the
* `?account=` switch, so registering the room here would file a DM for account B under
* account A. And `nostr:` is exported and browsable, so a web page can post one of these —
* which is also why the ids are checked rather than taken as given. The screen registers the
* room itself when it opens, on the account that is current by then.
*/
@Test
fun aRoomLinkResolvesWithoutTouchingTheAccount() {
val account = mockk<Account>(relaxed = true)
val uri = NotificationRoutes.chatroomUri(ChatroomKey(setOf(alice)), npub)
assertEquals(Route.Room(ChatroomKey(setOf(alice))), uriToRoute(uri, account))
verify(exactly = 0) { account.chatroomList }
}
@Test
fun aRoomLinkThatIsNotMadeOfPubkeysIsNotARoute() {
assertNull(chatroomRoute("chatroom?id=not-a-pubkey&account=$npub"))
assertNull(chatroomRoute("chatroom?id=${alice.dropLast(1)}&account=$npub"))
assertNull(chatroomRoute("chatroom?id=${alice.uppercase()}&account=$npub"))
// one bad member poisons the whole key — a room is the exact set or nothing
assertNull(chatroomRoute("chatroom?id=$alice,nope&account=$npub"))
assertNull(chatroomRoute("chatroom?id=&account=$npub"))
}
}
@@ -0,0 +1,68 @@
/*
* 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
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Why a screen waiting on a gift wrap has to be woken a second time.
*
* `LocalCache.consumeRegularEvent` emits the wrap note the moment the wrap is cached — which
* is before anything has decrypted it, so `innerEventId` is still null and the wrap can say
* nothing about where it leads. Everything that links the chain afterwards is plain assignment
* on the note, which emits nothing. So that first emission is the only one a
* `Route.EventRedirect` parked on a wrap ever sees, and the case below is what it sees: no
* route, nothing to navigate to, wait forever — while the message it wanted sits decrypted in
* the cache a few milliseconds later. `DecryptAndIndexProcessor` re-emits the wrap and seal
* notes once the chain is linked, which is what turns the second case into the live one.
*/
class GiftWrapRedirectRouteTest {
private val account = mockk<Account>()
private val signer = NostrSignerSync(KeyPair())
private fun wrap(): GiftWrapEvent = GiftWrapEvent.create(signer.sign(TextNoteEvent.build("psst")), signer.pubKey)
@Test
fun anUnopenedWrapHasNowhereToGo() {
assertNull(routeFor(wrap(), account))
}
@Test
fun anOpenedWrapLeadsToWhatItCarried() {
val sealId = "5".repeat(64)
val opened = wrap().apply { innerEventId = sealId }
// The seal itself has not been cached in this test, so the walk stops there and hands
// back a redirect — the point is that it moves at all, which it cannot do until the
// wrap has been opened.
assertEquals(Route.EventRedirect(sealId), routeFor(opened, account))
}
}
@@ -0,0 +1,116 @@
/*
* 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
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.THREAD_VIEW_KINDS
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.routes.routeForPointer
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.utils.EventFactory
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* A notification tap on a cold process finds nothing in LocalCache, so the only thing left
* to route on is what the `nevent` itself states. Ordinary notes are fully described by
* their kind and can open their thread right away; anything that needs a tag out of the
* body must keep waiting on the redirect.
*/
class RouteForPointerTest {
private val id = "a".repeat(64)
@Test
fun plainNoteKindsOpenTheirThread() {
assertEquals(Route.Note(id), routeForPointer(TextNoteEvent.KIND, id))
assertEquals(Route.Note(id), routeForPointer(CommentEvent.KIND, id))
assertEquals(Route.Note(id), routeForPointer(PictureEvent.KIND, id))
}
@Test
fun aPointerWithoutAKindCannotDecide() {
assertNull(routeForPointer(null, id))
}
@Test
fun kindsThatNeedTheBodyKeepWaiting() {
// channel id lives in an `e` tag
assertNull(routeForPointer(ChannelMessageEvent.KIND, id))
// chatroom key lives in the `p` tags of the rumor
assertNull(routeForPointer(ChatMessageEvent.KIND, id))
// the wrap has to be opened before it can say anything
assertNull(routeForPointer(GiftWrapEvent.KIND, id))
// addressables are cited by `a` tag, not by id
assertNull(routeForPointer(LongTextNoteEvent.KIND, id))
// a kind:0 IS the person — Route.Profile, not a note
assertNull(routeForPointer(MetadataEvent.KIND, id))
}
/**
* The invariant the shortcut rests on, checked against the thing it claims to mirror rather
* than against a second hand-written list: for every kind it answers for, the answer must be
* the one [routeFor] gives once the body finally arrives. Walking `THREAD_VIEW_KINDS` itself
* means a kind added to it is covered by construction.
*
* This is what catches an addressable kind slipping in. `routeForInner` routes those by
* `addressTag()`, so the shortcut's `Route.Note(id)` would point at a version id that no
* relay will serve — a silent dead end, and exactly what the NIP-71 video pair did.
*/
@Test
fun everyShortcutKindAgreesWithTheRouteItsBodyWouldHaveTaken() {
val account = mockk<Account>(relaxed = true)
THREAD_VIEW_KINDS.forEach { kind ->
val event: Event =
EventFactory.create(id, "b".repeat(64), 1, kind, emptyArray(), "", "c".repeat(128))
assertEquals(
"kind $kind (${event::class.simpleName}) disagrees with routeFor",
routeFor(event, account),
routeForPointer(kind, id),
)
assertEquals("kind $kind must resolve to the thread view", Route.Note(id), routeForPointer(kind, id))
}
}
/**
* The regression itself. 21/22 are regular video and take the shortcut; 34235/34236 extend
* `AddressableVideoEvent`, are cited by `naddr`, and must wait for the body.
*/
@Test
fun addressableVideoKindsDoNotTakeTheShortcut() {
assertNull(routeForPointer(VideoHorizontalEvent.KIND, id))
assertNull(routeForPointer(VideoVerticalEvent.KIND, id))
}
}
@@ -1346,6 +1346,8 @@
<string name="poll_closing_date_time">Poll Closing Date &amp; Time</string>
<string name="poll_closing_in">Poll closes in %1$s</string>
<string name="looking_for_event">Looking for Event %1$s</string>
<string name="looking_for_event_title">Loading</string>
<string name="looking_for_private_event">Waiting for your private messages to load…</string>
<string name="send_zap">Send Zap</string>
<string name="custom_zaps_add_a_message">Add a public message</string>
<string name="custom_zaps_add_a_message_private">Add a private message</string>