diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt index cebacc25d..284846799 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt @@ -26,7 +26,6 @@ import android.util.LruCache import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.Log @@ -74,11 +73,13 @@ class PushMessageReceiver : MessagingReceiver() { return null } - private fun receiveIfNew(event: GiftWrapEvent) { + private suspend fun receiveIfNew(event: GiftWrapEvent) { if (eventCache.get(event.id) == null) { eventCache.put(event.id, event.id) - // Feeds the shared cache; NotificationDispatcher observes and dispatches. - LocalCache.justConsume(event, null, false) + // The push server re-wraps the real GiftWrap and strips the p tag; + // unwrap the outer layer, feed the inner into LocalCache, and let + // the usual Account → EventProcessor chain take over. + PushWrapDecryptor.unwrapAndFeed(event) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 1426f3f68..2254da21f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -67,6 +67,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryE import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent @@ -2808,6 +2809,7 @@ object LocalCache : ILocalCache, ICacheProvider { is VoiceReplyEvent -> consumeRegularEvent(event, relay, wasVerified) is WakeUpEvent -> consumeRegularEvent(event, relay, wasVerified) is WebBookmarkEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is WelcomeEvent -> consumeRegularEvent(event, relay, wasVerified) is WikiNoteEvent -> consume(event, relay, wasVerified) is PaymentTargetsEvent -> consume(event, relay, wasVerified) else -> Log.w("Event Not Supported") { "From ${relay?.url}: ${event.toJson()}" }.let { false } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 3a45e4616..3903dccef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -64,8 +64,6 @@ import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent @@ -117,15 +115,14 @@ class EventNotificationConsumer( } /** - * Entry point for events arriving into [LocalCache] from any source (FCM push, - * UnifiedPush, Pokey, active relay subscriptions, NotificationRelayService). - * The caller is the [NotificationDispatcher] which observes [LocalCache] for - * notification-relevant kinds and guarantees per-event first-delivery semantics - * (the [LocalCache] observer only fires on new insertions), so no hasConsumed - * check is needed here. + * Entry point for notification-relevant events arriving into [LocalCache] + * from any source (FCM push, UnifiedPush, Pokey, active relay subscriptions, + * NotificationRelayService). The [NotificationDispatcher] only invokes this + * after [Account.newNotesPreProcessor] has fully unwrapped wraps and seals, + * so this method receives the final inner payload directly. * - * Iterates the logged-in accounts, decrypting wraps with each signer until one - * matches (wraps don't carry a recipient hint). + * Matches the event to a logged-in account by its `p` tags and dispatches + * to [dispatchForAccount]. */ suspend fun consumeFromCache(event: Event) = withWakeLock { @@ -133,40 +130,27 @@ class EventNotificationConsumer( if (!notificationManager().areNotificationsEnabled()) return@withWakeLock + val taggedNpubs = + event + .taggedUserIds() + .mapTo(mutableSetOf()) { LocalCache.getOrCreateUser(it).pubkeyNpub() } + if (taggedNpubs.isEmpty()) return@withWakeLock + LocalPreferences.allSavedAccounts().forEach { savedAccount -> if (!savedAccount.hasPrivKey && !savedAccount.loggedInWithExternalSigner) return@forEach - - // For unwrapped events with `p` tags, only try accounts that are tagged. - if (event !is GiftWrapEvent) { - val taggedNpubs = event.taggedUserIds().mapTo(mutableSetOf()) { LocalCache.getOrCreateUser(it).pubkeyNpub() } - if (taggedNpubs.isNotEmpty() && savedAccount.npub !in taggedNpubs) return@forEach - } + if (savedAccount.npub !in taggedNpubs) return@forEach val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(savedAccount.npub) ?: return@forEach try { val account = Amethyst.instance.accountsCache.loadAccount(accountSettings) - if (consumeForAccount(event, account)) return@withWakeLock + dispatchForAccount(event, account) } catch (e: Exception) { if (e is CancellationException) throw e - Log.d(TAG) { "Message was not for user ${savedAccount.npub}: ${e.message}" } + Log.d(TAG) { "Failed to dispatch ${event.kind} ${event.id} for ${savedAccount.npub}: ${e.message}" } } } } - /** - * Tries to process [event] for [account]. Unwraps gift wraps and seals as needed, - * updating [LocalCache] so the UI can surface the content later. Returns true if - * the event matched this account (i.e. decryption succeeded or tagging matched). - */ - private suspend fun consumeForAccount( - event: Event, - account: Account, - ): Boolean { - val inner = unwrapFully(event, account.signer) ?: return false - dispatchForAccount(inner, account) - return true - } - private suspend fun dispatchForAccount( event: Event, account: Account, @@ -240,46 +224,6 @@ class EventNotificationConsumer( } } - /** - * Fully unwraps gift wraps and seals so the inner notification-relevant event - * can be dispatched. Updates [LocalCache] so the inner events are available to - * the UI and clears the outer encrypted payloads to save memory. Returns null - * if the signer cannot decrypt the wrap (i.e. this event isn't for this account). - * - * Idempotent: safe to call whether or not the outer event is already in cache, - * since [NotificationDispatcher] invokes this after [LocalCache] has already - * stored the wrapper. - */ - private suspend fun unwrapFully( - event: Event, - signer: NostrSigner, - ): Event? = - try { - when (event) { - // EphemeralGiftWrapEvent inherits GiftWrapEvent; both handled here. - is GiftWrapEvent -> { - val inner = event.unwrapThrowing(signer) - LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() - unwrapFully(inner, signer) - } - - is SealedRumorEvent -> { - val inner = event.unsealThrowing(signer) - LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() - // seal payload is not verifiable via signature - LocalCache.justConsume(inner, null, true) - inner - } - - else -> { - event - } - } - } catch (e: Exception) { - if (e is CancellationException) throw e - null - } - private suspend fun notify( event: ChatMessageEncryptedFileHeaderEvent, account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index 2664fc360..0788e20e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -23,15 +23,17 @@ package com.vitorpamplona.amethyst.service.notifications import android.content.Context import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -56,20 +58,26 @@ class NotificationDispatcher( companion object { private const val TAG = "NotificationDispatcher" - // Kinds that can trigger notifications. GiftWrap and EphemeralGiftWrap carry - // most encrypted payloads (DMs, Chess, CallOffer, Welcome); the rest arrive - // unwrapped. SealedRumor (kind 13) is intentionally omitted — it only exists - // inside a GiftWrap, and the wrapper drives the unwrap. + // The dispatcher observes the *final* notification payload kinds. + // GiftWrap/EphemeralGiftWrap (1059/21059) and SealedRumor (13) are NOT + // listed here — by the time we care, Account.newNotesPreProcessor has + // already unwrapped them and inserted the inner payload into LocalCache, + // which fires the observer a second time on the inner event. private val NOTIFICATION_KINDS = listOf( - GiftWrapEvent.KIND, - EphemeralGiftWrapEvent.KIND, + // Direct-arrival PrivateDmEvent.KIND, LnZapEvent.KIND, ReactionEvent.KIND, LiveChessGameAcceptEvent.KIND, LiveChessMoveEvent.KIND, WakeUpEvent.KIND, + // Unwrapped from GiftWrap → Seal + ChatMessageEvent.KIND, + ChatMessageEncryptedFileHeaderEvent.KIND, + WelcomeEvent.KIND, + // Unwrapped from EphemeralGiftWrap + CallOfferEvent.KIND, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PushWrapDecryptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PushWrapDecryptor.kt new file mode 100644 index 000000000..bd6b3735c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PushWrapDecryptor.kt @@ -0,0 +1,62 @@ +/* + * 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.Amethyst +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException + +/** + * Handles GiftWraps that arrive via FCM / UnifiedPush. The push server + * re-wraps the real NIP-59 GiftWrap inside its own outer GiftWrap and strips + * the recipient `p` tag, so neither the relay's [CacheClientConnector] nor + * the account's [GiftWrapEventHandler] can identify which account owns it. + * We have to probe every saved account's signer until one decrypts. + * + * Once decrypted, the inner event is fed into [LocalCache]. From there the + * normal `Account.newNotesPreProcessor` → `GiftWrapEventHandler` → + * `SealedRumorEventHandler` chain unwraps any remaining layers, and + * [NotificationDispatcher] picks up the final payload and notifies. + */ +object PushWrapDecryptor { + private const val TAG = "PushWrapDecryptor" + + suspend fun unwrapAndFeed(outerWrap: GiftWrapEvent) { + LocalPreferences.allSavedAccounts().forEach { savedAccount -> + if (!savedAccount.hasPrivKey && !savedAccount.loggedInWithExternalSigner) return@forEach + + val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(savedAccount.npub) ?: return@forEach + try { + val account = Amethyst.instance.accountsCache.loadAccount(accountSettings) + val inner = outerWrap.unwrapThrowing(account.signer) + LocalCache.justConsume(inner, null, false) + return + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.d(TAG) { "Push wrap not for ${savedAccount.npub}: ${e.message}" } + } + } + Log.w(TAG) { "Push wrap ${outerWrap.id} did not decrypt for any saved account" } + } +} 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 b1ab71975..943e2d2d7 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 @@ -311,28 +311,21 @@ class GiftWrapEventHandler( eventNote.event = event.copyNoContent() - // Check if the unwrapped event is a Marmot WelcomeEvent (kind:444) - if (MarmotInboundProcessor.isWelcomeEvent(innerGift)) { - Log.d("MarmotDbg") { - "GiftWrapEventHandler: detected Marmot WelcomeEvent — routing to processMarmotWelcome" - } - processMarmotWelcome(innerGift, eventNote, publicNote) - return - } - if (cache.justConsume(innerGift, null, false)) { cache.copyRelaysFromTo(publicNote, innerGift) val innerGiftNote = cache.getOrCreateNote(innerGift.id) - eventProcessor.consumeEvent(innerGift, innerGiftNote, publicNote) - } - } - private suspend fun processMarmotWelcome( - innerEvent: Event, - eventNote: Note, - publicNote: Note, - ) { - processMarmotWelcomeFlow(innerEvent, account) + // Marmot Welcomes need MLS processing in addition to being cached, + // but they do not route through the normal eventProcessor consumer. + if (MarmotInboundProcessor.isWelcomeEvent(innerGift)) { + Log.d("MarmotDbg") { + "GiftWrapEventHandler: detected Marmot WelcomeEvent — routing to processMarmotWelcomeFlow" + } + processMarmotWelcomeFlow(innerGift, account) + } else { + eventProcessor.consumeEvent(innerGift, innerGiftNote, publicNote) + } + } } private suspend fun processExistingGiftWrap( @@ -472,23 +465,23 @@ class SealedRumorEventHandler( eventNote.event = event.copyNoContent() + cache.justConsume(innerRumor, null, true) + cache.copyRelaysFromTo(publicNote, innerRumor) + + val innerRumorNote = cache.getOrCreateNote(innerRumor.id) + // Marmot Welcome: GiftWrap → Seal → WelcomeEvent. The Seal handler // is the actual point at which we see the kind:444 inner. Route it - // straight to the shared flow — there's no normal LocalCache event - // handler for kind:444, so otherwise it would be silently dropped. + // to the MLS flow for group joining in addition to caching — there's + // no normal eventProcessor consumer for kind:444. if (MarmotInboundProcessor.isWelcomeEvent(innerRumor)) { Log.d("MarmotDbg") { "SealedRumorEventHandler: detected Marmot WelcomeEvent inside seal — routing to processMarmotWelcomeFlow" } processMarmotWelcomeFlow(innerRumor, account) - return + } else { + eventProcessor.consumeEvent(innerRumor, innerRumorNote, publicNote) } - - cache.justConsume(innerRumor, null, true) - cache.copyRelaysFromTo(publicNote, innerRumor) - - val innerRumorNote = cache.getOrCreateNote(innerRumor.id) - eventProcessor.consumeEvent(innerRumor, innerRumorNote, publicNote) } private suspend fun processExistingSealedRumor( diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt index 59cdf05cf..599860025 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt @@ -27,7 +27,6 @@ import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.Log @@ -65,11 +64,13 @@ class PushNotificationReceiverService : FirebaseMessagingService() { return null } - private fun receiveIfNew(event: GiftWrapEvent) { + private suspend fun receiveIfNew(event: GiftWrapEvent) { if (eventCache.get(event.id) == null) { eventCache.put(event.id, event.id) - // Feeds the shared cache; NotificationDispatcher observes and dispatches. - LocalCache.justConsume(event, null, false) + // The push server re-wraps the real GiftWrap and strips the p tag; + // unwrap the outer layer, feed the inner into LocalCache, and let + // the usual Account → EventProcessor chain take over. + PushWrapDecryptor.unwrapAndFeed(event) } }