mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-09-26 21:46:30 +02:00
Migrates to Encrypted Push Notifications
This commit is contained in:
@@ -24,25 +24,41 @@ import kotlinx.collections.immutable.persistentSetOf
|
|||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
|
||||||
class EventNotificationConsumer(private val applicationContext: Context) {
|
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) {
|
// PushNotification Wraps don't include a receiver.
|
||||||
if (LocalCache.notes[event.id] == null) {
|
// Test with all logged in accounts
|
||||||
if (LocalCache.justVerify(event)) {
|
LocalPreferences.allSavedAccounts().forEach {
|
||||||
LocalCache.justConsume(event, null)
|
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||||
|
if (acc != null && acc.keyPair.privKey != null) {
|
||||||
|
consumeIfMatchesAccount(event, acc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val manager = notificationManager()
|
private suspend fun consumeIfMatchesAccount(pushWrappedEvent: GiftWrapEvent, account: Account) {
|
||||||
if (manager.areNotificationsEnabled()) {
|
val key = account.keyPair.privKey ?: return
|
||||||
when (event) {
|
pushWrappedEvent.unwrap(key)?.let { notificationEvent ->
|
||||||
is PrivateDmEvent -> notify(event)
|
if (!LocalCache.justVerify(notificationEvent)) return // invalid event
|
||||||
is LnZapEvent -> notify(event)
|
if (LocalCache.notes[notificationEvent.id] != null) return // already processed
|
||||||
is GiftWrapEvent -> unwrapAndNotify(event)
|
|
||||||
}
|
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
|
if (!LocalCache.justVerify(event)) return null
|
||||||
|
|
||||||
return when (event) {
|
return when (event) {
|
||||||
@@ -67,75 +83,67 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun unwrapAndNotify(giftWrap: GiftWrapEvent) {
|
private fun notify(event: ChatMessageEvent, acc: Account) {
|
||||||
val giftWrapNote = LocalCache.notes[giftWrap.id] ?: return
|
if (event.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted
|
||||||
|
event.pubKey != acc.userProfile().pubkeyHex
|
||||||
|
) { // from the user
|
||||||
|
|
||||||
LocalPreferences.allSavedAccounts().forEach {
|
val chatNote = LocalCache.notes[event.id] ?: return
|
||||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey())
|
||||||
|
|
||||||
if (acc != null && acc.userProfile().pubkeyHex == giftWrap.recipientPubKey()) {
|
val followingKeySet = acc.followingKeySet()
|
||||||
val chatEvent = unwrapAndConsume(giftWrap, account = acc)
|
|
||||||
|
|
||||||
if (chatEvent is ChatMessageEvent && // must be messages, not any other event
|
val isKnownRoom = (
|
||||||
acc.keyPair.privKey != null && // can't decrypt
|
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
|
||||||
chatEvent.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted
|
acc.userProfile().hasSentMessagesTo(chatRoom)
|
||||||
chatEvent.pubKey != acc.userProfile().pubkeyHex // from the user
|
) && !acc.isAllHidden(chatRoom.users)
|
||||||
) {
|
|
||||||
val chatNote = LocalCache.notes[chatEvent.id] ?: return
|
|
||||||
val chatRoom = chatEvent.chatroomKey(acc.keyPair.pubKey.toHexKey())
|
|
||||||
|
|
||||||
val followingKeySet = acc.followingKeySet()
|
if (isKnownRoom) {
|
||||||
|
val content = chatNote.event?.content() ?: ""
|
||||||
val isKnownRoom = (
|
val user = chatNote.author?.toBestDisplayName() ?: ""
|
||||||
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
|
val userPicture = chatNote.author?.profilePicture()
|
||||||
acc.userProfile().hasSentMessagesTo(chatRoom)
|
val noteUri = chatNote.toNEvent()
|
||||||
) && !acc.isAllHidden(chatRoom.users)
|
notificationManager().sendDMNotification(
|
||||||
|
event.id,
|
||||||
if (isKnownRoom) {
|
content,
|
||||||
val content = chatNote.event?.content() ?: ""
|
user,
|
||||||
val user = chatNote.author?.toBestDisplayName() ?: ""
|
userPicture,
|
||||||
val userPicture = chatNote.author?.profilePicture()
|
noteUri,
|
||||||
val noteUri = chatNote.toNEvent()
|
applicationContext
|
||||||
notificationManager().sendDMNotification(chatEvent.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
|
val note = LocalCache.notes[event.id] ?: return
|
||||||
|
|
||||||
// old event being re-broadcast
|
// old event being re-broadcast
|
||||||
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
|
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
|
||||||
|
|
||||||
LocalPreferences.allSavedAccounts().forEach {
|
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
|
||||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
val followingKeySet = acc.followingKeySet()
|
||||||
|
|
||||||
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
|
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
|
||||||
val followingKeySet = acc.followingKeySet()
|
(
|
||||||
|
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
|
||||||
|
acc.userProfile().hasSentMessagesTo(it)
|
||||||
|
) && !acc.isAllHidden(it.users)
|
||||||
|
}.toSet()
|
||||||
|
|
||||||
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
|
note.author?.let {
|
||||||
(
|
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
|
||||||
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
|
val content = acc.decryptContent(note) ?: ""
|
||||||
acc.userProfile().hasSentMessagesTo(it)
|
val user = note.author?.toBestDisplayName() ?: ""
|
||||||
) && !acc.isAllHidden(it.users)
|
val userPicture = note.author?.profilePicture()
|
||||||
}.toSet()
|
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
|
val noteZapEvent = LocalCache.notes[event.id] ?: return
|
||||||
|
|
||||||
// old event being re-broadcast
|
// old event being re-broadcast
|
||||||
@@ -146,39 +154,35 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
|||||||
|
|
||||||
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
|
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
|
||||||
|
|
||||||
LocalPreferences.allSavedAccounts().forEach {
|
if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
|
||||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
val amount = showAmount(event.amount)
|
||||||
|
val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let {
|
||||||
if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
|
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
|
||||||
val amount = showAmount(event.amount)
|
if (decryptedContent != null) {
|
||||||
val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let {
|
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||||
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
|
Pair(author, decryptedContent.content)
|
||||||
if (decryptedContent != null) {
|
} else if (!noteZapRequest.event?.content().isNullOrBlank()) {
|
||||||
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
Pair(noteZapRequest.author, noteZapRequest.event?.content())
|
||||||
Pair(author, decryptedContent.content)
|
} else {
|
||||||
} else if (!noteZapRequest.event?.content().isNullOrBlank()) {
|
Pair(noteZapRequest.author, null)
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
package com.vitorpamplona.amethyst.service.notifications
|
package com.vitorpamplona.amethyst.service.notifications
|
||||||
|
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import android.util.LruCache
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.google.firebase.messaging.FirebaseMessagingService
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
import com.google.firebase.messaging.RemoteMessage
|
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.getOrCreateDMChannel
|
||||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel
|
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel
|
||||||
import com.vitorpamplona.quartz.events.Event
|
import com.vitorpamplona.quartz.events.Event
|
||||||
|
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -15,19 +17,34 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class PushNotificationReceiverService : FirebaseMessagingService() {
|
class PushNotificationReceiverService : FirebaseMessagingService() {
|
||||||
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
private val eventCache = LruCache<String, String>(100)
|
||||||
|
|
||||||
// this is called when a message is received
|
// this is called when a message is received
|
||||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
remoteMessage.data.let {
|
parseMessage(remoteMessage.data)?.let {
|
||||||
val eventStr = remoteMessage.data["event"] ?: return@let
|
receiveIfNew(it)
|
||||||
val event = Event.fromJson(eventStr)
|
|
||||||
EventNotificationConsumer(applicationContext).consume(event)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun parseMessage(params: Map<String, String>): 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() {
|
override fun onDestroy() {
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
@@ -5,6 +5,8 @@ import com.vitorpamplona.quartz.encoders.hexToByteArray
|
|||||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
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.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
@@ -5,7 +5,9 @@ import com.vitorpamplona.quartz.encoders.hexToByteArray
|
|||||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.encoders.Hex
|
||||||
import com.vitorpamplona.quartz.encoders.HexKey
|
import com.vitorpamplona.quartz.encoders.HexKey
|
||||||
|
import com.vitorpamplona.quartz.encoders.decodePublicKey
|
||||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||||
import com.vitorpamplona.quartz.events.Event
|
import com.vitorpamplona.quartz.events.Event
|
||||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||||
@@ -557,4 +559,28 @@ class GiftWrapEventTest {
|
|||||||
null
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -21,7 +21,7 @@ class LnZapEvent(
|
|||||||
fromJson(it)
|
fromJson(it)
|
||||||
} as? LnZapRequestEvent
|
} as? LnZapRequestEvent
|
||||||
} catch (e: Exception) {
|
} 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
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user