refactor: decouple Android NOTIFICATION_KINDS from desktop's subscription list

Android never uses NotificationKinds.SUBSCRIPTION_KINDS for relay
subscriptions — every kind in it already arrives via Android's own
datasources (FilterNotificationsToPubkey, the chat datasources, the
wallet assembler) or via local unwrapping (14/15). Spreading it into
NOTIFICATION_KINDS only coupled Android's display gate to a list whose
job is desktop's relay-filter/toast allow-list, which is exactly how
the kind-1059 wrap leaked into the Android notifications tab.

Restore NOTIFICATION_KINDS as an explicit flat set (identical content
to the previous commit's subtraction) and add
NotificationKindsContractTest as the drift tripwire: envelope kinds
(1059/21059) must never render on the Android tab, and every kind
desktop notifies on must be either displayable on Android or a known
envelope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011A6iSrJJHUoz686eDnCQMV
This commit is contained in:
Claude
2026-07-14 00:19:01 +00:00
parent 19bbfb3be4
commit 7a30c18590
2 changed files with 119 additions and 37 deletions
@@ -64,7 +64,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEven
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
@@ -80,6 +80,7 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import kotlinx.coroutines.flow.MutableStateFlow
@@ -120,42 +121,47 @@ class NotificationFeedFilter(
)
val NOTIFICATION_KINDS =
// The core subscription kinds are shared with Desktop through
// `commons/.../moderation/notifications/NotificationKinds.SUBSCRIPTION_KINDS`
// so a change on either platform automatically propagates.
// Android-only extras (badge awards, git issues/patches/PRs,
// highlights, polls, videos, voice, public messages,
// live-activities chat) stay here because Desktop has no
// rendering for those kinds today.
//
// GiftWrap (1059) is subscription-only: the wrap is an envelope whose
// created_at is randomized up to 2 days back (NIP-59), so rendering it
// as a feed row would misorder the tab with undecryptable entries. On
// Android the unwrapped inner event (kind 14/15/…) is what notifies —
// same rule NotificationDispatcher applies to push. Desktop keeps the
// wrap in its inbox because it has no unwrap pipeline there yet.
(
com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds
.SUBSCRIPTION_KINDS
.toSet() - GiftWrapEvent.KIND
) +
setOf(
BadgeAwardEvent.KIND,
GitIssueEvent.KIND,
GitPatchEvent.KIND,
GitPullRequestEvent.KIND,
GitPullRequestUpdateEvent.KIND,
HighlightEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
PictureEvent.KIND,
PollEvent.KIND,
ZapPollEvent.KIND,
PublicMessageEvent.KIND,
VideoNormalEvent.KIND,
VideoShortEvent.KIND,
VoiceEvent.KIND,
VoiceReplyEvent.KIND,
) + ADDRESSABLE_KINDS
// Kinds that RENDER as a row on the Notifications tab. This is a
// display gate over whatever is already in LocalCache — it plays no
// part in relay subscriptions (those live in
// FilterNotificationsToPubkey, the chat datasources, and the wallet
// assembler). It deliberately does NOT share Desktop's
// `NotificationKinds.SUBSCRIPTION_KINDS`: that list answers "what to
// ask relays for / toast on" and includes envelope kinds like
// GiftWrap (1059), whose created_at is randomized up to 2 days back
// (NIP-59). Envelopes never render here — the unwrapped inner event
// (kind 14/15/…) is the feed row, same rule NotificationDispatcher
// applies to push. NotificationKindsContractTest pins the
// relationship between the two lists.
setOf(
BadgeAwardEvent.KIND,
ChannelMessageEvent.KIND,
ChatMessageEvent.KIND,
ChatMessageEncryptedFileHeaderEvent.KIND,
CommentEvent.KIND,
GenericRepostEvent.KIND,
GitIssueEvent.KIND,
GitPatchEvent.KIND,
GitPullRequestEvent.KIND,
GitPullRequestUpdateEvent.KIND,
HighlightEvent.KIND,
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
LnZapEvent.KIND,
NutzapEvent.KIND,
OnchainZapEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
PictureEvent.KIND,
PollEvent.KIND,
ZapPollEvent.KIND,
PrivateDmEvent.KIND,
PublicMessageEvent.KIND,
VideoNormalEvent.KIND,
VideoShortEvent.KIND,
VoiceEvent.KIND,
VoiceReplyEvent.KIND,
) + ADDRESSABLE_KINDS
// How deep to walk a public chat reply chain looking for one of the
// user's own messages. Bounds the cost on very long threads; the
@@ -0,0 +1,76 @@
/*
* 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.screen.loggedIn.notifications.dal
import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins the relationship between the two independently-maintained kind lists:
*
* - `NotificationKinds.SUBSCRIPTION_KINDS` (commons) — what Desktop asks
* relays for and toasts on. May contain ENVELOPE kinds (gift wraps) whose
* created_at is randomized per NIP-59, because Desktop surfaces the wrap
* itself as a DM row.
* - `NotificationFeedFilter.NOTIFICATION_KINDS` (Android) — what renders as
* a row on the Notifications tab. Envelopes must never appear here; the
* unwrapped inner event is the row.
*
* The lists were briefly coupled (NOTIFICATION_KINDS spread
* SUBSCRIPTION_KINDS), which silently pulled the kind-1059 wrap into the
* Android feed. They are now maintained separately; this test is the tripwire
* that keeps them from drifting apart unintentionally in either direction.
*/
class NotificationKindsContractTest {
private val envelopeKinds = setOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND)
@Test
fun `envelope kinds never render on the Android notifications tab`() {
val leaked = envelopeKinds.intersect(NotificationFeedFilter.NOTIFICATION_KINDS.toSet())
assertTrue(
"Envelope kinds $leaked are in NOTIFICATION_KINDS. Wraps have a " +
"randomized created_at (NIP-59) and no decryptable payload to render — " +
"the unwrapped inner event is the feed row. If a new envelope kind is " +
"intentional, unwrap it instead of displaying it.",
leaked.isEmpty(),
)
}
@Test
fun `every kind desktop notifies on is displayable on Android or a known envelope`() {
val unaccounted =
NotificationKinds.SUBSCRIPTION_KINDS.toSet() -
NotificationFeedFilter.NOTIFICATION_KINDS.toSet() -
envelopeKinds
assertTrue(
"Kinds $unaccounted were added to the shared SUBSCRIPTION_KINDS but are " +
"neither displayable on the Android notifications tab nor a known " +
"envelope kind. Either add them to NOTIFICATION_KINDS (if Android " +
"should render them) or to envelopeKinds in this test (if they only " +
"deliver an inner payload).",
unaccounted.isEmpty(),
)
}
}