mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 05:05:20 +00:00
Nostr refactor simplify (#390)
* fix bug * geoDM receive works, send doesnt, and incoming message doesnt make sender appear in peer list * fix nostr dm * geohash dms work * Geohash DM UI: stop mixing Nostr DM temp chats into mesh offline list; ensure geohash DM senders are added to geohash people list only. Removed nostr_* sidebar append in PeopleSection; kept 64-hex mesh offline favorites. Verified build. * refactor * nice * works * merging nostr -> mesh works * tripple click to delete all * fix sidebar icon * remove hash * dms have correct recipient * works * wip unread badge * geohash dms wip * dms work
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* GeohashAliasRegistry
|
||||
* - Global, thread-safe registry for alias->Nostr pubkey mappings (e.g., nostr_<pub16> -> pubkeyHex)
|
||||
* - Allows non-UI components (e.g., MessageRouter) to resolve geohash DM aliases without depending on UI ViewModels
|
||||
*/
|
||||
object GeohashAliasRegistry {
|
||||
private val map: MutableMap<String, String> = ConcurrentHashMap()
|
||||
|
||||
fun put(alias: String, pubkeyHex: String) {
|
||||
map[alias] = pubkeyHex
|
||||
}
|
||||
|
||||
fun get(alias: String): String? = map[alias]
|
||||
|
||||
fun contains(alias: String): Boolean = map.containsKey(alias)
|
||||
|
||||
fun snapshot(): Map<String, String> = HashMap(map)
|
||||
|
||||
fun clear() { map.clear() }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.ui.ChatState
|
||||
import com.bitchat.android.ui.MessageManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* GeohashMessageHandler
|
||||
* - Processes kind=20000 Nostr events for geohash channels
|
||||
* - Updates repository for participants + nicknames
|
||||
* - Emits messages to MessageManager
|
||||
*/
|
||||
class GeohashMessageHandler(
|
||||
private val application: Application,
|
||||
private val state: ChatState,
|
||||
private val messageManager: MessageManager,
|
||||
private val repo: GeohashRepository,
|
||||
private val scope: CoroutineScope
|
||||
) {
|
||||
companion object { private const val TAG = "GeohashMessageHandler" }
|
||||
|
||||
// Simple event deduplication
|
||||
private val processedIds = ArrayDeque<String>()
|
||||
private val seen = HashSet<String>()
|
||||
private val max = 2000
|
||||
|
||||
private fun dedupe(id: String): Boolean {
|
||||
if (seen.contains(id)) return true
|
||||
seen.add(id)
|
||||
processedIds.addLast(id)
|
||||
if (processedIds.size > max) {
|
||||
val old = processedIds.removeFirst()
|
||||
seen.remove(old)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun onEvent(event: NostrEvent, subscribedGeohash: String) {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
try {
|
||||
if (event.kind != 20000) return@launch
|
||||
val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1)
|
||||
if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch
|
||||
if (dedupe(event.id)) return@launch
|
||||
|
||||
// PoW validation (if enabled)
|
||||
val pow = PoWPreferenceManager.getCurrentSettings()
|
||||
if (pow.enabled && pow.difficulty > 0) {
|
||||
if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch
|
||||
}
|
||||
|
||||
// Blocked users check
|
||||
if (com.bitchat.android.ui.DataManager(application).isGeohashUserBlocked(event.pubkey)) return@launch
|
||||
|
||||
// Update repository (participants, nickname, teleport)
|
||||
// Update repository on a background-safe path; repository will post updates to LiveData
|
||||
repo.updateParticipant(subscribedGeohash, event.pubkey, Date(event.createdAt * 1000L))
|
||||
event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(event.pubkey, it[1]) }
|
||||
event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(event.pubkey) }
|
||||
// Register a geohash DM alias for this participant so MessageRouter can route DMs via Nostr
|
||||
try {
|
||||
com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${event.pubkey.take(16)}", event.pubkey)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Skip our own events for message emission
|
||||
val my = NostrIdentityBridge.deriveIdentity(subscribedGeohash, application)
|
||||
if (my.publicKeyHex.equals(event.pubkey, true)) return@launch
|
||||
|
||||
val isTeleportPresence = event.tags.any { it.size >= 2 && it[0] == "t" && it[1] == "teleport" } &&
|
||||
event.content.trim().isEmpty()
|
||||
if (isTeleportPresence) return@launch
|
||||
|
||||
val senderName = repo.displayNameForNostrPubkeyUI(event.pubkey)
|
||||
val msg = BitchatMessage(
|
||||
id = event.id,
|
||||
sender = senderName,
|
||||
content = event.content,
|
||||
timestamp = Date(event.createdAt * 1000L),
|
||||
isRelay = false,
|
||||
originalSender = repo.displayNameForNostrPubkey(event.pubkey),
|
||||
senderPeerID = "nostr:${event.pubkey.take(8)}",
|
||||
mentions = null,
|
||||
channel = "#$subscribedGeohash",
|
||||
powDifficulty = try { NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } } catch (_: Exception) { null }
|
||||
)
|
||||
withContext(Dispatchers.Main) { messageManager.addChannelMessage("geo:$subscribedGeohash", msg) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "onEvent error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.bitchat.android.ui.ChatState
|
||||
import com.bitchat.android.ui.GeoPerson
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* GeohashRepository
|
||||
* - Owns geohash participant tracking and nickname caching
|
||||
* - Maintains lightweight state for geohash-related UI
|
||||
*/
|
||||
class GeohashRepository(
|
||||
private val application: Application,
|
||||
private val state: ChatState
|
||||
) {
|
||||
companion object { private const val TAG = "GeohashRepository" }
|
||||
|
||||
// geohash -> (participant pubkeyHex -> lastSeen)
|
||||
private val geohashParticipants: MutableMap<String, MutableMap<String, Date>> = mutableMapOf()
|
||||
|
||||
|
||||
// pubkeyHex(lowercase) -> nickname (without #hash)
|
||||
private val geoNicknames: MutableMap<String, String> = mutableMapOf()
|
||||
|
||||
// conversation key (e.g., "nostr_<pub16>") -> source geohash it belongs to
|
||||
private val conversationGeohash: MutableMap<String, String> = mutableMapOf()
|
||||
|
||||
fun setConversationGeohash(convKey: String, geohash: String) {
|
||||
if (geohash.isNotEmpty()) {
|
||||
conversationGeohash[convKey] = geohash
|
||||
}
|
||||
}
|
||||
|
||||
fun getConversationGeohash(convKey: String): String? = conversationGeohash[convKey]
|
||||
|
||||
fun findPubkeyByNickname(targetNickname: String): String? {
|
||||
return geoNicknames.entries.firstOrNull { (_, nickname) ->
|
||||
val base = nickname.split("#").firstOrNull() ?: nickname
|
||||
base == targetNickname
|
||||
}?.key
|
||||
}
|
||||
|
||||
// peerID alias -> nostr pubkey mapping for geohash DMs and temp aliases
|
||||
private val nostrKeyMapping: MutableMap<String, String> = mutableMapOf()
|
||||
|
||||
// Current geohash in view
|
||||
private var currentGeohash: String? = null
|
||||
|
||||
fun setCurrentGeohash(geo: String?) { currentGeohash = geo }
|
||||
fun getCurrentGeohash(): String? = currentGeohash
|
||||
|
||||
fun clearAll() {
|
||||
geohashParticipants.clear()
|
||||
geoNicknames.clear()
|
||||
nostrKeyMapping.clear()
|
||||
state.setGeohashPeople(emptyList())
|
||||
state.setTeleportedGeo(emptySet())
|
||||
state.setGeohashParticipantCounts(emptyMap())
|
||||
currentGeohash = null
|
||||
}
|
||||
|
||||
fun cacheNickname(pubkeyHex: String, nickname: String) {
|
||||
val lower = pubkeyHex.lowercase()
|
||||
val previous = geoNicknames[lower]
|
||||
geoNicknames[lower] = nickname
|
||||
if (previous != nickname && currentGeohash != null) {
|
||||
refreshGeohashPeople()
|
||||
}
|
||||
}
|
||||
|
||||
fun getCachedNickname(pubkeyHex: String): String? = geoNicknames[pubkeyHex.lowercase()]
|
||||
|
||||
fun markTeleported(pubkeyHex: String) {
|
||||
val set = state.getTeleportedGeoValue().toMutableSet()
|
||||
val key = pubkeyHex.lowercase()
|
||||
if (!set.contains(key)) {
|
||||
set.add(key)
|
||||
// Background safe update
|
||||
state.postTeleportedGeo(set)
|
||||
}
|
||||
}
|
||||
|
||||
fun isPersonTeleported(pubkeyHex: String): Boolean {
|
||||
return state.getTeleportedGeoValue().contains(pubkeyHex.lowercase())
|
||||
}
|
||||
|
||||
fun updateParticipant(geohash: String, participantId: String, lastSeen: Date) {
|
||||
val participants = geohashParticipants.getOrPut(geohash) { mutableMapOf() }
|
||||
participants[participantId] = lastSeen
|
||||
if (currentGeohash == geohash) refreshGeohashPeople()
|
||||
updateReactiveParticipantCounts()
|
||||
}
|
||||
|
||||
fun geohashParticipantCount(geohash: String): Int {
|
||||
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||
val participants = geohashParticipants[geohash] ?: return 0
|
||||
// prune expired
|
||||
val it = participants.iterator()
|
||||
while (it.hasNext()) {
|
||||
val e = it.next()
|
||||
if (e.value.before(cutoff)) it.remove()
|
||||
}
|
||||
return participants.size
|
||||
}
|
||||
|
||||
fun refreshGeohashPeople() {
|
||||
val geohash = currentGeohash
|
||||
if (geohash == null) {
|
||||
// Use postValue for thread safety - this can be called from background threads
|
||||
state.postGeohashPeople(emptyList())
|
||||
return
|
||||
}
|
||||
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||
val participants = geohashParticipants[geohash] ?: mutableMapOf()
|
||||
// prune expired
|
||||
val it = participants.iterator()
|
||||
while (it.hasNext()) {
|
||||
val e = it.next()
|
||||
if (e.value.before(cutoff)) it.remove()
|
||||
}
|
||||
geohashParticipants[geohash] = participants
|
||||
val people = participants.map { (pubkeyHex, lastSeen) ->
|
||||
val base = getCachedNickname(pubkeyHex) ?: "anon"
|
||||
GeoPerson(
|
||||
id = pubkeyHex.lowercase(),
|
||||
displayName = base, // UI can add #hash if necessary
|
||||
lastSeen = lastSeen
|
||||
)
|
||||
}.sortedByDescending { it.lastSeen }
|
||||
// Use postValue for thread safety - this can be called from background threads
|
||||
state.postGeohashPeople(people)
|
||||
}
|
||||
|
||||
fun updateReactiveParticipantCounts() {
|
||||
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||
val counts = mutableMapOf<String, Int>()
|
||||
for ((gh, participants) in geohashParticipants) {
|
||||
val active = participants.values.count { !it.before(cutoff) }
|
||||
counts[gh] = active
|
||||
}
|
||||
// Use postValue for thread safety - this can be called from background threads
|
||||
state.postGeohashParticipantCounts(counts)
|
||||
}
|
||||
|
||||
fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) {
|
||||
nostrKeyMapping[tempKeyOrPeer] = pubkeyHex
|
||||
}
|
||||
|
||||
fun getNostrKeyMapping(): Map<String, String> = nostrKeyMapping.toMap()
|
||||
|
||||
fun displayNameForNostrPubkey(pubkeyHex: String): String {
|
||||
val suffix = pubkeyHex.takeLast(4)
|
||||
val lower = pubkeyHex.lowercase()
|
||||
// Self nickname if matches current identity of current geohash
|
||||
val current = currentGeohash
|
||||
if (current != null) {
|
||||
try {
|
||||
val my = NostrIdentityBridge.deriveIdentity(current, application)
|
||||
if (my.publicKeyHex.equals(lower, true)) {
|
||||
return "${state.getNicknameValue()}#$suffix"
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
val nick = geoNicknames[lower] ?: "anon"
|
||||
return "$nick#$suffix"
|
||||
}
|
||||
|
||||
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String {
|
||||
val lower = pubkeyHex.lowercase()
|
||||
val suffix = pubkeyHex.takeLast(4)
|
||||
val current = currentGeohash
|
||||
val base: String = try {
|
||||
if (current != null) {
|
||||
val my = NostrIdentityBridge.deriveIdentity(current, application)
|
||||
if (my.publicKeyHex.equals(lower, true)) {
|
||||
state.getNicknameValue() ?: "anon"
|
||||
} else geoNicknames[lower] ?: "anon"
|
||||
} else geoNicknames[lower] ?: "anon"
|
||||
} catch (_: Exception) { geoNicknames[lower] ?: "anon" }
|
||||
if (current == null) return base
|
||||
return try {
|
||||
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||
val participants = geohashParticipants[current] ?: emptyMap()
|
||||
var count = 0
|
||||
for ((k, t) in participants) {
|
||||
if (t.before(cutoff)) continue
|
||||
val name = if (k.equals(lower, true)) base else (geoNicknames[k.lowercase()] ?: "anon")
|
||||
if (name.equals(base, true)) { count++; if (count > 1) break }
|
||||
}
|
||||
if (!participants.containsKey(lower)) count += 1
|
||||
if (count > 1) "$base#$suffix" else base
|
||||
} catch (_: Exception) { base }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for any geohash (not just current one) for header titles
|
||||
*/
|
||||
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String {
|
||||
val lower = pubkeyHex.lowercase()
|
||||
val suffix = pubkeyHex.takeLast(4)
|
||||
val base = geoNicknames[lower] ?: "anon"
|
||||
return try {
|
||||
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||
val participants = geohashParticipants[sourceGeohash] ?: emptyMap()
|
||||
var count = 0
|
||||
for ((k, t) in participants) {
|
||||
if (t.before(cutoff)) continue
|
||||
val name = if (k.equals(lower, true)) base else (geoNicknames[k.lowercase()] ?: "anon")
|
||||
if (name.equals(base, true)) { count++; if (count > 1) break }
|
||||
}
|
||||
if (!participants.containsKey(lower)) count += 1
|
||||
if (count > 1) "$base#$suffix" else base
|
||||
} catch (_: Exception) { base }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.services.SeenMessageStore
|
||||
import com.bitchat.android.ui.ChatState
|
||||
import com.bitchat.android.ui.MeshDelegateHandler
|
||||
import com.bitchat.android.ui.PrivateChatManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Date
|
||||
|
||||
class NostrDirectMessageHandler(
|
||||
private val application: Application,
|
||||
private val state: ChatState,
|
||||
private val privateChatManager: PrivateChatManager,
|
||||
private val meshDelegateHandler: MeshDelegateHandler,
|
||||
private val scope: CoroutineScope,
|
||||
private val repo: GeohashRepository
|
||||
) {
|
||||
companion object { private const val TAG = "NostrDirectMessageHandler" }
|
||||
|
||||
private val seenStore by lazy { SeenMessageStore.getInstance(application) }
|
||||
|
||||
// Simple event deduplication
|
||||
private val processedIds = ArrayDeque<String>()
|
||||
private val seen = HashSet<String>()
|
||||
private val max = 2000
|
||||
|
||||
private fun dedupe(id: String): Boolean {
|
||||
if (seen.contains(id)) return true
|
||||
seen.add(id)
|
||||
processedIds.addLast(id)
|
||||
if (processedIds.size > max) {
|
||||
val old = processedIds.removeFirst()
|
||||
seen.remove(old)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun onGiftWrap(giftWrap: NostrEvent, geohash: String, identity: NostrIdentity) {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
try {
|
||||
if (dedupe(giftWrap.id)) return@launch
|
||||
|
||||
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
|
||||
if (messageAge > 173700) return@launch // 48 hours + 15 mins
|
||||
|
||||
val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity)
|
||||
if (decryptResult == null) {
|
||||
Log.w(TAG, "Failed to decrypt Nostr message")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val (content, senderPubkey, rumorTimestamp) = decryptResult
|
||||
if (!content.startsWith("bitchat1:")) return@launch
|
||||
|
||||
val base64Content = content.removePrefix("bitchat1:")
|
||||
val packetData = base64URLDecode(base64Content) ?: return@launch
|
||||
val packet = BitchatPacket.fromBinaryData(packetData) ?: return@launch
|
||||
|
||||
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch
|
||||
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch
|
||||
val messageTimestamp = Date(rumorTimestamp * 1000L)
|
||||
val convKey = "nostr_${senderPubkey.take(16)}"
|
||||
repo.putNostrKeyMapping(convKey, senderPubkey)
|
||||
com.bitchat.android.nostr.GeohashAliasRegistry.put(convKey, senderPubkey)
|
||||
if (geohash.isNotEmpty()) {
|
||||
// Remember which geohash this conversation belongs to so we can subscribe on-demand
|
||||
repo.setConversationGeohash(convKey, geohash)
|
||||
GeohashConversationRegistry.set(convKey, geohash)
|
||||
}
|
||||
|
||||
// Ensure sender appears in geohash people list even if they haven't posted publicly yet
|
||||
if (geohash.isNotEmpty()) {
|
||||
// Cache a best-effort nickname and mark as participant
|
||||
val cached = repo.getCachedNickname(senderPubkey)
|
||||
if (cached == null) {
|
||||
val base = repo.displayNameForNostrPubkeyUI(senderPubkey).substringBefore("#")
|
||||
repo.cacheNickname(senderPubkey, base)
|
||||
}
|
||||
repo.updateParticipant(geohash, senderPubkey, messageTimestamp)
|
||||
}
|
||||
|
||||
val senderNickname = repo.displayNameForNostrPubkeyUI(senderPubkey)
|
||||
|
||||
processNoisePayload(noisePayload, convKey, senderNickname, messageTimestamp, senderPubkey, identity)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "onGiftWrap error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun processNoisePayload(
|
||||
payload: com.bitchat.android.model.NoisePayload,
|
||||
convKey: String,
|
||||
senderNickname: String,
|
||||
timestamp: Date,
|
||||
senderPubkey: String,
|
||||
recipientIdentity: NostrIdentity
|
||||
) {
|
||||
when (payload.type) {
|
||||
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> {
|
||||
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(payload.data) ?: return
|
||||
val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList()
|
||||
if (existingMessages.any { it.id == pm.messageID }) return
|
||||
|
||||
val message = BitchatMessage(
|
||||
id = pm.messageID,
|
||||
sender = senderNickname,
|
||||
content = pm.content,
|
||||
timestamp = timestamp,
|
||||
isRelay = false,
|
||||
isPrivate = true,
|
||||
recipientNickname = state.getNicknameValue(),
|
||||
senderPeerID = convKey,
|
||||
deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date())
|
||||
)
|
||||
|
||||
val isViewing = state.getSelectedPrivateChatPeerValue() == convKey
|
||||
val suppressUnread = seenStore.hasRead(pm.messageID)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread)
|
||||
}
|
||||
|
||||
if (!seenStore.hasDelivered(pm.messageID)) {
|
||||
val nostrTransport = NostrTransport.getInstance(application)
|
||||
nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity)
|
||||
seenStore.markDelivered(pm.messageID)
|
||||
}
|
||||
|
||||
if (isViewing && !suppressUnread) {
|
||||
val nostrTransport = NostrTransport.getInstance(application)
|
||||
nostrTransport.sendReadReceiptGeohash(pm.messageID, senderPubkey, recipientIdentity)
|
||||
seenStore.markRead(pm.messageID)
|
||||
}
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
|
||||
val messageId = String(payload.data, Charsets.UTF_8)
|
||||
withContext(Dispatchers.Main) {
|
||||
meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey)
|
||||
}
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> {
|
||||
val messageId = String(payload.data, Charsets.UTF_8)
|
||||
withContext(Dispatchers.Main) {
|
||||
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun base64URLDecode(input: String): ByteArray? {
|
||||
return try {
|
||||
val padded = input.replace("-", "+")
|
||||
.replace("_", "/")
|
||||
.let { str ->
|
||||
val padding = (4 - str.length % 4) % 4
|
||||
str + "=".repeat(padding)
|
||||
}
|
||||
android.util.Base64.decode(padded, android.util.Base64.DEFAULT)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to decode base64url: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* NostrSubscriptionManager
|
||||
* - Encapsulates subscription lifecycle with NostrRelayManager
|
||||
*/
|
||||
class NostrSubscriptionManager(
|
||||
private val application: Application,
|
||||
private val scope: CoroutineScope
|
||||
) {
|
||||
companion object { private const val TAG = "NostrSubscriptionManager" }
|
||||
|
||||
private val relayManager get() = NostrRelayManager.getInstance(application)
|
||||
|
||||
fun connect() = scope.launch { runCatching { relayManager.connect() }.onFailure { Log.e(TAG, "connect failed: ${it.message}") } }
|
||||
fun disconnect() = scope.launch { runCatching { relayManager.disconnect() }.onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } }
|
||||
|
||||
fun subscribeGiftWraps(pubkey: String, sinceMs: Long, id: String, handler: (NostrEvent) -> Unit) {
|
||||
scope.launch {
|
||||
val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs)
|
||||
relayManager.subscribe(filter, id, handler)
|
||||
}
|
||||
}
|
||||
|
||||
fun subscribeGeohash(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) {
|
||||
scope.launch {
|
||||
val filter = NostrFilter.geohashEphemeral(geohash, sinceMs, limit)
|
||||
relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5)
|
||||
}
|
||||
}
|
||||
|
||||
fun unsubscribe(id: String) { scope.launch { runCatching { relayManager.unsubscribe(id) } } }
|
||||
}
|
||||
@@ -56,8 +56,7 @@ class NostrTransport(
|
||||
// Resolve favorite by full noise key or by short peerID fallback
|
||||
var recipientNostrPubkey: String? = null
|
||||
|
||||
// Try to resolve from favorites persistence service
|
||||
// This would need integration with the existing favorites system
|
||||
// Resolve by peerID first (new peerID→npub index), then fall back to noise key mapping
|
||||
recipientNostrPubkey = resolveNostrPublicKey(to)
|
||||
|
||||
if (recipientNostrPubkey == null) {
|
||||
@@ -86,13 +85,23 @@ class NostrTransport(
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Strict: lookup the recipient's current BitChat peer ID using favorites mapping
|
||||
val recipientPeerIDForEmbed = try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared
|
||||
.findPeerIDForNostrPubkey(recipientNostrPubkey)
|
||||
} catch (_: Exception) { null }
|
||||
if (recipientPeerIDForEmbed.isNullOrBlank()) {
|
||||
Log.e(TAG, "NostrTransport: no peerID stored for recipient npub; cannot embed PM. npub=${recipientNostrPubkey.take(16)}...")
|
||||
return@launch
|
||||
}
|
||||
val embedded = NostrEmbeddedBitChat.encodePMForNostr(
|
||||
content = content,
|
||||
messageID = messageID,
|
||||
recipientPeerID = to,
|
||||
recipientPeerID = recipientPeerIDForEmbed,
|
||||
senderPeerID = senderPeerID
|
||||
)
|
||||
|
||||
|
||||
if (embedded == null) {
|
||||
Log.e(TAG, "NostrTransport: failed to embed PM packet")
|
||||
return@launch
|
||||
@@ -412,39 +421,58 @@ class NostrTransport(
|
||||
fun sendPrivateMessageGeohash(
|
||||
content: String,
|
||||
toRecipientHex: String,
|
||||
fromIdentity: NostrIdentity,
|
||||
messageID: String
|
||||
messageID: String,
|
||||
sourceGeohash: String? = null
|
||||
) {
|
||||
// Use provided geohash or derive from current location
|
||||
val geohash = sourceGeohash ?: run {
|
||||
val selected = try {
|
||||
com.bitchat.android.geohash.LocationChannelManager.getInstance(context).selectedChannel.value
|
||||
} catch (_: Exception) { null }
|
||||
if (selected !is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
Log.w(TAG, "NostrTransport: cannot send geohash PM - not in a location channel and no geohash provided")
|
||||
return
|
||||
}
|
||||
selected.channel.geohash
|
||||
}
|
||||
|
||||
val fromIdentity = try {
|
||||
NostrIdentityBridge.deriveIdentity(geohash, context)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "NostrTransport: cannot derive geohash identity for $geohash: ${e.message}")
|
||||
return
|
||||
}
|
||||
|
||||
transportScope.launch {
|
||||
try {
|
||||
if (toRecipientHex.isEmpty()) return@launch
|
||||
|
||||
Log.d(TAG, "GeoDM: send PM -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}...")
|
||||
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"GeoDM: send PM -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}... geohash=$geohash"
|
||||
)
|
||||
|
||||
// Build embedded BitChat packet without recipient peer ID
|
||||
val embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
||||
content = content,
|
||||
messageID = messageID,
|
||||
senderPeerID = senderPeerID
|
||||
)
|
||||
|
||||
if (embedded == null) {
|
||||
) ?: run {
|
||||
Log.e(TAG, "NostrTransport: failed to embed geohash PM packet")
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
val giftWraps = NostrProtocol.createPrivateMessage(
|
||||
content = embedded,
|
||||
recipientPubkey = toRecipientHex,
|
||||
senderIdentity = fromIdentity
|
||||
)
|
||||
|
||||
|
||||
giftWraps.forEach { event ->
|
||||
Log.d(TAG, "NostrTransport: sending geohash PM giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send geohash private message: ${e.message}")
|
||||
}
|
||||
@@ -458,14 +486,15 @@ class NostrTransport(
|
||||
*/
|
||||
private fun resolveNostrPublicKey(peerID: String): String? {
|
||||
try {
|
||||
// Try to resolve from favorites persistence service
|
||||
// 1) Fast path: direct peerID→npub mapping (mutual favorites after mesh mapping)
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(peerID)?.let { return it }
|
||||
|
||||
// 2) Legacy path: resolve by noise public key association
|
||||
val noiseKey = hexStringToByteArray(peerID)
|
||||
val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
|
||||
if (favoriteStatus?.peerNostrPublicKey != null) {
|
||||
return favoriteStatus.peerNostrPublicKey
|
||||
}
|
||||
|
||||
// Fallback: try with 16-hex peerID lookup
|
||||
if (favoriteStatus?.peerNostrPublicKey != null) return favoriteStatus.peerNostrPublicKey
|
||||
|
||||
// 3) Prefix match on noiseHex from 16-hex peerID
|
||||
if (peerID.length == 16) {
|
||||
val fallbackStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
||||
return fallbackStatus?.peerNostrPublicKey
|
||||
|
||||
Reference in New Issue
Block a user