diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ConversationShortcuts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ConversationShortcuts.kt new file mode 100644 index 0000000000..49b41c91f7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ConversationShortcuts.kt @@ -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}" } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/InlineReplyFeedback.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/InlineReplyFeedback.kt new file mode 100644 index 0000000000..88a9f7da7f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/InlineReplyFeedback.kt @@ -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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt index 48c0e20cab..7bb5c88e3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRoutes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRoutes.kt index 5424019b61..05ce0b5f58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRoutes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRoutes.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 6d4791386c..bb697c2f53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/BuzzDmNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/BuzzDmNotification.kt index eaf4a66d09..97f1e1fc8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/BuzzDmNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/BuzzDmNotification.kt @@ -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, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt index 4556e575bf..23a67fc369 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt @@ -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() } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/GroupMessageNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/GroupMessageNotification.kt index 1f280a0c19..c28ee82a8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/GroupMessageNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/GroupMessageNotification.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt index f18f9e3ad7..cde6322ad4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt @@ -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 { + val flow = remember(note) { note.flow().metadata.stateFlow } + return flow.collectAsStateWithLifecycle() +} + @OptIn(ExperimentalCoroutinesApi::class) @Composable inline fun observeNoteEvent( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index ac76fd1d14..c19fdebf92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -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=&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 -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index da2095d408..561b4e0686 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -886,7 +886,7 @@ fun BuildNavigation( composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } composableFromBottomArgs { ShareToDMScreen(it.message, it.attachment, accountViewModel, nav) } - composableArgs { LoadRedirectScreen(it.id, accountViewModel, nav) } + composableArgs { LoadRedirectScreen(it.id, it.isPrivate, accountViewModel, nav) } composableFromBottomArgs { 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:?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 } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index a3f9b3df60..ea5c36f26f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index ef0fa3ca11..9bd0ebeed4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index 3288cdb54a..10198ba3dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 1afbd547a4..74ba4e8f7e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/redirect/LoadRedirectScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/redirect/LoadRedirectScreen.kt index 09f63de06e..144a54e01a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/redirect/LoadRedirectScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/redirect/LoadRedirectScreen.kt @@ -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)) - } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 22f30b780d..e6d0b967c3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1002,6 +1002,11 @@ Reply Mark Read + Sending… + Still sending… + Not sent + Retry + This account was removed from Amethyst. Me New message You\'ve been added to %1$s diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/ConversationShortcutIdTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/ConversationShortcutIdTest.kt new file mode 100644 index 0000000000..09299862c1 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/ConversationShortcutIdTest.kt @@ -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)) + } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationDeepLinkTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationDeepLinkTest.kt new file mode 100644 index 0000000000..a3de843313 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationDeepLinkTest.kt @@ -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:?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(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(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")) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/GiftWrapRedirectRouteTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/GiftWrapRedirectRouteTest.kt new file mode 100644 index 0000000000..03aef6a6cd --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/GiftWrapRedirectRouteTest.kt @@ -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() + 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)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/RouteForPointerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/RouteForPointerTest.kt new file mode 100644 index 0000000000..4fd28a9662 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/RouteForPointerTest.kt @@ -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(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)) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index ab19800ef0..fbf50b1938 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -1346,6 +1346,8 @@ Poll Closing Date & Time Poll closes in %1$s Looking for Event %1$s + Loading + Waiting for your private messages to load… Send Zap Add a public message Add a private message