Fix durable Nostr fallback for private chats

This commit is contained in:
callebtc 2026-09-08 00:16:09 +03:00
parent 936a4cdf6d
commit 9c083f9b95
54 changed files with 2062 additions and 2434 deletions

View File

@ -2,7 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<application android:networkSecurityConfig="@xml/nostr_fixture_network_security">
<!-- Debug-only ADB test hook. Drives mesh operations (scan, connect,
handshake, DMs, files, broadcast, raw packets) from host scripts.
Exported intentionally so `adb shell am broadcast` can reach it;

View File

@ -70,6 +70,9 @@ object TestHookDriver {
"broadcast_msg" -> broadcastMsg(context, intent.requiredString("content"), intent.getStringExtra("channel"))
"dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id"))
"dm_recv" -> dmRecv(context, intent)
"nostr_fixture" -> nostrFixture(context, intent)
"routed_dm_send" -> routedDmSend(context, intent)
"routed_dm_wait" -> routedDmWait(context, intent)
"msg_recv" -> msgRecv(context, intent)
"favorite_set" -> favoriteSet(
context,
@ -243,6 +246,53 @@ object TestHookDriver {
return ok("dm_send").put("peer", peerID).put("msg_id", id)
}
private fun nostrFixture(context: Context, intent: Intent): JSONObject {
val port = intent.getIntExtra("port", 8765)
require(port in 1024..65535)
val manager = com.bitchat.android.nostr.NostrRelayManager.getInstance(context)
manager.clearAllSubscriptions()
manager.configureAccountRelays(listOf("ws://127.0.0.1:$port"))
com.bitchat.android.nostr.NostrBackgroundRuntime.resetSubscriptions()
if (intent.getBooleanExtra("offline_mesh", false)) mesh(context).stopServices()
com.bitchat.android.services.PrivateDeliveryCoordinator.getInstance(context).bindMesh(mesh(context))
return ok("nostr_fixture")
}
private suspend fun routedDmSend(context: Context, intent: Intent): JSONObject {
val peer = intent.requiredString("peer")
val service = mesh(context)
val conversation = com.bitchat.android.services.ContactDirectory.canonicalConversationId(peer)
val message = com.bitchat.android.model.BitchatMessage(
id = intent.requiredString("msg_id"), sender = "Lab sender", content = intent.requiredString("content"),
timestamp = java.util.Date(), isPrivate = true, senderPeerID = service.myPeerID,
deliveryStatus = com.bitchat.android.model.DeliveryStatus.Sending)
if (!AppStateStore.addPrivateMessageDurably(conversation, message, forceRead = true, queueForDelivery = true)) {
return err("routed_dm_send", "durable admission failed")
}
com.bitchat.android.services.MessageRouter.getInstance(context, service)
.sendPrivate(message.content, conversation, "Lab contact", message.id)
return ok("routed_dm_send")
}
private suspend fun routedDmWait(context: Context, intent: Intent): JSONObject {
val conversation = com.bitchat.android.services.ContactDirectory.canonicalConversationId(intent.requiredString("peer"))
val wireID = intent.requiredString("msg_id")
val delivered = intent.getBooleanExtra("delivered", false)
val published = intent.getBooleanExtra("published", false)
val id = if (delivered || published) wireID else AppStateStore.incomingLocalID(conversation, wireID)
val repository = com.bitchat.android.services.ConversationRepository.getInstance(context)
val found = withTimeoutOrNull(intent.getLongExtra("timeout_ms", 90_000)) {
while (true) {
val message = repository.storedMessage(conversation, id)
if (message != null && (!published || message.deliveryStatus == com.bitchat.android.model.DeliveryStatus.Sent) && (!delivered || message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.Delivered ||
message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.Read)) return@withTimeoutOrNull message
delay(100)
}
@Suppress("UNREACHABLE_CODE") null
} ?: return err("routed_dm_wait", "message or authenticated receipt did not arrive")
return ok("routed_dm_wait").put("content", found.content)
}
private suspend fun dmRecv(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
val fromPeer = intent.getStringExtra("peer")

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>

View File

@ -66,6 +66,8 @@ class BitchatApplication : Application() {
com.bitchat.android.nostr.GeohashConversationRegistry.initialize(this)
} catch (_: Exception) { }
com.bitchat.android.services.PrivateDeliveryCoordinator.getInstance(this).start()
// Own relay connectivity, selected-channel subscriptions, and presence scheduling at the
// process level so closing the Activity does not disconnect Nostr.
try { com.bitchat.android.nostr.NostrBackgroundRuntime.initialize(this) } catch (_: Exception) { }

View File

@ -13,8 +13,8 @@ data class FavoriteControlMessage(
fun parse(content: String): FavoriteControlMessage? {
val trimmed = content.trim()
val isFavorite = when {
trimmed.startsWith(FAVORITED) -> true
trimmed.startsWith(UNFAVORITED) -> false
(trimmed == FAVORITED || trimmed.startsWith("$FAVORITED:")) -> true
(trimmed == UNFAVORITED || trimmed.startsWith("$UNFAVORITED:")) -> false
else -> return null
}
val encodedKey = trimmed.substringAfter(":", "").trim()

View File

@ -19,7 +19,11 @@ data class FavoriteRelationship(
val isFavorite: Boolean, // We favorited them
val theyFavoritedUs: Boolean, // They favorited us
val favoritedAt: Date,
val lastUpdated: Date
val lastUpdated: Date,
val peerUpdatedAt: Long = 0,
val peerUpdateID: String = "",
val pendingControlID: String? = null,
val pendingControlTimestamp: Long = 0
) {
val isMutual: Boolean get() = isFavorite && theyFavoritedUs
@ -76,7 +80,10 @@ interface FavoritesChangeListener {
* Manages favorites with Noise↔Nostr mapping
* Singleton pattern matching iOS implementation.
*/
class FavoritesPersistenceService private constructor(private val context: Context) {
class FavoritesPersistenceService internal constructor(
private val context: Context,
private val stateManager: SecureIdentityStateManager = SecureIdentityStateManager(context)
) {
companion object {
private const val TAG = "FavoritesPersistenceService"
@ -100,24 +107,27 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
}
private val stateManager = SecureIdentityStateManager(context)
private val gson = Gson()
private val favorites = mutableMapOf<String, FavoriteRelationship>() // noiseHex -> relationship
private var persistedFavorites: Map<String, FavoriteRelationship> = emptyMap()
private val peerIdIndex = mutableMapOf<String, String>() // peerID (lowercase 16-hex) -> npub
private val listeners = mutableListOf<FavoritesChangeListener>()
init {
loadFavorites()
persistedFavorites = favorites.toMap()
loadPeerIdIndex()
}
/** Get favorite status for Noise public key */
@Synchronized
fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
return favorites[keyHex]
}
/** Get favorite status for a mesh peer ID or full Noise public key hex. */
@Synchronized
fun getFavoriteStatus(peerID: String): FavoriteRelationship? {
val pid = peerID.trim().lowercase()
@ -145,6 +155,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
/** Update Nostr public key for a peer (indexed by Noise key) */
@Synchronized
fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey)
@ -173,11 +184,11 @@ class FavoritesPersistenceService private constructor(private val context: Conte
saveFavorites()
notifyChanged(keyHex)
Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...")
}
/** Update Nostr pubkey for a specific mesh peerID. */
@Synchronized
fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) {
val pid = peerID.trim().lowercase()
val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey)
@ -187,20 +198,20 @@ class FavoritesPersistenceService private constructor(private val context: Conte
peerIdIndex[pid] = normalizedNpub
savePeerIdIndex()
notifyChanged(pid)
Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}…")
} else {
Log.w(TAG, "updateNostrPublicKeyForPeerID called with non-16hex peerID: $peerID")
}
}
/** Resolve Nostr pubkey via current peerID mapping or stored Noise identity. */
@Synchronized
fun findNostrPubkeyForPeerID(peerID: String): String? {
val pid = peerID.trim().lowercase()
return peerIdIndex[pid] ?: getFavoriteStatus(pid)?.peerNostrPublicKey
}
/** Resolve mesh peerID for a given Nostr pubkey (npub or hex). */
@Synchronized
fun findPeerIDForNostrPubkey(nostrPubkey: String): String? {
val targetHex = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) ?: return null
@ -218,6 +229,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
/** Update favorite status */
@Synchronized
fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
@ -228,7 +240,9 @@ class FavoritesPersistenceService private constructor(private val context: Conte
peerNickname = nickname,
isFavorite = isFavorite,
lastUpdated = Date(),
favoritedAt = if (isFavorite && !existing.isFavorite) Date() else existing.favoritedAt
favoritedAt = if (isFavorite && !existing.isFavorite) Date() else existing.favoritedAt,
pendingControlID = if (existing.isFavorite != isFavorite) UUID.randomUUID().toString() else existing.pendingControlID,
pendingControlTimestamp = if (existing.isFavorite != isFavorite) maxOf(System.currentTimeMillis(), existing.pendingControlTimestamp + 1) else existing.pendingControlTimestamp
)
} else {
FavoriteRelationship(
@ -238,7 +252,9 @@ class FavoritesPersistenceService private constructor(private val context: Conte
isFavorite = isFavorite,
theyFavoritedUs = false,
favoritedAt = Date(),
lastUpdated = Date()
lastUpdated = Date(),
pendingControlID = UUID.randomUUID().toString(),
pendingControlTimestamp = System.currentTimeMillis()
)
}
@ -246,10 +262,37 @@ class FavoritesPersistenceService private constructor(private val context: Conte
saveFavorites()
notifyChanged(keyHex)
Log.d(TAG, "Updated favorite status for $nickname: $isFavorite")
}
@Synchronized
fun acknowledgeLocalControl(conversationID: String, messageID: String) {
val entry = favorites.entries.firstOrNull {
ContactIdentityResolver.contactConversationIdForNoiseKey(it.value.peerNoisePublicKey) == conversationID &&
it.value.pendingControlID == messageID
} ?: return
favorites[entry.key] = entry.value.copy(pendingControlID = null)
saveFavorites()
}
/** Authenticated remote state is ordered by its original packet time, never relay order. */
@Synchronized
fun applyRemoteFavorite(noisePublicKey: ByteArray, value: Boolean, timestamp: Long, messageID: String, nostrPubkey: String? = null): Boolean {
if (timestamp <= 0 || timestamp > System.currentTimeMillis() + 900_000) return false
val normalizedNostrKey = nostrPubkey?.let { ContactIdentityResolver.nostrPubkeyHex(it) ?: return false }
val key = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val current = favorites[key]
if (current != null && (timestamp < current.peerUpdatedAt ||
(timestamp == current.peerUpdatedAt && messageID <= current.peerUpdateID))) return false
favorites[key] = current.withPeerFavoritedUs(noisePublicKey, value)
.copy(peerUpdatedAt = timestamp, peerUpdateID = messageID,
peerNostrPublicKey = normalizedNostrKey ?: current?.peerNostrPublicKey)
saveFavorites()
notifyChanged(key)
return true
}
/** Update peer favorited-us flag */
@Synchronized
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val existing = favorites[keyHex]
@ -259,13 +302,16 @@ class FavoritesPersistenceService private constructor(private val context: Conte
saveFavorites()
notifyChanged(keyHex)
Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs")
}
@Synchronized
fun getMutualFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isMutual }
@Synchronized
fun getOurFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isFavorite }
@Synchronized
fun getAllRelationships(): List<FavoriteRelationship> = favorites.values.toList()
@Synchronized
fun clearAllFavorites() {
favorites.clear()
saveFavorites()
@ -276,6 +322,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
/** Find Noise key by Nostr pubkey */
@Synchronized
fun findNoiseKey(forNostrPubkey: String): ByteArray? {
val targetHex = ContactIdentityResolver.nostrPubkeyHex(forNostrPubkey) ?: return null
return favorites.values.firstOrNull { rel ->
@ -284,6 +331,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
/** Find Nostr pubkey by Noise key */
@Synchronized
fun findNostrPubkey(forNoiseKey: ByteArray): String? {
val keyHex = ContactIdentityResolver.noiseKeyHex(forNoiseKey)
return favorites[keyHex]?.peerNostrPublicKey
@ -305,7 +353,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
Log.d(TAG, "Loaded ${favorites.size} favorite relationships")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load favorites: ${e.message}")
Log.e(TAG, "Failed to load favorites")
}
}
@ -315,10 +363,13 @@ class FavoritesPersistenceService private constructor(private val context: Conte
FavoriteRelationshipData.fromFavoriteRelationship(relationship)
}
val favoritesJson = gson.toJson(data)
stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson)
check(stateManager.storeSecureValueAndWait(FAVORITES_KEY, favoritesJson)) { "Unable to persist relationship" }
persistedFavorites = favorites.toMap()
Log.d(TAG, "Saved ${favorites.size} favorite relationships")
} catch (e: Exception) {
Log.e(TAG, "Failed to save favorites: ${e.message}")
favorites.clear()
favorites.putAll(persistedFavorites)
throw IllegalStateException("Unable to persist relationship", e)
}
}
@ -338,7 +389,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load peerID index: ${e.message}")
Log.e(TAG, "Failed to load peerID index")
}
}
@ -348,25 +399,31 @@ class FavoritesPersistenceService private constructor(private val context: Conte
stateManager.storeSecureValue(PEERID_INDEX_KEY, json)
Log.d(TAG, "Saved ${peerIdIndex.size} peerID→npub mappings")
} catch (e: Exception) {
Log.e(TAG, "Failed to save peerID index: ${e.message}")
Log.e(TAG, "Failed to save peerID index")
}
}
// MARK: - Listeners
@Synchronized
fun addListener(listener: FavoritesChangeListener) {
synchronized(listeners) { if (!listeners.contains(listener)) listeners.add(listener) }
}
@Synchronized
fun removeListener(listener: FavoritesChangeListener) {
synchronized(listeners) { listeners.remove(listener) }
}
private fun notifyChanged(noiseKeyHex: String) {
runCatching { AppStateStore.canonicalizePrivateChats() }
val snapshot = synchronized(listeners) { listeners.toList() }
snapshot.forEach { runCatching { it.onFavoriteChanged(noiseKeyHex) } }
android.os.Handler(android.os.Looper.getMainLooper()).post {
runCatching { AppStateStore.canonicalizePrivateChats() }
snapshot.forEach { runCatching { it.onFavoriteChanged(noiseKeyHex) } }
}
}
private fun notifyAllCleared() {
val snapshot = synchronized(listeners) { listeners.toList() }
snapshot.forEach { runCatching { it.onAllCleared() } }
android.os.Handler(android.os.Looper.getMainLooper()).post {
snapshot.forEach { runCatching { it.onAllCleared() } }
}
}
}
@ -378,7 +435,11 @@ private data class FavoriteRelationshipData(
val isFavorite: Boolean,
val theyFavoritedUs: Boolean,
val favoritedAt: Long,
val lastUpdated: Long
val lastUpdated: Long,
val peerUpdatedAt: Long = 0,
val peerUpdateID: String? = null,
val pendingControlID: String? = null,
val pendingControlTimestamp: Long = 0
) {
companion object {
fun fromFavoriteRelationship(relationship: FavoriteRelationship): FavoriteRelationshipData {
@ -389,7 +450,11 @@ private data class FavoriteRelationshipData(
isFavorite = relationship.isFavorite,
theyFavoritedUs = relationship.theyFavoritedUs,
favoritedAt = relationship.favoritedAt.time,
lastUpdated = relationship.lastUpdated.time
lastUpdated = relationship.lastUpdated.time,
peerUpdatedAt = relationship.peerUpdatedAt,
peerUpdateID = relationship.peerUpdateID,
pendingControlID = relationship.pendingControlID,
pendingControlTimestamp = relationship.pendingControlTimestamp
)
}
}
@ -403,7 +468,11 @@ private data class FavoriteRelationshipData(
isFavorite = isFavorite,
theyFavoritedUs = theyFavoritedUs,
favoritedAt = Date(favoritedAt),
lastUpdated = Date(lastUpdated)
lastUpdated = Date(lastUpdated),
peerUpdatedAt = peerUpdatedAt,
peerUpdateID = peerUpdateID.orEmpty(),
pendingControlID = pendingControlID,
pendingControlTimestamp = pendingControlTimestamp
)
}
}

View File

@ -493,6 +493,10 @@ class SecureIdentityStateManager {
/**
* Store a string value in secure preferences
*/
/** Synchronous durability barrier for protocol acknowledgements. Call from a worker. */
fun storeSecureValueAndWait(key: String, value: String): Boolean =
prefs.edit().putString(key, value).commit()
fun storeSecureValue(key: String, value: String) {
prefs.edit().putString(key, value).apply()
}

View File

@ -484,6 +484,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
// Callbacks
override suspend fun isPrivateMessageStored(message: BitchatMessage): Boolean =
com.bitchat.android.services.AppStateStore.isIncomingPrivateStored(message)
override fun onMessageReceived(message: BitchatMessage) {
// Private-message admission is authoritative. In particular, do not forward a
// callback or notify after panic mode rejected the message while wiping state.
@ -493,7 +496,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
) return
// And forward to UI delegate if attached
delegate?.didReceiveMessage(message)
delegate?.didReceiveMessage(com.bitchat.android.services.IncomingMessageAdmission.forDisplay(message))
// If no UI delegate attached (app closed), show DM notification via service manager
if (delegate == null && message.isPrivate && message.sender != "system") {
@ -516,22 +519,16 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun onDeliveryAckReceived(messageID: String, peerID: String) {
// Status events can arrive while MainActivity has detached the UI delegate.
// Persist first so the next UI collector observes the advancement.
try {
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
messageID,
com.bitchat.android.model.DeliveryStatus.Delivered(peerID, Date())
)
} catch (_: Exception) { }
if (!kotlinx.coroutines.runBlocking {
com.bitchat.android.services.AppStateStore.acknowledgePrivateReceipt(peerID, messageID, false)
}) return
delegate?.didReceiveDeliveryAck(messageID, peerID)
}
override fun onReadReceiptReceived(messageID: String, peerID: String) {
try {
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
messageID,
com.bitchat.android.model.DeliveryStatus.Read(peerID, Date())
)
} catch (_: Exception) { }
if (!kotlinx.coroutines.runBlocking {
com.bitchat.android.services.AppStateStore.acknowledgePrivateReceipt(peerID, messageID, true)
}) return
delegate?.didReceiveReadReceipt(messageID, peerID)
}

View File

@ -401,9 +401,12 @@ class MeshCore(
return delegate?.decryptChannelMessage(encryptedContent, channel)
}
override suspend fun isPrivateMessageStored(message: BitchatMessage): Boolean =
com.bitchat.android.services.AppStateStore.isIncomingPrivateStored(message)
override fun onMessageReceived(message: BitchatMessage) {
if (hooks.onMessageReceived?.invoke(message) == false) return
delegate?.didReceiveMessage(message)
delegate?.didReceiveMessage(com.bitchat.android.services.IncomingMessageAdmission.forDisplay(message))
}
override fun onChannelLeave(channel: String, fromPeer: String) {
@ -411,22 +414,16 @@ class MeshCore(
}
override fun onDeliveryAckReceived(messageID: String, peerID: String) {
try {
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
messageID,
com.bitchat.android.model.DeliveryStatus.Delivered(peerID, java.util.Date())
)
} catch (_: Exception) { }
if (!kotlinx.coroutines.runBlocking {
com.bitchat.android.services.AppStateStore.acknowledgePrivateReceipt(peerID, messageID, false)
}) return
delegate?.didReceiveDeliveryAck(messageID, peerID)
}
override fun onReadReceiptReceived(messageID: String, peerID: String) {
try {
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
messageID,
com.bitchat.android.model.DeliveryStatus.Read(peerID, java.util.Date())
)
} catch (_: Exception) { }
if (!kotlinx.coroutines.runBlocking {
com.bitchat.android.services.AppStateStore.acknowledgePrivateReceipt(peerID, messageID, true)
}) return
delegate?.didReceiveReadReceipt(messageID, peerID)
}

View File

