diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 9b740b2f6..b80f29195 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -24,25 +24,41 @@ import kotlinx.collections.immutable.persistentSetOf import java.math.BigDecimal class EventNotificationConsumer(private val applicationContext: Context) { + suspend fun consume(event: GiftWrapEvent) { + if (!LocalCache.justVerify(event)) return + if (!notificationManager().areNotificationsEnabled()) return - suspend fun consume(event: Event) { - if (LocalCache.notes[event.id] == null) { - if (LocalCache.justVerify(event)) { - LocalCache.justConsume(event, null) + // PushNotification Wraps don't include a receiver. + // Test with all logged in accounts + LocalPreferences.allSavedAccounts().forEach { + val acc = LocalPreferences.loadFromEncryptedStorage(it.npub) + if (acc != null && acc.keyPair.privKey != null) { + consumeIfMatchesAccount(event, acc) + } + } + } - val manager = notificationManager() - if (manager.areNotificationsEnabled()) { - when (event) { - is PrivateDmEvent -> notify(event) - is LnZapEvent -> notify(event) - is GiftWrapEvent -> unwrapAndNotify(event) - } + private suspend fun consumeIfMatchesAccount(pushWrappedEvent: GiftWrapEvent, account: Account) { + val key = account.keyPair.privKey ?: return + pushWrappedEvent.unwrap(key)?.let { notificationEvent -> + if (!LocalCache.justVerify(notificationEvent)) return // invalid event + if (LocalCache.notes[notificationEvent.id] != null) return // already processed + + LocalCache.justConsume(notificationEvent, null) + + unwrapAndConsume(notificationEvent, account)?.let { innerEvent -> + if (innerEvent is PrivateDmEvent) { + notify(innerEvent, account) + } else if (innerEvent is LnZapEvent) { + notify(innerEvent, account) + } else if (innerEvent is ChatMessageEvent) { + notify(innerEvent, account) } } } } - suspend fun unwrapAndConsume(event: Event, account: Account): Event? { + private fun unwrapAndConsume(event: Event, account: Account): Event? { if (!LocalCache.justVerify(event)) return null return when (event) { @@ -67,75 +83,67 @@ class EventNotificationConsumer(private val applicationContext: Context) { } } - private suspend fun unwrapAndNotify(giftWrap: GiftWrapEvent) { - val giftWrapNote = LocalCache.notes[giftWrap.id] ?: return + private fun notify(event: ChatMessageEvent, acc: Account) { + if (event.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted + event.pubKey != acc.userProfile().pubkeyHex + ) { // from the user - LocalPreferences.allSavedAccounts().forEach { - val acc = LocalPreferences.loadFromEncryptedStorage(it.npub) + val chatNote = LocalCache.notes[event.id] ?: return + val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey()) - if (acc != null && acc.userProfile().pubkeyHex == giftWrap.recipientPubKey()) { - val chatEvent = unwrapAndConsume(giftWrap, account = acc) + val followingKeySet = acc.followingKeySet() - if (chatEvent is ChatMessageEvent && // must be messages, not any other event - acc.keyPair.privKey != null && // can't decrypt - chatEvent.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted - chatEvent.pubKey != acc.userProfile().pubkeyHex // from the user - ) { - val chatNote = LocalCache.notes[chatEvent.id] ?: return - val chatRoom = chatEvent.chatroomKey(acc.keyPair.pubKey.toHexKey()) + val isKnownRoom = ( + acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || + acc.userProfile().hasSentMessagesTo(chatRoom) + ) && !acc.isAllHidden(chatRoom.users) - val followingKeySet = acc.followingKeySet() - - val isKnownRoom = ( - acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || - acc.userProfile().hasSentMessagesTo(chatRoom) - ) && !acc.isAllHidden(chatRoom.users) - - if (isKnownRoom) { - val content = chatNote.event?.content() ?: "" - val user = chatNote.author?.toBestDisplayName() ?: "" - val userPicture = chatNote.author?.profilePicture() - val noteUri = chatNote.toNEvent() - notificationManager().sendDMNotification(chatEvent.id, content, user, userPicture, noteUri, applicationContext) - } - } + if (isKnownRoom) { + val content = chatNote.event?.content() ?: "" + val user = chatNote.author?.toBestDisplayName() ?: "" + val userPicture = chatNote.author?.profilePicture() + val noteUri = chatNote.toNEvent() + notificationManager().sendDMNotification( + event.id, + content, + user, + userPicture, + noteUri, + applicationContext + ) } } } - private fun notify(event: PrivateDmEvent) { + private fun notify(event: PrivateDmEvent, acc: Account) { val note = LocalCache.notes[event.id] ?: return // old event being re-broadcast if (event.createdAt < TimeUtils.fiveMinutesAgo()) return - LocalPreferences.allSavedAccounts().forEach { - val acc = LocalPreferences.loadFromEncryptedStorage(it.npub) + if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) { + val followingKeySet = acc.followingKeySet() - if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) { - val followingKeySet = acc.followingKeySet() + val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter { + ( + acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true || + acc.userProfile().hasSentMessagesTo(it) + ) && !acc.isAllHidden(it.users) + }.toSet() - val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter { - ( - acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true || - acc.userProfile().hasSentMessagesTo(it) - ) && !acc.isAllHidden(it.users) - }.toSet() - - note.author?.let { - if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) { - val content = acc.decryptContent(note) ?: "" - val user = note.author?.toBestDisplayName() ?: "" - val userPicture = note.author?.profilePicture() - val noteUri = note.toNEvent() - notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext) - } + note.author?.let { + if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) { + val content = acc.decryptContent(note) ?: "" + val user = note.author?.toBestDisplayName() ?: "" + val userPicture = note.author?.profilePicture() + val noteUri = note.toNEvent() + notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext) } } } } - private fun notify(event: LnZapEvent) { + private fun notify(event: LnZapEvent, acc: Account) { val noteZapEvent = LocalCache.notes[event.id] ?: return // old event being re-broadcast @@ -146,39 +154,35 @@ class EventNotificationConsumer(private val applicationContext: Context) { if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return - LocalPreferences.allSavedAccounts().forEach { - val acc = LocalPreferences.loadFromEncryptedStorage(it.npub) - - if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) { - val amount = showAmount(event.amount) - val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let { - val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest) - if (decryptedContent != null) { - val author = LocalCache.getOrCreateUser(decryptedContent.pubKey) - Pair(author, decryptedContent.content) - } else if (!noteZapRequest.event?.content().isNullOrBlank()) { - Pair(noteZapRequest.author, noteZapRequest.event?.content()) - } else { - Pair(noteZapRequest.author, null) - } + if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) { + val amount = showAmount(event.amount) + val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let { + val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest) + if (decryptedContent != null) { + val author = LocalCache.getOrCreateUser(decryptedContent.pubKey) + Pair(author, decryptedContent.content) + } else if (!noteZapRequest.event?.content().isNullOrBlank()) { + Pair(noteZapRequest.author, noteZapRequest.event?.content()) + } else { + Pair(noteZapRequest.author, null) } - - val zappedContent = - noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) } - - val user = senderInfo?.first?.toBestDisplayName() ?: "" - var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount) - senderInfo?.second?.ifBlank { null }?.let { - title += " ($it)" - } - var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user) - zappedContent?.let { - content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent) - } - val userPicture = senderInfo?.first?.profilePicture() - val noteUri = "nostr:Notifications" - notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext) } + + val zappedContent = + noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) } + + val user = senderInfo?.first?.toBestDisplayName() ?: "" + var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount) + senderInfo?.second?.ifBlank { null }?.let { + title += " ($it)" + } + var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user) + zappedContent?.let { + content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent) + } + val userPicture = senderInfo?.first?.profilePicture() + val noteUri = "nostr:Notifications" + notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext) } } diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt index f262d246b..ea77ee39a 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.service.notifications import android.app.NotificationManager +import android.util.LruCache import androidx.core.content.ContextCompat import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage @@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateDMChannel import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.events.GiftWrapEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -15,19 +17,34 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch class PushNotificationReceiverService : FirebaseMessagingService() { - val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val eventCache = LruCache(100) // this is called when a message is received override fun onMessageReceived(remoteMessage: RemoteMessage) { scope.launch(Dispatchers.IO) { - remoteMessage.data.let { - val eventStr = remoteMessage.data["event"] ?: return@let - val event = Event.fromJson(eventStr) - EventNotificationConsumer(applicationContext).consume(event) + parseMessage(remoteMessage.data)?.let { + receiveIfNew(it) } } } + private suspend fun parseMessage(params: Map): GiftWrapEvent? { + params["encryptedEvent"]?.let { eventStr -> + (Event.fromJson(eventStr) as? GiftWrapEvent)?.let { + return it + } + } + return null + } + + private suspend fun receiveIfNew(event: GiftWrapEvent) { + if (eventCache.get(event.id) == null) { + eventCache.put(event.id, event.id) + EventNotificationConsumer(applicationContext).consume(event) + } + } + override fun onDestroy() { scope.cancel() super.onDestroy() diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/CryptoUtilsTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/CryptoUtilsTest.kt index 905f48e95..352c96ba3 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/CryptoUtilsTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/CryptoUtilsTest.kt @@ -5,6 +5,8 @@ import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.crypto.KeyPair +import com.vitorpamplona.quartz.encoders.Hex +import com.vitorpamplona.quartz.encoders.decodePublicKey import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/GiftWrapEventTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/GiftWrapEventTest.kt index 8536549f5..3d563efb1 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/GiftWrapEventTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/GiftWrapEventTest.kt @@ -5,7 +5,9 @@ import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.crypto.KeyPair +import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.decodePublicKey import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.GiftWrapEvent @@ -557,4 +559,28 @@ class GiftWrapEventTest { null } } + + @Test + fun decryptMsgFromNostrTools() { + val receiversPrivateKey = Hex.decode("df51ec558372612918a83446d279d683039bece79b7a721274b1d3cb612dc6af") + val msg = """ + { + "tags": [], + "content": "AUC1i3lHsEOYQZaqav8jAw/Dv25r6BpUX4r7ARaj/7JEqvtHkbtaWXEx3LvMlDJstNX1C90RIelgYTzxb4Xnql7zFmXtxGGd/gXOZzW/OCNWECTrhFTruZUcsyn2ssJMgEMBZKY3PgbAKykHlGCuWR3KI9bo+IA5sTqHlrwDGAysxBypRuAxTdtEApw1LSu2A+1UQsdHK/4HcW/fQLPguWGyPv09dftJIJkFWM8VYBQT7b5FeAEMhjlUM+lEmLMnx6qb07Ji/YMESkhzFlgGjHNVl1Q/BT4i6X+Skogl6Si3lWQzlS9oebUim1BQW+RO0IOyQLalZwjzGP+eE7Ry62ukQg7cPiqk62p7NNula17SF2Q8aVFLxr8WjbLXoWhZOWY25uFbTl7OPGGQb5TewRsjHoFeU4h05Ien3Ymf1VPqJVJCMIxU+yFZ1IMZh/vQW4BSx8VotRdNA05fz03ST88GzGxUvqEm4VW/Yp5q4UUkCDQTKmUImaSFmTser39WmvS5+dHY6ne4RwnrZR0ZYrG1bthRHycnPmaJiYsHn9Ox37EzgLR07pmNxr2+86NR3S3TLAVfTDN3XaXRee/7UfW/MXULVyuyweksIHOYBvANC0PxmGSs4UiFoCbwNi45DT2y0SwP6CxzDuM=", + "kind": 1059, + "created_at": 1694192155914, + "pubkey": "8253eb518413b57f0df329d3d4287bdef4031fd71c32ad1952d854e703dae6a7", + "id": "ae625fd43612127d63bfd1967ba32ae915100842a205fc2c3b3fc02ab3827f08", + "sig": "2807a7ab5728984144676fd34686267cbe6fe38bc2f65a3640ba9243c13e8a1ae5a9a051e8852aa0c997a3623d7fa066cf2073a233c6d7db46fb1a0d4c01e5a3" + } + """.trimIndent() + + val wrap = Event.fromJson(msg) as GiftWrapEvent + wrap.checkSignature() + + val event = wrap.unwrap(receiversPrivateKey) + assertNotNull(event) + + println(event) + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEvent.kt index e1ab6a9ed..20ec5fd5e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEvent.kt @@ -21,7 +21,7 @@ class LnZapEvent( fromJson(it) } as? LnZapRequestEvent } catch (e: Exception) { - Log.w("LnZapEvent", "Failed to Parse Contained Post ${description()}", e) + Log.w("LnZapEvent", "Failed to Parse Contained Post ${description()} in event ${id}", e) null }