mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 02:25:21 +00:00
fix nip-17 (#305)
* NIP-59 stuff * nip-17 ios to androing works, android to ios still wip * fix impl * ios compat * default relays for DMs * delivery ack works * delivery ack --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
@@ -465,7 +465,8 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
* Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService
|
* Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService
|
||||||
*/
|
*/
|
||||||
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) {
|
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) {
|
||||||
if (content.isEmpty() || recipientPeerID.isEmpty() || recipientNickname.isEmpty()) return
|
if (content.isEmpty() || recipientPeerID.isEmpty()) return
|
||||||
|
if (!recipientPeerID.startsWith("nostr_") && recipientNickname.isEmpty()) return
|
||||||
|
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
|
val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
|
||||||
|
|||||||
@@ -107,17 +107,18 @@ class NostrClient private constructor(private val context: Context) {
|
|||||||
|
|
||||||
val recipientPubkeyHex = pubkeyBytes.toHexString()
|
val recipientPubkeyHex = pubkeyBytes.toHexString()
|
||||||
|
|
||||||
// Create and send gift wrap
|
// Create and send gift wraps (receiver and sender copies)
|
||||||
val giftWrap = NostrProtocol.createPrivateMessage(
|
val giftWraps = NostrProtocol.createPrivateMessage(
|
||||||
content = content,
|
content = content,
|
||||||
recipientPubkey = recipientPubkeyHex,
|
recipientPubkey = recipientPubkeyHex,
|
||||||
senderIdentity = identity
|
senderIdentity = identity
|
||||||
)
|
)
|
||||||
|
|
||||||
// Track this as a pending gift wrap for logging
|
// Track and send all gift wraps
|
||||||
NostrRelayManager.registerPendingGiftWrap(giftWrap.id)
|
giftWraps.forEach { wrap ->
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(wrap.id)
|
||||||
relayManager.sendEvent(giftWrap)
|
relayManager.sendEvent(wrap)
|
||||||
|
}
|
||||||
|
|
||||||
Log.i(TAG, "📤 Sent private message to ${recipientNpub.take(16)}...")
|
Log.i(TAG, "📤 Sent private message to ${recipientNpub.take(16)}...")
|
||||||
onSuccess?.invoke()
|
onSuccess?.invoke()
|
||||||
@@ -141,7 +142,7 @@ class NostrClient private constructor(private val context: Context) {
|
|||||||
|
|
||||||
val filter = NostrFilter.giftWrapsFor(
|
val filter = NostrFilter.giftWrapsFor(
|
||||||
pubkey = identity.publicKeyHex,
|
pubkey = identity.publicKeyHex,
|
||||||
since = System.currentTimeMillis() - 86400000L // Last 24 hours
|
since = System.currentTimeMillis() - 172800000L // Last 48 hours (align with NIP-17 randomization)
|
||||||
)
|
)
|
||||||
|
|
||||||
relayManager.subscribe(filter, "private-messages", { giftWrap ->
|
relayManager.subscribe(filter, "private-messages", { giftWrap ->
|
||||||
@@ -241,7 +242,7 @@ class NostrClient private constructor(private val context: Context) {
|
|||||||
) {
|
) {
|
||||||
// Age filtering (24h + 15min buffer for randomized timestamps)
|
// Age filtering (24h + 15min buffer for randomized timestamps)
|
||||||
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
|
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
|
||||||
if (messageAge > 87300) { // 24 hours + 15 minutes
|
if (messageAge > 173700) { // 48 hours + 15 minutes
|
||||||
Log.v(TAG, "Ignoring old private message")
|
Log.v(TAG, "Ignoring old private message")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,11 @@ import org.bouncycastle.crypto.params.ECPublicKeyParameters
|
|||||||
import org.bouncycastle.math.ec.ECPoint
|
import org.bouncycastle.math.ec.ECPoint
|
||||||
import org.bouncycastle.crypto.generators.ECKeyPairGenerator
|
import org.bouncycastle.crypto.generators.ECKeyPairGenerator
|
||||||
import org.bouncycastle.crypto.params.ECKeyGenerationParameters
|
import org.bouncycastle.crypto.params.ECKeyGenerationParameters
|
||||||
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
|
|
||||||
import org.bouncycastle.crypto.agreement.ECDHBasicAgreement
|
import org.bouncycastle.crypto.agreement.ECDHBasicAgreement
|
||||||
import org.bouncycastle.crypto.digests.SHA256Digest
|
import org.bouncycastle.crypto.digests.SHA256Digest
|
||||||
import org.bouncycastle.crypto.macs.HMac
|
import org.bouncycastle.crypto.macs.HMac
|
||||||
import org.bouncycastle.crypto.params.KeyParameter
|
import org.bouncycastle.crypto.params.KeyParameter
|
||||||
import javax.crypto.Cipher
|
import com.google.crypto.tink.subtle.XChaCha20Poly1305
|
||||||
import javax.crypto.spec.GCMParameterSpec
|
|
||||||
import javax.crypto.spec.SecretKeySpec
|
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
@@ -26,6 +23,7 @@ import java.math.BigInteger
|
|||||||
object NostrCrypto {
|
object NostrCrypto {
|
||||||
|
|
||||||
private val secureRandom = SecureRandom()
|
private val secureRandom = SecureRandom()
|
||||||
|
// NIP-44 v2 only
|
||||||
|
|
||||||
// secp256k1 curve parameters
|
// secp256k1 curve parameters
|
||||||
val secp256k1Curve = CustomNamedCurves.getByName("secp256k1")
|
val secp256k1Curve = CustomNamedCurves.getByName("secp256k1")
|
||||||
@@ -99,7 +97,7 @@ object NostrCrypto {
|
|||||||
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
|
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
|
||||||
val privateKeyParams = ECPrivateKeyParameters(privateKeyBigInt, secp256k1Params)
|
val privateKeyParams = ECPrivateKeyParameters(privateKeyBigInt, secp256k1Params)
|
||||||
|
|
||||||
// Try to recover full public key point from x-only coordinate
|
// Recover full public key point from x-only coordinate (prefer even y per BIP-340)
|
||||||
val publicKeyPoint = recoverPublicKeyPoint(publicKeyBytes)
|
val publicKeyPoint = recoverPublicKeyPoint(publicKeyBytes)
|
||||||
val publicKeyParams = ECPublicKeyParameters(publicKeyPoint, secp256k1Params)
|
val publicKeyParams = ECPublicKeyParameters(publicKeyPoint, secp256k1Params)
|
||||||
|
|
||||||
@@ -119,10 +117,38 @@ object NostrCrypto {
|
|||||||
maxOf(0, 32 - sharedSecretBytes.size),
|
maxOf(0, 32 - sharedSecretBytes.size),
|
||||||
minOf(sharedSecretBytes.size, 32)
|
minOf(sharedSecretBytes.size, 32)
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
// If BigInteger added a leading sign byte, keep the last 32 bytes
|
||||||
|
System.arraycopy(sharedSecretBytes, sharedSecretBytes.size - 32, result, 0, 32)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform ECDH with explicit parity choice for the x-only public key
|
||||||
|
* If preferOddY is true, use the odd-y lift; otherwise even-y lift.
|
||||||
|
*/
|
||||||
|
private fun performECDHWithParity(privateKeyHex: String, publicKeyHex: String, preferOddY: Boolean): ByteArray {
|
||||||
|
val privateKeyBytes = privateKeyHex.hexToByteArray()
|
||||||
|
val publicKeyBytes = publicKeyHex.hexToByteArray()
|
||||||
|
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
|
||||||
|
val privateKeyParams = ECPrivateKeyParameters(privateKeyBigInt, secp256k1Params)
|
||||||
|
val point = recoverPublicKeyPointWithParity(publicKeyBytes, preferOddY)
|
||||||
|
val publicKeyParams = ECPublicKeyParameters(point, secp256k1Params)
|
||||||
|
val agreement = ECDHBasicAgreement()
|
||||||
|
agreement.init(privateKeyParams)
|
||||||
|
val sharedSecret = agreement.calculateAgreement(publicKeyParams)
|
||||||
|
val sharedSecretBytes = sharedSecret.toByteArray()
|
||||||
|
val result = ByteArray(32)
|
||||||
|
if (sharedSecretBytes.size <= 32) {
|
||||||
|
System.arraycopy(sharedSecretBytes, maxOf(0, sharedSecretBytes.size - 32), result, maxOf(0, 32 - sharedSecretBytes.size), minOf(sharedSecretBytes.size, 32))
|
||||||
|
} else {
|
||||||
|
// If BigInteger added a leading sign byte, keep the last 32 bytes
|
||||||
|
System.arraycopy(sharedSecretBytes, sharedSecretBytes.size - 32, result, 0, 32)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recover full EC point from x-only coordinate
|
* Recover full EC point from x-only coordinate
|
||||||
@@ -147,96 +173,138 @@ object NostrCrypto {
|
|||||||
return secp256k1Curve.curve.decodePoint(compressedBytes)
|
return secp256k1Curve.curve.decodePoint(compressedBytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun recoverPublicKeyPointWithParity(xOnlyBytes: ByteArray, preferOddY: Boolean): ECPoint {
|
||||||
|
require(xOnlyBytes.size == 32) { "X-only public key must be 32 bytes" }
|
||||||
|
val prefix: Byte = if (preferOddY) 0x03 else 0x02
|
||||||
|
val compressedBytes = ByteArray(33)
|
||||||
|
compressedBytes[0] = prefix
|
||||||
|
System.arraycopy(xOnlyBytes, 0, compressedBytes, 1, 32)
|
||||||
|
return secp256k1Curve.curve.decodePoint(compressedBytes)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-44 key derivation using HKDF
|
* Compute the ECDH shared point (uncompressed) with an explicit parity choice for the x-only public key
|
||||||
|
*/
|
||||||
|
private fun computeSharedPointWithParity(privateKeyHex: String, publicKeyHex: String, preferOddY: Boolean): ECPoint {
|
||||||
|
val privateKeyBytes = privateKeyHex.hexToByteArray()
|
||||||
|
val publicKeyBytes = publicKeyHex.hexToByteArray()
|
||||||
|
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
|
||||||
|
val point = recoverPublicKeyPointWithParity(publicKeyBytes, preferOddY)
|
||||||
|
return point.multiply(privateKeyBigInt).normalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compressedPoint(point: ECPoint): ByteArray {
|
||||||
|
val normalized = point.normalize()
|
||||||
|
val x = normalized.xCoord.encoded
|
||||||
|
val prefix: Byte = if (hasEvenY(normalized)) 0x02 else 0x03
|
||||||
|
val out = ByteArray(33)
|
||||||
|
out[0] = prefix
|
||||||
|
System.arraycopy(x, 0, out, 1, 32)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-44 v2 key derivation using HKDF-SHA256
|
||||||
|
* salt = empty, info = "nip44-v2", length = 32 bytes
|
||||||
*/
|
*/
|
||||||
fun deriveNIP44Key(sharedSecret: ByteArray): ByteArray {
|
fun deriveNIP44Key(sharedSecret: ByteArray): ByteArray {
|
||||||
val salt = "nip44-v2".toByteArray(Charsets.UTF_8)
|
val zeroSalt = ByteArray(0)
|
||||||
|
val prk = hkdfExtract(zeroSalt, sharedSecret)
|
||||||
// HKDF-Extract
|
return hkdfExpand(prk, info = "nip44-v2".toByteArray(Charsets.UTF_8), length = 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hkdfExtract(salt: ByteArray, ikm: ByteArray): ByteArray {
|
||||||
val hmac = HMac(SHA256Digest())
|
val hmac = HMac(SHA256Digest())
|
||||||
hmac.init(KeyParameter(salt))
|
hmac.init(KeyParameter(salt))
|
||||||
hmac.update(sharedSecret, 0, sharedSecret.size)
|
hmac.update(ikm, 0, ikm.size)
|
||||||
val prk = ByteArray(hmac.macSize)
|
val prk = ByteArray(hmac.macSize)
|
||||||
hmac.doFinal(prk, 0)
|
hmac.doFinal(prk, 0)
|
||||||
|
return prk
|
||||||
// HKDF-Expand (we need 32 bytes for AES-256)
|
|
||||||
hmac.init(KeyParameter(prk))
|
|
||||||
hmac.update(byteArrayOf(0x01), 0, 1) // info = empty, N = 1
|
|
||||||
val okm = ByteArray(hmac.macSize)
|
|
||||||
hmac.doFinal(okm, 0)
|
|
||||||
|
|
||||||
// Return first 32 bytes
|
|
||||||
return okm.copyOf(32)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun hkdfExpand(prk: ByteArray, info: ByteArray?, length: Int): ByteArray {
|
||||||
|
val hmac = HMac(SHA256Digest())
|
||||||
|
hmac.init(KeyParameter(prk))
|
||||||
|
if (info != null && info.isNotEmpty()) {
|
||||||
|
hmac.update(info, 0, info.size)
|
||||||
|
}
|
||||||
|
hmac.update(byteArrayOf(0x01), 0, 1)
|
||||||
|
val t = ByteArray(hmac.macSize)
|
||||||
|
hmac.doFinal(t, 0)
|
||||||
|
return t.copyOf(length)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-44 encryption using AES-256-GCM
|
* NIP-44 v2 encryption using XChaCha20-Poly1305
|
||||||
|
* Output format: "v2:" + base64url(nonce24 || ciphertext || tag)
|
||||||
*/
|
*/
|
||||||
fun encryptNIP44(plaintext: String, recipientPublicKeyHex: String, senderPrivateKeyHex: String): String {
|
fun encryptNIP44(
|
||||||
|
plaintext: String,
|
||||||
|
recipientPublicKeyHex: String,
|
||||||
|
senderPrivateKeyHex: String
|
||||||
|
): String {
|
||||||
try {
|
try {
|
||||||
// Perform ECDH
|
// Match iOS: derive HKDF input from the compressed shared point (33 bytes)
|
||||||
val sharedSecret = performECDH(senderPrivateKeyHex, recipientPublicKeyHex)
|
val sharedPoint = computeSharedPointWithParity(senderPrivateKeyHex, recipientPublicKeyHex, preferOddY = false)
|
||||||
|
val secretMaterial = compressedPoint(sharedPoint)
|
||||||
// Derive encryption key
|
val encryptionKey = deriveNIP44Key(secretMaterial)
|
||||||
val encryptionKey = deriveNIP44Key(sharedSecret)
|
val aead = XChaCha20Poly1305(encryptionKey)
|
||||||
|
val combined = aead.encrypt(plaintext.toByteArray(Charsets.UTF_8), null) // nonce||ct||tag
|
||||||
// Generate random nonce (12 bytes for GCM)
|
val b64 = base64UrlNoPad(combined)
|
||||||
val nonce = ByteArray(12)
|
android.util.Log.d("NostrCrypto", "NIP44 v2 encrypt: len=${b64.length}")
|
||||||
secureRandom.nextBytes(nonce)
|
return "v2:$b64"
|
||||||
|
|
||||||
// Encrypt using AES-256-GCM
|
|
||||||
val plaintextBytes = plaintext.toByteArray(Charsets.UTF_8)
|
|
||||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
|
||||||
val secretKey = SecretKeySpec(encryptionKey, "AES")
|
|
||||||
val gcmSpec = GCMParameterSpec(128, nonce) // 128-bit auth tag
|
|
||||||
|
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec)
|
|
||||||
val ciphertext = cipher.doFinal(plaintextBytes)
|
|
||||||
|
|
||||||
// Combine nonce + ciphertext (includes auth tag)
|
|
||||||
val result = ByteArray(nonce.size + ciphertext.size)
|
|
||||||
System.arraycopy(nonce, 0, result, 0, nonce.size)
|
|
||||||
System.arraycopy(ciphertext, 0, result, nonce.size, ciphertext.size)
|
|
||||||
|
|
||||||
// Base64 encode
|
|
||||||
return android.util.Base64.encodeToString(result, android.util.Base64.NO_WRAP)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw RuntimeException("NIP-44 encryption failed: ${e.message}", e)
|
throw RuntimeException("NIP-44 v2 encryption failed: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-44 decryption using AES-256-GCM
|
* NIP-44 v2 decryption using XChaCha20-Poly1305
|
||||||
|
* Only accepts the exact "v2:" base64url format.
|
||||||
|
* Tries both even/odd Y parities for x-only pubkeys.
|
||||||
*/
|
*/
|
||||||
fun decryptNIP44(ciphertext: String, senderPublicKeyHex: String, recipientPrivateKeyHex: String): String {
|
fun decryptNIP44(ciphertext: String, senderPublicKeyHex: String, recipientPrivateKeyHex: String): String {
|
||||||
try {
|
try {
|
||||||
// Decode base64
|
require(ciphertext.startsWith("v2:")) { "Invalid NIP-44 version prefix" }
|
||||||
val encryptedData = android.util.Base64.decode(ciphertext, android.util.Base64.NO_WRAP)
|
val encoded = ciphertext.substring(3)
|
||||||
|
val encryptedData = base64UrlDecode(encoded)
|
||||||
// Extract nonce and ciphertext
|
?: throw IllegalArgumentException("Invalid base64url payload")
|
||||||
require(encryptedData.size >= 12 + 16) { "Ciphertext too short" }
|
|
||||||
val nonce = encryptedData.copyOfRange(0, 12)
|
var lastError: Exception? = null
|
||||||
val ciphertextBytes = encryptedData.copyOfRange(12, encryptedData.size)
|
// Try even-Y first, then odd-Y
|
||||||
|
for (preferOdd in listOf(false, true)) {
|
||||||
// Perform ECDH
|
try {
|
||||||
val sharedSecret = performECDH(recipientPrivateKeyHex, senderPublicKeyHex)
|
// Match iOS: derive HKDF input from the compressed shared point (33 bytes)
|
||||||
|
val point = computeSharedPointWithParity(recipientPrivateKeyHex, senderPublicKeyHex, preferOddY = preferOdd)
|
||||||
// Derive decryption key
|
val secretMaterial = compressedPoint(point)
|
||||||
val decryptionKey = deriveNIP44Key(sharedSecret)
|
val key = deriveNIP44Key(secretMaterial)
|
||||||
|
val aead = XChaCha20Poly1305(key)
|
||||||
// Decrypt using AES-256-GCM
|
val pt = aead.decrypt(encryptedData, null) // expects nonce||ct||tag
|
||||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
return String(pt, Charsets.UTF_8)
|
||||||
val secretKey = SecretKeySpec(decryptionKey, "AES")
|
} catch (e: Exception) {
|
||||||
val gcmSpec = GCMParameterSpec(128, nonce)
|
lastError = e
|
||||||
|
}
|
||||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec)
|
}
|
||||||
val plaintextBytes = cipher.doFinal(ciphertextBytes)
|
throw lastError ?: RuntimeException("NIP-44 v2 decryption failed")
|
||||||
|
|
||||||
return String(plaintextBytes, Charsets.UTF_8)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw RuntimeException("NIP-44 decryption failed: ${e.message}", e)
|
throw RuntimeException("NIP-44 v2 decryption failed: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun base64UrlNoPad(data: ByteArray): String {
|
||||||
|
val b64 = android.util.Base64.encodeToString(data, android.util.Base64.NO_WRAP)
|
||||||
|
return b64.replace('+', '-').replace('/', '_').replace("=", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun base64UrlDecode(s: String): ByteArray? {
|
||||||
|
var str = s.replace('-', '+').replace('_', '/')
|
||||||
|
val pad = (4 - (str.length % 4)) % 4
|
||||||
|
if (pad > 0) str += "=".repeat(pad)
|
||||||
|
return try {
|
||||||
|
android.util.Base64.decode(str, android.util.Base64.NO_WRAP)
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +316,15 @@ object NostrCrypto {
|
|||||||
return (baseTimestamp + offset).toInt()
|
return (baseTimestamp + offset).toInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Random timestamp up to maxPastSeconds in the past (default 2 days)
|
||||||
|
*/
|
||||||
|
fun randomizeTimestampUpToPast(maxPastSeconds: Int = 172800): Int {
|
||||||
|
val now = (System.currentTimeMillis() / 1000).toInt()
|
||||||
|
val offset = if (maxPastSeconds > 0) secureRandom.nextInt(maxPastSeconds + 1) else 0
|
||||||
|
return now - offset
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate secp256k1 private key
|
* Validate secp256k1 private key
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.bitchat.android.nostr
|
package com.bitchat.android.nostr
|
||||||
|
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
@@ -106,6 +107,14 @@ data class NostrEvent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute event ID (NIP-01) without signing
|
||||||
|
*/
|
||||||
|
fun computeEventIdHex(): String {
|
||||||
|
val (eventId, _) = calculateEventId()
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate event ID according to NIP-01
|
* Calculate event ID according to NIP-01
|
||||||
* Returns (hex_id, hash_bytes)
|
* Returns (hex_id, hash_bytes)
|
||||||
@@ -122,7 +131,7 @@ data class NostrEvent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Convert to JSON without escaping slashes (compact format)
|
// Convert to JSON without escaping slashes (compact format)
|
||||||
val gson = Gson()
|
val gson = GsonBuilder().disableHtmlEscaping().create()
|
||||||
val jsonString = gson.toJson(serialized)
|
val jsonString = gson.toJson(serialized)
|
||||||
|
|
||||||
// SHA256 hash of the JSON string
|
// SHA256 hash of the JSON string
|
||||||
@@ -201,6 +210,8 @@ data class NostrEvent(
|
|||||||
object NostrKind {
|
object NostrKind {
|
||||||
const val METADATA = 0
|
const val METADATA = 0
|
||||||
const val TEXT_NOTE = 1
|
const val TEXT_NOTE = 1
|
||||||
|
const val DIRECT_MESSAGE = 14 // NIP-17 direct message (unsigned)
|
||||||
|
const val FILE_MESSAGE = 15 // NIP-17 file message (unsigned)
|
||||||
const val SEAL = 13 // NIP-17 sealed event
|
const val SEAL = 13 // NIP-17 sealed event
|
||||||
const val GIFT_WRAP = 1059 // NIP-17 gift wrap
|
const val GIFT_WRAP = 1059 // NIP-17 gift wrap
|
||||||
const val EPHEMERAL_EVENT = 20000 // For geohash channels
|
const val EPHEMERAL_EVENT = 20000 // For geohash channels
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ class NostrGeohashService(
|
|||||||
// Subscribe to gift wraps (NIP-17 private messages)
|
// Subscribe to gift wraps (NIP-17 private messages)
|
||||||
val dmFilter = NostrFilter.giftWrapsFor(
|
val dmFilter = NostrFilter.giftWrapsFor(
|
||||||
pubkey = currentIdentity.publicKeyHex,
|
pubkey = currentIdentity.publicKeyHex,
|
||||||
since = System.currentTimeMillis() - 86400000L // Last 24 hours
|
since = System.currentTimeMillis() - 172800000L // Last 48 hours (align with NIP-17 randomization)
|
||||||
)
|
)
|
||||||
|
|
||||||
nostrRelayManager.subscribe(
|
nostrRelayManager.subscribe(
|
||||||
@@ -234,7 +234,7 @@ class NostrGeohashService(
|
|||||||
|
|
||||||
// Client-side filtering: ignore messages older than 24 hours + 15 minutes buffer
|
// Client-side filtering: ignore messages older than 24 hours + 15 minutes buffer
|
||||||
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
|
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
|
||||||
if (messageAge > 87300) { // 24 hours + 15 minutes
|
if (messageAge > 173700) { // 48 hours + 15 minutes
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +316,27 @@ class NostrGeohashService(
|
|||||||
// Store Nostr key mapping
|
// Store Nostr key mapping
|
||||||
nostrKeyMapping[targetPeerID] = senderPubkey
|
nostrKeyMapping[targetPeerID] = senderPubkey
|
||||||
|
|
||||||
|
// Process payload and update UI/state
|
||||||
processNoisePayload(noisePayload, targetPeerID, senderNickname, messageTimestamp)
|
processNoisePayload(noisePayload, targetPeerID, senderNickname, messageTimestamp)
|
||||||
|
|
||||||
|
// If this was a private message, send a delivery ACK back over Nostr
|
||||||
|
if (noisePayload.type == com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) {
|
||||||
|
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data)
|
||||||
|
pm?.let { pmsg ->
|
||||||
|
val nostrTransport = NostrTransport.getInstance(application)
|
||||||
|
// Prefer mapped peer route; fallback to direct Nostr using sender pubkey
|
||||||
|
if (senderNoiseKey != null) {
|
||||||
|
val peerIdHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) }
|
||||||
|
nostrTransport.sendDeliveryAck(pmsg.messageID, peerIdHex)
|
||||||
|
} else {
|
||||||
|
// Fallback: direct to sender’s Nostr pubkey (geohash-style)
|
||||||
|
val identity = NostrIdentityBridge.getCurrentNostrIdentity(application)
|
||||||
|
if (identity != null) {
|
||||||
|
nostrTransport.sendDeliveryAckGeohash(pmsg.messageID, senderPubkey, identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error processing Nostr message: ${e.message}")
|
Log.e(TAG, "Error processing Nostr message: ${e.message}")
|
||||||
@@ -900,18 +920,17 @@ class NostrGeohashService(
|
|||||||
|
|
||||||
val dmFilter = NostrFilter.giftWrapsFor(
|
val dmFilter = NostrFilter.giftWrapsFor(
|
||||||
pubkey = dmIdentity.publicKeyHex,
|
pubkey = dmIdentity.publicKeyHex,
|
||||||
since = System.currentTimeMillis() - 86400000L // Last 24 hours
|
since = System.currentTimeMillis() - 172800000L // Last 48 hours (align with NIP-17 randomization)
|
||||||
)
|
)
|
||||||
|
|
||||||
nostrRelayManager.subscribeForGeohash(
|
// IMPORTANT: For geohash DMs, use default relays (iOS behavior)
|
||||||
geohash = channel.channel.geohash,
|
nostrRelayManager.subscribe(
|
||||||
filter = dmFilter,
|
filter = dmFilter,
|
||||||
id = dmSubId,
|
id = dmSubId,
|
||||||
handler = { giftWrap ->
|
handler = { giftWrap ->
|
||||||
handleGeohashDmEvent(giftWrap, channel.channel.geohash, dmIdentity)
|
handleGeohashDmEvent(giftWrap, channel.channel.geohash, dmIdentity)
|
||||||
},
|
},
|
||||||
includeDefaults = false,
|
targetRelayUrls = null
|
||||||
nRelays = 5
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Log.i(TAG, "✅ Subscribed to geohash DMs for identity: ${dmIdentity.publicKeyHex.take(16)}...")
|
Log.i(TAG, "✅ Subscribed to geohash DMs for identity: ${dmIdentity.publicKeyHex.take(16)}...")
|
||||||
@@ -1177,7 +1196,7 @@ class NostrGeohashService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (decryptResult == null) {
|
if (decryptResult == null) {
|
||||||
Log.w(TAG, "Failed to decrypt geohash DM")
|
Log.d(TAG, "Skipping geohash DM: unwrap/open failed (non-fatal)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,7 +1254,11 @@ class NostrGeohashService(
|
|||||||
|
|
||||||
// Add to private chats
|
// Add to private chats
|
||||||
privateChatManager.handleIncomingPrivateMessage(message)
|
privateChatManager.handleIncomingPrivateMessage(message)
|
||||||
|
|
||||||
|
// Always send delivery ACK for geohash DMs
|
||||||
|
val nostrTransport = NostrTransport.getInstance(application)
|
||||||
|
nostrTransport.sendDeliveryAckGeohash(messageId, senderPubkey, identity)
|
||||||
|
|
||||||
// Send read receipt if viewing this chat
|
// Send read receipt if viewing this chat
|
||||||
if (isViewingThisChat) {
|
if (isViewingThisChat) {
|
||||||
// Send read receipt via Nostr for geohash DM
|
// Send read receipt via Nostr for geohash DM
|
||||||
|
|||||||
@@ -14,46 +14,42 @@ object NostrProtocol {
|
|||||||
private val gson = Gson()
|
private val gson = Gson()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a NIP-17 private message
|
* Create NIP-17 private message gift-wrap (receiver copy only per iOS)
|
||||||
* Returns gift-wrapped event ready for relay broadcast
|
* Returns a single gift-wrapped event ready for relay broadcast
|
||||||
*/
|
*/
|
||||||
fun createPrivateMessage(
|
fun createPrivateMessage(
|
||||||
content: String,
|
content: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderIdentity: NostrIdentity
|
senderIdentity: NostrIdentity
|
||||||
): NostrEvent {
|
): List<NostrEvent> {
|
||||||
Log.v(TAG, "Creating private message for recipient: ${recipientPubkey.take(16)}...")
|
Log.d(TAG, "Creating private message for recipient: ${recipientPubkey.take(16)}...")
|
||||||
|
|
||||||
// 1. Create the rumor (unsigned event)
|
// 1. Create the rumor (unsigned kind 14) with p-tag
|
||||||
val rumor = NostrEvent(
|
val rumorBase = NostrEvent(
|
||||||
pubkey = senderIdentity.publicKeyHex,
|
pubkey = senderIdentity.publicKeyHex,
|
||||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||||
kind = NostrKind.TEXT_NOTE,
|
kind = NostrKind.DIRECT_MESSAGE,
|
||||||
tags = emptyList(),
|
tags = listOf(listOf("p", recipientPubkey)),
|
||||||
content = content
|
content = content
|
||||||
)
|
)
|
||||||
|
val rumorId = rumorBase.computeEventIdHex()
|
||||||
|
val rumor = rumorBase.copy(id = rumorId)
|
||||||
|
|
||||||
// 2. Create ephemeral key for this message
|
// 2. Seal the rumor (kind 13) signed by sender, timestamp randomized up to 2 days
|
||||||
val (ephemeralPrivateKey, ephemeralPublicKey) = NostrCrypto.generateKeyPair()
|
|
||||||
Log.v(TAG, "Created ephemeral key for seal")
|
|
||||||
|
|
||||||
// 3. Seal the rumor (encrypt to recipient)
|
|
||||||
val sealedEvent = createSeal(
|
val sealedEvent = createSeal(
|
||||||
rumor = rumor,
|
rumor = rumor,
|
||||||
recipientPubkey = recipientPubkey,
|
recipientPubkey = recipientPubkey,
|
||||||
senderPrivateKey = ephemeralPrivateKey,
|
senderPrivateKey = senderIdentity.privateKeyHex,
|
||||||
senderPublicKey = ephemeralPublicKey
|
senderPublicKey = senderIdentity.publicKeyHex
|
||||||
)
|
)
|
||||||
|
|
||||||
// 4. Gift wrap the sealed event (encrypt to recipient again)
|
// 3. Gift wrap to recipient (kind 1059)
|
||||||
val giftWrap = createGiftWrap(
|
val giftWrapToRecipient = createGiftWrap(
|
||||||
seal = sealedEvent,
|
seal = sealedEvent,
|
||||||
recipientPubkey = recipientPubkey
|
recipientPubkey = recipientPubkey
|
||||||
)
|
)
|
||||||
|
Log.d(TAG, "Created gift wrap: toRecipient=${giftWrapToRecipient.id.take(16)}...")
|
||||||
Log.v(TAG, "Created gift wrap with id: ${giftWrap.id.take(16)}...")
|
return listOf(giftWrapToRecipient)
|
||||||
|
|
||||||
return giftWrap
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,7 +83,7 @@ object NostrProtocol {
|
|||||||
|
|
||||||
Triple(rumor.content, rumor.pubkey, rumor.createdAt)
|
Triple(rumor.content, rumor.pubkey, rumor.createdAt)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "❌ Failed to decrypt private message: ${e.message}")
|
Log.w(TAG, "Failed to decrypt private message: ${e.message}")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,7 +139,7 @@ object NostrProtocol {
|
|||||||
|
|
||||||
val seal = NostrEvent(
|
val seal = NostrEvent(
|
||||||
pubkey = senderPublicKey,
|
pubkey = senderPublicKey,
|
||||||
createdAt = NostrCrypto.randomizeTimestamp(),
|
createdAt = NostrCrypto.randomizeTimestampUpToPast(),
|
||||||
kind = NostrKind.SEAL,
|
kind = NostrKind.SEAL,
|
||||||
tags = emptyList(),
|
tags = emptyList(),
|
||||||
content = encrypted
|
content = encrypted
|
||||||
@@ -172,7 +168,7 @@ object NostrProtocol {
|
|||||||
|
|
||||||
val giftWrap = NostrEvent(
|
val giftWrap = NostrEvent(
|
||||||
pubkey = wrapPublicKey,
|
pubkey = wrapPublicKey,
|
||||||
createdAt = NostrCrypto.randomizeTimestamp(),
|
createdAt = NostrCrypto.randomizeTimestampUpToPast(),
|
||||||
kind = NostrKind.GIFT_WRAP,
|
kind = NostrKind.GIFT_WRAP,
|
||||||
tags = listOf(listOf("p", recipientPubkey)), // Tag recipient
|
tags = listOf(listOf("p", recipientPubkey)), // Tag recipient
|
||||||
content = encrypted
|
content = encrypted
|
||||||
@@ -186,7 +182,7 @@ object NostrProtocol {
|
|||||||
giftWrap: NostrEvent,
|
giftWrap: NostrEvent,
|
||||||
recipientPrivateKey: String
|
recipientPrivateKey: String
|
||||||
): NostrEvent? {
|
): NostrEvent? {
|
||||||
Log.v(TAG, "Unwrapping gift wrap")
|
Log.d(TAG, "Unwrapping gift wrap; content prefix='${giftWrap.content.take(3)}' length=${giftWrap.content.length}")
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val decrypted = NostrCrypto.decryptNIP44(
|
val decrypted = NostrCrypto.decryptNIP44(
|
||||||
@@ -215,7 +211,7 @@ object NostrProtocol {
|
|||||||
Log.v(TAG, "Unwrapped seal with kind: ${seal.kind}")
|
Log.v(TAG, "Unwrapped seal with kind: ${seal.kind}")
|
||||||
seal
|
seal
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to unwrap gift wrap: ${e.message}")
|
Log.w(TAG, "Failed to unwrap gift wrap: ${e.message}")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,7 +244,7 @@ object NostrProtocol {
|
|||||||
sig = jsonObject.get("sig")?.asString
|
sig = jsonObject.get("sig")?.asString
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to open seal: ${e.message}")
|
Log.w(TAG, "Failed to open seal: ${e.message}")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,9 +122,13 @@ class NostrTestManager(private val context: Context) {
|
|||||||
val (recipientPrivate, recipientPublic) = NostrCrypto.generateKeyPair()
|
val (recipientPrivate, recipientPublic) = NostrCrypto.generateKeyPair()
|
||||||
val plaintext = "Hello, Nostr world! This is a test message."
|
val plaintext = "Hello, Nostr world! This is a test message."
|
||||||
|
|
||||||
val encrypted = NostrCrypto.encryptNIP44(plaintext, recipientPublic, privateKey)
|
val encrypted = NostrCrypto.encryptNIP44(
|
||||||
|
plaintext,
|
||||||
|
recipientPublic,
|
||||||
|
privateKey
|
||||||
|
)
|
||||||
require(encrypted.isNotEmpty()) { "Encryption failed" }
|
require(encrypted.isNotEmpty()) { "Encryption failed" }
|
||||||
|
|
||||||
val decrypted = NostrCrypto.decryptNIP44(encrypted, publicKey, recipientPrivate)
|
val decrypted = NostrCrypto.decryptNIP44(encrypted, publicKey, recipientPrivate)
|
||||||
require(decrypted == plaintext) { "Decryption failed: expected '$plaintext', got '$decrypted'" }
|
require(decrypted == plaintext) { "Decryption failed: expected '$plaintext', got '$decrypted'" }
|
||||||
|
|
||||||
|
|||||||
@@ -98,14 +98,16 @@ class NostrTransport(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val event = NostrProtocol.createPrivateMessage(
|
val giftWraps = NostrProtocol.createPrivateMessage(
|
||||||
content = embedded,
|
content = embedded,
|
||||||
recipientPubkey = recipientHex,
|
recipientPubkey = recipientHex,
|
||||||
senderIdentity = senderIdentity
|
senderIdentity = senderIdentity
|
||||||
)
|
)
|
||||||
|
|
||||||
Log.d(TAG, "NostrTransport: sending PM giftWrap id=${event.id.take(16)}...")
|
giftWraps.forEach { event ->
|
||||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
Log.d(TAG, "NostrTransport: sending PM giftWrap id=${event.id.take(16)}...")
|
||||||
|
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to send private message via Nostr: ${e.message}")
|
Log.e(TAG, "Failed to send private message via Nostr: ${e.message}")
|
||||||
@@ -182,14 +184,16 @@ class NostrTransport(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val event = NostrProtocol.createPrivateMessage(
|
val giftWraps = NostrProtocol.createPrivateMessage(
|
||||||
content = ack,
|
content = ack,
|
||||||
recipientPubkey = recipientHex,
|
recipientPubkey = recipientHex,
|
||||||
senderIdentity = senderIdentity
|
senderIdentity = senderIdentity
|
||||||
)
|
)
|
||||||
|
|
||||||
Log.d(TAG, "NostrTransport: sending READ ack giftWrap id=${event.id.take(16)}...")
|
giftWraps.forEach { event ->
|
||||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
Log.d(TAG, "NostrTransport: sending READ ack giftWrap id=${event.id.take(16)}...")
|
||||||
|
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
scheduleNextReadAck()
|
scheduleNextReadAck()
|
||||||
|
|
||||||
@@ -256,14 +260,16 @@ class NostrTransport(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val event = NostrProtocol.createPrivateMessage(
|
val giftWraps = NostrProtocol.createPrivateMessage(
|
||||||
content = embedded,
|
content = embedded,
|
||||||
recipientPubkey = recipientHex,
|
recipientPubkey = recipientHex,
|
||||||
senderIdentity = senderIdentity
|
senderIdentity = senderIdentity
|
||||||
)
|
)
|
||||||
|
|
||||||
Log.d(TAG, "NostrTransport: sending favorite giftWrap id=${event.id.take(16)}...")
|
giftWraps.forEach { event ->
|
||||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
Log.d(TAG, "NostrTransport: sending favorite giftWrap id=${event.id.take(16)}...")
|
||||||
|
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to send favorite notification via Nostr: ${e.message}")
|
Log.e(TAG, "Failed to send favorite notification via Nostr: ${e.message}")
|
||||||
@@ -312,14 +318,16 @@ class NostrTransport(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val event = NostrProtocol.createPrivateMessage(
|
val giftWraps = NostrProtocol.createPrivateMessage(
|
||||||
content = ack,
|
content = ack,
|
||||||
recipientPubkey = recipientHex,
|
recipientPubkey = recipientHex,
|
||||||
senderIdentity = senderIdentity
|
senderIdentity = senderIdentity
|
||||||
)
|
)
|
||||||
|
|
||||||
Log.d(TAG, "NostrTransport: sending DELIVERED ack giftWrap id=${event.id.take(16)}...")
|
giftWraps.forEach { event ->
|
||||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
Log.d(TAG, "NostrTransport: sending DELIVERED ack giftWrap id=${event.id.take(16)}...")
|
||||||
|
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to send delivery ack via Nostr: ${e.message}")
|
Log.e(TAG, "Failed to send delivery ack via Nostr: ${e.message}")
|
||||||
@@ -346,15 +354,17 @@ class NostrTransport(
|
|||||||
|
|
||||||
if (embedded == null) return@launch
|
if (embedded == null) return@launch
|
||||||
|
|
||||||
val event = NostrProtocol.createPrivateMessage(
|
val giftWraps = NostrProtocol.createPrivateMessage(
|
||||||
content = embedded,
|
content = embedded,
|
||||||
recipientPubkey = toRecipientHex,
|
recipientPubkey = toRecipientHex,
|
||||||
senderIdentity = fromIdentity
|
senderIdentity = fromIdentity
|
||||||
)
|
)
|
||||||
|
|
||||||
// Register pending gift wrap for deduplication (like iOS)
|
// Register pending gift wrap for deduplication and send all
|
||||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
giftWraps.forEach { event ->
|
||||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||||
|
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to send geohash delivery ack: ${e.message}")
|
Log.e(TAG, "Failed to send geohash delivery ack: ${e.message}")
|
||||||
@@ -379,15 +389,17 @@ class NostrTransport(
|
|||||||
|
|
||||||
if (embedded == null) return@launch
|
if (embedded == null) return@launch
|
||||||
|
|
||||||
val event = NostrProtocol.createPrivateMessage(
|
val giftWraps = NostrProtocol.createPrivateMessage(
|
||||||
content = embedded,
|
content = embedded,
|
||||||
recipientPubkey = toRecipientHex,
|
recipientPubkey = toRecipientHex,
|
||||||
senderIdentity = fromIdentity
|
senderIdentity = fromIdentity
|
||||||
)
|
)
|
||||||
|
|
||||||
// Register pending gift wrap for deduplication (like iOS)
|
// Register pending gift wrap for deduplication and send all
|
||||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
giftWraps.forEach { event ->
|
||||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||||
|
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to send geohash read receipt: ${e.message}")
|
Log.e(TAG, "Failed to send geohash read receipt: ${e.message}")
|
||||||
@@ -421,17 +433,17 @@ class NostrTransport(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val event = NostrProtocol.createPrivateMessage(
|
val giftWraps = NostrProtocol.createPrivateMessage(
|
||||||
content = embedded,
|
content = embedded,
|
||||||
recipientPubkey = toRecipientHex,
|
recipientPubkey = toRecipientHex,
|
||||||
senderIdentity = fromIdentity
|
senderIdentity = fromIdentity
|
||||||
)
|
)
|
||||||
|
|
||||||
Log.d(TAG, "NostrTransport: sending geohash PM giftWrap id=${event.id.take(16)}...")
|
giftWraps.forEach { event ->
|
||||||
|
Log.d(TAG, "NostrTransport: sending geohash PM giftWrap id=${event.id.take(16)}...")
|
||||||
// Register pending gift wrap for deduplication (like iOS)
|
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to send geohash private message: ${e.message}")
|
Log.e(TAG, "Failed to send geohash private message: ${e.message}")
|
||||||
|
|||||||
@@ -257,11 +257,17 @@ fun ChatHeaderContent(
|
|||||||
|
|
||||||
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState")
|
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState")
|
||||||
|
|
||||||
|
// Pass geohash context and people for NIP-17 chat title formatting
|
||||||
|
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||||
|
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
|
||||||
|
|
||||||
PrivateChatHeader(
|
PrivateChatHeader(
|
||||||
peerID = selectedPrivatePeer,
|
peerID = selectedPrivatePeer,
|
||||||
peerNicknames = peerNicknames,
|
peerNicknames = peerNicknames,
|
||||||
isFavorite = isFavorite,
|
isFavorite = isFavorite,
|
||||||
sessionState = sessionState,
|
sessionState = sessionState,
|
||||||
|
selectedLocationChannel = selectedLocationChannel,
|
||||||
|
geohashPeople = geohashPeople,
|
||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) }
|
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) }
|
||||||
)
|
)
|
||||||
@@ -296,11 +302,25 @@ private fun PrivateChatHeader(
|
|||||||
peerNicknames: Map<String, String>,
|
peerNicknames: Map<String, String>,
|
||||||
isFavorite: Boolean,
|
isFavorite: Boolean,
|
||||||
sessionState: String?,
|
sessionState: String?,
|
||||||
|
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
||||||
|
geohashPeople: List<GeoPerson>,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onToggleFavorite: () -> Unit
|
onToggleFavorite: () -> Unit
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val peerNickname = peerNicknames[peerID] ?: peerID
|
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
|
||||||
|
|
||||||
|
// Compute title text: for NIP-17 chats show "#geohash/@username" (iOS parity)
|
||||||
|
val titleText: String = if (isNostrDM) {
|
||||||
|
val geohash = (selectedLocationChannel as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash
|
||||||
|
val shortId = peerID.removePrefix("nostr_").removePrefix("nostr:")
|
||||||
|
val person = geohashPeople.firstOrNull { it.id.startsWith(shortId, ignoreCase = true) }
|
||||||
|
val baseName = person?.displayName?.substringBefore('#') ?: peerNicknames[peerID] ?: "unknown"
|
||||||
|
val geoPart = geohash?.let { "#$it" } ?: "#geohash"
|
||||||
|
"$geoPart/@$baseName"
|
||||||
|
} else {
|
||||||
|
peerNicknames[peerID] ?: peerID
|
||||||
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
// Back button - positioned all the way to the left with minimal margin
|
// Back button - positioned all the way to the left with minimal margin
|
||||||
@@ -340,17 +360,20 @@ private fun PrivateChatHeader(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = peerNickname,
|
text = titleText,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = Color(0xFFFF9500) // Orange
|
color = Color(0xFFFF9500) // Orange
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
NoiseSessionIcon(
|
// For NIP-17 chats, do not show Noise session state icon (remove warning icon)
|
||||||
sessionState = sessionState,
|
if (!isNostrDM) {
|
||||||
modifier = Modifier.size(14.dp)
|
NoiseSessionIcon(
|
||||||
)
|
sessionState = sessionState,
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ fun GeohashPeopleList(
|
|||||||
if (person.id != myHex) {
|
if (person.id != myHex) {
|
||||||
// TODO: Re-enable when NIP-17 geohash DM issues are fixed
|
// TODO: Re-enable when NIP-17 geohash DM issues are fixed
|
||||||
// Start geohash DM (iOS-compatible)
|
// Start geohash DM (iOS-compatible)
|
||||||
// viewModel.startGeohashDM(person.id)
|
viewModel.startGeohashDM(person.id)
|
||||||
onTapPerson()
|
onTapPerson()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,15 +271,17 @@ fun DeliveryStatusIcon(status: DeliveryStatus) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
is DeliveryStatus.Sent -> {
|
is DeliveryStatus.Sent -> {
|
||||||
|
// Use a subtle hollow marker for Sent; single check is reserved for Delivered (iOS parity)
|
||||||
Text(
|
Text(
|
||||||
text = "✓",
|
text = "○",
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
color = colorScheme.primary.copy(alpha = 0.6f)
|
color = colorScheme.primary.copy(alpha = 0.6f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is DeliveryStatus.Delivered -> {
|
is DeliveryStatus.Delivered -> {
|
||||||
|
// Single check for Delivered (matches iOS expectations)
|
||||||
Text(
|
Text(
|
||||||
text = "✓✓",
|
text = "✓",
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
color = colorScheme.primary.copy(alpha = 0.8f)
|
color = colorScheme.primary.copy(alpha = 0.8f)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user