@ -95,7 +95,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
// Handle favorite/unfavorite notifications embedded as PMs
val pmContent = privateMessage.content
if (FavoriteControlMessage.parse(pmContent) != null) {
handleFavoriteNotificationFromMesh(pmContent, peerID)
handleFavoriteNotificationFromMesh(pmContent, peerID, decryption.authenticatedSession.remoteStaticKey, packet.timestamp.toLong(), privateMessage.messageID)
// Acknowledge delivery for UX parity
sendDeliveryAck(privateMessage.messageID, peerID)
return true
@ -119,7 +119,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
delegate?.onMessageReceived(message)
// Send delivery ACK exactly like iOS
sendDeliveryAck(privateMessage.messageID, peerID)
if (delegate?.isPrivateMessageStored(message) == true) {
sendDeliveryAck(privateMessage.messageID, peerID)
}
}
}
@ -624,16 +626,18 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
* Handle favorite/unfavorite notification received over mesh as a private message.
* Content format: "[FAVORITED]:npub..." or "[UNFAVORITED]:npub..."
*/
private fun handleFavoriteNotificationFromMesh(content: String, fromPeerID: String) {
private fun handleFavoriteNotificationFromMesh(content: String, fromPeerID: String, authenticatedKey: ByteArray, timestamp: Long, messageID: String) {
try {
val control = FavoriteControlMessage.parse(content) ?: return
val peerInfo = delegate?.getPeerInfo(fromPeerID)
val noiseKey = peerInfo?.noisePublicKey
if (noiseKey != null) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, control.isFavorite)
val noiseKey = authenticatedKey
if (peerInfo != null) {
val fingerprint = com.bitchat.android.services.ContactIdentityResolver.fingerprintHex(noiseKey)
if (appContext.getSharedPreferences("bitchat_prefs", android.content.Context.MODE_PRIVATE)
.getStringSet("blocked_users", emptySet())?.contains(fingerprint) == true) return
if (!com.bitchat.android.favorites.FavoritesPersistenceService.shared.applyRemoteFavorite(noiseKey, control.isFavorite, timestamp, messageID, control.npub)) return
if (control.npub != null) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, control.npub)
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(fromPeerID, control.npub)
}
@ -675,8 +679,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
// Best-effort; public notice already delivered
}
}
} catch (_: Exception) {
// Best-effort; ignore errors
} catch (error: Exception) {
// No delivery ACK until the authenticated control is durably stored.
throw error
}
}
}
@ -733,6 +738,7 @@ interface MessageHandlerDelegate {
// Callbacks
fun onMessageReceived(message: BitchatMessage)
suspend fun isPrivateMessageStored(message: BitchatMessage): Boolean = false
fun onChannelLeave(channel: String, fromPeer: String)
fun onDeliveryAckReceived(messageID: String, peerID: String)
fun onReadReceiptReceived(messageID: String, peerID: String)

View File

@ -65,6 +65,7 @@ class UnifiedMeshService(
}
startAnnouncementScheduler()
refreshDelegates()
com.bitchat.android.services.PrivateDeliveryCoordinator.getInstance(context).bindMesh(this)
}
override fun stopServices() {

View File

@ -77,7 +77,9 @@ data class BitchatMessage(
* surfaces color the sender by the same stable key while [senderPeerID] remains available for
* mesh IDs and private-chat routing aliases.
*/
val senderNostrPubkey: String? = null
val senderNostrPubkey: String? = null,
/** Original protocol ID when an incoming message has a conversation-scoped local ID. */
val wireMessageID: String? = null
) : Parcelable {
/**

View File

@ -1,16 +1,11 @@
package com.bitchat.android.nostr
import android.app.Application
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.DataManager
import com.bitchat.android.ui.MessageManager
import com.bitchat.android.ui.NoiseSessionDelegate
import com.bitchat.android.ui.PrivateChatManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Process-owned Nostr event processing.
@ -32,19 +27,7 @@ internal class NostrBackgroundEventProcessor(
loadBlockedUsers()
loadGeohashBlockedUsers()
}
private val messageManager = MessageManager(state)
private val geohashRepository = GeohashRepository(application, state, dataManager)
private val privateChatManager = PrivateChatManager(
state = state,
messageManager = messageManager,
dataManager = dataManager,
noiseSessionDelegate = object : NoiseSessionDelegate {
override fun hasEstablishedSession(peerID: String): Boolean = false
override fun initiateHandshake(peerID: String) = Unit
override fun getMyPeerID(): String = ""
},
trackUnreadMessages = false
)
private val geohashMessageHandler = GeohashMessageHandler(
application = application,
repo = geohashRepository,
@ -52,29 +35,11 @@ internal class NostrBackgroundEventProcessor(
dataManager = dataManager,
addChannelMessage = AppStateStore::addChannelMessage
)
private val directMessageHandler = NostrDirectMessageHandler(
application = application,
state = state,
privateChatManager = privateChatManager,
updateDeliveryStatus = ::updateDeliveryStatus,
scope = scope,
repo = geohashRepository,
dataManager = dataManager
)
private val directMessageHandler = NostrDirectMessageHandler(application, scope,
displayName = geohashRepository::displayNameForNostrPubkeyUI)
init {
// Keep the headless state aligned with messages sent or received through other transports.
// This preserves duplicate detection and focused-conversation behavior without retaining UI.
scope.launch {
AppStateStore.privateMessages.collect(state::setPrivateChats)
}
scope.launch {
AppStateStore.nickname.collect(state::setNickname)
}
scope.launch {
AppStateStore.selectedPrivateChatPeer.collect(state::setSelectedPrivateChatPeer)
}
}
suspend fun processAccountDm(event: NostrEvent, identity: NostrIdentity, token: Long?): Boolean =
directMessageHandler.process(event, "", identity, token)
fun onAccountDm(event: NostrEvent, identity: NostrIdentity) {
refreshBlockLists()
@ -101,13 +66,6 @@ internal class NostrBackgroundEventProcessor(
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String =
geohashRepository.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
private fun updateDeliveryStatus(messageId: String, status: DeliveryStatus) {
messageManager.updateMessageDeliveryStatus(messageId, status)
// The headless state may not yet contain a just-sent UI message. Update the process store
// unconditionally so a delivery/read receipt can never be lost during Activity handoff.
AppStateStore.updatePrivateMessageStatus(messageId, status)
}
private fun refreshBlockLists() {
dataManager.loadBlockedUsers()
dataManager.loadGeohashBlockedUsers()

View File

@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.ensureActive
import java.security.SecureRandom
import kotlin.random.asKotlinRandom
@ -58,6 +59,7 @@ object NostrBackgroundRuntime {
subscriptions.connect()
subscribeAccountDm()
startInboxSync()
observeSelectedChannel()
startPresenceScheduler()
}
@ -66,8 +68,7 @@ object NostrBackgroundRuntime {
if (!initialized) return
subscriptions.unsubscribeAllOwned()
scope.launch {
// Let CLOSE frames be queued before replacing the deterministic IDs.
delay(100)
subscriptions.connect()
subscribeAccountDm()
activeGeohash?.let { geohash ->
subscribeSelectedGeohash(geohash, activeGeohashLiveToken)
@ -97,13 +98,52 @@ object NostrBackgroundRuntime {
subscriptions.subscribeGiftWraps(
pubkey = identity.publicKeyHex,
sinceMs = System.currentTimeMillis() - 172_800_000L,
id = "chat-messages",
id = "account-dm-${java.util.UUID.randomUUID()}",
targetRelayUrls = NostrRelayManager.getInstance(application).accountRelayUrls,
handler = { event ->
eventProcessor.onAccountDm(event, identity)
if (NostrIdentityBridge.getCurrentNostrIdentity(application)?.publicKeyHex == identity.publicKeyHex) {
eventProcessor.onAccountDm(event, identity)
}
}
)
}
private fun startInboxSync() {
scope.launch {
while (true) {
val manager = NostrRelayManager.getInstance(application)
val identity = NostrIdentityBridge.getCurrentNostrIdentity(application)
if (identity != null) {
val repository = com.bitchat.android.services.ConversationRepository.getInstance(application)
for (relay in manager.getRelayStatuses().filter { it.isConnected && it.url in manager.accountRelayUrls }) {
try {
val token = com.bitchat.android.services.AppStateStore.privateConversationToken() ?: continue
val checkpoint = "${identity.publicKeyHex}:${relay.url}"
val now = System.currentTimeMillis()
val sync = NostrInboxSync(
fetch = { since, until, limit -> manager.fetchGiftWraps(relay.url, identity.publicKeyHex, since, until, limit) },
process = { event ->
NostrIdentityBridge.getCurrentNostrIdentity(application)?.publicKeyHex == identity.publicKeyHex &&
eventProcessor.processAccountDm(event, identity, token)
}
)
if (sync.scan(NostrInboxSync.since(now, repository.syncCheckpoint(checkpoint)), (now / 1000).toInt())) {
if (com.bitchat.android.services.AppStateStore.privateConversationToken() == token && NostrIdentityBridge.getCurrentNostrIdentity(application)?.publicKeyHex == identity.publicKeyHex) {
repository.saveSyncCheckpoint(checkpoint, now)
}
}
} catch (e: CancellationException) {
kotlinx.coroutines.currentCoroutineContext().ensureActive()
} catch (_: Exception) {
Log.w(TAG, "Inbox catch-up incomplete; retry scheduled")
}
}
}
delay(60_000)
}
}
}
private fun observeSelectedChannel() {
scope.launch {
locationChannels.selectedChannel.collectLatest { channel ->

View File

@ -1,313 +0,0 @@
package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* High-level Nostr client that manages identity, connections, and messaging
* Provides a simple API for the rest of the application
*/
class NostrClient private constructor(private val context: Context) {
companion object {
private const val TAG = "NostrClient"
@Volatile
private var INSTANCE: NostrClient? = null
fun getInstance(context: Context): NostrClient {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: NostrClient(context.applicationContext).also { INSTANCE = it }
}
}
}
// Core components
private val relayManager = NostrRelayManager.shared
private var currentIdentity: NostrIdentity? = null
// Client state
private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
private val _currentNpub = MutableStateFlow<String?>(null)
val currentNpub: StateFlow<String?> = _currentNpub.asStateFlow()
// Message processing
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
init {
Log.d(TAG, "Initializing Nostr client")
}
/**
* Initialize the Nostr client with identity and relay connections
*/
fun initialize() {
scope.launch {
try {
// Load or create identity
currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
if (currentIdentity != null) {
_currentNpub.value = currentIdentity!!.npub
Log.i(TAG, "✅ Nostr identity loaded: ${currentIdentity!!.getShortNpub()}")
// Connect to relays
relayManager.connect()
_isInitialized.value = true
Log.i(TAG, "✅ Nostr client initialized successfully")
} else {
Log.e(TAG, "❌ Failed to load/create Nostr identity")
_isInitialized.value = false
}
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to initialize Nostr client: ${e.message}")
_isInitialized.value = false
}
}
}
/**
* Shutdown the client and disconnect from relays
*/
fun shutdown() {
Log.d(TAG, "Shutting down Nostr client")
relayManager.disconnect()
_isInitialized.value = false
}
/**
* Send a private message using NIP-17
*/
fun sendPrivateMessage(
content: String,
recipientNpub: String,
onSuccess: (() -> Unit)? = null,
onError: ((String) -> Unit)? = null
) {
val identity = currentIdentity
if (identity == null) {
onError?.invoke("Nostr client not initialized")
return
}
scope.launch {
try {
// Decode recipient npub to hex pubkey
val (hrp, pubkeyBytes) = Bech32.decode(recipientNpub)
if (hrp != "npub") {
onError?.invoke("Invalid npub format")
return@launch
}
val recipientPubkeyHex = pubkeyBytes.toHexString()
// Create and send gift wraps (receiver and sender copies)
val giftWraps = NostrProtocol.createPrivateMessage(
content = content,
recipientPubkey = recipientPubkeyHex,
senderIdentity = identity
)
// Track and send all gift wraps
giftWraps.forEach { wrap ->
NostrRelayManager.registerPendingGiftWrap(wrap.id)
relayManager.sendEvent(wrap)
}
Log.i(TAG, "📤 Sent private message to ${recipientNpub.take(16)}...")
onSuccess?.invoke()
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to send private message: ${e.message}")
onError?.invoke("Failed to send message: ${e.message}")
}
}
}
/**
* Subscribe to private messages for current identity
*/
fun subscribeToPrivateMessages(handler: (content: String, senderNpub: String, timestamp: Int) -> Unit) {
val identity = currentIdentity
if (identity == null) {
Log.e(TAG, "Cannot subscribe to private messages: client not initialized")
return
}
val filter = NostrFilter.giftWrapsFor(
pubkey = identity.publicKeyHex,
since = System.currentTimeMillis() - 172800000L // Last 48 hours (align with NIP-17 randomization)
)
relayManager.subscribe(filter, "private-messages", { giftWrap ->
scope.launch {
handlePrivateMessage(giftWrap, handler)
}
})
Log.i(TAG, "🔑 Subscribed to private messages for: ${identity.getShortNpub()}")
}
/**
* Send a public message to a geohash channel
*/
fun sendGeohashMessage(
content: String,
geohash: String,
nickname: String? = null,
onSuccess: (() -> Unit)? = null,
onError: ((String) -> Unit)? = null
) {
scope.launch {
try {
// Derive geohash-specific identity
val geohashIdentity = NostrIdentityBridge.deriveIdentity(geohash, context)
// Create ephemeral event (with PoW if enabled)
val event = NostrProtocol.createEphemeralGeohashEvent(
content = content,
geohash = geohash,
senderIdentity = geohashIdentity,
nickname = nickname
)
relayManager.sendEvent(event)
Log.i(TAG, "📤 Sent geohash message")
onSuccess?.invoke()
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to send geohash message: ${e.message}")
onError?.invoke("Failed to send message: ${e.message}")
}
}
}
/**
* Subscribe to public messages in a geohash channel
*/
fun subscribeToGeohash(
geohash: String,
handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit
) {
val filter = NostrFilter.geohashEphemeral(
geohash = geohash,
since = System.currentTimeMillis() - 3600000L, // Last hour
limit = 200
)
relayManager.subscribe(filter, "geohash-$geohash", { event ->
scope.launch {
handleGeohashMessage(event, handler)
}
})
Log.i(TAG, "🌍 Subscribed to geohash channel")
}
/**
* Unsubscribe from a geohash channel
*/
fun unsubscribeFromGeohash(geohash: String) {
relayManager.unsubscribe("geohash-$geohash")
Log.i(TAG, "Unsubscribed from geohash channel")
}
/**
* Get current identity information
*/
fun getCurrentIdentity(): NostrIdentity? = currentIdentity
/**
* Get relay connection status
*/
val relayConnectionStatus: StateFlow<Boolean> = relayManager.isConnected
/**
* Get relay information
*/
val relayInfo: StateFlow<List<NostrRelayManager.Relay>> = relayManager.relays
// MARK: - Private Methods
private suspend fun handlePrivateMessage(
giftWrap: NostrEvent,
handler: (content: String, senderNpub: String, timestamp: Int) -> Unit
) {
// Age filtering (24h + 15min buffer for randomized timestamps)
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
if (messageAge > 173700) { // 48 hours + 15 minutes
Log.v(TAG, "Ignoring old private message")
return
}
val identity = currentIdentity ?: return
try {
val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity)
if (decryptResult != null) {
val (content, senderPubkey, timestamp) = decryptResult
// Convert sender pubkey to npub
val senderNpub = try {
Bech32.encode("npub", senderPubkey.hexToByteArray())
} catch (e: Exception) {
Log.w(TAG, "Failed to encode sender npub: ${e.message}")
"npub_decode_error"
}
Log.d(TAG, "📥 Received private message from ${senderNpub.take(16)}...")
// Dispatch to main thread for handler
withContext(Dispatchers.Main) {
handler(content, senderNpub, timestamp)
}
} else {
Log.w(TAG, "Failed to decrypt private message")
}
} catch (e: Exception) {
Log.e(TAG, "Error handling private message: ${e.message}")
}
}
private suspend fun handleGeohashMessage(
event: NostrEvent,
handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit
) {
try {
if (!event.isValidSignature()) {
Log.w(TAG, "🚫 Rejecting geohash event ${event.id.take(8)}... with invalid signature")
return
}
// Check Proof of Work validation for incoming geohash events
val powSettings = PoWPreferenceManager.getCurrentSettings()
if (powSettings.enabled && powSettings.difficulty > 0) {
if (!NostrProofOfWork.validateDifficulty(event, powSettings.difficulty)) {
Log.w(TAG, "🚫 Rejecting geohash event ${event.id.take(8)}... due to insufficient PoW (required: ${powSettings.difficulty})")
return
}
Log.v(TAG, "✅ PoW validation passed for geohash event ${event.id.take(8)}...")
}
// Extract nickname from tags
val nickname = event.tags.find { it.size >= 2 && it[0] == "n" }?.get(1)
Log.v(TAG, "📥 Received geohash message from ${event.pubkey.take(16)}...")
// Dispatch to main thread for handler
withContext(Dispatchers.Main) {
handler(event.content, event.pubkey, nickname, event.createdAt)
}
} catch (e: Exception) {
Log.e(TAG, "Error handling geohash message: ${e.message}")
}
}
}

View File

@ -236,10 +236,10 @@ object NostrCrypto {
}
/**
* NIP-44 v2 encryption using XChaCha20-Poly1305
* Legacy Bitchat DM encryption (not the standardized NIP-44 v2 codec)
* Output format: "v2:" + base64url(nonce24 || ciphertext || tag)
*/
fun encryptNIP44(
fun encryptLegacyBitchatDm(
plaintext: String,
recipientPublicKeyHex: String,
senderPrivateKeyHex: String
@ -264,7 +264,7 @@ object NostrCrypto {
* 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 decryptLegacyBitchatDm(ciphertext: String, senderPublicKeyHex: String, recipientPrivateKeyHex: String): String {
try {
require(ciphertext.startsWith("v2:")) { "Invalid NIP-44 version prefix" }
val encoded = ciphertext.substring(3)
@ -292,6 +292,14 @@ object NostrCrypto {
}
}
// Source compatibility for callers of the historically misnamed helpers. Wire bytes remain
// unchanged; switching to standard NIP-44 requires a negotiated cross-client migration.
fun encryptNIP44(plaintext: String, recipientPublicKeyHex: String, senderPrivateKeyHex: String): String =
encryptLegacyBitchatDm(plaintext, recipientPublicKeyHex, senderPrivateKeyHex)
fun decryptNIP44(ciphertext: String, senderPublicKeyHex: String, recipientPrivateKeyHex: String): String =
decryptLegacyBitchatDm(ciphertext, senderPublicKeyHex, recipientPrivateKeyHex)
private fun base64UrlNoPad(data: ByteArray): String {
val b64 = android.util.Base64.encodeToString(data, android.util.Base64.NO_WRAP)
return b64.replace('+', '-').replace('/', '_').replace("=", "")

View File

@ -1,322 +1,142 @@
package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import android.util.Base64
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.model.PrivateMessagePacket
import com.bitchat.android.model.*
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.services.SeenMessageStore
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.PrivateChatManager
import com.bitchat.android.ui.PrivateMessageOrigin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.services.*
import com.bitchat.android.ui.DataManager
import com.bitchat.android.ui.NotificationManager
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.Date
/** Authenticated, durable DM admission shared by live delivery and historical catch-up. */
class NostrDirectMessageHandler(
private val application: Application,
private val state: ChatState,
private val privateChatManager: PrivateChatManager,
private val updateDeliveryStatus: (String, DeliveryStatus) -> Unit,
private val scope: CoroutineScope,
private val repo: GeohashRepository,
private val dataManager: com.bitchat.android.ui.DataManager,
private val seenStoreProvider: () -> SeenMessageStore = {
SeenMessageStore.getInstance(application)
private val displayName: (String) -> String = { "Contact" },
private val favoritesProvider: () -> FavoritesPersistenceService = { FavoritesPersistenceService.shared },
private val repositoryProvider: () -> ConversationRepository = { ConversationRepository.getInstance(application) },
private val wakeDeliveries: () -> Unit = { PrivateDeliveryCoordinator.getInstance(application).wake() },
private val isViewing: (String) -> Boolean = { conversation ->
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) &&
AppStateStore.selectedPrivateChatPeer.value == conversation
}
) {
companion object { private const val TAG = "NostrDirectMessageHandler" }
private val mutex = Mutex()
private val repository get() = repositoryProvider()
private val notifications by lazy { NotificationManager(application, NotificationManagerCompat.from(application)) }
private val seenStore by lazy(seenStoreProvider)
// 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(event: NostrEvent, geohash: String, identity: NostrIdentity) {
val token = AppStateStore.privateConversationToken() ?: return
scope.launch { process(event, geohash, identity, token) }
}
fun onGiftWrap(giftWrap: NostrEvent, geohash: String, identity: NostrIdentity) {
scope.launch {
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
/** False means processing must be retried; malformed or unauthorized events are consumed. */
suspend fun process(event: NostrEvent, geohash: String, identity: NostrIdentity, token: Long? = AppStateStore.privateConversationToken()): Boolean = mutex.withLock {
if (token == null || AppStateStore.privateConversationToken() != token) return@withLock false
try {
val decoded = NostrProtocol.decryptPrivateMessage(event, identity) ?: return@withLock true
val (content, sender, timestamp) = decoded
val now = System.currentTimeMillis()
if (timestamp.toLong() * 1000 < now - PrivateDeliveryJob.RETENTION_MS || timestamp.toLong() * 1000 > now + 900_000) return@withLock true
if (!content.startsWith("bitchat1:") || content.length > 20_000_000) return@withLock true
val packet = runCatching { BitchatPacket.fromBinaryData(Base64.decode(content.removePrefix("bitchat1:"), Base64.URL_SAFE or Base64.NO_WRAP)) }.getOrNull() ?: return@withLock true
if (packet.type != MessageType.NOISE_ENCRYPTED.value) return@withLock true
val payload = runCatching { NoisePayload.decode(packet.payload) }.getOrNull() ?: return@withLock true
val favorites = favoritesProvider()
val noiseKey = favorites.findNoiseKey(sender)
val relationship = noiseKey?.let(favorites::getFavoriteStatus)
val data = DataManager(application).apply { loadBlockedUsers(); loadGeohashBlockedUsers() }
if (data.isGeohashUserBlocked(sender) || noiseKey?.let { data.isUserBlocked(ContactIdentityResolver.fingerprintHex(it)) } == true) return@withLock true
val alias = requireNotNull(ContactIdentityResolver.nostrAliasForPubkey(sender))
val conversation = noiseKey?.let(ContactIdentityResolver::contactConversationIdForNoiseKey) ?: alias
val favorite = if (payload.type == NoisePayloadType.PRIVATE_MESSAGE) {
PrivateMessagePacket.decode(payload.data)?.let { FavoriteControlMessage.parse(it.content) }
} else null
// Controls can establish mutuality, but never establish a new Noise-key binding over Nostr.
if (favorite != null) {
if (noiseKey == null || favorite.npub?.let(ContactIdentityResolver::nostrPubkeyHex)?.let { it != sender } == true) return@withLock true
val pm = PrivateMessagePacket.decode(payload.data) ?: return@withLock true
val updated = favorites.applyRemoteFavorite(noiseKey, favorite.isFavorite, packet.timestamp.toLong(), pm.messageID)
if (updated) {
val notice = BitchatMessage(id = "favorite:${pm.messageID}", sender = "system",
content = if (favorite.isFavorite) "Contact favorited you" else "Contact unfavorited you",
timestamp = Date(timestamp.toLong() * 1000), isPrivate = true, senderPeerID = conversation)
AppStateStore.admitIncomingPrivate(notice, forceRead = true)
}
val (content, rawSenderPubkey, rumorTimestamp) = decryptResult
val senderPubkey = rawSenderPubkey.lowercase()
// If sender is blocked for geohash contexts, drop any events from this pubkey
// Applies to both geohash DMs (geohash != "") and account DMs (geohash == "")
if (dataManager.isGeohashUserBlocked(senderPubkey)) return@launch
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 = 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)
if (AppStateStore.privateConversationToken() != token) return@withLock false
repository.saveDelivery(receiptJob(conversation, pm.messageID, sender, geohash, false), replace = false)
wakeDeliveries()
return@withLock true
}
val isReceipt = payload.type == NoisePayloadType.DELIVERED || payload.type == NoisePayloadType.READ_RECEIPT
if (geohash.isEmpty() && (relationship == null || (!isReceipt && !relationship.isMutual))) return@withLock true
GeohashAliasRegistry.put(alias, sender)
if (geohash.isNotEmpty()) GeohashConversationRegistry.set(alias, geohash)
when (payload.type) {
NoisePayloadType.PRIVATE_MESSAGE -> {
val pm = PrivateMessagePacket.decode(payload.data) ?: return@withLock true
if (pm.messageID.isBlank() || pm.messageID.length > 256) return@withLock true
val nickname = relationship?.peerNickname?.takeUnless { it == "Unknown" } ?: displayName(sender)
val message = BitchatMessage(id = pm.messageID, sender = nickname, content = pm.content,
timestamp = Date(timestamp.toLong() * 1000), isPrivate = true, senderPeerID = conversation,
senderNostrPubkey = sender, deliveryStatus = DeliveryStatus.Delivered(conversation, Date()))
val viewing = isViewing(conversation)
val receipt = receiptJob(conversation, pm.messageID, sender, geohash, viewing)
val admitted = AppStateStore.admitIncomingPrivate(message, forceRead = viewing, receiptJob = receipt)
if (admitted == AppStateStore.PrivateAdmission.RETRYABLE_FAILURE) return@withLock false
if (admitted == AppStateStore.PrivateAdmission.REJECTED) return@withLock true
wakeDeliveries()
if (admitted == AppStateStore.PrivateAdmission.INSERTED && !viewing) {
// Notification permissions and channels must not affect durable delivery.
runCatching {
notifications.setAppBackgroundState(true)
notifications.showPrivateMessageNotification(conversation, nickname, pm.content)
}
}
repo.updateParticipant(geohash, senderPubkey, messageTimestamp)
}
val senderNickname = repo.displayNameForNostrPubkeyUI(senderPubkey)
val conversationID = ContactDirectory.canonicalConversationId(convKey)
processNoisePayload(noisePayload, conversationID, senderNickname, messageTimestamp, senderPubkey, identity)
} catch (e: Exception) {
Log.e(TAG, "onGiftWrap error: ${e.message}")
}
}
}
private suspend fun processNoisePayload(
payload: NoisePayload,
conversationID: String,
senderNickname: String,
timestamp: Date,
senderPubkey: String,
recipientIdentity: NostrIdentity
) {
when (payload.type) {
NoisePayloadType.PRIVATE_MESSAGE -> {
val pm = PrivateMessagePacket.decode(payload.data) ?: return
val existingMessages = state.getPrivateChatsValue()[conversationID] ?: emptyList()
if (existingMessages.any { it.id == pm.messageID }) return
val favoriteControl = FavoriteControlMessage.parse(pm.content)
if (favoriteControl != null) {
val admitted = handleFavoriteControl(
favoriteControl,
conversationID,
senderNickname,
timestamp,
senderPubkey
)
if (!admitted) return
if (!seenStore.hasDelivered(pm.messageID)) {
val nostrTransport = NostrTransport.getInstance(application)
nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity)
seenStore.markDelivered(pm.messageID)
}
return
NoisePayloadType.DELIVERED, NoisePayloadType.READ_RECEIPT -> {
val id = payload.data.toString(Charsets.UTF_8)
if (id.length in 1..256) AppStateStore.acknowledgePrivateReceipt(conversation, id, payload.type == NoisePayloadType.READ_RECEIPT)
}
val message = BitchatMessage(
id = pm.messageID,
sender = senderNickname,
content = pm.content,
timestamp = timestamp,
isRelay = false,
isPrivate = true,
recipientNickname = state.getNicknameValue(),
senderPeerID = conversationID,
senderNostrPubkey = senderPubkey,
deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date())
)
val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID
val suppressUnread = seenStore.hasBeenReadLocally(pm.messageID)
val admitted = withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessageDurably(
message = message,
suppressUnread = suppressUnread,
origin = PrivateMessageOrigin.NOSTR
)
}
if (!admitted) return
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.markReadLocally(pm.messageID)
seenStore.markReadReceiptSent(pm.messageID)
}
}
NoisePayloadType.DELIVERED -> {
val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) {
updateDeliveryStatus(
messageId,
DeliveryStatus.Delivered(conversationID, Date())
)
}
}
NoisePayloadType.READ_RECEIPT -> {
val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) {
updateDeliveryStatus(
messageId,
DeliveryStatus.Read(conversationID, Date())
)
}
}
NoisePayloadType.FILE_TRANSFER -> {
// Properly handle encrypted file transfer
val file = BitchatFilePacket.decode(payload.data)
if (file != null) {
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(application, file)
val message = BitchatMessage(
id = uniqueMsgId,
sender = senderNickname,
content = savedPath,
NoisePayloadType.FILE_TRANSFER -> {
// Existing receive compatibility only. Deduplicate before creating a file.
val wireID = "file:" + java.security.MessageDigest.getInstance("SHA-256").digest(payload.data)
.joinToString("") { "%02x".format(it) }
val file = BitchatFilePacket.decode(payload.data) ?: return@withLock true
val localID = AppStateStore.incomingLocalID(conversation, wireID)
if (repository.isDeletedMessage(localID)) return@withLock true
val marker = repository.storedMessage(conversation, localID)
if (marker != null) return@withLock true
val path = com.bitchat.android.features.file.FileUtils.saveIncomingFile(application, file)
val message = BitchatMessage(id = localID, wireMessageID = wireID, sender = displayName(sender), content = path,
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
timestamp = timestamp,
isRelay = false,
isPrivate = true,
recipientNickname = state.getNicknameValue(),
senderPeerID = conversationID,
senderNostrPubkey = senderPubkey
)
Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)")
val admitted = withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessageDurably(
message = message,
suppressUnread = false,
origin = PrivateMessageOrigin.NOSTR
)
timestamp = Date(timestamp.toLong() * 1000), isPrivate = true, senderPeerID = conversation, senderNostrPubkey = sender)
if (!AppStateStore.addPrivateMessageDurably(conversation, message, forceRead = isViewing(conversation))) {
com.bitchat.android.features.file.FileUtils.deleteStoredMediaPaths(application, listOf(path))
return@withLock false
}
if (!admitted) {
com.bitchat.android.features.file.FileUtils.deleteStoredMediaPaths(
application,
listOf(savedPath)
)
}
} else {
Log.w(TAG, "Failed to decode Nostr file transfer from $conversationID")
}
else -> Unit
}
NoisePayloadType.VERIFY_CHALLENGE,
NoisePayloadType.VERIFY_RESPONSE,
NoisePayloadType.VOICE_FRAME,
NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation.
}
true
} catch (e: CancellationException) { throw e }
catch (_: Exception) { false }
}
private suspend fun handleFavoriteControl(
control: FavoriteControlMessage,
conversationID: String,
senderNickname: String,
timestamp: Date,
senderPubkey: String
): Boolean {
return try {
val senderNpub = control.npub ?: ContactIdentityResolver.npubFromHex(senderPubkey)
val noiseKey = senderNpub?.let { FavoritesPersistenceService.shared.findNoiseKey(it) }
?: FavoritesPersistenceService.shared.findNoiseKey(senderPubkey)
if (noiseKey == null) {
Log.w(TAG, "Favorite notification from Nostr sender without known Noise key: ${senderPubkey.take(16)}...")
return false
}
FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, control.isFavorite)
senderNpub?.let { FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, it) }
val targetConversationID = ContactDirectory.canonicalConversationId(conversationID)
val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
val displayName = relationship
?.peerNickname
?.takeUnless { it.equals("Unknown", ignoreCase = true) }
?: senderNickname
val guidance = if (control.isFavorite) {
if (relationship?.isFavorite == true) {
" - mutual! You can continue DMs via Nostr when out of mesh."
} else {
" - favorite back to continue DMs later."
}
} else {
". DMs over Nostr will pause unless you both favorite again."
}
val action = if (control.isFavorite) "favorited" else "unfavorited"
val systemMessage = BitchatMessage(
sender = "system",
content = "$displayName $action you$guidance",
timestamp = timestamp,
isRelay = false,
isPrivate = true,
senderPeerID = targetConversationID
)
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessageDurably(
message = systemMessage,
suppressUnread = true,
origin = PrivateMessageOrigin.NOSTR
)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to handle Nostr favorite notification: ${e.message}")
false
}
}
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
}
private fun receiptJob(conversation: String, messageID: String, sender: String, geohash: String, read: Boolean): PrivateDeliveryJob {
val kind = if (read) PrivateDeliveryJob.Kind.READ else PrivateDeliveryJob.Kind.DELIVERED
return PrivateDeliveryJob("${kind.name}:$conversation:$messageID", conversation, messageID, kind,
recipientPubkey = sender, sourceGeohash = geohash.takeIf(String::isNotEmpty),
localMessageID = AppStateStore.incomingLocalID(conversation, messageID))
}
}

View File

