mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Fix durable Nostr fallback for private chats
This commit is contained in:
parent
936a4cdf6d
commit
9c083f9b95
@ -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;
|
||||
|
||||
@ -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")
|
||||
|
||||
7
app/src/debug/res/xml/nostr_fixture_network_security.xml
Normal file
7
app/src/debug/res/xml/nostr_fixture_network_security.xml
Normal 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>
|
||||
@ -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) { }
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -65,6 +65,7 @@ class UnifiedMeshService(
|
||||
}
|
||||
startAnnouncementScheduler()
|
||||
refreshDelegates()
|
||||
com.bitchat.android.services.PrivateDeliveryCoordinator.getInstance(context).bindMesh(this)
|
||||
}
|
||||
|
||||
override fun stopServices() {
|
||||
|
||||
@ -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 {
|
||||
|
||||
/**
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 ->
|
||||
|
||||
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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("=", "")
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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) { }
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
|
||||
|
||||
@ -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) { }
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
129
docs/nostr-private-messaging-review.md
Normal file
129
docs/nostr-private-messaging-review.md
Normal 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.
|
||||
@ -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":
|
||||
|
||||
157
tools/release_gate/nostr_relay_fixture.py
Normal file
157
tools/release_gate/nostr_relay_fixture.py
Normal 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)
|
||||
51
tools/release_gate/test_nostr_relay_fixture.py
Normal file
51
tools/release_gate/test_nostr_relay_fixture.py
Normal 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()
|
||||
@ -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",
|
||||
|
||||
@ -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 -> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user