From b4f1c4d13e0d39e545cdd7c7ac69581228a5bf7a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 8 Sep 2023 16:16:26 -0400 Subject: [PATCH 01/11] Migrates to Encrypted Push Notifications --- .../EventNotificationConsumer.kt | 188 +++++++++--------- .../PushNotificationReceiverService.kt | 27 ++- .../vitorpamplona/quartz/CryptoUtilsTest.kt | 2 + .../vitorpamplona/quartz/GiftWrapEventTest.kt | 26 +++ .../vitorpamplona/quartz/events/LnZapEvent.kt | 2 +- 5 files changed, 147 insertions(+), 98 deletions(-) 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 9b740b2f61..b80f29195e 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 f262d246b4..ea77ee39ae 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 905f48e95a..352c96ba36 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 8536549f5a..3d563efb1d 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 e1ab6a9ed8..20ec5fd5e9 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 } From e05ab104b96c1f4361c5966879a420935356d036 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 8 Sep 2023 16:16:53 -0400 Subject: [PATCH 02/11] 0.76.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 219aa8c2c7..b81f3f19bf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,8 +13,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 34 - versionCode 293 - versionName "0.75.14" + versionCode 294 + versionName "0.76.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 64f6811c00f9e008f00f37cb4ffcfc94760ea68f Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Fri, 8 Sep 2023 20:18:47 +0000 Subject: [PATCH 03/11] New Crowdin translations by GitHub Action --- app/src/main/res/values-zh-rCN/strings.xml | 86 ++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index bd6a9a69bb..af21e2074a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -6,6 +6,7 @@ 扫描二维码 一直显示 帖文被标记为不当 + 事件正在加载或无法在你的中继列表中找到 频道图片 未找到相关事件 无法解密消息 @@ -22,6 +23,7 @@ 复制笔记ID 广播 请求删除 + 屏蔽/举报 举报垃圾邮件/诈骗 举报冒充 @@ -46,6 +48,7 @@ " 在频道 " 个人档案横幅 付款成功 + 错误解析错误消息 " 关注" " 粉丝" 个人档案 @@ -249,23 +252,35 @@ 使用您的私钥在不离开应用程序的情况下支付 zaps。 任何具有访问 Nostr 私钥的人都可以使用您的钱包余额。要保留您的资金避免损失,如果可能,请使用私人中继器。中继器操作员可以看到您的付款元数据。 钱包绑定公钥 钱包绑定中继 + 钱包连接密码 显示私钥 nsec / 十六进制私钥 聪数量 + 发布投票 必填字段: 打闪接收人 + 投票主要说明… 选项 %s + 投票选项说明 可选字段: 打闪最低金额 打闪最高金额 + 共识 (0–100)% + 后关闭 + 投票不再接收新投票 打闪金额 + 这种投票只允许每个用户一票 "“正在查找事件%1$s”" 添加公开消息 添加私信 添加发票消息 + 感谢你的所有工作! 创建并添加 + 投票作者不能在自己的投票中投票。 + 此内容与发布时相同 + 此内容已更改。作者可能没有看到或批准修改 添加图片 添加视频 添加文件 @@ -279,16 +294,50 @@ 私人 发送方和接收方能互相看到并读取消息 匿名 + 接收人和公众不知道谁发送了付款 非打闪 + Nostr 中没有痕迹,仅在闪电上 + 文件服务器 + LnAddress 或 @User + imgur.com - 受信任的 + Imgur 可以修改文件 + nostrimg.com - 受信任的 + NostrImg 可以修改文件 + nostr.build - 受信任的 + NostrImg 可以修改文件 + nostrfiles.dev - 受信任的 + Nostrfiles.dev 可以修改文件 + nostrcheck.me - 受信任的 + nostrcheck.me 可以修改文件 + 可验证的 Imgur (NIP-94) + 检查 Imgur 是否修改了文件。新的 NIP:其他客户端可能看不到它 + 可验证的 NostrImg (NIP-94) + 检查 NostrImg 是否修改了文件。新的 NIP:其他客户端可能看不到它 + 可验证的 Nostr.build (NIP-94) + 检查 Nostr.build 是否修改了文件。新的 NIP:其他客户端可能看不到它 + 可验证的 Nostrfiles.dev (NIP-94) + 检查 Nostrfiles.dev 是否修改了文件。新的 NIP:其他客户端可能看不到它 + 可验证的 Nostrcheck.me (NIP-94) + 检查 Nostrcheck.me 是否修改了文件。新的 NIP:其他客户端可能看不到它 你的中继器 (NIP-95) + 文件由你的继电器托管。新NIP:检查它们是否支持 Tor/Orbot 设置 通过你的 Orbot 设置连接 断开与你的 Orbot/Tor 连接? + 你的数据将立即在普通网络上传输 关注列表 所有关注 全球 + ## 通过 Orbot 连线 Tor + \n\n1. 安装 [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) + \n2. 开启 Orbot + \n3. 在 Orbot 中检查 Socks 端口。默认使用9050 + \n4. 如果需要,在 Orbot 中更改端口 + \n5. 在此屏幕中配置 Socks 端口 + \n6. 按“启用”按钮以使用 Orbot 作为代理 + Orbot Socks 端口 无效端口 使用 Orbot @@ -298,6 +347,8 @@ 收到打闪 收到打闪时通知你 %1$s聪 + 来自 %1$s + 为 %1$s 通知: 加入对话 用户或群组 ID @@ -306,17 +357,23 @@ 加入 今天 内容警告 + 这个帖子包含敏感内容,一些人可能会觉得有冒犯性或令人不安。 始终隐藏敏感内容 始终显示敏感内容 始终显示内容警告 推荐: 过滤来自陌生人的垃圾信息 + 当帖子有你的关注的报告时警告 新反应符号 + 未选择回应类型。长按可更改 Zapraiser + 为这个帖子添加目标聪金额。支持的客户端可以将此显示为奖励捐赠的进度条 目标聪金额 Zapraiser 位于 %1$s。距离目标%2$s聪 从中继器读取 写入到继电器 + 尝试从 %1$s 获取中继器信息时出错 + 机主 版本 软件 联络 @@ -327,28 +384,38 @@ 国家 语言 标签 + 发布政策 消息长度 订阅 筛选器 订阅 ID 长度 + 最少前缀 + 最多事件标签 内容长度 + 最低工作量证明 认证 支付 + Cashu 代币 兑换 未设置闪电地址 已复制令牌至剪贴板 直播 离线 已结束 + 预定 直播处于离线状态 直播已结束 + 登出将删除你的本地信息。 请确保备份你的私钥以避免失去你的帐户。你想要继续吗? 已关注的标签 中继器 直播 社区 聊天 批准帖子 + 此群组没有描述或规则。联系群主来添加 + 此社区没有描述或规则。联系群主来添加 敏感内容 + 在显示此内容之前添加敏感内容警告 设置 始终 仅限 Wifi @@ -363,16 +430,23 @@ 视频播放 URL 预览 加载图像 + 垃圾邮件 静音。点击取消静音 声音开启。点击静音 + 搜索本地和远程记录 Nostr 地址已验证 Nostr 地址验证失败 正在检查 Nostr 地址 + 全部选择/取消选择 默认 选择中继器以继续 将打闪转发到: + 支持的客户端会将打闪转发到以下的闪电地址或用户个人档案,而不是你的 将位置显示为 + 将你所在位置的地理位置添加到帖子。公众会知道你在当前位置的5千米之内(3mi) + 在显示你的内容之前添加敏感的内容警告。针对任何 NSFW 内容或一些人可能觉得有冒犯性或令人不安的内容。 新功能 + 启用此模式需要 Amethyst 发送一条 NIP-24 消息(包装的、密封的私信和群聊消息)。因为 NIP-24 是新的,大多数客户端尚未执行。请确保接收方正在使用兼容的客户端。 启用 公开 私人 @@ -382,6 +456,7 @@ "\@User1、@User2、@User3" 此群组成员 对成员的解释 + 为新目标更改名称。 用于应用程序界面 暗色、亮色或系统主题 自动加载图像和 GIF @@ -394,7 +469,18 @@ 规则 更新你的状态 错误解析错误消息 + 投票按照打闪金额进行加权。 你可以设置最小金额以避免垃圾邮件,也可以设置最大金额以避免大型攻击者攻击民意调查。在两个字段中使用相同的金额,以确保每张选票的价值相同。 留空即可接受任何金额。 无法发送打闪 向用户发送消息 + 无法连接 %1$s: %2$s + 无法连接 %1$s: %2$s + 无法解析来自 %1$s的结果: %2$s + %1$s 失败,代码 %2$s + 活跃于: + 主页 + 私信 + 聊天 + 全球 + 搜索 From ce15bc46a7252fe212610bd57096cab0f8a7308e Mon Sep 17 00:00:00 2001 From: Rif'at Ahdi R <10791791+atrifat@users.noreply.github.com> Date: Sat, 9 Sep 2023 08:24:17 +0800 Subject: [PATCH 04/11] Fix vitorpamplona/amethyst#550 - change broadcast button position --- .../vitorpamplona/amethyst/ui/note/UserProfilePicture.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 90d899f834..3d8656dd42 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -438,6 +438,10 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState, accountVi Text(stringResource(R.string.quick_action_share)) } Divider() + DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.broadcast(note); onDismiss() } }) { + Text(stringResource(R.string.broadcast)) + } + Divider() if (state.isPrivateBookmarkNote) { DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.removePrivateBookmark(note); onDismiss() } }) { Text(stringResource(R.string.remove_from_private_bookmarks)) @@ -457,10 +461,6 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState, accountVi } } Divider() - DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.broadcast(note); onDismiss() } }) { - Text(stringResource(R.string.broadcast)) - } - Divider() if (state.isLoggedUser) { DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.delete(note); onDismiss() } }) { Text(stringResource(R.string.request_deletion)) From 5c882c81c84dbd833cd87a68b3f8475182c51f33 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 9 Sep 2023 14:24:29 -0400 Subject: [PATCH 05/11] Account is never null here. --- .../amethyst/service/notifications/EventNotificationConsumer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b80f29195e..a964cbc42c 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 @@ -154,7 +154,7 @@ class EventNotificationConsumer(private val applicationContext: Context) { if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return - if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) { + if (acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) { val amount = showAmount(event.amount) val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let { val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest) From d723a8ab5c4ea9daa9d0a06742d79ccb0028421e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 9 Sep 2023 14:26:20 -0400 Subject: [PATCH 06/11] Refactors the split between nip19 and the rest of the text. --- .../amethyst/ui/actions/NewMessageTagger.kt | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index 83b4c13937..f8741da59c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -101,37 +101,50 @@ class NewMessageTagger( key = key.removePrefix("@") - if (key.length < 63) { - return null - } - try { - val keyB32 = key.substring(0, 63) - val restOfWord = key.substring(63) - if (key.startsWith("nsec1", true)) { + if (key.length < 63) { + return null + } + + val keyB32 = key.substring(0, 63) + val restOfWord = key.substring(63) // Converts to npub val pubkey = Nip19.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null return DirtyKeyInfo(pubkey, restOfWord) } else if (key.startsWith("npub1", true)) { + if (key.length < 63) { + return null + } + + val keyB32 = key.substring(0, 63) + val restOfWord = key.substring(63) + val pubkey = Nip19.uriToRoute(keyB32) ?: return null return DirtyKeyInfo(pubkey, restOfWord) } else if (key.startsWith("note1", true)) { + if (key.length < 63) { + return null + } + + val keyB32 = key.substring(0, 63) + val restOfWord = key.substring(63) + val noteId = Nip19.uriToRoute(keyB32) ?: return null return DirtyKeyInfo(noteId, restOfWord) } else if (key.startsWith("nprofile", true)) { - val pubkeyRelay = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null + val pubkeyRelay = Nip19.uriToRoute(key) ?: return null return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars) } else if (key.startsWith("nevent1", true)) { - val noteRelayId = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null + val noteRelayId = Nip19.uriToRoute(key) ?: return null return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars) } else if (key.startsWith("naddr1", true)) { - val address = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null + val address = Nip19.uriToRoute(key) ?: return null return DirtyKeyInfo(address, address.additionalChars) // no way to know when they address ends and dirt begins } From be2402702f48666d7fd955a4f88179844d6eb2a7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 9 Sep 2023 14:32:27 -0400 Subject: [PATCH 07/11] Forces the ZapRequest to exist when processing the LnZapEvent. --- .../vitorpamplona/amethyst/model/LocalCache.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 6c08e18f95..bbc75081b8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -98,6 +98,8 @@ object LocalCache { checkNotInMainThread() return notes.get(idHex) ?: run { + require(isValidHex(idHex)) + val newObject = Note(idHex) notes.putIfAbsent(idHex, newObject) ?: newObject } @@ -807,23 +809,23 @@ object LocalCache { // Already processed this event. if (note.event != null) return - val zapRequest = event.zapRequest?.id?.let { getOrCreateNote(it) } + val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) } + + if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) { + Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}") + return + } val author = getOrCreateUser(event.pubKey) val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + ( - (zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet() + (zapRequest.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet() ) note.loadEvent(event, author, repliesTo) - if (zapRequest == null) { - Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}") - return - } - // Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") repliesTo.forEach { From cda821e5af02842d104c5ab733b52f463b75edb0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 9 Sep 2023 14:33:19 -0400 Subject: [PATCH 08/11] Stops accepting space as a Valid hex char and requires an even number of chars (padding) --- .../java/com/vitorpamplona/quartz/encoders/HexUtils.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt index 11ccbfdeb7..a219da8d30 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt @@ -12,18 +12,19 @@ fun HexKey.hexToByteArray(): ByteArray { } object HexValidator { - private fun isHex2(c: Char): Boolean { + private fun isHexChar(c: Char): Boolean { return when (c) { - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F', ' ' -> true + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F' -> true else -> false } } fun isHex(hex: String?): Boolean { if (hex == null) return false + if (hex.length % 2 != 0) return false // must be even var isHex = true for (c in hex.toCharArray()) { - if (!isHex2(c)) { + if (!isHexChar(c)) { isHex = false break } From 0a8923be6b9ba3061c3a1482d8e02c355bb40a47 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sat, 9 Sep 2023 18:34:33 +0000 Subject: [PATCH 09/11] New Crowdin translations by GitHub Action --- app/src/main/res/values-it-rIT/strings.xml | 100 +++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/app/src/main/res/values-it-rIT/strings.xml b/app/src/main/res/values-it-rIT/strings.xml index fc704e4d89..0d4d9f4db0 100644 --- a/app/src/main/res/values-it-rIT/strings.xml +++ b/app/src/main/res/values-it-rIT/strings.xml @@ -89,6 +89,56 @@ Nome Visualizzato Il mio nome visualizzato Username + Il mio nome utente + Su di me + Immagine del profilo URL + Copertina URL + Sito web URL + Indirizzo LN + LN URL (obsoleto) + Immagine salvata nella galleria + Salvataggio dell\'immagine non riuscito + Carica immagine + Caricamento… + L\'utente non dispone di un indirizzo Lightning per ricevere sats + "rispondi qui.. " + Copia l\'ID della nota negli appunti per la condivisione in Nostr + Copia l\'ID del canale (Nota) negli appunti + Modifica i metadati del canale + Unisciti + Conosciuto + Nuove richieste + Utenti bloccati + Nuove Discussioni + Conversazioni + Note + Risposte + "Seguiti" + "Segnalazioni" + Più Opzioni + " Relè" + Sito web + Indirizzo Lightning + Copia l\'ID Nsec (la password) negli appunti per il backup + Copia la chiave segreta negli appunti + Copia la chiave pubblica negli appunti per la condivisione + Copia la chiave pubblica (Npub) negli appunti + Invia un messaggio diretto + Modifica i metadati dell\'utente + Segui + Segui anche tu + Sblocca + Copia ID Utente + Sblocca utente + "npub, nome utente, testo" + Pulisci + Logo dell\'app + nsec.. o npub.. + Mostra password + Nascondi password + Chiave non valida + "Accetto il/lo/la/i/gli/le " + Condizioni di utilizzo L\'accettazione dei termini è obbligatoria La chiave è obbligatoria Accesso @@ -137,4 +187,54 @@ \n- Gli sviluppatori di Amethyst non chiederanno **mai** la tua chiave privata. \n- Mantieni **sempre** al sicuro un backup della tua chiave privata per recuperare l\'account. Ti suggeriamo di usare un password manager. + Motivo + Seleziona un motivo… + Pubblica una segnalazione + Blocca e Segnala + Blocca + Segnalibri + Segnalibri privati + Segnalibri pubblici + Aggiungi ai segnalibri privati + Aggiungi ai segnalibri pubblici + Rimuovi dai segnalibri privati + Rimuovi dai segnalibri pubblici + Servizio di Connessione Portafoglio + Autorizza un Nostr Secret a pagare gli zaps senza lasciare l\'app. Mantieni il segreto al sicuro e usa un relè privato se possibile + Wallet Connect Pubkey + Wallet Connect Relè + Wallet Connect Secret + Mostra chiave segreta + nsec / chiave privata esadecimale + Importo in pegno in sats + Pubblica sondaggio + Campi obbligatori: + Destinatari zap + Descrizione principale del sondaggio… + Opzione %s + Descrizione opzione sondaggio + Campi facoltativi: + Zap minimo + Zap massimo + Consenso + (0–100)% + Chiudi dopo + giorni + Il sondaggio è chiuso a nuovi voti + Quantità in zap + Per questo tipo di sondaggio è consentito solo un voto per utente + "Cercando l'evento %1$s" + Aggiungi un messaggio pubblico + Aggiungi un messaggio privato + Aggiungi un messaggio di fattura + Grazie per tutto il tuo lavoro! + Crea e Aggiungi + Gli autori del sondaggio non possono votare nei propri sondaggi. + Questo contenuto è lo stesso da quando è stato pubblicato + Questo contenuto è cambiato. L\'autore potrebbe non aver visto o approvato la modifica + Aggiungi Immagine + Aggiungi Video + Aggiungi Documento + Aggiungi al Messaggio + Descrizione dei contenuti From 8518819dfa24e9a7a6aadb22717ff1e0db7bc79e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 9 Sep 2023 14:39:41 -0400 Subject: [PATCH 10/11] Forces valid hexes for users and notes --- .../java/com/vitorpamplona/amethyst/model/LocalCache.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index bbc75081b8..6f54777fa5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -53,6 +53,10 @@ object LocalCache { // checkNotInMainThread() return users[key] ?: run { + require(isValidHex(key = key)) { + "$key is not a valid hex" + } + val newObject = User(key) users.putIfAbsent(key, newObject) ?: newObject } @@ -98,7 +102,9 @@ object LocalCache { checkNotInMainThread() return notes.get(idHex) ?: run { - require(isValidHex(idHex)) + require(isValidHex(idHex)) { + "$idHex is not a valid hex" + } val newObject = Note(idHex) notes.putIfAbsent(idHex, newObject) ?: newObject From fee2764338b0af07bfb4e820938dc147bdf0bdea Mon Sep 17 00:00:00 2001 From: OrigiSize <144188458+OrigiSize@users.noreply.github.com> Date: Sat, 9 Sep 2023 12:33:09 -0700 Subject: [PATCH 11/11] Minor README.md edits Fixing grammar and flow --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c2ac6b03f6..8d95c2f1ff 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases) This is a native Android app made with Kotlin and Jetpack Compose. The app uses a modified version of the [nostrpostrlib](https://github.com/Giszmo/NostrPostr/tree/master/nostrpostrlib) to talk to Nostr relays. -The overall architecture consists in the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays, +The overall architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays, and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph. The repository layer stores Nostr Events as Notes and Users separately. Those classes use LiveData objects to @@ -172,7 +172,7 @@ For the Play build: keytool -genkey -v -keystore -alias -keyalg RSA -keysize 2048 -validity 10000 openssl base64 < | tr -d '\n' | tee some_signing_key.jks.base64.txt ``` -2. Create 4 Secret Key variables on your GitHub repository and fill in with the signing key information +2. Create four Secret Key variables on your GitHub repository and fill in the signing key information - `KEY_ALIAS` <- `` - `KEY_PASSWORD` <- `` - `KEY_STORE_PASSWORD` <- `` @@ -192,15 +192,15 @@ The relay also learns which public keys you are requesting, meaning your public Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of Private Zaps and Private DMs. # DM Privacy # -While the content of direct messages (DMs) is only visible to you, and your DM nostr counterparty, everyone can see that and when you and your counterparty are DM-ing each other. +While the content of direct messages (DMs) is only visible to you and your DM counterparty, everyone can see when you and your counterparty DM each other. # Visibility & Permanence of Your Content on nostr ## Information Visibility ## Content that you share can be shared to other relays. -Information that you share is publicly visible to anyone reading from relays that have your information. Your information may also be visible to nostr users who do not share relays with you. +Information that you share publicly is visible to anyone reading from relays that have your information. Your information may also be visible to nostr users who do not share relays with you. ## Information Permanence ## -Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted. +Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee edit or deletion of any content once posted. # Screenshots