@ -24,7 +24,8 @@ object NostrEmbeddedBitChat {
content: String,
messageID: String,
recipientPeerID: String,
senderPeerID: String
senderPeerID: String,
timestampMs: Long = System.currentTimeMillis()
): String? {
try {
// TLV-encode the private message
@ -44,7 +45,7 @@ object NostrEmbeddedBitChat {
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(senderPeerID),
recipientID = hexStringToByteArray(recipientIDHex),
timestamp = System.currentTimeMillis().toULong(),
timestamp = timestampMs.toULong(),
payload = payload,
signature = null,
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
@ -65,7 +66,8 @@ object NostrEmbeddedBitChat {
type: NoisePayloadType,
messageID: String,
recipientPeerID: String,
senderPeerID: String
senderPeerID: String,
timestampMs: Long = System.currentTimeMillis()
): String? {
if (type != NoisePayloadType.DELIVERED && type != NoisePayloadType.READ_RECEIPT) {
return null
@ -84,7 +86,7 @@ object NostrEmbeddedBitChat {
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(senderPeerID),
recipientID = hexStringToByteArray(recipientIDHex),
timestamp = System.currentTimeMillis().toULong(),
timestamp = timestampMs.toULong(),
payload = payload,
signature = null,
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
@ -104,7 +106,8 @@ object NostrEmbeddedBitChat {
fun encodeAckForNostrNoRecipient(
type: NoisePayloadType,
messageID: String,
senderPeerID: String
senderPeerID: String,
timestampMs: Long = System.currentTimeMillis()
): String? {
if (type != NoisePayloadType.DELIVERED && type != NoisePayloadType.READ_RECEIPT) {
return null
@ -121,7 +124,7 @@ object NostrEmbeddedBitChat {
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(senderPeerID),
recipientID = null, // No recipient for geohash DMs
timestamp = System.currentTimeMillis().toULong(),
timestamp = timestampMs.toULong(),
payload = payload,
signature = null,
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
@ -141,7 +144,8 @@ object NostrEmbeddedBitChat {
fun encodePMForNostrNoRecipient(
content: String,
messageID: String,
senderPeerID: String
senderPeerID: String,
timestampMs: Long = System.currentTimeMillis()
): String? {
try {
val pm = PrivateMessagePacket(messageID = messageID, content = content)
@ -156,7 +160,7 @@ object NostrEmbeddedBitChat {
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(senderPeerID),
recipientID = null, // No recipient for geohash DMs
timestamp = System.currentTimeMillis().toULong(),
timestamp = timestampMs.toULong(),
payload = payload,
signature = null,
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS

View File

@ -0,0 +1,30 @@
package com.bitchat.android.nostr
import com.bitchat.android.services.PrivateDeliveryJob
/** Bounded historical scans. Saturated ranges are subdivided; equal-second ties never skip. */
internal class NostrInboxSync(
private val fetch: suspend (since: Int, until: Int, limit: Int) -> List<NostrEvent>?,
private val process: suspend (NostrEvent) -> Boolean
) {
companion object {
const val WRAPPER_OVERLAP_MS = 172_800_000L + 900_000L
fun since(now: Long, lastCompleted: Long?): Int =
((maxOf(now - PrivateDeliveryJob.RETENTION_MS, lastCompleted ?: Long.MIN_VALUE) - WRAPPER_OVERLAP_MS) / 1000).toInt()
}
suspend fun scan(since: Int, until: Int, limit: Int = 500): Boolean {
val events = fetch(since, until, limit) ?: return false
if (events.size >= limit) {
if (since == until) {
if (limit >= 16_000) return false // Report incomplete, never advance a truncated range.
return scan(since, until, limit * 2)
}
val middle = since + (until - since) / 2
// Process recent controls first; authenticated freshness rejects old replays.
return scan(middle + 1, until) && scan(since, middle)
}
for (event in events) if (!process(event)) return false
return true
}
}

View File

@ -39,6 +39,10 @@ internal class NostrPendingEventQueue(
if (pendingRelays.isEmpty()) return null
return synchronized(lock) {
entries.firstOrNull { it.event.id == event.id && it.liveLocationToken == liveLocationToken }?.let { existing ->
existing.pendingRelayUrls.addAll(pendingRelays)
return@synchronized existing.queueId
}
if (entries.size >= capacity) entries.removeFirst()
val queueId = nextQueueId++
entries.addLast(
@ -74,6 +78,18 @@ internal class NostrPendingEventQueue(
}
}
fun acknowledge(eventId: String, relayUrl: String) {
synchronized(lock) {
val iterator = entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
if (entry.event.id != eventId) continue
entry.pendingRelayUrls.remove(relayUrl)
if (entry.pendingRelayUrls.isEmpty()) iterator.remove()
}
}
}
fun removeLiveLocationEvents() {
synchronized(lock) {
entries.removeAll { it.liveLocationToken != null }

View File

@ -7,7 +7,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* NIP-17 Protocol Implementation for Private Direct Messages
* Bitchat private-message envelopes using the legacy Bitchat DM codec
* Compatible with iOS implementation
*/
object NostrProtocol {
@ -22,14 +22,14 @@ object NostrProtocol {
fun createPrivateMessage(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity
senderIdentity: NostrIdentity,
timestampMs: Long = System.currentTimeMillis()
): List<NostrEvent> {
Log.d(TAG, "Creating private message for recipient: ${recipientPubkey.take(16)}...")
// 1. Create the rumor (unsigned kind 14) with p-tag
val rumorBase = NostrEvent(
pubkey = senderIdentity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
createdAt = (timestampMs / 1000).toInt(),
kind = NostrKind.DIRECT_MESSAGE,
tags = listOf(listOf("p", recipientPubkey)),
content = content
@ -50,7 +50,6 @@ object NostrProtocol {
seal = sealedEvent,
recipientPubkey = recipientPubkey
)
Log.d(TAG, "Created gift wrap: toRecipient=${giftWrapToRecipient.id.take(16)}...")
return listOf(giftWrapToRecipient)
}
@ -62,9 +61,10 @@ object NostrProtocol {
giftWrap: NostrEvent,
recipientIdentity: NostrIdentity
): Triple<String, String, Int>? {
Log.v(TAG, "Starting decryption of gift wrap: ${giftWrap.id.take(16)}...")
return try {
if (giftWrap.kind != NostrKind.GIFT_WRAP || !giftWrap.isValidSignature() ||
giftWrap.tags.none { it.size >= 2 && it[0] == "p" && it[1] == recipientIdentity.publicKeyHex }) return null
// 1. Unwrap the gift wrap
val seal = unwrapGiftWrap(giftWrap, recipientIdentity.privateKeyHex)
?: run {
@ -72,7 +72,6 @@ object NostrProtocol {
return null
}
Log.v(TAG, "Successfully unwrapped gift wrap from: ${seal.pubkey.take(16)}...")
if (seal.kind != NostrKind.SEAL || !seal.isValidSignature()) {
Log.w(TAG, "❌ Invalid NIP-17 seal signature")
@ -91,11 +90,13 @@ object NostrProtocol {
return null
}
if (rumor.kind != NostrKind.DIRECT_MESSAGE || rumor.id != rumor.computeEventIdHex() ||
rumor.tags.none { it.size >= 2 && it[0] == "p" && it[1] == recipientIdentity.publicKeyHex }) return null
Log.v(TAG, "Successfully opened seal")
Triple(rumor.content, rumor.pubkey, rumor.createdAt)
} catch (e: Exception) {
Log.w(TAG, "Failed to decrypt private message: ${e.message}")
Log.w(TAG, "Failed to decrypt private message")
null
}
}
@ -223,7 +224,7 @@ object NostrProtocol {
): NostrEvent {
val rumorJSON = gson.toJson(rumor)
val encrypted = NostrCrypto.encryptNIP44(
val encrypted = NostrCrypto.encryptLegacyBitchatDm(
plaintext = rumorJSON,
recipientPublicKeyHex = recipientPubkey,
senderPrivateKeyHex = senderPrivateKey
@ -252,7 +253,7 @@ object NostrProtocol {
Log.v(TAG, "Creating gift wrap with ephemeral key")
// Encrypt the seal with the new ephemeral key
val encrypted = NostrCrypto.encryptNIP44(
val encrypted = NostrCrypto.encryptLegacyBitchatDm(
plaintext = sealJSON,
recipientPublicKeyHex = recipientPubkey,
senderPrivateKeyHex = wrapPrivateKey
@ -275,7 +276,7 @@ object NostrProtocol {
recipientPrivateKey: String
): NostrEvent? {
return try {
val decrypted = NostrCrypto.decryptNIP44(
val decrypted = NostrCrypto.decryptLegacyBitchatDm(
ciphertext = giftWrap.content,
senderPublicKeyHex = giftWrap.pubkey,
recipientPrivateKeyHex = recipientPrivateKey
@ -301,7 +302,7 @@ object NostrProtocol {
Log.v(TAG, "Unwrapped seal with kind: ${seal.kind}")
seal
} catch (e: Exception) {
Log.w(TAG, "Failed to unwrap gift wrap: ${e.message}")
Log.w(TAG, "Failed to unwrap gift wrap")
null
}
}
@ -311,7 +312,7 @@ object NostrProtocol {
recipientPrivateKey: String
): NostrEvent? {
return try {
val decrypted = NostrCrypto.decryptNIP44(
val decrypted = NostrCrypto.decryptLegacyBitchatDm(
ciphertext = seal.content,
senderPublicKeyHex = seal.pubkey,
recipientPrivateKeyHex = recipientPrivateKey
@ -334,7 +335,7 @@ object NostrProtocol {
sig = jsonObject.get("sig")?.asString
)
} catch (e: Exception) {
Log.w(TAG, "Failed to open seal: ${e.message}")
Log.w(TAG, "Failed to open seal")
null
}
}
@ -352,7 +353,7 @@ object NostrProtocol {
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to parse tags: ${e.message}")
Log.e(TAG, "Failed to parse tags")
null
}
}

View File

@ -21,11 +21,14 @@ import kotlin.math.pow
* Manages WebSocket connections to Nostr relays
* Compatible with iOS implementation with Android-specific optimizations
*/
class NostrRelayManager private constructor() {
class NostrRelayManager internal constructor(
private val initialRelays: List<String> = DEFAULT_RELAYS,
private val clientProvider: () -> OkHttpClient = { com.bitchat.android.net.OkHttpProvider.webSocketClient() }
) {
companion object {
@JvmStatic
val shared = NostrRelayManager()
val shared by lazy { NostrRelayManager() }
private const val TAG = "NostrRelayManager"
private const val MAX_QUEUED_EVENTS = 500
@ -37,6 +40,7 @@ class NostrRelayManager private constructor() {
*/
fun getInstance(context: android.content.Context): NostrRelayManager {
shared.appContext = context.applicationContext
shared.monitorNetwork(context.applicationContext)
return shared
}
@ -51,13 +55,14 @@ class NostrRelayManager private constructor() {
// Exponential backoff configuration (same as iOS)
private const val INITIAL_BACKOFF_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.INITIAL_BACKOFF_INTERVAL_MS // 1 second
private const val MAX_BACKOFF_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS // 5 minutes
private const val BACKOFF_MULTIPLIER = com.bitchat.android.util.AppConstants.Nostr.BACKOFF_MULTIPLIER
private const val MAX_RECONNECT_ATTEMPTS = com.bitchat.android.util.AppConstants.Nostr.MAX_RECONNECT_ATTEMPTS
private const val BACKOFF_MULTIPLIER = com.bitchat.android.util.AppConstants.Nostr.BACKOFF_MULTIPLIER
// Track gift-wraps we initiated for logging
private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet<String>()
fun registerPendingGiftWrap(id: String) {
if (pendingGiftWrapIDs.size >= 2_000) pendingGiftWrapIDs.clear()
pendingGiftWrapIDs.add(id)
}
@ -87,7 +92,7 @@ class NostrRelayManager private constructor() {
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
// Internal state
private val relaysList = mutableListOf<Relay>()
private val relaysList = java.util.concurrent.CopyOnWriteArrayList<Relay>()
private val connections = ConcurrentHashMap<String, WebSocket>()
private val reconnectJobs = ConcurrentHashMap<String, Job>()
private val desiredConnected = AtomicBoolean(false)
@ -118,6 +123,7 @@ class NostrRelayManager private constructor() {
private val messageQueue = NostrPendingEventQueue(MAX_QUEUED_EVENTS)
// Coroutine scope for background operations
private val resetGeneration = java.util.concurrent.atomic.AtomicLong()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// Subscription validation timer
@ -128,7 +134,7 @@ class NostrRelayManager private constructor() {
// OkHttp client for WebSocket connections (via provider to honor Tor)
private val httpClient: OkHttpClient
get() = com.bitchat.android.net.OkHttpProvider.webSocketClient()
get() = clientProvider()
private val gson by lazy { NostrRequest.createGson() }
@ -139,6 +145,19 @@ class NostrRelayManager private constructor() {
private val nonLiveRelayUrls = ConcurrentHashMap.newKeySet<String>()
private val liveLocationConnectionJobs = ConcurrentHashMap.newKeySet<Job>()
private val networkMonitoring = AtomicBoolean(false)
private fun monitorNetwork(context: android.content.Context) {
if (!networkMonitoring.compareAndSet(false, true)) return
val connectivity = context.getSystemService(android.net.ConnectivityManager::class.java) ?: return
try {
connectivity.registerDefaultNetworkCallback(object : android.net.ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: android.net.Network) {
if (desiredConnected.get()) connect()
}
})
} catch (_: Exception) { networkMonitoring.set(false) }
}
// --- Public API for geohash-specific operation ---
/**
@ -375,15 +394,10 @@ class NostrRelayManager private constructor() {
init {
// Initialize with default relays - avoid static initialization order issues
try {
val defaultRelayUrls = listOf(
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://offchain.pub",
"wss://nostr21.com"
)
val defaultRelayUrls = initialRelays
relaysList.addAll(defaultRelayUrls.map { Relay(it) })
nonLiveRelayUrls.addAll(defaultRelayUrls)
_relays.value = relaysList.toList()
_relays.value = relaysList.map { it.copy() }
updateConnectionStatus()
LiveLocationPrivacyGate.addRevocationListener(::revokeLiveLocationAccess)
} catch (e: Exception) {
@ -394,6 +408,22 @@ class NostrRelayManager private constructor() {
}
}
@Volatile var accountRelayUrls: List<String> = initialRelays.toList()
private set
/** Replace account relay endpoints after disconnecting; callers reinstall scoped subscriptions. */
fun configureAccountRelays(urls: List<String>) {
require(urls.isNotEmpty() && urls.all { it.startsWith("wss://") || it.startsWith("ws://") })
disconnect()
val previous = accountRelayUrls.toSet()
relaysList.removeAll { it.url in previous }
nonLiveRelayUrls.removeAll(previous)
accountRelayUrls = urls.distinct()
relaysList.addAll(accountRelayUrls.filter { url -> relaysList.none { it.url == url } }.map { Relay(it) })
nonLiveRelayUrls.addAll(accountRelayUrls)
updateRelaysList()
}
/**
* Connect to all configured relays
*/
@ -459,20 +489,20 @@ class NostrRelayManager private constructor() {
.distinct()
if (targetRelays.isEmpty()) return
val generation = resetGeneration.get()
val queued = runNetworkAction(liveLocationToken) {
val queueId = messageQueue.enqueue(
messageQueue.enqueue(
event = event,
relayUrls = targetRelays,
liveLocationToken = liveLocationToken
) ?: return@runNetworkAction
scope.launch {
if (!isNetworkActionAllowed(liveLocationToken)) return@launch
if (generation != resetGeneration.get() || !isNetworkActionAllowed(liveLocationToken)) return@launch
targetRelays.forEach { relayUrl ->
val webSocket = connections[relayUrl]
if (webSocket != null) {
if (sendToRelay(event, webSocket, relayUrl, liveLocationToken)) {
messageQueue.markDelivered(queueId, relayUrl)
}
// Keep queued until this relay's OK response.
sendToRelay(event, webSocket, relayUrl, liveLocationToken)
}
}
}
@ -480,6 +510,52 @@ class NostrRelayManager private constructor() {
if (!queued) return
}
private data class HistoryQuery(
val relay: String,
val filter: NostrFilter,
val events: MutableMap<String, NostrEvent> = linkedMapOf(),
val complete: CompletableDeferred<List<NostrEvent>> = CompletableDeferred()
)
private val closedRetries = ConcurrentHashMap.newKeySet<String>()
private val historyQueries = ConcurrentHashMap<String, HistoryQuery>()
suspend fun fetchGiftWraps(relay: String, pubkey: String, since: Int, until: Int, limit: Int): List<NostrEvent>? {
val socket = connections[relay] ?: return null
val id = "history-${UUID.randomUUID()}"
val filter = NostrFilter.giftWrapsFor(pubkey, since.toLong() * 1000).copy(until = until, limit = limit)
val query = HistoryQuery(relay, filter)
historyQueries[id] = query
return try {
if (!socket.send(gson.toJson(NostrRequest.Subscribe(id, listOf(filter)), NostrRequest::class.java))) return null
withTimeoutOrNull(15_000) { query.complete.await() }
} finally {
historyQueries.remove(id, query)
socket.send(gson.toJson(NostrRequest.Close(id), NostrRequest::class.java))
}
}
private data class Publication(
val remaining: MutableSet<String>,
val result: CompletableDeferred<Boolean>
)
private val publications = ConcurrentHashMap<String, Publication>()
/** True only after at least one selected relay acknowledges storage of this signed event. */
suspend fun publishConfirmed(event: NostrEvent, relayUrls: List<String> = accountRelayUrls): Boolean {
val publication = Publication(relayUrls.toMutableSet(), CompletableDeferred())
val previous = publications.putIfAbsent(event.id, publication)
if (previous != null) return withTimeoutOrNull(10_000) { previous.result.await() } ?: false
return try {
ensureConnectionsFor(relayUrls.toSet())
// The durable DM worker owns retries. Do not leave a second, uncancellable
// in-memory copy that could publish after a message or contact is deleted.
relayUrls.forEach { relay -> connections[relay]?.let { sendToRelay(event, it, relay, null) } }
withTimeoutOrNull(10_000) { publication.result.await() } ?: false
} finally {
publications.remove(event.id, publication)
}
}
/**
* Subscribe to events matching a filter
* The subscription will be automatically re-established on reconnection
@ -519,7 +595,7 @@ class NostrRelayManager private constructor() {
val message = gson.toJson(request, NostrRequest::class.java)
scope.launch {
if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return@launch
if (activeSubscriptions[subscriptionInfo.id] !== subscriptionInfo || !isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return@launch
val targetRelays = subscriptionInfo.targetRelayUrls?.toList() ?: connections.keys.toList()
targetRelays.forEach { relayUrl ->
@ -574,6 +650,7 @@ class NostrRelayManager private constructor() {
val message = gson.toJson(request, NostrRequest::class.java)
scope.launch {
if (activeSubscriptions.containsKey(id)) return@launch
if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) {
closeSubscriptionsOnConnectedRelays(setOf(id))
subscriptions.replaceAll { _, ids -> ids - id }
@ -662,6 +739,7 @@ class NostrRelayManager private constructor() {
* Intended for panic/reset flows prior to reconnecting and re-subscribing from scratch.
*/
fun clearAllSubscriptions() {
resetGeneration.incrementAndGet()
try {
// Clear persistent subscription tracking
activeSubscriptions.clear()
@ -673,6 +751,11 @@ class NostrRelayManager private constructor() {
// Clear any queued messages waiting to be sent
messageQueue.clear()
pendingGiftWrapIDs.clear()
publications.values.forEach { it.result.cancel() }
publications.clear()
historyQueries.values.forEach { it.complete.cancel() }
historyQueries.clear()
Log.i(TAG, "Cleared all Nostr subscriptions and routing caches")
} catch (e: Exception) {
@ -914,6 +997,7 @@ class NostrRelayManager private constructor() {
private fun handleMessage(message: String, relayUrl: String) {
try {
if (message.length > 32 * 1024 * 1024) return
val jsonElement = JsonParser.parseString(message)
if (!jsonElement.isJsonArray) {
Log.w(TAG, "Received non-array message from relay")
@ -924,6 +1008,14 @@ class NostrRelayManager private constructor() {
when (response) {
is NostrResponse.Event -> {
historyQueries[response.subscriptionId]?.let { query ->
if (query.relay == relayUrl && query.filter.matches(response.event) && response.event.isValidSignature()) {
synchronized(query) {
if (query.events.size < (query.filter.limit ?: 500)) query.events[response.event.id] = response.event
}
}
return
}
// Update relay stats
relaysList.find { it.url == relayUrl }?.let { relay ->
relay.messagesReceived += 1
@ -942,13 +1034,14 @@ class NostrRelayManager private constructor() {
}
}
// DEDUPLICATION: Check if we've already processed this event
eventDeduplicator.processEvent(response.event) { event ->
if (!response.event.isValidSignature()) return
val generation = resetGeneration.get()
val dispatch: (NostrEvent) -> Unit = { event ->
// Call handler for new events only
val handler = messageHandlers[response.subscriptionId]
if (handler != null) {
scope.launch(Dispatchers.Main) {
if (isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) {
if (generation == resetGeneration.get() && isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) {
handler(event)
}
}
@ -956,14 +1049,42 @@ class NostrRelayManager private constructor() {
Log.w(TAG, "⚠️ No handler for Nostr subscription")
}
}
if (response.event.kind == NostrKind.GIFT_WRAP) dispatch(response.event)
else eventDeduplicator.processEvent(response.event, dispatch)
}
is NostrResponse.Closed -> {
historyQueries[response.subscriptionId]?.let { query ->
if (query.relay == relayUrl) query.complete.cancel()
}
subscriptions.computeIfPresent(relayUrl) { _, ids -> ids - response.subscriptionId }
val info = activeSubscriptions[response.subscriptionId]
val retryKey = "$relayUrl:${response.subscriptionId}"
if (info != null && closedRetries.add(retryKey)) scope.launch {
try {
delay(30_000)
if (desiredConnected.get() && activeSubscriptions[info.id] === info) sendSubscriptionToRelays(info)
} finally { closedRetries.remove(retryKey) }
}
}
is NostrResponse.EndOfStoredEvents -> {
// No action needed
historyQueries[response.subscriptionId]?.let { query ->
if (query.relay == relayUrl) synchronized(query) { query.complete.complete(query.events.values.toList()) }
}
}
is NostrResponse.Ok -> {
messageQueue.acknowledge(response.eventId, relayUrl)
publications[response.eventId]?.let { publication ->
synchronized(publication) {
if (publication.remaining.remove(relayUrl)) {
if (response.accepted) publication.result.complete(true)
else if (publication.remaining.isEmpty()) publication.result.complete(false)
}
}
}
val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId)
if (!response.accepted) {
val level = if (wasGiftWrap) Log.WARN else Log.ERROR
@ -994,6 +1115,7 @@ class NostrRelayManager private constructor() {
// newer socket or schedule a reconnect after a controlled disconnect/privacy revocation.
if (!connections.remove(relayUrl, webSocket)) return
subscriptions.remove(relayUrl)
historyQueries.values.filter { it.relay == relayUrl }.forEach { it.complete.cancel() }
handleCurrentDisconnection(relayUrl, error, liveLocationToken)
}
@ -1019,29 +1141,9 @@ class NostrRelayManager private constructor() {
!isNetworkActionAllowed(connectionToken)
) return
// Check if this is a DNS error
val errorMessage = error.message?.lowercase() ?: ""
if (errorMessage.contains("hostname could not be found") ||
errorMessage.contains("dns") ||
errorMessage.contains("unable to resolve host")) {
val relay = relaysList.find { it.url == relayUrl }
if (relay?.lastError == null) {
Log.w(TAG, "Nostr relay DNS failure; not retrying")
}
return
}
// Implement exponential backoff for non-DNS errors
val relay = relaysList.find { it.url == relayUrl } ?: return
relay.reconnectAttempts++
// Stop attempting after max attempts
if (relay.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
Log.w(TAG, "Max Nostr relay reconnection attempts reached")
return
}
relay.reconnectAttempts = (relay.reconnectAttempts + 1).coerceAtMost(MAX_RECONNECT_ATTEMPTS)
// Calculate backoff interval
val backoffInterval = min(
INITIAL_BACKOFF_INTERVAL * BACKOFF_MULTIPLIER.pow(relay.reconnectAttempts - 1.0),
@ -1086,7 +1188,7 @@ class NostrRelayManager private constructor() {
}
private fun updateRelaysList() {
_relays.value = relaysList.toList()
_relays.value = relaysList.map { it.copy() }
}
private fun updateConnectionStatus() {
@ -1163,15 +1265,8 @@ class NostrRelayManager private constructor() {
val queuedForRelay = messageQueue.pendingForRelay(relayUrl)
.filter { isNetworkActionAllowed(it.liveLocationToken) }
queuedForRelay.forEach { delivery ->
if (sendToRelay(
delivery.event,
webSocket,
relayUrl,
delivery.liveLocationToken
)
) {
messageQueue.markDelivered(delivery.queueId, relayUrl)
}
// Keep queued until this relay's OK response.
sendToRelay(delivery.event, webSocket, relayUrl, delivery.liveLocationToken)
}
}
@ -1181,7 +1276,8 @@ class NostrRelayManager private constructor() {
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
// Server-initiated close; onClosed will follow
// Complete the close handshake so onClosed can schedule reconnection.
webSocket.close(code, reason)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {

View File

@ -96,6 +96,8 @@ sealed class NostrResponse {
/**
* EOSE response - end of stored events
*/
data class Closed(val subscriptionId: String) : NostrResponse()
data class EndOfStoredEvents(
val subscriptionId: String
) : NostrResponse()
@ -141,6 +143,8 @@ sealed class NostrResponse {
}
}
"CLOSED" -> if (jsonArray.size() >= 2) Closed(jsonArray[1].asString) else Unknown(jsonArray.toString())
"EOSE" -> {
if (jsonArray.size() >= 2) {
val subscriptionId = jsonArray[1].asString

View File

@ -35,7 +35,8 @@ class NostrSubscriptionManager(
sinceMs: Long,
id: String,
handler: (NostrEvent) -> Unit,
liveLocationToken: Long? = null
liveLocationToken: Long? = null,
targetRelayUrls: List<String>? = null
) {
if (!isAllowed(liveLocationToken)) return
val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs)
@ -44,6 +45,7 @@ class NostrSubscriptionManager(
id = id,
handler = handler,
owner = owner,
targetRelayUrls = targetRelayUrls,
liveLocationToken = liveLocationToken
)
}

View File

@ -1,276 +0,0 @@
package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
import kotlinx.coroutines.*
/**
* Test manager for Nostr functionality
* Use this to verify the Nostr client works correctly
*/
class NostrTestManager(private val context: Context) {
companion object {
private const val TAG = "NostrTestManager"
}
private val testScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private lateinit var nostrClient: NostrClient
/**
* Run comprehensive Nostr tests
*/
fun runTests() {
Log.i(TAG, "🧪 Starting Nostr functionality tests...")
testScope.launch {
try {
// Test 1: Initialize client
testClientInitialization()
// Test 2: Test identity generation and storage
testIdentityManagement()
// Test 3: Test relay connections
testRelayConnections()
// Test 4: Test cryptography
testCryptography()
// Test 5: Test Bech32 encoding
testBech32()
// Test 6: Test message subscription (without sending)
testMessageSubscription()
Log.i(TAG, "✅ All Nostr tests completed successfully!")
} catch (e: Exception) {
Log.e(TAG, "❌ Nostr tests failed: ${e.message}", e)
}
}
}
private suspend fun testClientInitialization() {
Log.d(TAG, "Testing client initialization...")
nostrClient = NostrClient.getInstance(context)
nostrClient.initialize()
// Wait for initialization
delay(2000)
val isInitialized = nostrClient.isInitialized.value ?: false
require(isInitialized) { "Client failed to initialize" }
Log.d(TAG, "✅ Client initialization successful")
}
private suspend fun testIdentityManagement() {
Log.d(TAG, "Testing identity management...")
// Test current identity
val identity = nostrClient.getCurrentIdentity()
requireNotNull(identity) { "No current identity" }
Log.d(TAG, "Current identity npub: ${identity.getShortNpub()}")
require(identity.npub.startsWith("npub1")) { "Invalid npub format" }
require(identity.publicKeyHex.length == 64) { "Invalid public key length" }
require(identity.privateKeyHex.length == 64) { "Invalid private key length" }
// Test geohash identity derivation
val geohashIdentity = NostrIdentityBridge.deriveIdentity("u4pruydq", context)
require(geohashIdentity.npub.startsWith("npub1")) { "Invalid geohash identity npub" }
require(geohashIdentity.publicKeyHex != identity.publicKeyHex) { "Geohash identity should be different" }
Log.d(TAG, "Geohash identity npub: ${geohashIdentity.getShortNpub()}")
Log.d(TAG, "✅ Identity management test successful")
}
private suspend fun testRelayConnections() {
Log.d(TAG, "Testing relay connections...")
// Wait for potential relay connections
delay(3000)
val relayInfo = nostrClient.relayInfo.value ?: emptyList()
require(relayInfo.isNotEmpty()) { "No relays configured" }
Log.d(TAG, "Configured relays: ${relayInfo.size}")
relayInfo.forEach { relay ->
Log.d(TAG, "Relay: ${relay.url} - Connected: ${relay.isConnected}")
}
Log.d(TAG, "✅ Relay configuration test successful")
}
private suspend fun testCryptography() {
Log.d(TAG, "Testing cryptography functions...")
// Test key generation
val (privateKey, publicKey) = NostrCrypto.generateKeyPair()
require(privateKey.length == 64) { "Invalid private key length" }
require(publicKey.length == 64) { "Invalid public key length" }
require(NostrCrypto.isValidPrivateKey(privateKey)) { "Generated private key is invalid" }
require(NostrCrypto.isValidPublicKey(publicKey)) { "Generated public key is invalid" }
// Test key derivation
val derivedPublic = NostrCrypto.derivePublicKey(privateKey)
require(derivedPublic == publicKey) { "Key derivation mismatch" }
// Test encryption/decryption
val (recipientPrivate, recipientPublic) = NostrCrypto.generateKeyPair()
val plaintext = "Hello, Nostr world! This is a test message."
val encrypted = NostrCrypto.encryptNIP44(
plaintext,
recipientPublic,
privateKey
)
require(encrypted.isNotEmpty()) { "Encryption failed" }
val decrypted = NostrCrypto.decryptNIP44(encrypted, publicKey, recipientPrivate)
require(decrypted == plaintext) { "Decryption failed: expected '$plaintext', got '$decrypted'" }
Log.d(TAG, "✅ Cryptography test successful")
}
private suspend fun testBech32() {
Log.d(TAG, "Testing Bech32 encoding...")
val testData = "hello world test data for bech32".toByteArray()
val encoded = Bech32.encode("test", testData)
require(encoded.startsWith("test1")) { "Invalid bech32 encoding" }
val (hrp, decoded) = Bech32.decode(encoded)
require(hrp == "test") { "HRP mismatch" }
require(decoded.contentEquals(testData)) { "Data mismatch after decode" }
// Test with actual public key
val (_, publicKey) = NostrCrypto.generateKeyPair()
val npub = Bech32.encode("npub", publicKey.hexToByteArray())
require(npub.startsWith("npub1")) { "Invalid npub encoding" }
val (npubHrp, npubData) = Bech32.decode(npub)
require(npubHrp == "npub") { "npub HRP mismatch" }
require(npubData.toHexString() == publicKey) { "npub data mismatch" }
Log.d(TAG, "✅ Bech32 test successful")
}
private suspend fun testMessageSubscription() {
Log.d(TAG, "Testing message subscription...")
var messageReceived = false
// Subscribe to private messages (won't receive any in test, but tests the subscription mechanism)
nostrClient.subscribeToPrivateMessages { content, senderNpub, timestamp ->
Log.d(TAG, "Received test private message (${content.length} chars)")
messageReceived = true
}
// Subscribe to a test geohash
nostrClient.subscribeToGeohash("u4pru") { content, senderPubkey, nickname, timestamp ->
Log.d(TAG, "📥 Received test geohash message from ${senderPubkey.take(16)}...: $content")
messageReceived = true
}
// Wait a bit to see if any messages come through
delay(2000)
Log.d(TAG, "✅ Message subscription test successful (no messages expected in test)")
}
/**
* Test sending a message to yourself (loopback test)
*/
fun testLoopbackMessage() {
testScope.launch {
try {
val identity = nostrClient.getCurrentIdentity()
requireNotNull(identity) { "No identity available for loopback test" }
Log.i(TAG, "🔄 Testing loopback private message...")
// Send message to ourselves
nostrClient.sendPrivateMessage(
content = "Test loopback message at ${System.currentTimeMillis()}",
recipientNpub = identity.npub,
onSuccess = {
Log.i(TAG, "✅ Loopback message sent successfully")
},
onError = { error ->
Log.e(TAG, "❌ Loopback message failed: $error")
}
)
} catch (e: Exception) {
Log.e(TAG, "❌ Loopback test failed: ${e.message}", e)
}
}
}
/**
* Test sending a geohash message
*/
fun testGeohashMessage() {
testScope.launch {
try {
Log.i(TAG, "🌍 Testing geohash message...")
nostrClient.sendGeohashMessage(
content = "Test geohash message from Android at ${System.currentTimeMillis()}",
geohash = "u4pru",
nickname = "android-test",
onSuccess = {
Log.i(TAG, "✅ Geohash message sent successfully")
},
onError = { error ->
Log.e(TAG, "❌ Geohash message failed: $error")
}
)
} catch (e: Exception) {
Log.e(TAG, "❌ Geohash test failed: ${e.message}", e)
}
}
}
/**
* Get debug information about the Nostr client
*/
fun getDebugInfo(): String {
return buildString {
appendLine("=== Nostr Client Debug Info ===")
val identity = nostrClient.getCurrentIdentity()
if (identity != null) {
appendLine("Identity: ${identity.getShortNpub()}")
appendLine("Public Key: ${identity.publicKeyHex.take(16)}...")
appendLine("Created: ${java.util.Date(identity.createdAt)}")
} else {
appendLine("No identity loaded")
}
val isInitialized = nostrClient.isInitialized.value ?: false
appendLine("Initialized: $isInitialized")
val isConnected = nostrClient.relayConnectionStatus.value ?: false
appendLine("Relay Connected: $isConnected")
val relays = nostrClient.relayInfo.value ?: emptyList()
appendLine("Relays (${relays.size}):")
relays.forEach { relay ->
appendLine(" ${relay.url}: ${if (relay.isConnected) "✅" else "❌"} (sent: ${relay.messagesSent}, received: ${relay.messagesReceived})")
}
}
}
/**
* Shutdown test manager
*/
fun shutdown() {
testScope.cancel()
nostrClient.shutdown()
}
}

View File

@ -1,459 +1,102 @@
package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import kotlinx.coroutines.*
import java.util.*
import java.util.concurrent.ConcurrentLinkedQueue
import com.bitchat.android.services.PrivateDeliveryCoordinator
import com.bitchat.android.services.PrivateDeliveryJob
import com.google.gson.Gson
/**
* Nostr transport for offline private messages and receipts.
*/
class NostrTransport(
private val context: Context,
var senderPeerID: String = ""
) {
/** Bitchat wire adapter. Persistence, retry and delivery state belong to the coordinator. */
class NostrTransport(private val context: Context, var senderPeerID: String = "") {
companion object {
private const val TAG = "NostrTransport"
private const val READ_ACK_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.READ_ACK_INTERVAL_MS // ~3 per second (0.35s interval like iOS)
@Volatile
private var INSTANCE: NostrTransport? = null
fun getInstance(context: Context): NostrTransport {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: NostrTransport(context.applicationContext).also { INSTANCE = it }
}
@Volatile private var instance: NostrTransport? = null
fun getInstance(context: Context): NostrTransport = instance ?: synchronized(this) {
instance ?: NostrTransport(context.applicationContext).also { instance = it }
}
}
// Throttle READ receipts to avoid relay rate limits (like iOS)
private data class QueuedRead(
val receipt: ReadReceipt,
val peerID: String
)
private val readQueue = ConcurrentLinkedQueue<QueuedRead>()
private var isSendingReadAcks = false
private val transportScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// MARK: - Transport Interface Methods
val myPeerID: String get() = senderPeerID
fun sendPrivateMessage(
content: String,
to: String,
recipientNickname: String,
messageID: String
) {
transportScope.launch {
try {
val recipientNostrPubkey = resolveNostrPublicKey(to)
if (recipientNostrPubkey == null) {
Log.w(TAG, "No Nostr public key found for peerID: $to")
return@launch
}
val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
if (senderIdentity == null) {
Log.e(TAG, "No Nostr identity available")
return@launch
}
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
if (recipientHex == null) {
Log.e(TAG, "NostrTransport: recipient key is not a valid Nostr pubkey")
return@launch
}
private val gson = Gson()
private val coordinator get() = PrivateDeliveryCoordinator.getInstance(context)
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")
return@launch
}
val embedded = NostrEmbeddedBitChat.encodePMForNostr(
content = content,
messageID = messageID,
recipientPeerID = recipientPeerIDForEmbed,
senderPeerID = senderPeerID
)
if (embedded == null) {
Log.e(TAG, "NostrTransport: failed to embed PM packet")
return@launch
}
val giftWraps = NostrProtocol.createPrivateMessage(
content = embedded,
recipientPubkey = recipientHex,
senderIdentity = senderIdentity
)
giftWraps.forEach { event ->
NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
fun sendPrivateMessage(content: String, to: String, recipientNickname: String, messageID: String) {
coordinator.enqueue(PrivateDeliveryJob("message:$messageID", ContactDirectory.canonicalConversationId(to), messageID,
PrivateDeliveryJob.Kind.MESSAGE, content, recipientNickname))
}
} catch (e: Exception) {
Log.e(TAG, "Failed to send private message via Nostr: ${e.message}")
}
}
}
fun sendReadReceipt(receipt: ReadReceipt, to: String) {
// Enqueue and process with throttling to avoid relay rate limits
readQueue.offer(QueuedRead(receipt, to))
processReadQueueIfNeeded()
}
private fun processReadQueueIfNeeded() {
if (isSendingReadAcks) return
if (readQueue.isEmpty()) return
isSendingReadAcks = true
sendNextReadAck()
}
private fun sendNextReadAck() {
val item = readQueue.poll()
if (item == null) {
isSendingReadAcks = false
return
}
transportScope.launch {
try {
val recipientNostrPubkey = resolveNostrPublicKey(item.peerID)
if (recipientNostrPubkey == null) {
Log.w(TAG, "No Nostr public key found for read receipt to: ${item.peerID}")
scheduleNextReadAck()
return@launch
}
val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
if (senderIdentity == null) {
Log.e(TAG, "No Nostr identity available for read receipt")
scheduleNextReadAck()
return@launch
}
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
if (recipientHex == null) {
scheduleNextReadAck()
return@launch
}
val ack = NostrEmbeddedBitChat.encodeAckForNostr(
type = NoisePayloadType.READ_RECEIPT,
messageID = item.receipt.originalMessageID,
recipientPeerID = item.peerID,
senderPeerID = senderPeerID
)
if (ack == null) {
Log.e(TAG, "NostrTransport: failed to embed READ ack")
scheduleNextReadAck()
return@launch
}
val giftWraps = NostrProtocol.createPrivateMessage(
content = ack,
recipientPubkey = recipientHex,
senderIdentity = senderIdentity
)
giftWraps.forEach { event ->
NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
scheduleNextReadAck()
} catch (e: Exception) {
Log.e(TAG, "Failed to send read receipt via Nostr: ${e.message}")
scheduleNextReadAck()
}
}
}
private fun scheduleNextReadAck() {
transportScope.launch {
delay(READ_ACK_INTERVAL)
isSendingReadAcks = false
processReadQueueIfNeeded()
}
}
fun sendFavoriteNotification(to: String, isFavorite: Boolean) {
transportScope.launch {
try {
val recipientNostrPubkey = resolveNostrPublicKey(to)
if (recipientNostrPubkey == null) {
Log.w(TAG, "No Nostr public key found for favorite notification to: $to")
return@launch
}
val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
if (senderIdentity == null) {
Log.e(TAG, "No Nostr identity available for favorite notification")
return@launch
}
val content = FavoriteControlMessage.encode(isFavorite, senderIdentity.npub)
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
if (recipientHex == null) {
return@launch
}
val embedded = NostrEmbeddedBitChat.encodePMForNostr(
content = content,
messageID = UUID.randomUUID().toString(),
recipientPeerID = to,
senderPeerID = senderPeerID
)
if (embedded == null) {
Log.e(TAG, "NostrTransport: failed to embed favorite notification")
return@launch
}
val giftWraps = NostrProtocol.createPrivateMessage(
content = embedded,
recipientPubkey = recipientHex,
senderIdentity = senderIdentity
)
giftWraps.forEach { event ->
NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to send favorite notification via Nostr: ${e.message}")
}
}
// The relationship repository persists the latest intent. The worker recovers it even
// if this wake-up is lost to process death or precedes key exchange.
coordinator.wake()
}
fun sendDeliveryAck(messageID: String, to: String) {
transportScope.launch {
try {
val recipientNostrPubkey = resolveNostrPublicKey(to)
if (recipientNostrPubkey == null) {
Log.w(TAG, "No Nostr public key found for delivery ack to: $to")
return@launch
}
val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
if (senderIdentity == null) {
Log.e(TAG, "No Nostr identity available for delivery ack")
return@launch
}
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
if (recipientHex == null) {
return@launch
}
val ack = NostrEmbeddedBitChat.encodeAckForNostr(
type = NoisePayloadType.DELIVERED,
messageID = messageID,
recipientPeerID = to,
senderPeerID = senderPeerID
)
if (ack == null) {
Log.e(TAG, "NostrTransport: failed to embed DELIVERED ack")
return@launch
}
val giftWraps = NostrProtocol.createPrivateMessage(
content = ack,
recipientPubkey = recipientHex,
senderIdentity = senderIdentity
)
giftWraps.forEach { event ->
NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
fun sendReadReceipt(receipt: ReadReceipt, to: String) = queueReceipt(receipt.originalMessageID, to, true)
fun sendDeliveryAck(messageID: String, to: String) = queueReceipt(messageID, to, false)
} catch (e: Exception) {
Log.e(TAG, "Failed to send delivery ack via Nostr: ${e.message}")
}
}
private fun queueReceipt(messageID: String, to: String, read: Boolean) {
val conversation = ContactDirectory.canonicalConversationId(to)
val kind = if (read) PrivateDeliveryJob.Kind.READ else PrivateDeliveryJob.Kind.DELIVERED
val geo = GeohashConversationRegistry.get(to)
coordinator.enqueue(PrivateDeliveryJob("${kind.name}:$conversation:$messageID", conversation, messageID, kind,
recipientPubkey = if (geo != null) GeohashAliasRegistry.get(to) else null, sourceGeohash = geo))
}
// MARK: - Geohash ACK helpers (for per-geohash identity DMs)
fun sendDeliveryAckGeohash(
messageID: String,
toRecipientHex: String,
fromIdentity: NostrIdentity
) {
transportScope.launch {
try {
val embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
type = NoisePayloadType.DELIVERED,
messageID = messageID,
senderPeerID = senderPeerID
)
if (embedded == null) return@launch
val giftWraps = NostrProtocol.createPrivateMessage(
content = embedded,
recipientPubkey = toRecipientHex,
senderIdentity = fromIdentity
)
// Register pending gift wrap for deduplication and send all
giftWraps.forEach { event ->
NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to send geohash delivery ack: ${e.message}")
}
}
fun sendDeliveryAckGeohash(messageID: String, toRecipientHex: String, fromIdentity: NostrIdentity, sourceGeohash: String? = null) =
queueReceiptToKey(messageID, toRecipientHex, fromIdentity, false, sourceGeohash)
fun sendReadReceiptGeohash(messageID: String, toRecipientHex: String, fromIdentity: NostrIdentity, sourceGeohash: String? = null) =
queueReceiptToKey(messageID, toRecipientHex, fromIdentity, true, sourceGeohash)
private fun queueReceiptToKey(messageID: String, pubkey: String, identity: NostrIdentity, read: Boolean, geohash: String?) {
val account = NostrIdentityBridge.getCurrentNostrIdentity(context) ?: return
require(geohash != null || identity.publicKeyHex == account.publicKeyHex) { "Missing receipt identity scope" }
val alias = requireNotNull(ContactIdentityResolver.nostrAliasForPubkey(pubkey))
val conversation = ContactDirectory.canonicalConversationId(alias)
val kind = if (read) PrivateDeliveryJob.Kind.READ else PrivateDeliveryJob.Kind.DELIVERED
coordinator.enqueue(PrivateDeliveryJob("${kind.name}:$conversation:$messageID", conversation, messageID, kind,
recipientPubkey = pubkey, sourceGeohash = geohash))
}
fun sendReadReceiptGeohash(
messageID: String,
toRecipientHex: String,
fromIdentity: NostrIdentity
) {
transportScope.launch {
try {
val embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
type = NoisePayloadType.READ_RECEIPT,
messageID = messageID,
senderPeerID = senderPeerID
)
if (embedded == null) return@launch
val giftWraps = NostrProtocol.createPrivateMessage(
content = embedded,
recipientPubkey = toRecipientHex,
senderIdentity = fromIdentity
)
// Register pending gift wrap for deduplication and send all
giftWraps.forEach { event ->
NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to send geohash read receipt: ${e.message}")
}
}
fun sendPrivateMessageGeohash(content: String, toRecipientHex: String, messageID: String, sourceGeohash: String? = null) {
val alias = requireNotNull(ContactIdentityResolver.nostrAliasForPubkey(toRecipientHex))
val geohash = sourceGeohash ?: GeohashConversationRegistry.get(alias) ?: return
coordinator.enqueue(PrivateDeliveryJob("message:$messageID", alias, messageID, PrivateDeliveryJob.Kind.MESSAGE,
content = content, recipientPubkey = toRecipientHex, sourceGeohash = geohash))
}
// MARK: - Geohash DMs (per-geohash identity)
fun sendPrivateMessageGeohash(
content: String,
toRecipientHex: 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
internal fun prepare(job: PrivateDeliveryJob): PrivateDeliveryJob {
val identity = job.sourceGeohash?.let { NostrIdentityBridge.deriveIdentity(it, context) }
?: requireNotNull(NostrIdentityBridge.getCurrentNostrIdentity(context)) { "Identity unavailable" }
require(job.sourceIdentityPubkey == null || job.sourceIdentityPubkey == identity.publicKeyHex) { "Delivery identity changed" }
val contact = ContactDirectory.resolve(job.conversationID)
val recipient = requireNotNull(ContactIdentityResolver.nostrPubkeyHex(
(if (job.sourceGeohash == null) contact.nostrPubkey else job.recipientPubkey) ?: job.recipientPubkey ?: error("Recipient key unavailable")
)) { "Invalid recipient key" }
if (job.eventJson != null && job.recipientPubkey == recipient && job.sourceIdentityPubkey == identity.publicKeyHex) return job
val sender = senderPeerID.takeIf(ContactIdentityResolver::isMeshPeerId) ?: "0000000000000000"
// The authenticated Nostr recipient is authoritative. Account PMs retain their legacy
// recipient field for older Bitchat peers; ephemeral mesh IDs are never used as identity.
val embedded = when (job.kind) {
PrivateDeliveryJob.Kind.MESSAGE, PrivateDeliveryJob.Kind.FAVORITE -> {
if (job.sourceGeohash == null && contact.noiseKeyHex != null) {
NostrEmbeddedBitChat.encodePMForNostr(job.content, job.messageID, contact.noiseKeyHex!!, sender, job.createdAt)
} else NostrEmbeddedBitChat.encodePMForNostrNoRecipient(job.content, job.messageID, sender, job.createdAt)
}
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
// Build embedded BitChat packet without recipient peer ID
val embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content = content,
messageID = messageID,
senderPeerID = senderPeerID
) ?: 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 ->
NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to send geohash private message: ${e.message}")
}
}
PrivateDeliveryJob.Kind.DELIVERED, PrivateDeliveryJob.Kind.READ ->
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
if (job.kind == PrivateDeliveryJob.Kind.READ) NoisePayloadType.READ_RECEIPT else NoisePayloadType.DELIVERED,
job.messageID, sender)
} ?: error("Message encoding failed")
val event = NostrProtocol.createPrivateMessage(embedded, recipient, identity, job.createdAt).single()
return job.copy(eventJson = gson.toJson(event), recipientPubkey = recipient, sourceIdentityPubkey = identity.publicKeyHex)
}
// MARK: - Helper Methods
/**
* Resolve Nostr public key for a peer ID
*/
private fun resolveNostrPublicKey(peerID: String): String? {
try {
ContactDirectory.resolve(peerID).nostrPubkey?.let { return it }
com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(peerID)?.let { return it }
if (ContactIdentityResolver.isNoiseKeyHex(peerID)) {
val noiseKey = ContactIdentityResolver.bytesFromHex(peerID) ?: return null
val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
if (favoriteStatus?.peerNostrPublicKey != null) return favoriteStatus.peerNostrPublicKey
}
if (ContactIdentityResolver.isMeshPeerId(peerID)) {
val fallbackStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
return fallbackStatus?.peerNostrPublicKey
}
return null
} catch (e: Exception) {
Log.e(TAG, "Failed to resolve Nostr public key for $peerID: ${e.message}")
return null
}
}
fun cleanup() {
transportScope.cancel()
internal suspend fun publish(job: PrivateDeliveryJob): Boolean {
val event = gson.fromJson(requireNotNull(job.eventJson), NostrEvent::class.java)
return NostrRelayManager.getInstance(context).publishConfirmed(event)
}
fun cleanup() = coordinator.stop()
}

View File

@ -54,6 +54,7 @@ object AppShutdownCoordinator {
app.sendBroadcast(intent, com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH)
} catch (_: Exception) { }
com.bitchat.android.services.PrivateDeliveryCoordinator.getInstance(app).stop()
// Stop mesh (best-effort)
try { mesh?.stopServices() } catch (_: Exception) { }
try { com.bitchat.android.nostr.NostrRelayManager.shared.disconnect() } catch (_: Exception) { }

View File

@ -62,7 +62,9 @@ class ConversationNotificationReceiver : BroadcastReceiver() {
val persisted = AppStateStore.addPrivateMessageDurably(
peerID = conversationID,
msg = message,
forceRead = true
forceRead = true,
queueForDelivery = true,
outgoingJob = MessageRouter.deliveryJob(message, conversationID)
)
if (persisted) {
MessageRouter.getInstance(context.applicationContext, mesh)

View File

@ -259,6 +259,48 @@ object AppStateStore {
}
}
@Synchronized
fun privateConversationToken(): Long? = privateConversationGeneration.takeUnless { privateConversationWritesSuspended }
enum class PrivateAdmission { INSERTED, ALREADY_STORED, REJECTED, RETRYABLE_FAILURE }
fun incomingLocalID(conversation: String, wireID: String): String =
"incoming_" + java.security.MessageDigest.getInstance("SHA-256")
.digest("$conversation:$wireID".toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
suspend fun isIncomingPrivateStored(message: BitchatMessage): Boolean {
val conversation = message.senderPeerID?.let(ContactDirectory::canonicalConversationId) ?: return false
val repository = conversationRepository ?: return false
val wireID = message.wireMessageID ?: message.id
val stored = repository.storedMessage(conversation, incomingLocalID(conversation, wireID))
?: repository.storedMessage(conversation, wireID)
?: return false
return stored.content == message.content && stored.senderPeerID?.let(ContactDirectory::canonicalConversationId) == conversation
}
suspend fun admitIncomingPrivate(
message: BitchatMessage,
forceRead: Boolean = false,
receiptJob: PrivateDeliveryJob? = null
): PrivateAdmission {
if (synchronized(this) { privateConversationWritesSuspended }) return PrivateAdmission.REJECTED
val conversation = message.senderPeerID?.let(ContactDirectory::canonicalConversationId) ?: return PrivateAdmission.REJECTED
val repository = conversationRepository ?: return PrivateAdmission.RETRYABLE_FAILURE
if (isIncomingPrivateStored(message)) {
if (receiptJob != null) repository.saveDelivery(receiptJob, replace = false)
return PrivateAdmission.ALREADY_STORED
}
val wireID = message.wireMessageID ?: message.id
val localID = incomingLocalID(conversation, wireID)
if (repository.isDeletedMessage(localID) || repository.isDeletedMessage(wireID) ||
repository.storedMessage(conversation, localID) != null) return PrivateAdmission.REJECTED
val local = message.copy(id = localID, senderPeerID = conversation, wireMessageID = wireID)
return if (addPrivateMessageDurably(conversation, local, forceRead, receiptJob = receiptJob)) {
PrivateAdmission.INSERTED
} else if (isIncomingPrivateStored(message)) PrivateAdmission.ALREADY_STORED else PrivateAdmission.RETRYABLE_FAILURE
}
/**
* Persists an incoming private message before it is admitted to UI, unread, haptic, or
* notification state. Transport callbacks invoke this from their background worker.
@ -266,7 +308,10 @@ object AppStateStore {
suspend fun addPrivateMessageDurably(
peerID: String,
msg: BitchatMessage,
forceRead: Boolean = false
forceRead: Boolean = false,
queueForDelivery: Boolean = false,
receiptJob: PrivateDeliveryJob? = null,
outgoingJob: PrivateDeliveryJob? = null
): Boolean {
val persistence = synchronized(this) {
if (privateConversationWritesSuspended) return false
@ -285,7 +330,10 @@ object AppStateStore {
aliases = persistence.aliases,
displayName = persistence.displayName,
message = msg,
isRead = persistence.isRead
isRead = persistence.isRead,
queueForDelivery = queueForDelivery,
receiptJob = receiptJob?.copy(localMessageID = msg.id),
outgoingJob = outgoingJob
)
return synchronized(this) {
reservedPrivateMessageIds.remove(msg.id)
@ -324,7 +372,7 @@ object AppStateStore {
val isRead = forceRead ||
msg.sender == "system" ||
msg.sender == _nickname.value ||
(msg.senderNostrPubkey == null && msg.senderPeerID?.let(ContactDirectory::canonicalConversationId)?.let { it != conversationID } == true) ||
_selectedPrivateChatPeer.value
?.let(ContactDirectory::canonicalConversationId)
?.equals(conversationID, ignoreCase = true) == true
@ -367,7 +415,7 @@ object AppStateStore {
val existingMessages = _privateMessages.value[conversationID].orEmpty()
val isRead = forceRead ||
msg.sender == "system" ||
msg.sender == _nickname.value ||
(msg.senderNostrPubkey == null && msg.senderPeerID?.let(ContactDirectory::canonicalConversationId)?.let { it != conversationID } == true) ||
_selectedPrivateChatPeer.value
?.let(ContactDirectory::canonicalConversationId)
?.equals(conversationID, ignoreCase = true) == true
@ -418,6 +466,27 @@ object AppStateStore {
is DeliveryStatus.Failed -> 0
}
suspend fun acknowledgePrivateReceipt(conversationID: String, messageID: String, read: Boolean): Boolean {
val canonical = ContactDirectory.canonicalConversationId(conversationID)
val repository = conversationRepository ?: return false
val control = repository.deliveryJobs().firstOrNull {
it.kind == PrivateDeliveryJob.Kind.FAVORITE && it.messageID == messageID &&
ContactDirectory.canonicalConversationId(it.conversationID) == canonical
}
if (control != null) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.acknowledgeLocalControl(canonical, messageID)
repository.removeDelivery(control.id)
return true
}
val message = repository.storedMessage(canonical, messageID) ?: return false
val author = message.senderPeerID ?: return false
if (message.sender == "system" || ContactDirectory.canonicalConversationId(author) == canonical || message.senderNostrPubkey != null) return false
val status = if (read) DeliveryStatus.Read(canonical, java.util.Date()) else DeliveryStatus.Delivered(canonical, java.util.Date())
if (!repository.acknowledgeDelivery(canonical, messageID, status)) return false
updatePrivateMessageStatus(messageID, status)
return true
}
fun updatePrivateMessageStatus(messageID: String, status: DeliveryStatus) {
synchronized(this) {
if (privateConversationWritesSuspended) return
@ -545,10 +614,13 @@ object AppStateStore {
}
}
fun markPrivateMessageRead(messageID: String) {
fun markPrivateMessageRead(messageID: String, receiptJob: PrivateDeliveryJob? = null) {
synchronized(this) {
if (privateConversationWritesSuspended) return
if (messageID in _readPrivateMessageIDs.value) return
if (messageID in _readPrivateMessageIDs.value) {
if (receiptJob != null) conversationRepository?.markRead(messageID, receiptJob.copy(localMessageID = messageID))
return
}
canonicalizePrivateConversationStateLocked()
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value + messageID
val conversationID = _privateMessages.value.entries
@ -562,7 +634,7 @@ object AppStateStore {
}
_unreadPrivateMessageCounts.value = counts
}
conversationRepository?.markRead(messageID)
conversationRepository?.markRead(messageID, receiptJob?.copy(localMessageID = messageID))
}
}

View File

@ -129,15 +129,36 @@ class ConversationRepository internal constructor(
}
}
suspend fun deliveryJobs(): List<PrivateDeliveryJob> = withContext(dispatcher) { database.deliveryJobs() }
suspend fun saveDelivery(job: PrivateDeliveryJob, replace: Boolean = true): Boolean =
withContext(dispatcher) { database.saveDelivery(job, replace) }
suspend fun acknowledgeDelivery(conversationID: String, messageID: String, status: DeliveryStatus): Boolean =
withContext(dispatcher) { database.acknowledgeDelivery(conversationID, messageID, status) }
suspend fun updateDelivery(job: PrivateDeliveryJob): Boolean =
withContext(dispatcher) { database.updateDelivery(job) }
suspend fun removeDelivery(id: String) = withContext(dispatcher) { database.removeDelivery(id) }
suspend fun isDeletedMessage(messageID: String): Boolean =
withContext(dispatcher) { database.isDeletedMessage(messageID) }
suspend fun storedMessage(conversationID: String, messageID: String): BitchatMessage? =
withContext(dispatcher) { database.storedMessage(conversationID, messageID) }
suspend fun syncCheckpoint(identity: String): Long? = withContext(dispatcher) { database.syncCheckpoint(identity) }
suspend fun saveSyncCheckpoint(identity: String, time: Long) = withContext(dispatcher) { database.saveSyncCheckpoint(identity, time) }
fun upsertMessage(
conversationID: String,
aliases: Set<String>,
displayName: String?,
message: BitchatMessage,
isRead: Boolean
isRead: Boolean,
queueForDelivery: Boolean = false,
receiptJob: PrivateDeliveryJob? = null,
outgoingJob: PrivateDeliveryJob? = null
) {
scope.launch {
upsertMessageLocked(conversationID, aliases, displayName, message, isRead)
upsertMessageLocked(conversationID, aliases, displayName, message, isRead, queueForDelivery, receiptJob, outgoingJob)
}
}
@ -146,9 +167,12 @@ class ConversationRepository internal constructor(
aliases: Set<String>,
displayName: String?,
message: BitchatMessage,
isRead: Boolean
isRead: Boolean,
queueForDelivery: Boolean = false,
receiptJob: PrivateDeliveryJob? = null,
outgoingJob: PrivateDeliveryJob? = null
): Boolean = withContext(dispatcher) {
upsertMessageLocked(conversationID, aliases, displayName, message, isRead)
upsertMessageLocked(conversationID, aliases, displayName, message, isRead, queueForDelivery, receiptJob, outgoingJob)
}
private fun upsertMessageLocked(
@ -156,14 +180,20 @@ class ConversationRepository internal constructor(
aliases: Set<String>,
displayName: String?,
message: BitchatMessage,
isRead: Boolean
isRead: Boolean,
queueForDelivery: Boolean = false,
receiptJob: PrivateDeliveryJob? = null,
outgoingJob: PrivateDeliveryJob? = null
): Boolean = try {
val result = database.upsertMessage(
conversationID = conversationID,
aliases = aliases,
displayName = displayName,
message = message,
isRead = isRead
isRead = isRead,
queueForDelivery = queueForDelivery,
receiptJob = receiptJob,
outgoingJob = outgoingJob
)
deleteStoredMedia(result.orphanedMediaPaths)
_storeState.value = ConversationStoreState.Ready
@ -186,10 +216,10 @@ class ConversationRepository internal constructor(
}
}
fun markRead(messageID: String) {
fun markRead(messageID: String, receiptJob: PrivateDeliveryJob? = null) {
scope.launch {
try {
database.markRead(messageID)
database.markRead(messageID, receiptJob)
} catch (error: Exception) {
Log.e(TAG, "Unable to persist local read state: ${error.message}")
}
@ -391,7 +421,7 @@ internal class ConversationDatabase(
const val MAX_MEDIA_BYTES = 256L * 1024L * 1024L
internal const val DEFAULT_DATABASE_NAME = "private_conversations.db"
internal const val DATABASE_VERSION = 4
internal const val DATABASE_VERSION = 5
private const val PRUNE_INTERVAL = 64
private const val PRUNE_BATCH_SIZE = 256
}
@ -507,6 +537,7 @@ internal class ConversationDatabase(
"CREATE INDEX idx_deleted_private_messages_time " +
"ON deleted_private_messages(deleted_at)"
)
createDeliveryTables(db)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@ -542,11 +573,102 @@ internal class ConversationDatabase(
}
version = 4
}
if (version == 4) {
createDeliveryTables(db)
// Old Sending rows have no recoverable delivery intent. Never resend historical Sent.
db.execSQL("UPDATE private_messages SET delivery_type = 5 WHERE delivery_type = 1")
version = 5
}
check(version == newVersion) {
"Missing conversation database migration from $version to $newVersion"
}
}
private fun createDeliveryTables(db: SQLiteDatabase) {
db.execSQL("""CREATE TABLE private_delivery_jobs (
job_id TEXT PRIMARY KEY NOT NULL,
conversation_id TEXT COLLATE NOCASE NOT NULL,
message_id TEXT NOT NULL,
local_message_id TEXT,
kind TEXT NOT NULL,
next_attempt_at INTEGER NOT NULL,
payload_ciphertext BLOB NOT NULL
)""")
db.execSQL("CREATE INDEX idx_delivery_due ON private_delivery_jobs(next_attempt_at)")
db.execSQL("CREATE TABLE dm_sync_checkpoints (identity TEXT PRIMARY KEY NOT NULL, completed_at INTEGER NOT NULL)")
}
private val deliveryGson = com.google.gson.Gson()
fun deliveryJobs(): List<PrivateDeliveryJob> = readableDatabase.rawQuery(
"SELECT job_id, payload_ciphertext FROM private_delivery_jobs ORDER BY next_attempt_at", null
).use { cursor ->
buildList {
while (cursor.moveToNext()) {
val id = cursor.getString(0)
val json = storageCipher.decrypt(cursor.getBlob(1), "delivery:$id".toByteArray())
add(deliveryGson.fromJson(json.toString(Charsets.UTF_8), PrivateDeliveryJob::class.java))
}
}
}
fun saveDelivery(job: PrivateDeliveryJob, replace: Boolean = true): Boolean {
val db = writableDatabase
val existing = db.rawQuery("SELECT 1 FROM private_delivery_jobs WHERE job_id = ?", arrayOf(job.id))
.use { it.moveToFirst() }
if (!existing) {
val counts = db.rawQuery("SELECT COUNT(*), SUM(CASE WHEN conversation_id = ? THEN 1 ELSE 0 END) FROM private_delivery_jobs", arrayOf(job.conversationID))
.use { it.moveToFirst(); it.getInt(0) to it.getInt(1) }
check(counts.first < PrivateDeliveryJob.MAX_TOTAL && counts.second < PrivateDeliveryJob.MAX_PER_CONTACT) {
"Private delivery queue is full"
}
}
return db.insertWithOnConflict("private_delivery_jobs", null, ContentValues().apply {
put("job_id", job.id)
put("conversation_id", job.conversationID)
put("message_id", job.messageID)
put("local_message_id", job.localMessageID)
put("kind", job.kind.name)
put("next_attempt_at", job.nextAttemptAt)
put("payload_ciphertext", encryptText(deliveryGson.toJson(job), "delivery:${job.id}".toByteArray()))
}, if (replace) SQLiteDatabase.CONFLICT_REPLACE else SQLiteDatabase.CONFLICT_IGNORE) != -1L
}
fun acknowledgeDelivery(conversationID: String, messageID: String, status: DeliveryStatus): Boolean = writableDatabase.inTransaction {
if (storedMessage(conversationID, messageID) == null) return@inTransaction false
updateDeliveryStatus(messageID, status)
removeDelivery("message:$messageID")
true
}
fun updateDelivery(job: PrivateDeliveryJob): Boolean = writableDatabase.inTransaction {
val current = rawQuery("SELECT message_id FROM private_delivery_jobs WHERE job_id = ?", arrayOf(job.id))
.use { if (it.moveToFirst()) it.getString(0) else null }
if (current != job.messageID) false else saveDelivery(job)
}
fun removeDelivery(id: String) { writableDatabase.delete("private_delivery_jobs", "job_id = ?", arrayOf(id)) }
fun isDeletedMessage(messageID: String): Boolean = isDeletedMessageLocked(readableDatabase, messageID)
fun storedMessage(conversationID: String, messageID: String): BitchatMessage? =
readableDatabase.query("private_messages", MESSAGE_COLUMNS, "message_id = ?", arrayOf(messageID), null, null, null).use { cursor ->
if (!cursor.moveToFirst()) return@use null
val stored = resolveStoredConversationLocked(readableDatabase, cursor.string("conversation_id"))
if (!stored.equals(resolveStoredConversationLocked(readableDatabase, conversationID), true)) return@use null
cursor.toMessage()
}
fun syncCheckpoint(identity: String): Long? = readableDatabase.rawQuery(
"SELECT completed_at FROM dm_sync_checkpoints WHERE identity = ?", arrayOf(identity)
).use { if (it.moveToFirst()) it.getLong(0) else null }
fun saveSyncCheckpoint(identity: String, time: Long) {
writableDatabase.insertWithOnConflict("dm_sync_checkpoints", null, ContentValues().apply {
put("identity", identity); put("completed_at", time)
}, SQLiteDatabase.CONFLICT_REPLACE)
}
private fun migrateVersion1To2(db: SQLiteDatabase) {
db.execSQL("ALTER TABLE conversations ADD COLUMN display_name_ciphertext BLOB")
db.execSQL("ALTER TABLE private_messages ADD COLUMN payload_ciphertext BLOB")
@ -779,7 +901,10 @@ internal class ConversationDatabase(
aliases: Set<String>,
displayName: String?,
message: BitchatMessage,
isRead: Boolean
isRead: Boolean,
queueForDelivery: Boolean = false,
receiptJob: PrivateDeliveryJob? = null,
outgoingJob: PrivateDeliveryJob? = null
): ConversationUpsertResult {
val normalizedID = conversationID.trim()
if (normalizedID.isBlank()) {
@ -790,6 +915,10 @@ internal class ConversationDatabase(
var messageInserted = false
writableDatabase.inTransaction {
if (isDeletedMessageLocked(this, message.id)) return@inTransaction
val existingOwner = rawQuery("SELECT conversation_id FROM private_messages WHERE message_id = ?", arrayOf(message.id))
.use { if (it.moveToFirst()) it.getString(0) else null }
if (existingOwner != null && !resolveStoredConversationLocked(this, existingOwner)
.equals(resolveStoredConversationLocked(this, normalizedID), true)) return@inTransaction
mergeAliasesLocked(
db = this,
targetConversationID = normalizedID,
@ -807,6 +936,14 @@ internal class ConversationDatabase(
messageInserted = inserted != -1L
if (inserted != -1L) {
registerAttachmentLocked(this, message)
if (queueForDelivery && message.deliveryStatus == DeliveryStatus.Sending && message.type == BitchatMessageType.Message) {
saveDelivery(outgoingJob ?: PrivateDeliveryJob(
id = "message:${message.id}", conversationID = normalizedID,
messageID = message.id, kind = PrivateDeliveryJob.Kind.MESSAGE,
content = message.content, nickname = message.recipientNickname.orEmpty(),
createdAt = message.timestamp.time
), replace = false)
}
}
if (inserted == -1L) {
val existingConversation = rawQuery(
@ -825,13 +962,7 @@ internal class ConversationDatabase(
existingConversation.first
)
if (!canonicalExisting.equals(normalizedID, ignoreCase = true)) {
mergeAliasesLocked(
db = this,
targetConversationID = normalizedID,
aliases = aliases + canonicalExisting,
displayName = displayName,
now = now
)
return@inTransaction
}
if (isRead && !existingConversation.second) {
update(
@ -843,6 +974,7 @@ internal class ConversationDatabase(
}
}
}
if (receiptJob != null) saveDelivery(receiptJob, replace = false)
updateConversationMetadataLocked(this, normalizedID, displayName, now)
orphanedMediaPaths += pruneConversationLocked(this, normalizedID)
}
@ -907,13 +1039,15 @@ internal class ConversationDatabase(
}
}
fun markRead(messageID: String) {
writableDatabase.update(
"private_messages",
ContentValues().apply { put("is_read", 1) },
"message_id = ?",
arrayOf(messageID)
)
fun markRead(messageID: String, receiptJob: PrivateDeliveryJob? = null) {
writableDatabase.inTransaction {
if (isDeletedMessageLocked(this, messageID)) return@inTransaction
if (receiptJob != null && storedMessage(receiptJob.conversationID, messageID) != null) {
saveDelivery(receiptJob, replace = false)
}
update("private_messages", ContentValues().apply { put("is_read", 1) },
"message_id = ?", arrayOf(messageID))
}
}
fun setConversationRead(conversationID: String, isRead: Boolean): String? {
@ -1007,6 +1141,7 @@ internal class ConversationDatabase(
}
val attachmentCandidates = attachmentCandidatesLocked(this, messageIDs)
ids.filter { it.isNotBlank() }.forEach { id ->
delete("private_delivery_jobs", "conversation_id = ? COLLATE NOCASE", arrayOf(id))
delete(
"conversations",
"conversation_id = ? COLLATE NOCASE",
@ -1029,6 +1164,7 @@ internal class ConversationDatabase(
},
SQLiteDatabase.CONFLICT_REPLACE
)
delete("private_delivery_jobs", "message_id = ? OR local_message_id = ?", arrayOf(messageID, messageID))
delete("private_messages", "message_id = ?", arrayOf(messageID))
delete(
"conversations",
@ -1087,6 +1223,8 @@ internal class ConversationDatabase(
// Destroy the only usable copy of the history key before attempting filesystem cleanup.
storageCipher.destroyKey()
writableDatabase.inTransaction {
delete("private_delivery_jobs", null, null)
delete("dm_sync_checkpoints", null, null)
delete("conversation_aliases", null, null)
delete("message_attachments", null, null)
delete("private_messages", null, null)
@ -1175,7 +1313,8 @@ internal class ConversationDatabase(
tombstoneMessagesLocked(this, candidates)
val attachmentCandidates = attachmentCandidatesLocked(this, candidates)
candidates.forEach { messageID ->
delete("private_messages", "message_id = ?", arrayOf(messageID))
delete("private_delivery_jobs", "message_id = ? OR local_message_id = ?", arrayOf(messageID, messageID))
delete("private_messages", "message_id = ?", arrayOf(messageID))
}
orphanedMediaPaths +=
unreferencedAttachmentPathsLocked(this, attachmentCandidates)
@ -1379,6 +1518,7 @@ internal class ConversationDatabase(
SELECT message_id
FROM private_messages
WHERE conversation_id = ? COLLATE NOCASE
AND message_id NOT IN (SELECT message_id FROM private_delivery_jobs WHERE kind = 'MESSAGE')
AND arrival_sequence < (
SELECT MAX(arrival_sequence)
FROM private_messages
@ -1569,7 +1709,7 @@ internal class ConversationDatabase(
"""
SELECT candidate.message_id
FROM private_messages AS candidate
WHERE 1 = 1
WHERE candidate.message_id NOT IN (SELECT message_id FROM private_delivery_jobs WHERE kind = 'MESSAGE')
$newerMessageClause
$readClause
ORDER BY candidate.arrival_sequence ASC
@ -1739,7 +1879,8 @@ internal class ConversationDatabase(
encryptedContent = payload.encryptedContent,
isEncrypted = boolean("is_encrypted"),
deliveryStatus = toDeliveryStatus(payload.deliveryText),
senderNostrPubkey = payload.senderNostrPubkey
senderNostrPubkey = payload.senderNostrPubkey,
wireMessageID = payload.wireMessageID
)
}
@ -1823,6 +1964,7 @@ internal class ConversationDatabase(
)
putNullable("delivery_text", message.deliveryStatus.sensitiveText())
putNullable("sender_nostr_pubkey", message.senderNostrPubkey)
putNullable("wire_message_id", message.wireMessageID)
}
return storageCipher.encrypt(
json.toString().toByteArray(Charsets.UTF_8),
@ -1850,7 +1992,8 @@ internal class ConversationDatabase(
Base64.decode(it, Base64.NO_WRAP)
},
deliveryText = json.optionalString("delivery_text"),
senderNostrPubkey = json.optionalString("sender_nostr_pubkey")
senderNostrPubkey = json.optionalString("sender_nostr_pubkey"),
wireMessageID = json.optionalString("wire_message_id")
)
}
@ -1908,7 +2051,8 @@ internal class ConversationDatabase(
val channel: String?,
val encryptedContent: ByteArray?,
val deliveryText: String?,
val senderNostrPubkey: String?
val senderNostrPubkey: String?,
val wireMessageID: String?
)
private val MESSAGE_COLUMNS = arrayOf(

View File

@ -11,6 +11,14 @@ import kotlinx.coroutines.runBlocking
* Public and channel messages retain their existing best-effort behavior if state reflection fails.
*/
internal object IncomingMessageAdmission {
fun forDisplay(message: BitchatMessage): BitchatMessage {
if (!message.isPrivate) return message
val conversation = message.senderPeerID?.let(ContactDirectory::canonicalConversationId) ?: return message
val wireID = message.wireMessageID ?: message.id
return message.copy(id = AppStateStore.incomingLocalID(conversation, wireID),
senderPeerID = conversation, wireMessageID = wireID)
}
fun admitToAppState(message: BitchatMessage): Boolean = try {
when {
message.isPrivate -> {
@ -20,7 +28,7 @@ internal object IncomingMessageAdmission {
// serialized SQLite transaction so a notification can never advertise a message
// that an immediate process death would lose.
runBlocking {
AppStateStore.addPrivateMessageDurably(peerID, message)
AppStateStore.admitIncomingPrivate(message) == AppStateStore.PrivateAdmission.INSERTED
}
}

View File

@ -1,428 +1,66 @@
package com.bitchat.android.services
import android.content.Context
import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.nostr.GeohashConversationRegistry
import com.bitchat.android.nostr.NostrTransport
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
/**
* Routes messages between local mesh transports and Nostr, matching iOS behavior.
*/
class MessageRouter private constructor(
private val context: Context,
private var mesh: MeshService,
private val nostr: NostrTransport
) {
enum class RouteResult {
MESH,
NOSTR,
QUEUED,
DROPPED
}
private data class QueuedMessage(
val content: String,
val nickname: String,
val messageID: String,
val enqueuedAtMs: Long
)
private data class ConversationRetry(
val handshakeAttempts: Int,
val nextHandshakeAttemptAtMs: Long
)
/** UI facade. A route is not a delivery result; all private sends enter the durable worker. */
class MessageRouter private constructor(private val context: Context, private var mesh: MeshService) {
enum class RouteResult { MESH, NOSTR, QUEUED, DROPPED }
companion object {
private const val TAG = "MessageRouter"
private const val OUTBOX_TICK_MS = AppConstants.Router.OUTBOX_TICK_MS
private const val OUTBOX_MESSAGE_TTL_MS = AppConstants.Router.OUTBOX_MESSAGE_TTL_MS
private const val OUTBOX_MAX_PER_PEER = AppConstants.Router.OUTBOX_MAX_PER_PEER
private val HANDSHAKE_RETRY_BACKOFF_MS = AppConstants.Router.HANDSHAKE_RETRY_BACKOFF_MS
@Volatile private var instance: MessageRouter? = null
fun tryGetInstance(): MessageRouter? = instance
@Volatile private var INSTANCE: MessageRouter? = null
internal var disableSchedulerForTesting = false
fun tryGetInstance(): MessageRouter? = INSTANCE
fun deliveryJob(message: com.bitchat.android.model.BitchatMessage, to: String): PrivateDeliveryJob {
val conversation = ContactDirectory.canonicalConversationId(to)
val geo = GeohashConversationRegistry.get(to) ?: GeohashConversationRegistry.get(conversation)
return PrivateDeliveryJob("message:${message.id}", conversation, message.id, PrivateDeliveryJob.Kind.MESSAGE,
content = message.content, nickname = message.recipientNickname.orEmpty(), createdAt = message.timestamp.time,
recipientPubkey = if (geo != null) GeohashAliasRegistry.get(to) ?: GeohashAliasRegistry.get(conversation) else null,
sourceGeohash = geo)
}
fun getInstance(context: Context, mesh: MeshService): MessageRouter {
val instance = INSTANCE ?: synchronized(this) {
INSTANCE ?: run {
val nostr = NostrTransport.getInstance(context)
MessageRouter(context.applicationContext, mesh, nostr).also { instance ->
// Register for favorites changes to flush outbox
try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener)
} catch (_: Exception) {}
INSTANCE = instance
}
}
val router = instance ?: synchronized(this) {
instance ?: MessageRouter(context.applicationContext, mesh).also { instance = it }
}
// Always update mesh reference and sync peer ID, and make sure the retry
// scheduler is running (it is stopped together with MeshForegroundService).
instance.mesh = mesh
instance.nostr.senderPeerID = mesh.myPeerID
instance.startOutboxScheduler()
return instance
}
internal fun resetForTesting() {
INSTANCE?.schedulerScope?.cancel()
INSTANCE = null
}
}
// Outbox: conversationID -> queued messages, oldest first
private val outbox = ConcurrentHashMap<String, MutableList<QueuedMessage>>()
// Per-conversation handshake retry state for queued messages
private val retryState = ConcurrentHashMap<String, ConversationRetry>()
private val schedulerScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var schedulerJob: kotlinx.coroutines.Job? = null
// Injectable clock for tests
internal var clock: () -> Long = { System.currentTimeMillis() }
// Called with the messageID of queued messages that expired or were evicted
var onMessageExpired: ((String) -> Unit)? = null
init {
startOutboxScheduler()
}
fun clearAll() {
outbox.clear()
retryState.clear()
Log.d(TAG, "Cleared all MessageRouter outbox messages and retry state")
}
// Listener for favorites changes to flush outbox when npub mapping appears/changes
private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener {
override fun onFavoriteChanged(noiseKeyHex: String) {
flushOutboxFor(noiseKeyHex)
ContactIdentityResolver.peerIdForNoiseKeyHex(noiseKeyHex)?.let { flushOutboxFor(it) }
}
override fun onAllCleared() {
router.mesh = mesh
PrivateDeliveryCoordinator.getInstance(context).bindMesh(mesh)
return router
}
}
private val coordinator get() = PrivateDeliveryCoordinator.getInstance(context)
private val nostr get() = NostrTransport.getInstance(context)
fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String): RouteResult {
val resolution = ContactDirectory.resolve(toPeerID)
val conversationID = resolution.conversationID
val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
val nostrTarget = resolution.noiseKeyHex ?: toPeerID
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) {
Log.d(TAG, "Routing PM via Nostr (geohash) to alias ${toPeerID.take(12)}… id=${messageID.take(8)}…")
val recipientHex = com.bitchat.android.nostr.GeohashAliasRegistry.get(toPeerID)
if (recipientHex != null) {
val sourceGeohash = com.bitchat.android.nostr.GeohashConversationRegistry.get(toPeerID)
nostr.sendPrivateMessageGeohash(content, recipientHex, messageID, sourceGeohash)
return RouteResult.NOSTR
}
return RouteResult.DROPPED
}
val hasMesh = meshTarget?.let { isConnected(mesh, it) } == true
if (meshTarget != null && isReady(mesh, meshTarget)) {
Log.d(TAG, "Routing PM via mesh to ${meshTarget} msg_id=${messageID.take(8)}…")
mesh.sendPrivateMessage(content, meshTarget, recipientNickname, messageID)
return RouteResult.MESH
} else if (canSendViaNostr(nostrTarget)) {
Log.d(TAG, "Routing PM via Nostr to ${conversationID.take(32)}… msg_id=${messageID.take(8)}…")
nostr.sendPrivateMessage(content, nostrTarget, recipientNickname, messageID)
return RouteResult.NOSTR
} else {
Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}…")
enqueue(conversationID, QueuedMessage(content, recipientNickname, messageID, clock()))
Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}…")
if (hasMesh) meshTarget?.let { kickHandshake(conversationID, it, immediate = true) }
return RouteResult.QUEUED
}
val conversation = ContactDirectory.canonicalConversationId(toPeerID)
val geohash = GeohashConversationRegistry.get(toPeerID)
val key = if (geohash != null) GeohashAliasRegistry.get(toPeerID) else null
coordinator.enqueue(PrivateDeliveryJob(
"message:$messageID", conversation, messageID, PrivateDeliveryJob.Kind.MESSAGE,
content, recipientNickname, key, geohash
))
return RouteResult.QUEUED
}
fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) {
val resolution = ContactDirectory.resolve(toPeerID)
val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
val nostrTarget = resolution.noiseKeyHex ?: toPeerID
if (meshTarget != null && isReady(mesh, meshTarget)) {
Log.d(TAG, "Routing READ via mesh to ${meshTarget.take(8)}… id=${receipt.originalMessageID.take(8)}…")
mesh.sendReadReceipt(receipt.originalMessageID, meshTarget, mesh.getPeerNicknames()[meshTarget] ?: mesh.myPeerID)
} else {
Log.d(TAG, "Routing READ via Nostr to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…")
nostr.sendReadReceipt(receipt, nostrTarget)
}
fun queueReadReceipt(message: com.bitchat.android.model.BitchatMessage, conversation: String) {
val wireID = message.wireMessageID ?: message.id
val geo = GeohashConversationRegistry.get(conversation)
val job = PrivateDeliveryJob("READ:$conversation:$wireID", conversation, wireID, PrivateDeliveryJob.Kind.READ,
recipientPubkey = message.senderNostrPubkey, sourceGeohash = geo, localMessageID = message.id)
AppStateStore.markPrivateMessageRead(message.id, job)
coordinator.wake()
}
fun sendDeliveryAck(messageID: String, toPeerID: String) {
// Mesh delivery ACKs are sent by the receiver automatically.
// Only route via Nostr when mesh path isn't available or when this is a geohash alias
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) {
val recipientHex = com.bitchat.android.nostr.GeohashAliasRegistry.get(toPeerID)
if (recipientHex != null) {
nostr.sendDeliveryAckGeohash(messageID, recipientHex, try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)!! } catch (_: Exception) { return })
return
}
}
val resolution = ContactDirectory.resolve(toPeerID)
val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
if (!(meshTarget != null && (mesh.getPeerInfo(meshTarget)?.isConnected == true) && mesh.hasEstablishedSession(meshTarget))) {
nostr.sendDeliveryAck(messageID, resolution.noiseKeyHex ?: toPeerID)
}
}
fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) {
val resolution = ContactDirectory.resolve(toPeerID)
val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
if (meshTarget != null && mesh.getPeerInfo(meshTarget)?.isConnected == true && mesh.hasEstablishedSession(meshTarget)) {
val myNpub = try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub } catch (_: Exception) { null }
val content = FavoriteControlMessage.encode(isFavorite, myNpub)
val nickname = mesh.getPeerNicknames()[meshTarget] ?: meshTarget
mesh.sendPrivateMessage(content, meshTarget, nickname, null)
} else {
nostr.sendFavoriteNotification(resolution.noiseKeyHex ?: toPeerID, isFavorite)
}
}
// Flush any queued messages for a specific peerID.
// All outbox mutations happen under the router monitor so a concurrent enqueue cannot
// be lost between the empty check and the map removal.
@Synchronized
fun flushOutboxFor(peerID: String) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val queued = outbox[conversationID] ?: outbox[peerID] ?: return
if (queued.isEmpty()) return
Log.d(TAG, "Flushing outbox for ${conversationID.take(16)}… count=${queued.size}")
val iterator = queued.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
val resolution = ContactDirectory.resolve(conversationID)
val meshTarget = resolution.meshPeerID
val nostrTarget = resolution.noiseKeyHex ?: conversationID
if (meshTarget != null && isReady(mesh, meshTarget)) {
mesh.sendPrivateMessage(entry.content, meshTarget, entry.nickname, entry.messageID)
iterator.remove()
} else if (canSendViaNostr(nostrTarget)) {
nostr.sendPrivateMessage(entry.content, nostrTarget, entry.nickname, entry.messageID)
iterator.remove()
}
}
if (queued.isEmpty()) {
outbox.remove(conversationID, queued)
outbox.remove(peerID, queued)
retryState.remove(conversationID)
retryState.remove(peerID)
}
}
// Flush everything (rarely used)
fun flushAllOutbox() {
outbox.keys.toList().forEach { flushOutboxFor(it) }
}
@Synchronized
private fun enqueue(conversationID: String, entry: QueuedMessage) {
val queue = outbox.getOrPut(conversationID) { mutableListOf() }
queue.add(entry)
while (queue.size > OUTBOX_MAX_PER_PEER) {
val evicted = queue.removeAt(0)
Log.w(TAG, "Outbox full for ${conversationID.take(16)}…; evicting oldest msg_id=${evicted.messageID.take(8)}…")
notifyExpired(evicted.messageID)
}
}
private fun notifyExpired(messageID: String) {
try { onMessageExpired?.invoke(messageID) } catch (_: Exception) { }
}
/**
* Initiate a Noise handshake for a conversation with queued messages, applying
* exponential backoff between attempts. [immediate] resets the backoff (peer just
* appeared or a new message was queued). Kicks are suppressed while a previous
* attempt is still inside its backoff window, so alias duplicates and frequent
* peer-list updates cannot spam handshakes.
*/
@Synchronized
private fun kickHandshake(conversationID: String, meshTarget: String, immediate: Boolean) {
val now = clock()
val current = retryState[conversationID]
if (current != null && now < current.nextHandshakeAttemptAtMs) return
val attempts = if (immediate) 0 else (current?.handshakeAttempts ?: 0)
try { mesh.initiateNoiseHandshake(meshTarget) } catch (_: Exception) { }
val backoff = HANDSHAKE_RETRY_BACKOFF_MS[attempts.coerceAtMost(HANDSHAKE_RETRY_BACKOFF_MS.size - 1)]
retryState[conversationID] = ConversationRetry(
handshakeAttempts = attempts + 1,
nextHandshakeAttemptAtMs = now + backoff
)
Log.d(TAG, "Handshake attempt ${attempts + 1} for ${conversationID.take(16)}…, next retry in ${backoff}ms")
}
@Synchronized
private fun startOutboxScheduler() {
if (disableSchedulerForTesting) return
if (schedulerJob?.isActive == true) return
schedulerJob = schedulerScope.launch {
while (isActive) {
delay(OUTBOX_TICK_MS)
try { tickOutbox() } catch (e: Exception) {
Log.w(TAG, "Outbox scheduler tick failed: ${e.message}")
}
}
}
}
/**
* Stop retrying while the mesh transports are down. Persistent network work must
* follow the MeshForegroundService lifecycle; getInstance restarts the scheduler
* and rebinds the mesh reference when the service comes back.
*/
fun stopOutboxScheduler() {
schedulerJob?.cancel()
schedulerJob = null
}
internal val isSchedulerRunning: Boolean get() = schedulerJob?.isActive == true
/**
* One scheduler pass over the outbox: expire old entries, flush what can be sent,
* and re-initiate handshakes (with backoff) for peers that are connected but have
* no established session yet.
*/
@Synchronized
internal fun tickOutbox(nowMs: Long = clock()) {
outbox.keys.toList().forEach { conversationID ->
expireOldEntries(conversationID, nowMs)
val queued = outbox[conversationID] ?: return@forEach
if (queued.isEmpty()) return@forEach
val resolution = ContactDirectory.resolve(conversationID)
val meshTarget = resolution.meshPeerID
if (meshTarget != null && isReady(mesh, meshTarget)) {
flushOutboxFor(conversationID)
return@forEach
}
if (canSendViaNostr(resolution.noiseKeyHex ?: conversationID)) {
flushOutboxFor(conversationID)
return@forEach
}
// Peer visible but no session: retry the handshake with backoff.
if (meshTarget != null && isConnected(mesh, meshTarget)) {
kickHandshake(conversationID, meshTarget, immediate = false)
}
}
}
private fun expireOldEntries(conversationID: String, nowMs: Long) {
val queued = outbox[conversationID] ?: return
val iterator = queued.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
if (nowMs - entry.enqueuedAtMs > OUTBOX_MESSAGE_TTL_MS) {
Log.w(TAG, "Expiring queued PM for ${conversationID.take(16)}… msg_id=${entry.messageID.take(8)}…")
iterator.remove()
notifyExpired(entry.messageID)
}
}
if (queued.isEmpty()) {
outbox.remove(conversationID, queued)
retryState.remove(conversationID)
}
}
private fun canSendViaNostr(peerID: String): Boolean {
return try {
val resolution = ContactDirectory.resolve(peerID)
if (resolution.isMutualFavorite && resolution.nostrPubkey != null) return true
val target = resolution.noiseKeyHex ?: peerID
if (ContactIdentityResolver.isNoiseKeyHex(target)) {
val noiseKey = ContactIdentityResolver.bytesFromHex(target) ?: return false
val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
fav?.isMutual == true && fav.peerNostrPublicKey != null
} else if (ContactIdentityResolver.isMeshPeerId(target)) {
val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(target)
fav?.isMutual == true && fav.peerNostrPublicKey != null
} else {
false
}
} catch (_: Exception) { false }
}
private fun isConnected(service: MeshService, peerID: String): Boolean {
return try {
service.getPeerInfo(peerID)?.isConnected == true
} catch (_: Exception) {
false
}
}
private fun isReady(service: MeshService, peerID: String): Boolean {
return try {
service.getPeerInfo(peerID)?.isConnected == true &&
service.hasEstablishedSession(peerID)
} catch (_: Exception) {
false
}
}
// Called when mesh peer list changes; attempt to flush any matching outbox entries
fun onPeersUpdated(peers: List<String>) {
peers.forEach { pid ->
kickHandshakeIfPending(pid)
flushOutboxFor(pid)
val noiseHex = try {
mesh.getPeerInfo(pid)?.noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) }
} catch (_: Exception) { null }
noiseHex?.let {
kickHandshakeIfPending(it)
flushOutboxFor(it)
}
}
}
// Called when a Noise session becomes established; flush both the mesh peerID and its noiseHex alias
fun onSessionEstablished(peerID: String) {
resetRetry(peerID)
flushOutboxFor(peerID)
val noiseHex = try {
mesh.getPeerInfo(peerID)?.noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) }
} catch (_: Exception) { null }
noiseHex?.let {
resetRetry(it)
flushOutboxFor(it)
}
}
/** Reset handshake backoff for a conversation whose session just came up. */
private fun resetRetry(peerID: String) {
retryState.remove(ContactDirectory.canonicalConversationId(peerID))
retryState.remove(peerID)
}
/**
* A peer (re)appeared: if we still owe them queued messages and there is no working
* session yet, restart the handshake immediately instead of waiting for the backoff.
*/
@Synchronized
private fun kickHandshakeIfPending(peerID: String) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val queued = outbox[conversationID] ?: outbox[peerID] ?: return
if (queued.isEmpty()) return
val resolution = ContactDirectory.resolve(conversationID)
val meshTarget = resolution.meshPeerID ?: return
if (isReady(mesh, meshTarget)) return
if (!isConnected(mesh, meshTarget)) return
Log.d(TAG, "Peer ${meshTarget.take(8)}… reappeared with ${queued.size} queued PM(s); re-initiating handshake")
kickHandshake(conversationID, meshTarget, immediate = true)
}
fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) = nostr.sendReadReceipt(receipt, toPeerID)
fun sendDeliveryAck(messageID: String, toPeerID: String) = nostr.sendDeliveryAck(messageID, toPeerID)
fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) = nostr.sendFavoriteNotification(toPeerID, isFavorite)
fun flushOutboxFor(peerID: String) = coordinator.wake()
fun flushAllOutbox() = coordinator.wake()
fun onPeersUpdated(peers: List<String>) = coordinator.wake()
fun onSessionEstablished(peerID: String) = coordinator.wake()
fun clearAll() = coordinator.stop()
fun stopOutboxScheduler() = coordinator.stop()
}

View File

@ -0,0 +1,193 @@
package com.bitchat.android.services
import android.content.Context
import android.util.Log
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.nostr.NostrTransport
import com.bitchat.android.ui.DataManager
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/** One process-owned worker for persisted messages, relationship controls and receipts. */
class PrivateDeliveryCoordinator internal constructor(
private val context: Context,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
private val repositoryProvider: () -> ConversationRepository = { ConversationRepository.getInstance(context) },
private val transportProvider: () -> NostrTransport = { NostrTransport.getInstance(context) },
private val favoritesProvider: () -> FavoritesPersistenceService = { FavoritesPersistenceService.shared },
private val resolve: (String) -> ContactDirectory.ContactResolution = ContactDirectory::resolve,
private val clock: () -> Long = System::currentTimeMillis
) {
companion object {
@Volatile private var instance: PrivateDeliveryCoordinator? = null
fun getInstance(context: Context): PrivateDeliveryCoordinator = instance ?: synchronized(this) {
instance ?: PrivateDeliveryCoordinator(context.applicationContext).also { instance = it }
}
}
private val mutex = Mutex()
private val wakeups = kotlinx.coroutines.channels.Channel<Unit>(kotlinx.coroutines.channels.Channel.CONFLATED)
private val repository get() = repositoryProvider()
private val transport get() = transportProvider()
@Volatile private var mesh: MeshService? = null
@Volatile private var enabled = false
private var worker: Job? = null
@Volatile private var generation = 0L
fun bindMesh(service: MeshService) {
mesh = service
ContactDirectory.initialize(context) { mesh }
transport.senderPeerID = service.myPeerID
start()
}
@Synchronized fun start() {
enabled = true
if (worker?.isActive == true) return
worker = scope.launch {
while (isActive && enabled) {
try { drain() }
catch (e: CancellationException) { throw e }
catch (_: Exception) { Log.w("PrivateDelivery", "Delivery worker will retry") }
withTimeoutOrNull(2_000) { wakeups.receive() }
}
}
}
@Synchronized fun stop() {
enabled = false
generation++
worker?.cancel()
worker = null
}
fun wake() { if (enabled) wakeups.trySend(Unit) }
fun enqueue(job: PrivateDeliveryJob) {
val token = generation
scope.launch {
try {
mutex.withLock {
if (token != generation) return@withLock
if (job.kind == PrivateDeliveryJob.Kind.MESSAGE && repository.storedMessage(job.conversationID, job.messageID) == null) return@withLock
val existing = repository.deliveryJobs().firstOrNull { it.id == job.id }
if (job.kind == PrivateDeliveryJob.Kind.MESSAGE && existing != null) {
repository.updateDelivery(existing.copy(recipientPubkey = job.recipientPubkey ?: existing.recipientPubkey,
sourceGeohash = job.sourceGeohash ?: existing.sourceGeohash))
} else repository.saveDelivery(job, replace = job.kind == PrivateDeliveryJob.Kind.FAVORITE)
}
wake()
} catch (_: Exception) {
if (job.kind == PrivateDeliveryJob.Kind.MESSAGE) {
AppStateStore.updatePrivateMessageStatus(job.messageID, DeliveryStatus.Failed("Unable to queue message"))
}
}
}
}
private suspend fun drain() = mutex.withLock {
if (!enabled) return@withLock
val token = generation
val queued = repository.deliveryJobs().associateBy { it.id }
val data = DataManager(context).apply { loadBlockedUsers(); loadGeohashBlockedUsers() }
for (relationship in favoritesProvider().getAllRelationships()) {
val controlID = relationship.pendingControlID ?: continue
val conversation = ContactIdentityResolver.contactConversationIdForNoiseKey(relationship.peerNoisePublicKey)
if (data.isUserBlocked(ContactIdentityResolver.fingerprintHex(relationship.peerNoisePublicKey))) continue
val id = "favorite:$conversation"
val existing = queued[id]
if (existing?.messageID == controlID) continue
val identity = com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context) ?: continue
try {
repository.saveDelivery(PrivateDeliveryJob(id, conversation, controlID, PrivateDeliveryJob.Kind.FAVORITE,
com.bitchat.android.favorites.FavoriteControlMessage.encode(relationship.isFavorite, identity.npub),
relationship.peerNickname, createdAt = relationship.pendingControlTimestamp.takeIf { it > 0 } ?: relationship.lastUpdated.time))
} catch (_: Exception) {
// The relationship retains this intent; drain existing jobs to free capacity.
break
}
}
coroutineScope {
repository.deliveryJobs().filter { it.nextAttemptAt <= clock() }.take(4).forEach { job ->
launch { deliver(job, token, data) }
}
}
}
private suspend fun deliver(original: PrivateDeliveryJob, token: Long, data: DataManager) {
if (!enabled || token != generation) return
val now = clock()
if (original.nextAttemptAt > now) return
val contact = resolve(original.conversationID)
val job = original.copy(conversationID = contact.conversationID)
val blocked = contact.noisePublicKey?.let { data.isUserBlocked(ContactIdentityResolver.fingerprintHex(it)) } == true ||
job.recipientPubkey?.let(data::isGeohashUserBlocked) == true
if (blocked || now - job.createdAt >= PrivateDeliveryJob.RETENTION_MS) {
repository.removeDelivery(job.id)
if (job.kind == PrivateDeliveryJob.Kind.FAVORITE) favoritesProvider().acknowledgeLocalControl(job.conversationID, job.messageID)
if (job.kind == PrivateDeliveryJob.Kind.MESSAGE) AppStateStore.updatePrivateMessageStatus(
job.messageID, DeliveryStatus.Failed(if (blocked) "Contact blocked" else "Delivery expired")
)
return
}
if (job.kind == PrivateDeliveryJob.Kind.MESSAGE) {
val stored = repository.storedMessage(job.conversationID, job.messageID)
if (stored == null || stored.deliveryStatus is DeliveryStatus.Delivered || stored.deliveryStatus is DeliveryStatus.Read) {
repository.removeDelivery(job.id)
return
}
}
val service = mesh
val peer = contact.meshPeerID
val ready = peer != null && service != null && service.getPeerInfo(peer)?.isConnected == true && service.hasEstablishedSession(peer)
// One mesh attempt, then Nostr after a bounded acknowledgement window. Subsequent mesh
// attempts remain possible when no mutual Nostr route exists.
val canNostr = job.sourceGeohash != null ||
(contact.nostrPubkey != null && (contact.isMutualFavorite || job.kind != PrivateDeliveryJob.Kind.MESSAGE))
try {
if (ready && job.kind != PrivateDeliveryJob.Kind.DELIVERED && (job.attempts == 0 || !canNostr)) {
if (!repository.updateDelivery(job.copy(attempts = job.attempts + 1, nextAttemptAt = now + PrivateDeliveryJob.MESH_ACK_TIMEOUT_MS))) return
when (job.kind) {
PrivateDeliveryJob.Kind.MESSAGE, PrivateDeliveryJob.Kind.FAVORITE -> service!!.sendPrivateMessage(job.content, peer!!, job.nickname, job.messageID)
PrivateDeliveryJob.Kind.READ -> service!!.sendReadReceipt(job.messageID, peer!!, job.nickname)
PrivateDeliveryJob.Kind.DELIVERED -> Unit // Mesh reception emits its own delivery ACK.
}
} else if (canNostr || job.recipientPubkey != null) {
// Rewrap on later redelivery attempts so a newly published event remains in
// the recipient's overlap scan. Transport retries within an attempt reuse it.
val prepared = transport.prepare(if (job.attempts > 1) job.copy(eventJson = null) else job)
if (repository.deliveryJobs().none { it.id == job.id && it.messageID == job.messageID }) return
if (!repository.updateDelivery(prepared.copy(attempts = job.attempts + 1, nextAttemptAt = now + retryDelay(job.attempts)))) return
if (!enabled || token != generation) return
// Re-resolve consent immediately before publishing, including queued events.
val current = resolve(job.conversationID)
if (job.kind == PrivateDeliveryJob.Kind.MESSAGE && job.sourceGeohash == null && !current.isMutualFavorite) return
if (transport.publish(prepared) && enabled && token == generation) {
if (job.kind == PrivateDeliveryJob.Kind.MESSAGE) {
AppStateStore.updatePrivateMessageStatus(job.messageID, DeliveryStatus.Sent)
} else if (job.kind != PrivateDeliveryJob.Kind.FAVORITE) {
repository.removeDelivery(job.id)
}
}
} else {
if (peer != null) service?.initiateNoiseHandshake(peer)
repository.updateDelivery(job.copy(nextAttemptAt = now + 5_000))
}
} catch (e: CancellationException) { throw e }
catch (_: Exception) {
if (enabled && token == generation && repository.deliveryJobs().any { it.id == job.id && it.messageID == job.messageID }) {
repository.updateDelivery(job.copy(attempts = job.attempts + 1, nextAttemptAt = now + retryDelay(job.attempts)))
}
}
}
/** Only the intended authenticated contact can advance an outgoing message. */
suspend fun acknowledge(conversationID: String, messageID: String, read: Boolean): Boolean =
AppStateStore.acknowledgePrivateReceipt(conversationID, messageID, read)
private fun retryDelay(attempts: Int): Long =
(1_000L shl attempts.coerceIn(0, 8)).coerceAtMost(300_000L) + kotlin.random.Random.nextLong(1_000)
}

View File

@ -0,0 +1,28 @@
package com.bitchat.android.services
/** Persisted delivery intent. Payloads (including relay events) are encrypted by the repository. */
data class PrivateDeliveryJob(
val id: String,
val conversationID: String,
val messageID: String,
val kind: Kind,
val content: String = "",
val nickname: String = "",
val recipientPubkey: String? = null,
val sourceGeohash: String? = null,
val createdAt: Long = System.currentTimeMillis(),
val nextAttemptAt: Long = 0,
val attempts: Int = 0,
val localMessageID: String? = null,
val sourceIdentityPubkey: String? = null,
val eventJson: String? = null
) {
enum class Kind { MESSAGE, FAVORITE, DELIVERED, READ }
companion object {
const val RETENTION_MS = 30L * 24 * 60 * 60 * 1000
const val MESH_ACK_TIMEOUT_MS = 30_000L
const val MAX_PER_CONTACT = 100
const val MAX_TOTAL = 500
}
}

View File

@ -256,6 +256,8 @@ class ChatViewModel(
private fun refreshConversationDirectoryState() {
viewModelScope.launch {
dataManager.loadFavorites()
state.setFavoritePeers(dataManager.favoritePeers.toSet())
refreshPeerFavoritedUs()
conversationListPreferences.canonicalizeAliases()
conversationDirectoryRevision.update { it + 1L }
@ -438,12 +440,7 @@ class ChatViewModel(
)
// Mark queued private messages as failed when the router gives up on them
try {
com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh).onMessageExpired = { messageID ->
messageManager.updateMessageDeliveryStatus(
messageID,
com.bitchat.android.model.DeliveryStatus.Failed("Message expired before delivery")
)
}
com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh)
} catch (_: Exception) { }
// Hydrate UI state from process-wide AppStateStore to survive Activity recreation
viewModelScope.launch {
@ -1009,18 +1006,13 @@ class ChatViewModel(
getApplication(),
mesh
)
val route = router.sendPrivate(
router.sendPrivate(
messageContent,
peerID,
recipientNicknameParam,
messageId
)
if (route == com.bitchat.android.services.MessageRouter.RouteResult.NOSTR) {
messageManager.updateMessageDeliveryStatus(
messageId,
com.bitchat.android.model.DeliveryStatus.Sent
)
}
}
onAccepted(accepted)
}
@ -1079,53 +1071,31 @@ class ChatViewModel(
}
fun toggleFavorite(peerID: String) {
Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID")
privateChatManager.toggleFavorite(peerID)
// Persist relationship in FavoritesPersistenceService
try {
var noiseKey: ByteArray? = null
var nickname: String = mesh.getPeerNicknames()[peerID] ?: peerID
val peerInfo = mesh.getPeerInfo(peerID)
if (peerInfo?.noisePublicKey != null) {
noiseKey = peerInfo.noisePublicKey
nickname = peerInfo.nickname
} else if (ContactIdentityResolver.isNoiseKeyHex(peerID)) {
noiseKey = ContactIdentityResolver.bytesFromHex(peerID)
val rel = noiseKey?.let {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(it)
viewModelScope.launch {
val contact = ContactDirectory.resolve(peerID)
val key = contact.noisePublicKey
if (key == null) {
// Preserve legacy fingerprint-only favorites until authenticated key exchange.
privateChatManager.toggleFavorite(peerID)
return@launch
}
try {
withContext(kotlinx.coroutines.Dispatchers.IO) {
val favorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared
val wasFavorite = favorites.getFavoriteStatus(key)?.isFavorite
?: dataManager.isFavorite(ContactIdentityResolver.fingerprintHex(key))
favorites.updateFavoriteStatus(key, contact.displayName ?: "Contact", !wasFavorite)
}
if (rel != null) nickname = rel.peerNickname
} else {
val contact = ContactDirectory.resolve(peerID)
noiseKey = contact.noisePublicKey
contact.displayName?.let { nickname = it }
dataManager.loadFavorites()
state.setFavoritePeers(dataManager.favoritePeers.toSet())
com.bitchat.android.services.PrivateDeliveryCoordinator.getInstance(getApplication()).wake()
} catch (_: Exception) {
// Keep the previous visible state when durable intent cannot be saved.
Log.w("ChatViewModel", "Unable to save favorite change")
}
if (noiseKey != null) {
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
val fingerprint = identityManager.generateFingerprint(noiseKey!!)
val isNowFavorite = dataManager.favoritePeers.contains(fingerprint)
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateFavoriteStatus(
noisePublicKey = noiseKey!!,
nickname = nickname,
isFavorite = isNowFavorite
)
try {
com.bitchat.android.services.MessageRouter
.getInstance(getApplication(), mesh)
.sendFavoriteNotification(peerID, isNowFavorite)
} catch (_: Exception) { }
}
} catch (_: Exception) { }
// Log current state after toggle
logCurrentFavoriteState()
}
}
private fun refreshPeerFavoritedUs() {
try {
val fingerprints = com.bitchat.android.favorites.FavoritesPersistenceService.shared

View File

@ -158,8 +158,14 @@ class DataManager(private val context: Context) {
fun loadFavorites() {
val savedFavorites = prefs.getStringSet("favorites", emptySet()) ?: emptySet()
_favoritePeers.clear()
_favoritePeers.addAll(savedFavorites)
Log.d(TAG, "Loaded ${savedFavorites.size} favorite users from storage: $savedFavorites")
runCatching {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getAllRelationships().forEach { relationship ->
val fingerprint = com.bitchat.android.services.ContactIdentityResolver.fingerprintHex(relationship.peerNoisePublicKey)
if (relationship.isFavorite) _favoritePeers.add(fingerprint) else _favoritePeers.remove(fingerprint)
}
}
}
fun saveFavorites() {

View File

@ -118,8 +118,8 @@ class GeohashViewModel(
geoTimer?.cancel()
geoTimer = null
try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {}
NostrBackgroundRuntime.resetSubscriptions()
try { com.bitchat.android.nostr.NostrRelayManager.getInstance(getApplication()).clearAllOnPanic() } catch (_: Exception) {}
NostrBackgroundRuntime.resetSubscriptions()
try { com.bitchat.android.nostr.LocationNotesManager.getInstance().stop() } catch (_: Exception) {}
}

View File

@ -255,7 +255,10 @@ class MeshDelegateHandler(
)
// UI focus is the source of truth for local read state. Transport acceptance is a
// separate fact and may remain retryable when the peer disconnects.
try { markMessageReadLocally(message.id) } catch (_: Exception) { }
try {
markMessageReadLocally(message.id)
com.bitchat.android.services.MessageRouter.tryGetInstance()?.queueReadReceipt(message, senderConversationID!!)
} catch (_: Exception) { }
val nickname = state.getNicknameValue().ifBlank { "unknown" }
val mesh = getMeshService()
@ -268,7 +271,7 @@ class MeshDelegateHandler(
mesh.getPeerInfo(meshPeerID)?.isConnected == true &&
mesh.hasEstablishedSession(meshPeerID)
) {
mesh.sendReadReceipt(message.id, meshPeerID, nickname)
mesh.sendReadReceipt(message.wireMessageID ?: message.id, meshPeerID, nickname)
}
} catch (_: Exception) { }

View File

@ -130,7 +130,9 @@ class MessageManager(private val state: ChatState) {
com.bitchat.android.services.AppStateStore.addPrivateMessageDurably(
peerID = conversationID,
msg = message,
forceRead = forceRead
forceRead = forceRead,
queueForDelivery = message.deliveryStatus == DeliveryStatus.Sending,
outgoingJob = com.bitchat.android.services.MessageRouter.deliveryJob(message, peerID)
)
} catch (_: Exception) {
false

View File

@ -10,6 +10,7 @@ import com.bitchat.android.services.ContactIdentityResolver
import java.util.*
import android.util.Log
import kotlinx.coroutines.*
/**
* Interface for Noise session operations needed by PrivateChatManager
@ -41,6 +42,7 @@ class PrivateChatManager(
companion object {
private const val TAG = "PrivateChatManager"
private val deliveryScope by lazy { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) }
}
private val fingerprintManager = PeerFingerprintManager.getInstance()
@ -105,32 +107,10 @@ class PrivateChatManager(
myPeerID: String,
onSendMessage: (String, String, String, String) -> Unit
): Boolean {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
if (isPeerBlocked(peerID)) {
val systemMessage = BitchatMessage(
sender = "system",
content = "cannot send message to $recipientNickname: user is blocked.",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return false
if (isPeerBlocked(peerID)) return false
deliveryScope.launch {
sendPrivateMessageDurably(content, peerID, recipientNickname, senderNickname, myPeerID, onSendMessage)
}
val message = BitchatMessage(
sender = senderNickname ?: myPeerID,
content = content,
timestamp = Date(),
isRelay = false,
isPrivate = true,
recipientNickname = recipientNickname,
senderPeerID = myPeerID,
deliveryStatus = DeliveryStatus.Sending
)
messageManager.addPrivateMessage(conversationID, message)
onSendMessage(content, conversationID, recipientNickname ?: "", message.id)
return true
}
@ -504,10 +484,11 @@ class PrivateChatManager(
Log.w(TAG, "Failed to persist local read for message ${msg.id}: ${e.message}")
}
}
if (isFromTarget && meshPeerID != null && !hasReadReceiptBeenSent(msg.id)) {
if (isFromTarget && msg.sender != "system" && !hasReadReceiptBeenSent(msg.id)) {
try {
com.bitchat.android.services.MessageRouter.tryGetInstance()?.queueReadReceipt(msg, canonicalConversationID)
if (hasMesh) {
meshService.sendReadReceipt(msg.id, meshPeerID, myNickname)
meshService.sendReadReceipt(msg.wireMessageID ?: msg.id, meshPeerID!!, myNickname)
sentCount += 1
}
} catch (e: Exception) {

View File

@ -0,0 +1,37 @@
package com.bitchat.android.favorites
import android.content.Context
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.services.ContactIdentityResolver
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE)
class FavoritesPersistenceTest {
@Test fun `legacy relationships load without new control fields`() {
val context = RuntimeEnvironment.getApplication()
val secure = SecureIdentityStateManager(context.getSharedPreferences("legacy-${UUID.randomUUID()}", Context.MODE_PRIVATE), true)
val key = ByteArray(32) { 1 }
val hex = ContactIdentityResolver.noiseKeyHex(key)
secure.storeSecureValueAndWait("favorite_relationships", """{"$hex":{
"peerNoisePublicKeyHex":"$hex","peerNickname":"Synthetic contact",
"isFavorite":true,"theyFavoritedUs":true,"favoritedAt":1,"lastUpdated":1
}}""")
val favorites = FavoritesPersistenceService(context, secure)
assertTrue(favorites.getFavoriteStatus(key)!!.isMutual)
assertEquals("", favorites.getFavoriteStatus(key)!!.peerUpdateID)
favorites.updateFavoriteStatus(key, "Synthetic contact", false)
val pending = favorites.getFavoriteStatus(key)!!
assertNotNull(pending.pendingControlID)
assertTrue(pending.pendingControlTimestamp > 0)
val reopened = FavoritesPersistenceService(context, secure).getFavoriteStatus(key)!!
assertEquals(pending.pendingControlID, reopened.pendingControlID)
assertEquals(pending.pendingControlTimestamp, reopened.pendingControlTimestamp)
}
}

View File

@ -1,195 +1,168 @@
package com.bitchat.android.nostr
import android.os.Build
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.services.ConversationRepository
import com.bitchat.android.services.InMemoryConversationStorageCipher
import com.bitchat.android.services.SeenMessageStore
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.DataManager
import com.bitchat.android.ui.MessageManager
import com.bitchat.android.ui.NoiseSessionDelegate
import com.bitchat.android.ui.PrivateChatManager
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import android.app.Application
import android.content.Context
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.model.*
import com.bitchat.android.services.*
import kotlinx.coroutines.*
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.Date
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
@OptIn(ExperimentalCoroutinesApi::class)
@Config(manifest = Config.NONE)
class NostrDirectMessageHandlerTest {
private val gson = Gson()
private lateinit var scope: CoroutineScope
private lateinit var conversationRepository: ConversationRepository
private lateinit var conversationDatabaseName: String
@Before
fun setUp() {
Dispatchers.setMain(UnconfinedTestDispatcher())
scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
AppStateStore.clear()
conversationDatabaseName = "nostr-dm-${UUID.randomUUID()}.db"
conversationRepository = ConversationRepository(
context = RuntimeEnvironment.getApplication(),
dispatcher = Dispatchers.Unconfined,
databaseName = conversationDatabaseName,
storageCipher = InMemoryConversationStorageCipher()
)
AppStateStore.setConversationRepositoryForTest(conversationRepository)
}
@After
fun tearDown() {
AppStateStore.clear()
AppStateStore.setConversationRepositoryForTest(null)
conversationRepository.closeForTest()
RuntimeEnvironment.getApplication()
.deleteDatabase(conversationDatabaseName)
scope.cancel()
Dispatchers.resetMain()
}
@Test
fun `private messages use authenticated rumor time instead of randomized gift wrap time`() {
val application = RuntimeEnvironment.getApplication()
val state = ChatState(scope).apply { setNickname("recipient") }
val dataManager = DataManager(application)
val messageManager = MessageManager(state)
val privateChatManager = PrivateChatManager(
state = state,
messageManager = messageManager,
dataManager = dataManager,
noiseSessionDelegate = mock<NoiseSessionDelegate>()
)
val seenStore = mock<SeenMessageStore>()
whenever(seenStore.hasDelivered(any())).thenReturn(true)
whenever(seenStore.hasBeenReadLocally(any())).thenReturn(false)
val handler = NostrDirectMessageHandler(
application = application,
state = state,
privateChatManager = privateChatManager,
updateDeliveryStatus = { _, _ -> },
scope = scope,
repo = GeohashRepository(application, state, dataManager),
dataManager = dataManager,
seenStoreProvider = { seenStore }
)
val sender = NostrIdentity.generate()
val recipient = NostrIdentity.generate()
val now = (System.currentTimeMillis() / 1000).toInt()
val firstRumorTime = now - 120
val secondRumorTime = now - 60
val firstId = "first-real-time"
val secondId = "second-real-time"
val first = privateMessageGiftWrap(
content = requireNotNull(
NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content = "first",
messageID = firstId,
senderPeerID = "0011223344556677"
)
),
sender = sender,
recipient = recipient,
rumorCreatedAt = firstRumorTime,
giftWrapCreatedAt = now - 5
)
val second = privateMessageGiftWrap(
content = requireNotNull(
NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content = "second",
messageID = secondId,
senderPeerID = "0011223344556677"
)
),
sender = sender,
recipient = recipient,
rumorCreatedAt = secondRumorTime,
giftWrapCreatedAt = now - 86_400
)
handler.onGiftWrap(first, "", recipient)
waitForMessage(state, firstId)
handler.onGiftWrap(second, "", recipient)
waitForMessage(state, secondId)
val messages = state.getPrivateChatsValue().values.single()
assertEquals(listOf(firstId, secondId), messages.map { it.id })
assertEquals(firstRumorTime * 1000L, messages[0].timestamp.time)
assertEquals(secondRumorTime * 1000L, messages[1].timestamp.time)
}
private fun waitForMessage(state: ChatState, messageId: String) {
kotlinx.coroutines.runBlocking {
withTimeout(5_000) {
while (state.getPrivateChatsValue().values.flatten().none { it.id == messageId }) {
delay(10)
}
}
private lateinit var application: Application
private lateinit var repository: ConversationRepository
private lateinit var favorites: FavoritesPersistenceService
private lateinit var handler: NostrDirectMessageHandler
private lateinit var databaseName: String
private var failDeliveryWrites = false
private val backingCipher = InMemoryConversationStorageCipher()
private val cipher = object : ConversationStorageCipher by backingCipher {
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray {
if (failDeliveryWrites && associatedData.toString(Charsets.UTF_8).startsWith("delivery:")) error("synthetic storage failure")
return backingCipher.encrypt(plaintext, associatedData)
}
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
private val sender = NostrIdentity.generate()
private val recipient = NostrIdentity.generate()
private val noise = ByteArray(32) { 7 }
private val conversation = ContactIdentityResolver.contactConversationIdForNoiseKey(noise)
private fun privateMessageGiftWrap(
content: String,
sender: NostrIdentity,
recipient: NostrIdentity,
rumorCreatedAt: Int,
giftWrapCreatedAt: Int
): NostrEvent {
val rumorBase = NostrEvent(
pubkey = sender.publicKeyHex,
createdAt = rumorCreatedAt,
kind = NostrKind.DIRECT_MESSAGE,
tags = listOf(listOf("p", recipient.publicKeyHex)),
content = content
)
val rumor = rumorBase.copy(id = rumorBase.computeEventIdHex())
val sealContent = NostrCrypto.encryptNIP44(
plaintext = gson.toJson(rumor),
recipientPublicKeyHex = recipient.publicKeyHex,
senderPrivateKeyHex = sender.privateKeyHex
)
val seal = NostrEvent(
pubkey = sender.publicKeyHex,
createdAt = giftWrapCreatedAt,
kind = NostrKind.SEAL,
tags = emptyList(),
content = sealContent
).sign(sender.privateKeyHex)
val (wrapPrivateKey, wrapPublicKey) = NostrCrypto.generateKeyPair()
val giftWrapContent = NostrCrypto.encryptNIP44(
plaintext = gson.toJson(seal),
recipientPublicKeyHex = recipient.publicKeyHex,
senderPrivateKeyHex = wrapPrivateKey
)
return NostrEvent(
pubkey = wrapPublicKey,
createdAt = giftWrapCreatedAt,
kind = NostrKind.GIFT_WRAP,
tags = listOf(listOf("p", recipient.publicKeyHex)),
content = giftWrapContent
).sign(wrapPrivateKey)
@Before fun setup() {
application = RuntimeEnvironment.getApplication()
databaseName = "dm-test-${UUID.randomUUID()}.db"
repository = ConversationRepository(application, Dispatchers.Unconfined, databaseName, cipher)
AppStateStore.resumePrivateConversationsAfterPanic()
AppStateStore.clear()
AppStateStore.setConversationRepositoryForTest(repository)
val secure = SecureIdentityStateManager(application.getSharedPreferences("identity-${UUID.randomUUID()}", Context.MODE_PRIVATE), testOnly = true)
favorites = FavoritesPersistenceService(application, secure)
favorites.updateFavoriteStatus(noise, "Synthetic contact", true)
favorites.updateNostrPublicKey(noise, sender.publicKeyHex)
favorites.updatePeerFavoritedUs(noise, true)
handler = NostrDirectMessageHandler(application, scope, favoritesProvider = { favorites }, repositoryProvider = { repository },
wakeDeliveries = {}, isViewing = { false })
}
@After fun cleanup() {
scope.cancel()
AppStateStore.clear()
AppStateStore.setConversationRepositoryForTest(null)
repository.closeForTest()
application.deleteDatabase(databaseName)
}
private fun message(id: String, text: String, author: NostrIdentity = sender, timestamp: Long = System.currentTimeMillis()): NostrEvent {
val embedded = requireNotNull(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(text, id, "0011223344556677", timestamp))
return NostrProtocol.createPrivateMessage(embedded, recipient.publicKeyHex, author, timestamp).single()
}
@Test fun `background delivery is unread and duplicate replays recreate receipt intent`() = runBlocking {
val event = message("hello", "Synthetic message")
assertTrue(handler.process(event, "", recipient))
assertEquals(1, AppStateStore.unreadPrivateMessageCounts.value[conversation])
assertEquals("hello", AppStateStore.privateMessages.value[conversation]!!.single().wireMessageID)
val receipt = repository.deliveryJobs().single()
repository.removeDelivery(receipt.id) // Simulate a published receipt lost downstream.
assertTrue(handler.process(event, "", recipient))
assertEquals(1, repository.deliveryJobs().size)
assertEquals(1, AppStateStore.privateMessages.value[conversation]!!.size)
assertEquals(1, AppStateStore.unreadPrivateMessageCounts.value[conversation])
}
@Test fun `unknown and non-mutual account senders cannot inject messages`() = runBlocking {
assertTrue(handler.process(message("unknown", "ignored", NostrIdentity.generate()), "", recipient))
favorites.updatePeerFavoritedUs(noise, false)
assertTrue(handler.process(message("non-mutual", "ignored"), "", recipient))
assertTrue(AppStateStore.privateMessages.value.isEmpty())
assertTrue(repository.deliveryJobs().isEmpty())
}
@Test fun `favorite control cannot target another authenticated contact`() = runBlocking {
val impostor = NostrIdentity.generate()
val otherNoise = ByteArray(32) { 9 }
favorites.updateNostrPublicKey(otherNoise, impostor.publicKeyHex)
val forged = FavoriteControlMessage.encode(false, sender.npub)
assertTrue(handler.process(message("forged-control", forged, impostor), "", recipient))
assertTrue(favorites.getFavoriteStatus(noise)!!.theyFavoritedUs)
}
@Test fun `receipt from wrong contact cannot advance an outgoing message`() = runBlocking {
val otherConversation = "contact_" + "aa".repeat(32)
val outgoing = BitchatMessage(id = "outgoing", sender = "self", content = "test", timestamp = Date(),
isPrivate = true, senderPeerID = "ffeeddccbbaa0099", deliveryStatus = DeliveryStatus.Sent)
assertTrue(AppStateStore.addPrivateMessageDurably(otherConversation, outgoing))
val content = requireNotNull(NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(NoisePayloadType.READ_RECEIPT, outgoing.id, "0011223344556677"))
val event = NostrProtocol.createPrivateMessage(content, recipient.publicKeyHex, sender).single()
assertTrue(handler.process(event, "", recipient))
assertEquals(DeliveryStatus.Sent, repository.storedMessage(otherConversation, outgoing.id)!!.deliveryStatus)
}
@Test fun `same wire ID in different contacts does not merge histories`() = runBlocking {
val other = NostrIdentity.generate()
val otherNoise = ByteArray(32) { 11 }
favorites.updateFavoriteStatus(otherNoise, "Other synthetic contact", true)
favorites.updateNostrPublicKey(otherNoise, other.publicKeyHex)
favorites.updatePeerFavoritedUs(otherNoise, true)
assertTrue(handler.process(message("shared-wire-id", "first"), "", recipient))
assertTrue(handler.process(message("shared-wire-id", "second", other), "", recipient))
assertEquals(2, AppStateStore.privateMessages.value.size)
assertEquals(2, AppStateStore.privateMessages.value.values.flatten().map { it.id }.toSet().size)
}
@Test fun `twenty-nine-day-old message is admitted using its authenticated timestamp`() = runBlocking {
val timestamp = System.currentTimeMillis() - 29L * 86_400_000
assertTrue(handler.process(message("old", "retained", timestamp = timestamp), "", recipient))
assertEquals(timestamp / 1000 * 1000, AppStateStore.privateMessages.value[conversation]!!.single().timestamp.time)
}
@Test fun `receipt persistence failure rolls back admission and permits retransmission`() = runBlocking {
val event = message("retry-storage", "Synthetic retry")
failDeliveryWrites = true
assertFalse(handler.process(event, "", recipient))
assertTrue(AppStateStore.privateMessages.value.isEmpty())
assertTrue(repository.deliveryJobs().isEmpty())
failDeliveryWrites = false
assertTrue(handler.process(event, "", recipient))
assertEquals(1, AppStateStore.privateMessages.value[conversation]!!.size)
assertEquals(1, repository.deliveryJobs().size)
}
@Test fun `deleted conversation replay is consumed without recreating messages or receipts`() = runBlocking {
val event = message("deleted", "Synthetic deleted message")
assertTrue(handler.process(event, "", recipient))
repository.deleteConversationAndWait(conversation, setOf(conversation))
AppStateStore.clear()
assertTrue(handler.process(event, "", recipient))
assertTrue(repository.deliveryJobs().isEmpty())
assertTrue(AppStateStore.privateMessages.value.isEmpty())
}
@Test fun `stale control cannot reverse a newer authenticated unfavorite`() = runBlocking {
val now = System.currentTimeMillis()
assertTrue(handler.process(message("newer", FavoriteControlMessage.encode(false, sender.npub), timestamp = now), "", recipient))
assertTrue(handler.process(message("older", FavoriteControlMessage.encode(true, sender.npub), timestamp = now - 1000), "", recipient))
assertFalse(favorites.getFavoriteStatus(noise)!!.theyFavoritedUs)
}
@Test fun `event queued before a state reset cannot mutate the new generation`() = runBlocking {
val token = AppStateStore.privateConversationToken()
val event = message("stale-epoch", "Synthetic stale event")
AppStateStore.clear()
assertFalse(handler.process(event, "", recipient, token))
assertTrue(repository.deliveryJobs().isEmpty())
}
}

View File

@ -0,0 +1,30 @@
package com.bitchat.android.nostr
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Test
class NostrInboxSyncTest {
@Test fun `catch-up includes wrapper randomization beyond thirty days`() {
val now = 4_000_000_000L
assertEquals(((now - 30L * 86_400_000 - NostrInboxSync.WRAPPER_OVERLAP_MS) / 1000).toInt(), NostrInboxSync.since(now, null))
assertEquals(((now - 60_000 - NostrInboxSync.WRAPPER_OVERLAP_MS) / 1000).toInt(), NostrInboxSync.since(now, now - 60_000))
}
@Test fun `paginated history keeps every message including equal-second ties`() = runBlocking {
val source = (0..1200).map { NostrEvent(id = "$it", pubkey = "synthetic", createdAt = it / 600, kind = 1059, tags = emptyList(), content = "fixture") }
val processed = mutableSetOf<String>()
val sync = NostrInboxSync(
fetch = { since, until, limit -> source.filter { it.createdAt in since..until }.sortedByDescending { it.createdAt }.take(limit) },
process = { processed.add(it.id); true }
)
assertTrue(sync.scan(0, 10))
assertEquals(source.map { it.id }.toSet(), processed)
}
@Test fun `failed admission or missing EOSE leaves catch-up incomplete`() = runBlocking {
val event = NostrEvent(pubkey = "synthetic", createdAt = 1, kind = 1059, tags = emptyList(), content = "fixture")
assertFalse(NostrInboxSync({ _, _, _ -> null }, { true }).scan(0, 10))
assertFalse(NostrInboxSync({ _, _, _ -> listOf(event) }, { false }).scan(0, 10))
}
}

View File

@ -28,30 +28,28 @@ class NostrPendingEventQueueTest {
}
@Test
fun `duplicate event publishes retain independent delivery state`() {
fun `same envelope retries consolidate pending relays until acknowledged`() {
val queue = NostrPendingEventQueue(capacity = 4)
val signedEvent = event("same")
val firstId = requireNotNull(
queue.enqueue(signedEvent, listOf("relay-a", "relay-b"), liveLocationToken = null)
)
val secondId = requireNotNull(
queue.enqueue(signedEvent, listOf("relay-a"), liveLocationToken = null)
)
assertNotEquals(firstId, secondId)
queue.markDelivered(firstId, "relay-a")
assertEquals(
listOf(secondId),
queue.pendingForRelay("relay-a").map { it.queueId }
)
assertEquals(
listOf(firstId),
queue.pendingForRelay("relay-b").map { it.queueId }
)
queue.markDelivered(firstId, "relay-b")
val envelope = event("same")
val first = queue.enqueue(envelope, listOf("relay-a", "relay-b"), null)
assertEquals(first, queue.enqueue(envelope, listOf("relay-a"), null))
assertEquals(1, queue.size())
queue.acknowledge(envelope.id, "relay-a")
assertEquals(0, queue.pendingForRelay("relay-a").size)
assertEquals(1, queue.pendingForRelay("relay-b").size)
queue.acknowledge(envelope.id, "relay-b")
assertEquals(0, queue.size())
}
@Test
fun `same event with different privacy provenance remains independently revocable`() {
val queue = NostrPendingEventQueue(capacity = 4)
val envelope = event("same")
queue.enqueue(envelope, listOf("relay"), null)
queue.enqueue(envelope, listOf("relay"), 42L)
queue.removeLiveLocationEvents()
assertEquals(1, queue.size())
assertNull(queue.pendingForRelay("relay").single().liveLocationToken)
}
@Test

View File

@ -0,0 +1,68 @@
package com.bitchat.android.nostr
import com.google.gson.JsonParser
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.first
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** A loopback-only relay: no public services, persisted accounts, or timing sleeps. */
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE)
class NostrRelayDeliveryTest {
@Test fun `socket acceptance is not delivery and relay rejection stays unsuccessful`() = runBlocking {
exerciseRelay(accepted = false)
}
@Test fun `publication completes only after the selected relay accepts it`() = runBlocking {
exerciseRelay(accepted = true)
}
private suspend fun exerciseRelay(accepted: Boolean) = coroutineScope {
val received = CompletableDeferred<Pair<WebSocket, String>>()
val server = MockWebServer()
server.enqueue(MockResponse.Builder().webSocketUpgrade(object : WebSocketListener() {
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { webSocket.close(code, reason) }
override fun onMessage(webSocket: WebSocket, text: String) {
val frame = JsonParser.parseString(text).asJsonArray
if (frame[0].asString == "EVENT") received.complete(webSocket to frame[1].asJsonObject["id"].asString)
}
}).build())
server.start()
val url = server.url("/").toString().replace("http://", "ws://")
val client = OkHttpClient()
val manager = NostrRelayManager(listOf(url)) { client }
try {
manager.connect()
withTimeout(5_000) { manager.isConnected.first { it } }
val event = signedEvent()
val result = async { manager.publishConfirmed(event, listOf(url)) }
val (socket, id) = withTimeout(5_000) { received.await() }
assertEquals(event.id, id)
assertFalse("WebSocket send must not mark the message sent", result.isCompleted)
socket.send("[\"OK\",\"$id\",$accepted,\"fixture\"]")
assertEquals(accepted, withTimeout(5_000) { result.await() })
} finally {
manager.clearAllSubscriptions()
manager.disconnect()
server.close()
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
}
}
private fun signedEvent(): NostrEvent {
val key = "0".repeat(63) + "1"
return NostrEvent(pubkey = NostrCrypto.derivePublicKey(key), createdAt = 1,
kind = NostrKind.TEXT_NOTE, tags = emptyList(), content = "synthetic").sign(key)
}
}

View File

@ -90,13 +90,13 @@ class IncomingMessageAdmissionTest {
runBlocking { repository.awaitPendingWrites() }
assertEquals(
listOf(latest.id),
AppStateStore.privateMessages.value.getValue("peer-a").map { it.id }
AppStateStore.privateMessages.value.getValue("peer-a").map { it.wireMessageID ?: it.id }
)
assertFalse(IncomingMessageAdmission.admitToAppState(older))
assertEquals(
listOf(latest.id),
AppStateStore.privateMessages.value.getValue("peer-a").map { it.id }
AppStateStore.privateMessages.value.getValue("peer-a").map { it.wireMessageID ?: it.id }
)
}
@ -116,7 +116,7 @@ class IncomingMessageAdmissionTest {
AppStateStore.releasePrivateConversationHistory("peer-a")
assertEquals(
listOf(latest.id),
AppStateStore.privateMessages.value.getValue("peer-a").map { it.id }
AppStateStore.privateMessages.value.getValue("peer-a").map { it.wireMessageID ?: it.id }
)
val delivered = DeliveryStatus.Delivered(to = "alice", at = Date(3L))

View File

@ -1,217 +1,128 @@
package com.bitchat.android.services
import android.content.Context
import android.os.Build
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.PeerInfo
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.nostr.NostrTransport
import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.clearInvocations
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.mockito.kotlin.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.Date
import java.util.UUID
/** Routing contracts exercise the persisted worker, rather than the removed in-memory queue. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
@Config(manifest = Config.NONE)
@OptIn(ExperimentalCoroutinesApi::class)
class MessageRouterTest {
private lateinit var context: Context
private lateinit var repository: ConversationRepository
private lateinit var favorites: FavoritesPersistenceService
private lateinit var databaseName: String
private val cipher = InMemoryConversationStorageCipher()
private val conversation = "contact_" + "11".repeat(32)
private val peer = "1122334455667788"
private val myPeerID = "1111222233334444"
private val peerID = "aaaabbbbccccdddd"
private val noiseKey = ByteArray(32) { 0x0B }
private lateinit var mesh: MeshService
private lateinit var router: MessageRouter
private var fakeTime = 1_000_000L
private val expired = mutableListOf<String>()
@Before
fun setup() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences(
"message-router-test-${UUID.randomUUID()}",
Context.MODE_PRIVATE
)
val identityManager = SecureIdentityStateManager(prefs, testOnly = true)
ContactDirectory.identityManagerProvider = { identityManager }
mesh = mock()
whenever(mesh.myPeerID).thenReturn(myPeerID)
whenever(mesh.getPeerNicknames()).thenReturn(mapOf(peerID to "peer"))
ContactDirectory.initialize(context) { mesh }
MessageRouter.disableSchedulerForTesting = true
MessageRouter.resetForTesting()
fakeTime = 1_000_000L
expired.clear()
router = MessageRouter.getInstance(context, mesh)
router.clock = { fakeTime }
router.onMessageExpired = { expired.add(it) }
@Before fun setup() {
context = RuntimeEnvironment.getApplication()
databaseName = "delivery-test-${UUID.randomUUID()}.db"
repository = ConversationRepository(context, Dispatchers.Unconfined, databaseName, cipher)
favorites = FavoritesPersistenceService(context, SecureIdentityStateManager(context.getSharedPreferences("test-${UUID.randomUUID()}", 0), true))
AppStateStore.resumePrivateConversationsAfterPanic()
AppStateStore.clear()
AppStateStore.setConversationRepositoryForTest(repository)
}
@After fun cleanup() {
AppStateStore.clear()
AppStateStore.setConversationRepositoryForTest(null)
repository.closeForTest()
context.deleteDatabase(databaseName)
}
@After
fun tearDown() {
MessageRouter.resetForTesting()
MessageRouter.disableSchedulerForTesting = false
ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) }
private suspend fun queue() {
assertTrue(AppStateStore.addPrivateMessageDurably(conversation, BitchatMessage(
id = "message", sender = "self", content = "fixture", timestamp = Date(), isPrivate = true,
senderPeerID = "9988776655443322", deliveryStatus = DeliveryStatus.Sending
), queueForDelivery = true))
}
@Test
fun `queued message flushes after peer returns and session establishes`() {
peerOffline()
val result = router.sendPrivate("hello", peerID, "peer", "msg-1")
assertEquals(MessageRouter.RouteResult.QUEUED, result)
verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull())
verify(mesh, never()).initiateNoiseHandshake(any())
// Peer reappears without a session: handshake kicked immediately
peerConnectedNoSession()
router.onPeersUpdated(listOf(peerID))
verify(mesh, times(1)).initiateNoiseHandshake(peerID)
verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull())
// Session established: queued message is sent
peerReady()
router.onSessionEstablished(peerID)
verify(mesh, times(1)).sendPrivateMessage("hello", peerID, "peer", "msg-1")
@Test fun `unacknowledged mesh message falls back with the original ID`() = runTest {
queue()
val mesh = mock<MeshService>()
whenever(mesh.myPeerID).thenReturn("9988776655443322")
val peerInfo = mock<PeerInfo>()
whenever(peerInfo.isConnected).thenReturn(true)
whenever(mesh.getPeerInfo(peer)).thenReturn(peerInfo)
whenever(mesh.hasEstablishedSession(peer)).thenReturn(true)
val transport = mock<NostrTransport>()
whenever(transport.prepare(any())).thenAnswer { it.arguments[0] }
whenever(transport.publish(any())).thenReturn(true)
val base = System.currentTimeMillis()
val worker = PrivateDeliveryCoordinator(context, backgroundScope, { repository }, { transport }, { favorites },
{ ContactDirectory.ContactResolution(conversation, peer, null, "ab".repeat(32), "peer", true) }, { base + testScheduler.currentTime })
worker.bindMesh(mesh)
runCurrent()
verify(mesh).sendPrivateMessage("fixture", peer, "", "message")
verify(transport, never()).publish(any())
advanceTimeBy(32_000)
runCurrent()
verify(transport).publish(check { assertEquals("message", it.messageID) })
assertEquals(DeliveryStatus.Sent, repository.storedMessage(conversation, "message")!!.deliveryStatus)
assertEquals(1, repository.deliveryJobs().size) // Relay acceptance is not recipient delivery.
worker.stop()
}
@Test
fun `scheduler retries handshake with capped backoff`() {
peerConnectedNoSession()
val result = router.sendPrivate("hello", peerID, "peer", "msg-1")
assertEquals(MessageRouter.RouteResult.QUEUED, result)
verify(mesh, times(1)).initiateNoiseHandshake(peerID) // immediate kick at enqueue
clearInvocations(mesh)
router.tickOutbox() // backoff (5s) not yet elapsed
verify(mesh, never()).initiateNoiseHandshake(any())
fakeTime += 6_000
router.tickOutbox() // attempt 2, next in 15s
verify(mesh, times(1)).initiateNoiseHandshake(peerID)
fakeTime += 7_000
router.tickOutbox() // too early
verify(mesh, times(1)).initiateNoiseHandshake(peerID)
fakeTime += 9_000
router.tickOutbox() // attempt 3, next in 30s
verify(mesh, times(2)).initiateNoiseHandshake(peerID)
fakeTime += 31_000
router.tickOutbox() // attempt 4, next in 60s
verify(mesh, times(3)).initiateNoiseHandshake(peerID)
fakeTime += 61_000
router.tickOutbox() // attempt 5, capped at 60s
verify(mesh, times(4)).initiateNoiseHandshake(peerID)
@Test fun `recipient receipt removes durable work and never downgrades read`() = runTest {
queue()
assertTrue(AppStateStore.acknowledgePrivateReceipt(conversation, "message", true))
assertTrue(repository.deliveryJobs().isEmpty())
assertTrue(AppStateStore.acknowledgePrivateReceipt(conversation, "message", false))
assertTrue(repository.storedMessage(conversation, "message")!!.deliveryStatus is DeliveryStatus.Read)
assertFalse(AppStateStore.acknowledgePrivateReceipt("unrelated", "message", true))
}
@Test
fun `expired entries are dropped and reported`() {
peerOffline()
router.sendPrivate("old message", peerID, "peer", "msg-old")
fakeTime += 86_400_001L
router.tickOutbox()
assertEquals(listOf("msg-old"), expired)
// Nothing left to flush even when the peer becomes reachable
peerReady()
router.tickOutbox()
verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull())
@Test fun `outgoing echo and work survive reopening the database`() = runTest {
queue()
repository.closeForTest()
repository = ConversationRepository(context, Dispatchers.Unconfined, databaseName, cipher)
AppStateStore.setConversationRepositoryForTest(repository)
val jobs = repository.deliveryJobs()
assertEquals("message", jobs.single().messageID)
assertNotNull(repository.storedMessage(conversation, "message"))
// Deleting a conversation removes the unsent message and its work in the same database.
repository.deleteConversationAndWait(conversation, setOf(conversation))
assertTrue(repository.deliveryJobs().isEmpty())
}
@Test
fun `outbox cap evicts oldest and preserves order`() {
peerOffline()
repeat(101) { i ->
router.sendPrivate("content-$i", peerID, "peer", "msg-$i")
@Test fun `full outbox rolls back outgoing echo instead of losing delivery intent`() = runTest {
repeat(PrivateDeliveryJob.MAX_PER_CONTACT) { index ->
repository.saveDelivery(PrivateDeliveryJob("fixture:$index", conversation, "$index", PrivateDeliveryJob.Kind.READ))
}
assertEquals(listOf("msg-0"), expired)
peerReady()
router.onSessionEstablished(peerID)
verify(mesh, times(100)).sendPrivateMessage(any(), eq(peerID), any(), any())
verify(mesh, times(1)).sendPrivateMessage("content-1", peerID, "peer", "msg-1")
verify(mesh, times(1)).sendPrivateMessage("content-100", peerID, "peer", "msg-100")
verify(mesh, never()).sendPrivateMessage(eq("content-0"), any(), any(), anyOrNull())
assertFalse(AppStateStore.addPrivateMessageDurably(conversation, BitchatMessage(
id = "overflow", sender = "self", content = "fixture", timestamp = Date(), isPrivate = true,
senderPeerID = "9988776655443322", deliveryStatus = DeliveryStatus.Sending
), queueForDelivery = true))
assertNull(repository.storedMessage(conversation, "overflow"))
assertEquals(PrivateDeliveryJob.MAX_PER_CONTACT, repository.deliveryJobs().size)
}
@Test
fun `peer reappearance without pending messages does not kick handshake`() {
peerConnectedNoSession()
router.onPeersUpdated(listOf(peerID))
verify(mesh, never()).initiateNoiseHandshake(any())
@Test fun `completed delivery cannot be resurrected by a stale publish retry`() = runTest {
queue()
val stale = repository.deliveryJobs().single()
assertTrue(AppStateStore.acknowledgePrivateReceipt(conversation, "message", false))
assertFalse(repository.updateDelivery(stale.copy(attempts = 1)))
assertTrue(repository.deliveryJobs().isEmpty())
}
@Test
fun `established session flushes directly without handshake retry state`() {
peerReady()
val result = router.sendPrivate("direct", peerID, "peer", "msg-direct")
assertEquals(MessageRouter.RouteResult.MESH, result)
verify(mesh, times(1)).sendPrivateMessage("direct", peerID, "peer", "msg-direct")
verify(mesh, never()).initiateNoiseHandshake(any())
}
@Test
fun `scheduler stops with the mesh service and restarts on rebind`() {
MessageRouter.disableSchedulerForTesting = false
MessageRouter.resetForTesting()
val context = RuntimeEnvironment.getApplication()
val running = MessageRouter.getInstance(context, mesh)
assertTrue(running.isSchedulerRunning)
running.stopOutboxScheduler()
assertFalse(running.isSchedulerRunning)
val rebound = MessageRouter.getInstance(context, mesh)
assertTrue(rebound.isSchedulerRunning)
}
private fun peerOffline() {
whenever(mesh.getPeerInfo(peerID)).thenReturn(peerInfo(isConnected = false))
whenever(mesh.hasEstablishedSession(peerID)).thenReturn(false)
}
private fun peerConnectedNoSession() {
whenever(mesh.getPeerInfo(peerID)).thenReturn(peerInfo(isConnected = true))
whenever(mesh.hasEstablishedSession(peerID)).thenReturn(false)
}
private fun peerReady() {
whenever(mesh.getPeerInfo(peerID)).thenReturn(peerInfo(isConnected = true))
whenever(mesh.hasEstablishedSession(peerID)).thenReturn(true)
}
private fun peerInfo(isConnected: Boolean) = PeerInfo(
id = peerID,
nickname = "peer",
isConnected = isConnected,
isDirectConnection = true,
noisePublicKey = noiseKey,
signingPublicKey = ByteArray(32) { 0x0A },
isVerifiedNickname = false,
lastSeen = System.currentTimeMillis()
)
}

View File

@ -207,12 +207,16 @@ class PrivateChatManagerTest {
}
@Test
fun `canonical conversation send does not require resolved nickname`() {
fun `canonical conversation send does not require resolved nickname`() = kotlinx.coroutines.runBlocking {
val context = RuntimeEnvironment.getApplication()
val dbName = "private-send-test.db"
val repository = com.bitchat.android.services.ConversationRepository(context, kotlinx.coroutines.Dispatchers.Unconfined, dbName, com.bitchat.android.services.InMemoryConversationStorageCipher())
AppStateStore.setConversationRepositoryForTest(repository)
val conversationID =
ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32) { 4 })
var callbackInvoked = false
manager.sendPrivateMessage(
manager.sendPrivateMessageDurably(
content = "hello",
peerID = conversationID,
recipientNickname = null,
@ -225,6 +229,9 @@ class PrivateChatManagerTest {
assertEquals("", nickname)
}
AppStateStore.setConversationRepositoryForTest(null)
repository.closeForTest()
context.deleteDatabase(dbName)
assertTrue(callbackInvoked)
assertEquals(
"hello",

View File

@ -0,0 +1,129 @@
# Mutual-favorite private messaging review
This review covers the phone's path from favorite exchange and local echo through
mesh selection, Nostr publication, background reception, receipts, reconnect,
catch-up, deletion and process restart. Wear shares the admission and storage
changes, but does not gain Nostr transport.
## Findings addressed
| Failure | Change |
| --- | --- |
| Selecting Nostr marked a message sent before encoding or relay acceptance | Routing returns queued. Only an accepting relay `OK` advances Nostr messages to Sent; only the intended contact's authenticated receipt advances Delivered/Read. |
| Process death lost the in-memory outbox | Schema 5 stores encrypted delivery jobs in the same SQLite transaction as the outgoing echo. The process-owned worker recovers jobs on startup. |
| A mesh route selected just before disconnection never fell back | One mesh attempt gets a 30-second ACK window; eligible mutual contacts then use Nostr with the same wire message ID. |
| Multiple transport layers owned independent pending copies | The coordinator owns DM retries. Confirmed publication bypasses the relay manager's separate best-effort event queue. |
| Incoming deduplication consumed failed admissions, and replay did not re-ACK | Live gift wraps reach durable admission on every replay. A stored duplicate recreates receipt intent; storage failures remain retryable. |
| Message IDs could collide across unrelated conversations | New incoming local IDs include the canonical contact identity; the original wire ID is retained for receipts. Database conflicts cannot merge unrelated owners. |
| A claimed favorite public key could select the relationship to mutate | The authenticated Nostr author selects the existing Noise binding. Mesh controls use the authenticated Noise session key. Claims cannot target a different author. |
| Controls replayed in relay order could undo more recent state | Relationship state records authenticated packet time and a tie-break ID. Pending local controls retain their original ID and timestamp. |
| Receipts identified only a globally searchable message ID | Receipt admission verifies the canonical conversation and outgoing direction before updating status. Status advancement and message-job removal are transactional. |
| Background reception was effectively read and lacked the normal notification path | Background DMs are stored unread and notify once after durable admission. Read receipt intent is separate from delivery. |
| Opening an offline chat had no Nostr read-receipt path | The router persists read receipt intent together with the per-message local read write; live focused reception stores a read receipt with admission. |
| A 48-hour, 100-event subscription missed randomized gift wraps and busy history | Per-account, per-relay catch-up scans 30 days plus two days and 15 minutes of wrapper overlap. Saturated ranges are split, including same-second ties. Checkpoints advance only after EOSE and successful admission. |
| DNS failures and retry limits could permanently strand relay connections | DNS failures use capped exponential reconnects; network availability triggers reconnection. Server close handshakes are answered, and CLOSED subscriptions retry. |
| Delayed callbacks and retry writes could survive reset or completed delivery | Reset generations gate callbacks and processing. Retry updates require an existing matching job, so they cannot recreate work removed by an ACK or deletion. |
| Duplicate implementations obscured the active path | Removed unused NostrClient/NostrTestManager and the router/transport memory queues. Nostr reception no longer retains UI chat managers. |
## Ownership and status contract
`ConversationRepository` owns encrypted message and delivery storage.
`PrivateDeliveryCoordinator` performs bounded batches of four jobs, checks current
blocking and mutual-favorite state, chooses transport, and retries failures.
`NostrTransport` encodes the existing Bitchat envelope and requests confirmed
publication. `NostrDirectMessageHandler` authenticates, authorizes and admits
incoming content without a ViewModel. `NostrInboxSync` is the testable history
scan; `NostrBackgroundRuntime` supplies lifecycle and relay checkpoints.
Sending means durable work exists. Sent means at least one selected Nostr relay
accepted the event, not that the recipient received it. Delivered and Read require
authenticated contact receipts and cannot be downgraded by later relay results.
Messages remain retryable after relay acceptance until a recipient receipt arrives.
Receipts have no receipt-of-receipt protocol; Nostr receipt jobs complete on relay
acceptance and message retransmission can recreate them.
Jobs expire after 30 days. The queue allows 100 jobs per conversation and 500 in
total. Capacity failure rolls back a new outgoing echo rather than admitting a
message without its delivery intent. Retention pruning preserves pending outgoing
messages. Deleting messages/conversations removes their associated jobs and leaves
replay tombstones. Migration marks old Sending rows failed; it does not replay
historical Sent messages whose delivery history cannot be recovered.
## Compatibility boundaries
The existing Bitchat `v2:` encryption codec and embedded packet formats are retained.
The codec functions now explicitly name the legacy Bitchat format; compatibility
wrappers remain for existing callers. This is not a migration to standard NIP-44
ciphertext. Outer signature/recipient, seal signature/author, and rumor kind, ID and
recipient validation are enforced. The wire message ID is unchanged across mesh and
Nostr; incoming database IDs are local implementation details.
The catch-up window is a product retention policy, not a promise that any relay
retains 30 days of events. A relay that truncates below the requested limit without
indicating it cannot be detected reliably. A saturated single second beyond the
scan cap remains incomplete rather than silently advancing its checkpoint.
## Validation
Regression coverage includes loopback WebSocket OK acceptance/rejection, durable
restart recovery, ACK/retry races, queue-capacity rollback, receipt-storage failure,
background unread state, duplicate re-ACK, sender-bound controls and receipts,
contact-scoped ID collisions, deletion and reset replay, long-offline messages,
and bounded history pagination. Existing legacy protocol tests remain in place.
Run the JVM/build checks with the repository's configured JDK and SDK:
```sh
./gradlew testDebugUnitTest lintDebug :app:assembleDebug :wear:assembleDebug
python3 -m unittest tools.release_gate.test_nostr_relay_fixture
```
Physical Mesh Lab validation is **blocked (not run)** until two authorized,
disposable physical test devices are available. The new `nostr_dm` scenario is
explicit-only and is excluded from `all`: it requires externally enforced internet
egress isolation and an in-process loopback relay. It exercises the real durable
router, mutual favorite exchange, bidirectional out-of-mesh delivery and ACKs, and
receiver restart after relay publication. The existing `dm` scenario bypasses the
router and does not cover this contract.
After the operator authorizes destructive setup and establishes egress isolation,
use the normal setup from the Mesh Lab runbook, then:
```sh
MESH_LAB_NOSTR_ISOLATED=1 python3 tools/release_gate/mesh_lab.py scenario nostr_dm \
--serial-a <device-a> --serial-b <device-b> --out <local-evidence-directory>
python3 tools/release_gate/mesh_lab.py scenario all \
--serial-a <device-a> --serial-b <device-b> --out <local-evidence-directory>
```
The environment flag records operator-established isolation; it does not configure
a firewall. Keep non-loopback internet egress blocked before launching either app.
The debug hook permits cleartext only to loopback and uses ADB reverse for the local
relay. Scenario teardown removes its reverse mappings; the apps retain the fixture
relay configuration for that process and their mesh services were stopped. Restart
the apps before ordinary mesh testing, keeping egress isolated until disposable
fixture state has been cleared. Never publish raw evidence or device selectors.
## Follow-up work
1. Run the physical scenario, phone/watch mesh regression set, and Android/iOS
offline interoperability matrix before promoting this change to a release.
Include process death during each persistence boundary and a receiver offline
for several days. JVM tests do not establish radio or OEM background behavior.
2. Replace the remaining fingerprint-only favorite preference fallback after a
migration strategy for contacts without authenticated Noise keys is agreed.
Known phone contacts now persist relationship intent before updating the UI;
legacy fingerprint favorites and Wear's synchronous preference API still exist.
3. Add user-visible outbox capacity, expiry, retry and incomplete-sync diagnostics.
Today queue admission returns failure, failed jobs update message status, and
history failures retry; there is no complete delivery troubleshooting UI.
4. Consolidate bulk conversation read operations and per-message receipt intent.
The current durable path covers live reception and loaded messages on chat open;
reading an unloaded history through a bulk action needs an explicit receipt
policy rather than silently generating unbounded network work.
5. Separate corrupt-job quarantine from whole-queue loading, and reserve capacity
for receipts/control work under sustained backlog. Measure catch-up crypto/IO
cost before changing overlap or pagination bounds.
6. A standards-based codec migration, relay inbox discovery, authenticated-relay
support and offline media each need separate compatibility work. They are not
introduced implicitly by this reliability refactor.

View File

@ -23,6 +23,7 @@ import argparse
import concurrent.futures
import hashlib
import json
import os
import random
import shlex
import subprocess
@ -865,7 +866,53 @@ def scenario_file_oversize(a: Device, b: Device, fixtures: dict[str, dict]) -> d
return {"send": send, "receiver_saw_file": False}
def scenario_nostr_dm(a: Device, b: Device) -> dict:
"""Explicit-only controlled relay test; requires externally enforced egress isolation."""
if os.environ.get("MESH_LAB_NOSTR_ISOLATED") != "1":
raise MeshLabError("Nostr fixture requires disposable app data and externally blocked non-loopback internet egress")
from tools.release_gate.nostr_relay_fixture import LocalRelay
id_a, id_b = whoami(a)["peer_id"], whoami(b)["peer_id"]
with LocalRelay() as relay:
try:
for device in (a, b):
run_adb(device.serial, ["reverse", "tcp:8765", f"tcp:{relay.port}"])
device.cmd_ok("nostr_fixture", port=8765)
a.cmd_ok("handshake", timeout_ms=60_000, peer=id_b)
b.cmd_ok("handshake", timeout_ms=60_000, peer=id_a)
a.cmd_ok("favorite_set", peer=id_b, enabled=True)
b.cmd_ok("favorite_set", peer=id_a, enabled=True)
deadline = time.monotonic() + 60
while not (a.cmd_ok("favorite_status", peer=id_b)["is_mutual"] and
b.cmd_ok("favorite_status", peer=id_a)["is_mutual"]):
if time.monotonic() >= deadline:
raise MeshLabError("mutual favorite exchange timed out")
time.sleep(0.2)
for device in (a, b):
device.cmd_ok("nostr_fixture", port=8765, offline_mesh=True)
for sender, receiver, recipient, author in ((a, b, id_b, id_a), (b, a, id_a, id_b)):
message_id = f"nostr-fixture-{uuid.uuid4().hex}"
sender.cmd_ok("routed_dm_send", peer=recipient, msg_id=message_id, content="synthetic offline DM")
received = receiver.cmd_ok("routed_dm_wait", timeout_ms=120_000, peer=author, msg_id=message_id)
assert received["content"] == "synthetic offline DM"
sender.cmd_ok("routed_dm_wait", timeout_ms=120_000, peer=recipient, msg_id=message_id, delivered=True)
# Receiver process death between publication and catch-up.
run_adb(b.serial, ["shell", "am", "force-stop", b.package])
message_id = f"nostr-restart-{uuid.uuid4().hex}"
a.cmd_ok("routed_dm_send", peer=id_b, msg_id=message_id, content="synthetic restart DM")
a.cmd_ok("routed_dm_wait", timeout_ms=120_000, peer=id_b, msg_id=message_id, published=True)
run_adb(b.serial, ["shell", "am", "start", "-n", b.activity_component])
b.cmd_ok("nostr_fixture", port=8765, offline_mesh=True)
b.cmd_ok("routed_dm_wait", timeout_ms=120_000, peer=id_a, msg_id=message_id)
a.cmd_ok("routed_dm_wait", timeout_ms=120_000, peer=id_b, msg_id=message_id, delivered=True)
return {"bidirectional_delivery": True, "restart_catchup": True}
finally:
for device in (a, b):
run_adb(device.serial, ["reverse", "--remove", "tcp:8765"])
SCENARIOS = {
"nostr_dm": scenario_nostr_dm,
"dm": scenario_dm,
"favorite_verification": scenario_favorite_verification,
"broadcast": scenario_broadcast,
@ -918,6 +965,8 @@ def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict:
results = {}
failures = []
for n in supported:
if n == "nostr_dm":
continue # Explicit opt-in: isolated internet egress and a controlled relay.
sub = run_scenario(n, a, b, out)
results[n] = sub.get("results", {"error": sub.get("error", "unknown")})
if sub["status"] != "pass":

View File

@ -0,0 +1,157 @@
"""Loopback-only, in-memory WebSocket relay for disposable Mesh Lab fixtures.
No external network, authentication, logging, or persistence. This is a test
fixture, not a production relay. Payloads remain opaque to the fixture.
"""
from __future__ import annotations
import base64
import hashlib
import json
import socket
import struct
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
def matches(event: dict, query: dict) -> bool:
return (
(not query.get("kinds") or event.get("kind") in query["kinds"])
and event.get("created_at", 0) >= query.get("since", 0)
and event.get("created_at", 0) <= query.get("until", 2**63 - 1)
and (not query.get("#p") or any(
len(tag) >= 2 and tag[0] == "p" and tag[1] in query["#p"]
for tag in event.get("tags", [])
))
)
class LocalRelay:
def __init__(self):
self.events: dict[str, dict] = {}
self.clients: set[RelayConnection] = set()
self.lock = threading.Lock()
self.server = ThreadingHTTPServer(("127.0.0.1", 0), RelayConnection)
self.server.fixture = self
self.server.daemon_threads = True
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
@property
def port(self):
return self.server.server_port
def __enter__(self):
self.thread.start()
return self
def __exit__(self, *_):
with self.lock:
clients = list(self.clients)
for client in clients:
try:
client.connection.shutdown(socket.SHUT_RDWR)
except OSError:
pass
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
class RelayConnection(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *_):
pass
def do_GET(self):
key = self.headers.get("Sec-WebSocket-Key")
if not key:
self.send_error(400)
return
accept = base64.b64encode(hashlib.sha1(
(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("ascii")
).digest()).decode("ascii")
self.send_response(101)
self.send_header("Upgrade", "websocket")
self.send_header("Connection", "Upgrade")
self.send_header("Sec-WebSocket-Accept", accept)
self.end_headers()
self.send_lock = threading.Lock()
self.queries: dict[str, list[dict]] = {}
fixture = self.server.fixture
with fixture.lock:
fixture.clients.add(self)
try:
while True:
header = self.rfile.read(2)
if len(header) != 2:
return
opcode, masked, size = header[0] & 15, header[1] & 128, header[1] & 127
if size == 126:
size = struct.unpack("!H", self.rfile.read(2))[0]
elif size == 127:
size = struct.unpack("!Q", self.rfile.read(8))[0]
if size > 2_000_000:
return
mask = self.rfile.read(4) if masked else b""
payload = self.rfile.read(size)
if masked:
payload = bytes(v ^ mask[i % 4] for i, v in enumerate(payload))
if opcode == 8:
self.frame(payload, opcode=8)
return
if opcode == 9:
self.frame(payload, opcode=10)
elif opcode == 1:
self.dispatch(json.loads(payload))
except (OSError, ValueError, IndexError, KeyError, struct.error):
pass
finally:
with fixture.lock:
fixture.clients.discard(self)
self.close_connection = True
def frame(self, payload: bytes, opcode=1):
size = len(payload)
header = bytes([128 | opcode]) + (
bytes([size]) if size < 126 else
b"\x7e" + struct.pack("!H", size) if size < 65536 else
b"\x7f" + struct.pack("!Q", size)
)
with self.send_lock:
self.wfile.write(header + payload)
self.wfile.flush()
def send(self, value):
self.frame(json.dumps(value, separators=(",", ":")).encode())
def dispatch(self, value):
fixture = self.server.fixture
if value[0] == "EVENT":
event = value[1]
with fixture.lock:
fixture.events[event["id"]] = event
deliveries = [(client, sub) for client in fixture.clients
for sub, queries in client.queries.items()
if any(matches(event, q) for q in queries)]
self.send(["OK", event["id"], True, ""])
for client, sub in deliveries:
try:
client.send(["EVENT", sub, event])
except OSError:
pass
elif value[0] == "REQ":
sub, queries = value[1], value[2:]
with fixture.lock:
self.queries[sub] = queries
selected = {}
for query in queries:
events = sorted((e for e in fixture.events.values() if matches(e, query)),
key=lambda e: (e["created_at"], e["id"]), reverse=True)
selected.update((e["id"], e) for e in events[:query.get("limit", 500)])
for event in selected.values():
self.send(["EVENT", sub, event])
self.send(["EOSE", sub])
elif value[0] == "CLOSE":
with fixture.lock:
self.queries.pop(value[1], None)

View File

@ -0,0 +1,51 @@
"""Deterministic loopback checks for the disposable relay's wire contract."""
import json
import socket
import struct
import unittest
from tools.release_gate.nostr_relay_fixture import LocalRelay
class RelayFixtureTest(unittest.TestCase):
def test_publish_ack_and_paginated_history(self):
with LocalRelay() as relay:
with socket.create_connection(("127.0.0.1", relay.port), timeout=3) as connection:
connection.sendall(
b"GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\n"
b"Connection: Upgrade\r\nSec-WebSocket-Version: 13\r\n"
b"Sec-WebSocket-Key: c3ludGhldGljLWZpeHR1cmU=\r\n\r\n"
)
stream = connection.makefile("rb")
self.assertIn(b"101", stream.readline())
while stream.readline() != b"\r\n":
pass
def send(value):
payload = json.dumps(value).encode()
size = len(payload)
head = bytes([0x81, 0x80 | size]) if size < 126 else b"\x81\xfe" + struct.pack("!H", size)
connection.sendall(head + b"\0\0\0\0" + payload)
def receive():
header = stream.read(2)
size = header[1] & 127
if size == 126:
size = struct.unpack("!H", stream.read(2))[0]
elif size == 127:
size = struct.unpack("!Q", stream.read(8))[0]
return json.loads(stream.read(size))
event = {"id": "synthetic", "kind": 1059, "created_at": 100,
"tags": [["p", "recipient"]], "content": "opaque"}
send(["EVENT", event])
self.assertEqual(["OK", "synthetic", True, ""], receive())
send(["REQ", "history", {"kinds": [1059], "#p": ["recipient"], "since": 100, "until": 100, "limit": 1}])
self.assertEqual(["EVENT", "history", event], receive())
self.assertEqual(["EOSE", "history"], receive())
send(["REQ", "other", {"#p": ["different-recipient"]}])
self.assertEqual(["EOSE", "other"], receive())
stream.close()
if __name__ == "__main__":
unittest.main()

View File

@ -93,6 +93,8 @@ val sharedSourceIncludes = listOf(
"com/bitchat/android/services/ContactDirectory.kt",
"com/bitchat/android/services/ContactIdentityResolver.kt",
"com/bitchat/android/services/ConversationRepository.kt",
"com/bitchat/android/services/PrivateDeliveryJob.kt",
"com/bitchat/android/services/IncomingMessageAdmission.kt",
"com/bitchat/android/services/ConversationStorageCipher.kt",
"com/bitchat/android/services/PrivateMessageArrivalOrder.kt",
"com/bitchat/android/services/SeenMessageStore.kt",

View File

@ -234,8 +234,8 @@ class WearMeshService private constructor(private val context: Context) {
when {
message.isPrivate -> {
val peer = message.senderPeerID ?: return false
if (!AppStateStore.addPrivateMessage(peer, message)) return false
try { onPrivateMessage?.invoke(message) } catch (_: Exception) { }
if (kotlinx.coroutines.runBlocking { AppStateStore.admitIncomingPrivate(message) } != AppStateStore.PrivateAdmission.INSERTED) return false
try { onPrivateMessage?.invoke(com.bitchat.android.services.IncomingMessageAdmission.forDisplay(message)) } catch (_: Exception) { }
true
}
message.channel != null -> {