diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 31961e34..9c8e4b1a 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -88,6 +88,12 @@ class MainActivity : OrientationAwareActivity() { finishAndRemoveTask() return } + if (com.bitchat.android.service.AppShutdownCoordinator + .isShutdownCommitted() + ) { + finishAndRemoveTask() + return + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { this.setRecentsScreenshotEnabled(false) } @@ -118,8 +124,6 @@ class MainActivity : OrientationAwareActivity() { return } - com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown() - // Enable edge-to-edge display for modern Android look enableEdgeToEdge() @@ -724,6 +728,13 @@ class MainActivity : OrientationAwareActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) + + if (com.bitchat.android.service.AppShutdownCoordinator + .isShutdownCommitted() + ) { + finishAndRemoveTask() + return + } // Check if this is a quit request from the notification if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) { @@ -732,8 +743,6 @@ class MainActivity : OrientationAwareActivity() { return } - com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown() - // Handle notification intents when app is already running if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { handleNotificationIntent(intent) diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt index 1fc802d9..fd374772 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -6,6 +6,7 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.ui.ChatState import com.bitchat.android.ui.MessageManager import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.Date @@ -41,13 +42,22 @@ class GeohashMessageHandler( return false } - fun onEvent(event: NostrEvent, subscribedGeohash: String) { - scope.launch { + internal fun onEvent( + event: NostrEvent, + subscribedGeohash: String, + accountEpoch: NostrAccountEpoch + ) { + val accountContext = + NostrInboundAccountLifecycle.contextFor(accountEpoch) + ?: return + scope.launch(Dispatchers.Default + accountContext.receiveJob) { try { + if (!NostrInboundAccountLifecycle.isCurrent(accountEpoch)) { + return@launch + } if (event.kind != NostrKind.EPHEMERAL_EVENT && event.kind != NostrKind.GEOHASH_PRESENCE) return@launch val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1) if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch - if (dedupe(event.id)) return@launch // PoW validation (if enabled) - apply to chat messages primarily if (event.kind == NostrKind.EPHEMERAL_EVENT) { @@ -63,47 +73,71 @@ class GeohashMessageHandler( // Blocked users check (use injected DataManager which has loaded state) if (dataManager.isGeohashUserBlocked(pubkey)) return@launch - // Update participant count (last seen) on BOTH Presence (20001) and Chat (20000) events - if (event.kind == NostrKind.GEOHASH_PRESENCE || event.kind == NostrKind.EPHEMERAL_EVENT) { - repo.updateParticipant(subscribedGeohash, pubkey, Date(event.createdAt * 1000L)) - } - - event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(pubkey, it[1]) } - event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(pubkey) } - // Register a geohash DM alias for this participant so MessageRouter can route DMs via Nostr - try { - com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${pubkey.take(16)}", pubkey) - } catch (_: Exception) { } - - // Stop here for presence events - they don't produce chat messages - if (event.kind == NostrKind.GEOHASH_PRESENCE) return@launch - - // Skip our own events for message emission - val my = NostrIdentityBridge.deriveIdentity(subscribedGeohash, application) - if (my.publicKeyHex.equals(pubkey, true)) return@launch - val isTeleportPresence = event.tags.any { it.size >= 2 && it[0] == "t" && it[1] == "teleport" } && event.content.trim().isEmpty() - if (isTeleportPresence) return@launch + val emitMessage = + event.kind != NostrKind.GEOHASH_PRESENCE && + !isTeleportPresence && + !NostrIdentityBridge.deriveIdentity( + subscribedGeohash, + application + ).publicKeyHex.equals(pubkey, true) - val senderName = repo.displayNameForNostrPubkeyUI(pubkey) - val hasNonce = try { NostrProofOfWork.hasNonce(event) } catch (_: Exception) { false } - val msg = BitchatMessage( - id = event.id, - sender = senderName, - content = event.content, - timestamp = Date(event.createdAt * 1000L), - isRelay = false, - originalSender = repo.displayNameForNostrPubkey(pubkey), - senderPeerID = "nostr:${pubkey.take(8)}", - senderNostrPubkey = pubkey, - mentions = null, - channel = "#$subscribedGeohash", - powDifficulty = try { - if (hasNonce) NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } else null - } catch (_: Exception) { null } - ) - messageManager.addChannelMessage("geo:$subscribedGeohash", msg) + NostrInboundAccountLifecycle.runIfCurrent(accountEpoch) { + if (dedupe(event.id)) return@runIfCurrent + repo.updateParticipant( + subscribedGeohash, + pubkey, + Date(event.createdAt * 1000L) + ) + event.tags.find { + it.size >= 2 && it[0] == "n" + }?.let { + repo.cacheNickname(pubkey, it[1]) + } + event.tags.find { + it.size >= 2 && it[0] == "t" && it[1] == "teleport" + }?.let { + repo.markTeleported(pubkey) + } + GeohashAliasRegistry.put( + "nostr_${pubkey.take(16)}", + pubkey + ) + if (emitMessage) { + val hasNonce = try { + NostrProofOfWork.hasNonce(event) + } catch (_: Exception) { + false + } + val msg = BitchatMessage( + id = event.id, + sender = repo.displayNameForNostrPubkeyUI(pubkey), + content = event.content, + timestamp = Date(event.createdAt * 1000L), + isRelay = false, + originalSender = repo.displayNameForNostrPubkey(pubkey), + senderPeerID = "nostr:${pubkey.take(8)}", + senderNostrPubkey = pubkey, + mentions = null, + channel = "#$subscribedGeohash", + powDifficulty = try { + if (hasNonce) { + NostrProofOfWork.calculateDifficulty(event.id) + .takeIf { it > 0 } + } else { + null + } + } catch (_: Exception) { + null + } + ) + messageManager.addChannelMessage( + "geo:$subscribedGeohash", + msg + ) + } + } } catch (e: Exception) { Log.e(TAG, "onEvent error: ${e.message}") } diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrAccountEpochGuard.kt b/app/src/main/java/com/bitchat/android/nostr/NdrAccountEpochGuard.kt index 8b4f8c66..b6cffe15 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NdrAccountEpochGuard.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NdrAccountEpochGuard.kt @@ -6,9 +6,9 @@ internal data class NdrAccountEpoch( ) /** - * Serializes NDR receive-side mutations against account invalidation. + * Serializes account-bound receive mutations against account invalidation. * - * Panic invalidation waits for a mutation already inside [runIfCurrent], then + * Invalidation waits for a mutation already inside [runIfCurrent], then * advances the generation before the wipe starts. Old-account jobs can * therefore neither overlap nor repopulate the fresh post-wipe epoch. */ @@ -47,3 +47,7 @@ internal class NdrAccountEpochGuard { generation == epoch.generation && accountPubkeyHex == epoch.accountPubkeyHex } + +/** Account-wide names used by both legacy gift-wrap and NDR receive paths. */ +internal typealias NostrAccountEpoch = NdrAccountEpoch +internal typealias NostrAccountEpochGuard = NdrAccountEpochGuard diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt b/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt index 92f788d5..f777687c 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt @@ -19,12 +19,16 @@ internal class BitchatNdrRelayAdapter( id: String, handler: (NostrEvent) -> Boolean ) { - relayManager.subscribeAfterSuccessfulProcessing( - filter = filter, - id = id, - targetRelayUrls = accountRelayUrls, - handler = handler - ) + check( + relayManager.subscribeAfterSuccessfulProcessing( + filter = filter, + id = id, + targetRelayUrls = accountRelayUrls, + handler = handler + ) + ) { + "NDR relay subscription was rejected during account reset" + } } override fun unsubscribe(id: String) { @@ -221,6 +225,7 @@ class NdrNostrService( private val knownActivePeerPubkeys = linkedSetOf() private var nextRuntimeEpoch = 0L private var activeRuntimeEpoch: Long? = null + private var processExitShutdown = false init { relayManager.setOnConnectionAvailable { @@ -243,19 +248,27 @@ class NdrNostrService( } @Synchronized - fun configureIfNeeded(identity: NostrIdentity) { + fun configureIfNeeded( + identity: NostrIdentity, + accountGuard: () -> Boolean = { true } + ): Boolean { + if (!accountGuard()) return false if (!NdrFeatureGate.isEnabled()) { teardownLocked() configurationFailurePubkeyHex = null - return + return false } - configureRuntimeIfNeededLocked(identity, drainPendingActions = true) + return configureRuntimeIfNeededLocked(identity, drainPendingActions = true) } private fun configureRuntimeIfNeededLocked( identity: NostrIdentity, drainPendingActions: Boolean ): Boolean { + if (processExitShutdown) { + Log.w(TAG, "Refusing to reopen NDR during committed process exit") + return false + } if (panicResetBlocked) { Log.e(TAG, "Refusing to configure NDR after an incomplete panic wipe") return false @@ -365,8 +378,10 @@ class NdrNostrService( fun sendIfPossible( text: String, peerPubkeyHex: String, - expiresAtSeconds: ULong? = null + expiresAtSeconds: ULong? = null, + accountGuard: () -> Boolean = { true } ): NdrSendResult { + if (!accountGuard()) return NdrSendResult.FAILED if (!NdrFeatureGate.isEnabled()) return NdrSendResult.NO_SESSION val runtime = pairwiseRuntime ?: return if (configurationFailurePubkeyHex != null) { NdrSendResult.FAILED @@ -386,18 +401,24 @@ class NdrNostrService( if (!sessionInfo.sendReady) { return NdrSendResult.FAILED } - return try { + try { runtime.sendText(peer, text, expiresAtSeconds) - if (!persistEstablishedMarkerIfNeededLocked(runtime)) { - return NdrSendResult.FAILED - } - drainAndApplyPubSubEventsLocked() - NdrSendResult.SENT } catch (_: Throwable) { Log.d(TAG, "NDR send failed") - drainAndApplyPubSubEventsLocked() - NdrSendResult.FAILED + runCatching { drainAndApplyPubSubEventsLocked() } + return NdrSendResult.FAILED } + + // Once sendText returns, the native runtime has durably advanced the + // ratchet and owns the pending publish action. Never ask the caller to + // retry that plaintext, even if host-side marker/drain work now fails. + if (!persistEstablishedMarkerIfNeededLocked(runtime)) { + Log.e(TAG, "NDR message admitted before host marker persistence failed") + return NdrSendResult.SENT + } + runCatching { drainAndApplyPubSubEventsLocked() } + .onFailure { Log.w(TAG, "NDR message admitted but pending-action drain failed") } + return NdrSendResult.SENT } @Synchronized @@ -1091,8 +1112,19 @@ class NdrNostrService( .onFailure { Log.w(TAG, "Failed to destroy NDR runtime") } } + /** Quiesce account-bound runtime work for process exit without wiping durable sessions. */ + @Synchronized + fun shutdownForProcessExit() { + processExitShutdown = true + NostrInboundAccountLifecycle.invalidate() + onDecryptedMessage = null + onOutOfBandPayload = null + teardownLocked() + } + @Synchronized fun resetForPanic(): Boolean { + NostrInboundAccountLifecycle.invalidate() onDecryptedMessage = null teardownLocked() panicResetBlocked = true diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt index c1488037..34022f5f 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt @@ -79,6 +79,8 @@ class NostrClient private constructor(private val context: Context) { fun shutdown() { Log.d(TAG, "Shutting down Nostr client") relayManager.disconnect() + currentIdentity = null + _currentNpub.value = null _isInitialized.value = false } @@ -91,13 +93,22 @@ class NostrClient private constructor(private val context: Context) { onSuccess: (() -> Unit)? = null, onError: ((String) -> Unit)? = null ) { - val identity = currentIdentity + val accountGeneration = relayManager.captureAccountGeneration() + if (!relayManager.isAccountGenerationCurrent(accountGeneration)) { + onError?.invoke("Nostr account is resetting") + return + } + val identity = NostrIdentityBridge.getCurrentNostrIdentity(context) if (identity == null) { onError?.invoke("Nostr client not initialized") return } scope.launch { + if (!relayManager.isAccountGenerationCurrent(accountGeneration)) { + onError?.invoke("Nostr account changed before send") + return@launch + } try { // Decode recipient npub to hex pubkey val (hrp, pubkeyBytes) = Bech32.decode(recipientNpub) @@ -116,9 +127,18 @@ class NostrClient private constructor(private val context: Context) { ) // Track and send all gift wraps - giftWraps.forEach { wrap -> - NostrRelayManager.registerPendingGiftWrap(wrap.id) - relayManager.sendEvent(wrap) + val admitted = giftWraps.all { wrap -> + relayManager.registerPendingGiftWrap( + wrap.id, + accountGeneration + ) && relayManager.sendEvent( + event = wrap, + expectedAccountGeneration = accountGeneration + ) + } + if (!admitted) { + onError?.invoke("Nostr account changed before send") + return@launch } Log.i(TAG, "📤 Sent private message to ${recipientNpub.take(16)}...") @@ -135,7 +155,9 @@ class NostrClient private constructor(private val context: Context) { * Subscribe to private messages for current identity */ fun subscribeToPrivateMessages(handler: (content: String, senderNpub: String, timestamp: Int) -> Unit) { - val identity = currentIdentity + val accountGeneration = relayManager.captureAccountGeneration() + if (!relayManager.isAccountGenerationCurrent(accountGeneration)) return + val identity = NostrIdentityBridge.getCurrentNostrIdentity(context) if (identity == null) { Log.e(TAG, "Cannot subscribe to private messages: client not initialized") return @@ -146,11 +168,18 @@ class NostrClient private constructor(private val context: Context) { since = System.currentTimeMillis() - 172800000L // Last 48 hours (align with NIP-17 randomization) ) - relayManager.subscribe(filter, "private-messages", { giftWrap -> - scope.launch { - handlePrivateMessage(giftWrap, handler) - } - }) + relayManager.subscribe( + filter = filter, + id = "private-messages", + handler = { giftWrap -> + scope.launch { + if (relayManager.isAccountGenerationCurrent(accountGeneration)) { + handlePrivateMessage(giftWrap, handler) + } + } + }, + expectedAccountGeneration = accountGeneration + ) Log.i(TAG, "🔑 Subscribed to private messages for: ${identity.getShortNpub()}") } @@ -165,7 +194,12 @@ class NostrClient private constructor(private val context: Context) { onSuccess: (() -> Unit)? = null, onError: ((String) -> Unit)? = null ) { + val accountGeneration = relayManager.captureAccountGeneration() scope.launch { + if (!relayManager.isAccountGenerationCurrent(accountGeneration)) { + onError?.invoke("Nostr account changed before send") + return@launch + } try { // Derive geohash-specific identity val geohashIdentity = NostrIdentityBridge.deriveIdentity(geohash, context) @@ -178,7 +212,14 @@ class NostrClient private constructor(private val context: Context) { nickname = nickname ) - relayManager.sendEvent(event) + if (!relayManager.sendEvent( + event = event, + expectedAccountGeneration = accountGeneration + ) + ) { + onError?.invoke("Nostr account changed before send") + return@launch + } Log.i(TAG, "📤 Sent geohash message") onSuccess?.invoke() @@ -197,17 +238,26 @@ class NostrClient private constructor(private val context: Context) { geohash: String, handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit ) { + val accountGeneration = relayManager.captureAccountGeneration() + if (!relayManager.isAccountGenerationCurrent(accountGeneration)) return 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) - } - }) + relayManager.subscribe( + filter = filter, + id = "geohash-$geohash", + handler = { event -> + scope.launch { + if (relayManager.isAccountGenerationCurrent(accountGeneration)) { + handleGeohashMessage(event, handler) + } + } + }, + expectedAccountGeneration = accountGeneration + ) Log.i(TAG, "🌍 Subscribed to geohash channel") } @@ -223,7 +273,8 @@ class NostrClient private constructor(private val context: Context) { /** * Get current identity information */ - fun getCurrentIdentity(): NostrIdentity? = currentIdentity + fun getCurrentIdentity(): NostrIdentity? = + NostrIdentityBridge.getCurrentNostrIdentity(context) /** * Get relay connection status @@ -248,7 +299,8 @@ class NostrClient private constructor(private val context: Context) { return } - val identity = currentIdentity ?: return + val identity = NostrIdentityBridge.getCurrentNostrIdentity(context) + ?: return try { val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index ef1b917e..b0e9d16f 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -22,7 +22,6 @@ import com.bitchat.android.ui.PrivateMessageOrigin import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.Date @@ -47,9 +46,8 @@ class NostrDirectMessageHandler( private val seenStore by lazy(seenStoreProvider) private val ndrService by lazy { NdrNostrService.getInstance(application) } - private val ndrAccountEpochs = NdrAccountEpochGuard() - private val ndrReceiveJobLock = Any() - private var ndrReceiveJob: Job = SupervisorJob(scope.coroutineContext[Job]) + @Volatile + private var ndrCallbackInstalled = false // Simple event deduplication private val processedIds = ArrayDeque() @@ -75,23 +73,32 @@ class NostrDirectMessageHandler( dedupe(id) } - fun configureDoubleRatchet(identity: NostrIdentity) { + /** + * Begin a fresh account-wide receive epoch before installing any account or + * derived-geohash subscription. The returned epoch must be captured by the + * subscription handler so a delayed old subscription cannot join a newer + * account merely because its event arrives later. + */ + internal fun configureAccount(identity: NostrIdentity): NostrAccountEpoch { + val accountContext = NostrInboundAccountLifecycle.begin( + accountPubkeyHex = identity.publicKeyHex, + parentJob = scope.coroutineContext[Job] + ) if (!NdrFeatureGate.isEnabled()) { - invalidateDoubleRatchetAccount() - ndrService.onDecryptedMessage = null - return - } - val epoch = ndrAccountEpochs.begin(identity.publicKeyHex) - val receiveJob = synchronized(ndrReceiveJobLock) { - ndrReceiveJob.cancel() - SupervisorJob(scope.coroutineContext[Job]).also { ndrReceiveJob = it } + if (ndrCallbackInstalled) { + ndrService.onDecryptedMessage = null + ndrCallbackInstalled = false + } + return accountContext.epoch } // A prior account's callback must not consume and discard pending // deliveries while the replacement runtime is initialized. ndrService.onDecryptedMessage = null ndrService.configureIfNeeded(identity) ndrService.onDecryptedMessage = callback@{ message, completion -> - if (!NdrFeatureGate.isEnabled() || !ndrAccountEpochs.isCurrent(epoch)) { + if (!NdrFeatureGate.isEnabled() || + !NostrInboundAccountLifecycle.isCurrent(accountContext.epoch) + ) { completion(NdrDeliveryResult.REJECTED) return@callback } @@ -101,24 +108,49 @@ class NostrDirectMessageHandler( completion(NdrDeliveryResult.RETRY) return@callback } - if (!currentIdentity.publicKeyHex.equals(epoch.accountPubkeyHex, ignoreCase = true)) { + if (!currentIdentity.publicKeyHex.equals( + accountContext.epoch.accountPubkeyHex, + ignoreCase = true + ) + ) { completion(NdrDeliveryResult.REJECTED) return@callback } - onDoubleRatchetMessage(message, currentIdentity, epoch, receiveJob, completion) + onDoubleRatchetMessage( + message, + currentIdentity, + accountContext.epoch, + accountContext.receiveJob, + completion + ) + } + ndrCallbackInstalled = true + return accountContext.epoch + } + + internal fun currentAccountEpoch(): NostrAccountEpoch? = + NostrInboundAccountLifecycle.currentEpoch() + + fun invalidateAccount() { + NostrInboundAccountLifecycle.invalidate() + if (ndrCallbackInstalled) { + ndrService.onDecryptedMessage = null + ndrCallbackInstalled = false } } - fun invalidateDoubleRatchetAccount() { - ndrAccountEpochs.invalidate() - synchronized(ndrReceiveJobLock) { - ndrReceiveJob.cancel() - } - } - - fun onGiftWrap(giftWrap: NostrEvent, geohash: String, identity: NostrIdentity) { - scope.launch(Dispatchers.Default) { + internal fun onGiftWrap( + giftWrap: NostrEvent, + geohash: String, + identity: NostrIdentity, + accountEpoch: NostrAccountEpoch + ): Job? { + val accountContext = + NostrInboundAccountLifecycle.contextFor(accountEpoch) + ?: return null + return scope.launch(Dispatchers.Default + accountContext.receiveJob) { try { + if (!isAccountEpochCurrent(accountEpoch)) return@launch if (dedupe(giftWrap.id)) return@launch val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt @@ -132,6 +164,7 @@ class NostrDirectMessageHandler( val (content, rawSenderPubkey, rumorTimestamp) = decryptResult val senderPubkey = rawSenderPubkey.lowercase() + if (!isAccountEpochCurrent(accountEpoch)) return@launch val legacyAllowed = runCatching { legacyNostrInboundAllowed(senderPubkey) }.getOrDefault(false) @@ -139,6 +172,7 @@ class NostrDirectMessageHandler( Log.w(TAG, "Rejecting legacy DM for an NDR-pinned contact") return@launch } + if (!isAccountEpochCurrent(accountEpoch)) return@launch // If sender is blocked for geohash contexts, drop any events from this pubkey // Applies to both geohash DMs (geohash != "") and account DMs (geohash == "") @@ -148,7 +182,8 @@ class NostrDirectMessageHandler( senderPubkey = senderPubkey, timestamp = Date(rumorTimestamp * 1000L), geohash = geohash, - recipientIdentity = identity + recipientIdentity = identity, + accountEpoch = accountEpoch ) } catch (_: Exception) { @@ -160,14 +195,16 @@ class NostrDirectMessageHandler( private fun onDoubleRatchetMessage( message: NdrDecryptedMessage, identity: NostrIdentity, - epoch: NdrAccountEpoch, + accountEpoch: NostrAccountEpoch, receiveJob: Job, completion: (NdrDeliveryResult) -> Unit ) { scope.launch(Dispatchers.Default + receiveJob) { var result = NdrDeliveryResult.RETRY try { - if (!NdrFeatureGate.isEnabled() || !ndrAccountEpochs.isCurrent(epoch)) { + if (!NdrFeatureGate.isEnabled() || + !isAccountEpochCurrent(accountEpoch) + ) { result = NdrDeliveryResult.REJECTED return@launch } @@ -198,7 +235,9 @@ class NostrDirectMessageHandler( result = NdrDeliveryResult.REJECTED return@launch } - if (!NdrFeatureGate.isEnabled() || !ndrAccountEpochs.isCurrent(epoch)) { + if (!NdrFeatureGate.isEnabled() || + !isAccountEpochCurrent(accountEpoch) + ) { result = NdrDeliveryResult.REJECTED return@launch } @@ -213,7 +252,7 @@ class NostrDirectMessageHandler( timestamp = Date(applicationMessage.timestampMs), geohash = "", recipientIdentity = identity, - ndrEpoch = epoch, + accountEpoch = accountEpoch, ndrEventId = dedupeId, expiresAtSeconds = applicationMessage.expiresAtSeconds ) @@ -221,10 +260,20 @@ class NostrDirectMessageHandler( Log.e(TAG, "Failed to process double-ratchet message") result = NdrDeliveryResult.RETRY } finally { - if (result.shouldAcknowledge) { - if (seenStore.markProcessedNdr(message.eventId)) { - markProcessed(message.eventId) - } else { + if (!isAccountEpochCurrent(accountEpoch)) { + result = NdrDeliveryResult.REJECTED + } else if (result.shouldAcknowledge) { + var marked = false + val mutationApplied = + runIfAccountMutationCurrent(accountEpoch, null) { + marked = seenStore.markProcessedNdr(message.eventId) + if (marked) { + markProcessed(message.eventId) + } + } + if (!mutationApplied) { + result = NdrDeliveryResult.REJECTED + } else if (!marked) { result = NdrDeliveryResult.RETRY } } @@ -239,10 +288,11 @@ class NostrDirectMessageHandler( timestamp: Date, geohash: String, recipientIdentity: NostrIdentity, - ndrEpoch: NdrAccountEpoch? = null, + accountEpoch: NostrAccountEpoch, ndrEventId: String? = null, expiresAtSeconds: Long? = null ): NdrDeliveryResult { + if (!isAccountEpochCurrent(accountEpoch)) return NdrDeliveryResult.REJECTED if (isExpired(expiresAtSeconds)) return NdrDeliveryResult.REJECTED if (!content.startsWith("bitchat1:")) return NdrDeliveryResult.REJECTED @@ -257,7 +307,7 @@ class NostrDirectMessageHandler( val noisePayload = NoisePayload.decode(packet.payload) ?: return NdrDeliveryResult.REJECTED val convKey = "nostr_${senderPubkey.take(16)}" - if (!runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) { + if (!runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { repo.putNostrKeyMapping(convKey, senderPubkey) GeohashAliasRegistry.put(convKey, senderPubkey) @@ -282,7 +332,7 @@ class NostrDirectMessageHandler( senderPubkey = senderPubkey, recipientIdentity = recipientIdentity, allowAccountNdr = geohash.isEmpty(), - ndrEpoch = ndrEpoch, + accountEpoch = accountEpoch, ndrEventId = ndrEventId, expiresAtSeconds = expiresAtSeconds ) @@ -296,11 +346,11 @@ class NostrDirectMessageHandler( senderPubkey: String, recipientIdentity: NostrIdentity, allowAccountNdr: Boolean, - ndrEpoch: NdrAccountEpoch? = null, + accountEpoch: NostrAccountEpoch, ndrEventId: String? = null, expiresAtSeconds: Long? = null ): NdrDeliveryResult { - if (!isNdrEpochCurrent(ndrEpoch) || isExpired(expiresAtSeconds)) { + if (!isAccountEpochCurrent(accountEpoch) || isExpired(expiresAtSeconds)) { return NdrDeliveryResult.REJECTED } return when (payload.type) { @@ -314,7 +364,9 @@ class NostrDirectMessageHandler( val favoriteControl = FavoriteControlMessage.parse(pm.content) if (favoriteControl != null) { - if (!isNdrEpochCurrent(ndrEpoch) || isExpired(expiresAtSeconds)) { + if (!isAccountEpochCurrent(accountEpoch) || + isExpired(expiresAtSeconds) + ) { return NdrDeliveryResult.REJECTED } val favoriteResult = handleFavoriteControl( @@ -323,14 +375,14 @@ class NostrDirectMessageHandler( senderNickname, timestamp, senderPubkey, - ndrEpoch, + accountEpoch, ndrEventId, expiresAtSeconds ) if (favoriteResult != NdrDeliveryResult.CONSUMED && favoriteResult != NdrDeliveryResult.DUPLICATE ) return favoriteResult - if (!runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) { + if (!runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { if (!seenStore.hasDelivered(pm.messageID)) { sendDeliveryAck( pm.messageID, @@ -367,7 +419,7 @@ class NostrDirectMessageHandler( var messageAccepted = false withContext(Dispatchers.Main) { - runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) { + runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { privateChatManager.handleIncomingPrivateMessage( message = message, suppressUnread = suppressUnread, @@ -379,7 +431,7 @@ class NostrDirectMessageHandler( if (!messageAccepted) return NdrDeliveryResult.REJECTED runCatching { - runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) { + runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { if (!seenStore.hasDelivered(pm.messageID)) { sendDeliveryAck( pm.messageID, @@ -417,7 +469,7 @@ class NostrDirectMessageHandler( val messageId = String(payload.data, Charsets.UTF_8) var consumed = false withContext(Dispatchers.Main) { - runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) { + runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { meshDelegateHandler.didReceiveDeliveryAck(messageId, conversationID) consumed = true } @@ -428,7 +480,7 @@ class NostrDirectMessageHandler( val messageId = String(payload.data, Charsets.UTF_8) var consumed = false withContext(Dispatchers.Main) { - runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) { + runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { meshDelegateHandler.didReceiveReadReceipt(messageId, conversationID) consumed = true } @@ -446,7 +498,7 @@ class NostrDirectMessageHandler( return NdrDeliveryResult.DUPLICATE } var message: BitchatMessage? = null - if (!runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) { + if (!runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile( context = application, @@ -474,7 +526,7 @@ class NostrDirectMessageHandler( } var consumed = false withContext(Dispatchers.Main) { - runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) { + runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { message?.let { privateChatManager.handleIncomingPrivateMessage( message = it, @@ -506,33 +558,20 @@ class NostrDirectMessageHandler( } } - private fun isNdrEpochCurrent(epoch: NdrAccountEpoch?): Boolean = - epoch == null || - (NdrFeatureGate.isEnabled() && ndrAccountEpochs.isCurrent(epoch)) - - private fun runIfNdrEpochCurrent( - epoch: NdrAccountEpoch?, - mutation: () -> Unit - ): Boolean { - if (epoch == null) { - mutation() - return true - } - if (!NdrFeatureGate.isEnabled()) return false - return ndrAccountEpochs.runIfCurrent(epoch, mutation) - } + private fun isAccountEpochCurrent(epoch: NostrAccountEpoch): Boolean = + NostrInboundAccountLifecycle.isCurrent(epoch) private fun isExpired(expiresAtSeconds: Long?): Boolean = expiresAtSeconds?.let { it <= System.currentTimeMillis() / 1_000L } == true - private fun runIfNdrMutationCurrent( - epoch: NdrAccountEpoch?, + private fun runIfAccountMutationCurrent( + epoch: NostrAccountEpoch, expiresAtSeconds: Long?, mutation: () -> Unit ): Boolean { if (isExpired(expiresAtSeconds)) return false var applied = false - val epochCurrent = runIfNdrEpochCurrent(epoch) { + val epochCurrent = NostrInboundAccountLifecycle.runIfCurrent(epoch) { if (!isExpired(expiresAtSeconds)) { mutation() applied = true @@ -573,7 +612,7 @@ class NostrDirectMessageHandler( senderNickname: String, timestamp: Date, senderPubkey: String, - ndrEpoch: NdrAccountEpoch? = null, + accountEpoch: NostrAccountEpoch, ndrEventId: String? = null, expiresAtSeconds: Long? = null ): NdrDeliveryResult { @@ -597,7 +636,7 @@ class NostrDirectMessageHandler( } var systemMessage: BitchatMessage? = null - if (!runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) { + if (!runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { FavoritesPersistenceService.shared.updatePeerFavoritedUs( noiseKey, control.isFavorite @@ -635,7 +674,7 @@ class NostrDirectMessageHandler( var consumed = false var duplicate = false withContext(Dispatchers.Main) { - runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) { + runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { systemMessage?.let { if (state.getPrivateChatsValue()[targetConversationID] .orEmpty() diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrInboundAccountLifecycle.kt b/app/src/main/java/com/bitchat/android/nostr/NostrInboundAccountLifecycle.kt new file mode 100644 index 00000000..eef4e047 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrInboundAccountLifecycle.kt @@ -0,0 +1,60 @@ +package com.bitchat.android.nostr + +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob + +internal data class NostrInboundAccountContext( + val epoch: NostrAccountEpoch, + val receiveJob: Job +) + +/** + * Process-wide lifetime for all account-bound Nostr receive work. + * + * Subscription handlers capture an epoch when they are installed. Beginning a + * replacement account invalidates those handlers and cancels their launched + * work. Invalidation is also a mutation barrier: after it returns, an old + * receive can finish non-sensitive parsing but cannot mutate application state. + */ +internal object NostrInboundAccountLifecycle { + private val lifecycleLock = Any() + private val epochs = NostrAccountEpochGuard() + private var currentContext: NostrInboundAccountContext? = null + + fun begin( + accountPubkeyHex: String, + parentJob: Job? + ): NostrInboundAccountContext = synchronized(lifecycleLock) { + val epoch = epochs.begin(accountPubkeyHex) + currentContext?.receiveJob?.cancel() + NostrInboundAccountContext( + epoch = epoch, + receiveJob = SupervisorJob(parentJob) + ).also { currentContext = it } + } + + fun contextFor(epoch: NostrAccountEpoch): NostrInboundAccountContext? = + synchronized(lifecycleLock) { + currentContext?.takeIf { + it.epoch == epoch + } + } + + fun currentEpoch(): NostrAccountEpoch? = + synchronized(lifecycleLock) { currentContext?.epoch } + + fun isCurrent(epoch: NostrAccountEpoch): Boolean = epochs.isCurrent(epoch) + + fun runIfCurrent( + epoch: NostrAccountEpoch, + mutation: () -> Unit + ): Boolean = epochs.runIfCurrent(epoch, mutation) + + fun invalidate() { + synchronized(lifecycleLock) { + epochs.invalidate() + currentContext?.receiveJob?.cancel() + currentContext = null + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index 38b772a3..bc499494 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -13,6 +13,7 @@ import okhttp3.* import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong import kotlin.math.min import kotlin.math.pow @@ -58,13 +59,6 @@ class NostrRelayManager internal constructor( private const val MAX_RECONNECT_ATTEMPTS = com.bitchat.android.util.AppConstants.Nostr.MAX_RECONNECT_ATTEMPTS private const val CONFIRMED_PUBLISH_TIMEOUT_MS = 15_000L - // Track gift-wraps we initiated for logging - private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet() - - fun registerPendingGiftWrap(id: String) { - pendingGiftWrapIDs.add(id) - } - fun defaultRelays(): List = DEFAULT_RELAYS } @@ -97,6 +91,7 @@ class NostrRelayManager internal constructor( private val messageHandlers = ConcurrentHashMap Unit>() private val commitAwareMessageHandlers = ConcurrentHashMap Boolean>() + private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet() // Persistent subscription tracking for robust reconnection private val activeSubscriptions = ConcurrentHashMap() // subscription ID -> info @@ -117,18 +112,37 @@ class NostrRelayManager internal constructor( private data class QueuedEvent( val event: NostrEvent, val targetRelays: List, - val liveLocationToken: Long? = null + val liveLocationToken: Long? = null, + val accountGeneration: Long ) private val messageQueue = mutableListOf() private val messageQueueLock = Any() + private val accountGeneration = AtomicLong(0L) + private val accountGenerationLock = Any() + private val accountResetOperationLock = Any() + @Volatile + private var accountResetBlocked = false private data class ConfirmedPublish( val awaitingRelayUrls: MutableSet, val completion: (Boolean) -> Unit, + val accountGeneration: Long, @Volatile var timeoutJob: Job? = null ) + private data class EventDispatch( + val subscriptionInfo: SubscriptionInfo, + val commitAwareHandler: ((NostrEvent) -> Boolean)?, + val ordinaryHandler: ((NostrEvent) -> Unit)? + ) + + private data class DisconnectionOutcome( + val confirmedCompletions: List>, + val reconnectDelayMs: Long?, + val connectionToken: Long? + ) + private val confirmedPublishes = ConcurrentHashMap() @Volatile private var ndrConnectionAvailableHandler: (() -> Unit)? = null @@ -159,8 +173,11 @@ class NostrRelayManager internal constructor( geohash: String, nRelays: Int = 5, includeDefaults: Boolean = false, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + expectedAccountGeneration: Long = accountGeneration.get() ) { + val generation = expectedAccountGeneration + if (!isCurrentAccountGeneration(generation)) return if (!isNetworkActionAllowed(liveLocationToken)) return try { val nearest = RelayDirectory.closestRelaysForGeohash(geohash, nRelays) @@ -172,19 +189,33 @@ class NostrRelayManager internal constructor( return } runNetworkAction(liveLocationToken) { - geohashToRelays[geohash] = selected - if (liveLocationToken == null) { - liveGeohashTokens.remove(geohash) - nonLiveRelayUrls.addAll(selected) - } else { - liveGeohashTokens[geohash] = liveLocationToken - selected.forEach { relayUrl -> - if (relayUrl !in nonLiveRelayUrls) { - liveLocationRelayTokens[relayUrl] = liveLocationToken + val registered = synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) { + false + } else { + geohashToRelays[geohash] = selected + if (liveLocationToken == null) { + liveGeohashTokens.remove(geohash) + nonLiveRelayUrls.addAll(selected) + } else { + liveGeohashTokens[geohash] = liveLocationToken + selected.forEach { relayUrl -> + if (relayUrl !in nonLiveRelayUrls) { + liveLocationRelayTokens[relayUrl] = + liveLocationToken + } + } } + true } } - ensureConnectionsFor(selected, liveLocationToken) + if (registered) { + ensureConnectionsFor( + selected, + liveLocationToken, + generation + ) + } } } catch (e: Exception) { Log.e(TAG, "Failed to ensure geohash relays") @@ -208,14 +239,18 @@ class NostrRelayManager internal constructor( handler: (NostrEvent) -> Unit, includeDefaults: Boolean = false, nRelays: Int = 5, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + expectedAccountGeneration: Long = accountGeneration.get() ): String { + val generation = expectedAccountGeneration + if (!isCurrentAccountGeneration(generation)) return id if (!isNetworkActionAllowed(liveLocationToken)) return id ensureGeohashRelaysConnected( geohash, nRelays, includeDefaults, - liveLocationToken + liveLocationToken, + generation ) if (!isNetworkActionAllowed(liveLocationToken)) return id val relayUrls = getRelaysForGeohash(geohash) @@ -224,7 +259,8 @@ class NostrRelayManager internal constructor( id = id, handler = handler, targetRelayUrls = relayUrls, - liveLocationToken = liveLocationToken + liveLocationToken = liveLocationToken, + expectedAccountGeneration = generation ) } @@ -236,23 +272,32 @@ class NostrRelayManager internal constructor( geohash: String, includeDefaults: Boolean = false, nRelays: Int = 5, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + expectedAccountGeneration: Long = accountGeneration.get() ) { + val generation = expectedAccountGeneration + if (!isCurrentAccountGeneration(generation)) return if (!isNetworkActionAllowed(liveLocationToken)) return ensureGeohashRelaysConnected( geohash, nRelays, includeDefaults, - liveLocationToken + liveLocationToken, + generation ) if (!isNetworkActionAllowed(liveLocationToken)) return val relayUrls = getRelaysForGeohash(geohash) if (relayUrls.isEmpty()) { Log.w(TAG, "No target relays for geohash event; falling back to defaults") - sendEvent(event, Companion.defaultRelays(), liveLocationToken) + sendEvent( + event, + Companion.defaultRelays(), + liveLocationToken, + generation + ) return } - sendEvent(event, relayUrls, liveLocationToken) + sendEvent(event, relayUrls, liveLocationToken, generation) } // --- Internal helpers --- @@ -275,8 +320,12 @@ class NostrRelayManager internal constructor( * server-side delivery. Live subscription IDs are opaque, so CLOSE carries no * geohash. If a CLOSE cannot be queued, fail closed by dropping that socket. */ - private fun closeSubscriptionsOnConnectedRelays(subscriptionIds: Set) { + private fun closeSubscriptionsOnConnectedRelays( + subscriptionIds: Set, + generation: Long + ) { if (subscriptionIds.isEmpty()) return + if (!isCurrentAccountGeneration(generation)) return val closeTargets = NostrLiveSubscriptionPrivacy.closeTargets( liveSubscriptionIds = subscriptionIds, @@ -287,8 +336,12 @@ class NostrRelayManager internal constructor( relaySubscriptionIds.forEach { subscriptionId -> val request = NostrRequest.Close(subscriptionId) val message = gson.toJson(request, NostrRequest::class.java) - val closeQueued = runCatching { webSocket.send(message) } - .getOrDefault(false) + var closeQueued = false + synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) return + closeQueued = runCatching { webSocket.send(message) } + .getOrDefault(false) + } if (!closeQueued) { connections.remove(relayUrl, webSocket) webSocket.cancel() @@ -298,13 +351,14 @@ class NostrRelayManager internal constructor( } private fun revokeLiveLocationAccess() { + val generation = accountGeneration.get() liveLocationConnectionJobs.forEach(Job::cancel) liveLocationConnectionJobs.clear() val liveSubscriptionIds = activeSubscriptions.values .filter { it.liveLocationToken != null } .mapTo(mutableSetOf()) { it.id } - closeSubscriptionsOnConnectedRelays(liveSubscriptionIds) + closeSubscriptionsOnConnectedRelays(liveSubscriptionIds, generation) liveSubscriptionIds.forEach { id -> activeSubscriptions.remove(id) messageHandlers.remove(id) @@ -333,8 +387,11 @@ class NostrRelayManager internal constructor( private fun ensureConnectionsFor( relayUrls: Set, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + expectedAccountGeneration: Long = accountGeneration.get() ) { + val generation = expectedAccountGeneration + if (!isCurrentAccountGeneration(generation)) return if (!isNetworkActionAllowed(liveLocationToken)) return // Ensure relays are tracked for UI/status relayUrls.forEach { url -> @@ -345,13 +402,16 @@ class NostrRelayManager internal constructor( updateRelaysList() val job = scope.launch { - if (!isNetworkActionAllowed(liveLocationToken)) return@launch + if (!isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(liveLocationToken) + ) return@launch relayUrls.forEach { relayUrl -> launch { if (!connections.containsKey(relayUrl) && + isCurrentAccountGeneration(generation) && isNetworkActionAllowed(liveLocationToken) ) { - connectToRelay(relayUrl, liveLocationToken) + connectToRelay(relayUrl, liveLocationToken, generation) } } } @@ -388,20 +448,24 @@ class NostrRelayManager internal constructor( * Connect to all configured relays */ fun connect() { + val generation = accountGeneration.get() scope.launch { + if (!isCurrentAccountGeneration(generation)) return@launch relaysList.forEach { relay -> launch { val liveToken = liveLocationRelayTokens[relay.url] ?.takeIf { relay.url !in nonLiveRelayUrls } - if (liveToken == null || LiveLocationPrivacyGate.accepts(liveToken)) { - connectToRelay(relay.url, liveToken) + if (isCurrentAccountGeneration(generation) && + (liveToken == null || LiveLocationPrivacyGate.accepts(liveToken)) + ) { + connectToRelay(relay.url, liveToken, generation) } } } } // Start periodic subscription validation - startSubscriptionValidation() + startSubscriptionValidation(generation) } /** @@ -419,6 +483,13 @@ class NostrRelayManager internal constructor( webSocket.close(1000, "Manual disconnect") } connections.clear() + val disconnectedAt = System.currentTimeMillis() + relaysList.forEach { relay -> + if (relay.isConnected) { + relay.lastDisconnectedAt = disconnectedAt + } + relay.isConnected = false + } // Clear subscriptions subscriptions.clear() @@ -432,25 +503,47 @@ class NostrRelayManager internal constructor( fun sendEvent( event: NostrEvent, relayUrls: List? = null, - liveLocationToken: Long? = null - ) { + liveLocationToken: Long? = null, + expectedAccountGeneration: Long = accountGeneration.get() + ): Boolean { + val generation = expectedAccountGeneration + if (!isCurrentAccountGeneration(generation)) return false val targetRelays = relayUrls ?: relaysList.map { it.url } + var accepted = false val queued = runNetworkAction(liveLocationToken) { synchronized(messageQueueLock) { - messageQueue.add(QueuedEvent(event, targetRelays, liveLocationToken)) + if (!isCurrentAccountGeneration(generation)) return@runNetworkAction + messageQueue.add( + QueuedEvent( + event = event, + targetRelays = targetRelays, + liveLocationToken = liveLocationToken, + accountGeneration = generation + ) + ) + accepted = true } + if (!accepted) return@runNetworkAction scope.launch { - if (!isNetworkActionAllowed(liveLocationToken)) return@launch + if (!isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(liveLocationToken) + ) return@launch targetRelays.forEach { relayUrl -> val webSocket = connections[relayUrl] if (webSocket != null) { - sendToRelay(event, webSocket, relayUrl, liveLocationToken) + sendToRelay( + event, + webSocket, + relayUrl, + liveLocationToken, + generation + ) } } } } - if (!queued) return + return queued && accepted } /** @@ -462,6 +555,11 @@ class NostrRelayManager internal constructor( relayUrls: List? = null, completion: (Boolean) -> Unit ) { + val generation = accountGeneration.get() + if (!isCurrentAccountGeneration(generation)) { + completion(false) + return + } val requestedRelays = (relayUrls ?: relaysList.map { it.url }).toSet() val connectedTargets = requestedRelays.filterTo(linkedSetOf()) { connections.containsKey(it) @@ -475,7 +573,8 @@ class NostrRelayManager internal constructor( awaitingRelayUrls = ConcurrentHashMap.newKeySet().apply { addAll(connectedTargets) }, - completion = completion + completion = completion, + accountGeneration = generation ) if (confirmedPublishes.putIfAbsent(event.id, tracker) != null) { completion(false) @@ -483,12 +582,23 @@ class NostrRelayManager internal constructor( } tracker.timeoutJob = scope.launch { delay(CONFIRMED_PUBLISH_TIMEOUT_MS) + if (!isCurrentAccountGeneration(generation)) { + completeConfirmedPublish(event.id, tracker, accepted = false) + return@launch + } completeConfirmedPublish(event.id, tracker, accepted = false) } connectedTargets.forEach { relayUrl -> val webSocket = connections[relayUrl] - if (webSocket == null || !sendToRelay(event, webSocket, relayUrl)) { + if (webSocket == null || + !sendToRelay( + event, + webSocket, + relayUrl, + accountGeneration = generation + ) + ) { tracker.awaitingRelayUrls.remove(relayUrl) } } @@ -516,7 +626,9 @@ class NostrRelayManager internal constructor( ) { if (!confirmedPublishes.remove(eventId, tracker)) return tracker.timeoutJob?.cancel() - runCatching { tracker.completion(accepted) } + val acceptedForCurrentAccount = + accepted && isCurrentAccountGeneration(tracker.accountGeneration) + runCatching { tracker.completion(acceptedForCurrentAccount) } .onFailure { Log.w(TAG, "Confirmed publish callback failed") } } @@ -529,7 +641,8 @@ class NostrRelayManager internal constructor( id: String = generateSubscriptionId(), handler: (NostrEvent) -> Unit, targetRelayUrls: List? = null, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + expectedAccountGeneration: Long = accountGeneration.get() ): String { val subscriptionInfo = SubscriptionInfo( id = id, @@ -538,10 +651,12 @@ class NostrRelayManager internal constructor( targetRelayUrls = targetRelayUrls?.toSet(), liveLocationToken = liveLocationToken ) - return registerSubscription( + registerSubscription( subscriptionInfo = subscriptionInfo, - ordinaryHandler = handler + ordinaryHandler = handler, + expectedAccountGeneration = expectedAccountGeneration ) + return id } /** @@ -553,8 +668,9 @@ class NostrRelayManager internal constructor( filter: NostrFilter, id: String, targetRelayUrls: List? = null, + expectedAccountGeneration: Long = accountGeneration.get(), handler: (NostrEvent) -> Boolean - ): String { + ): Boolean { val subscriptionInfo = SubscriptionInfo( id = id, filter = filter, @@ -563,7 +679,8 @@ class NostrRelayManager internal constructor( ) return registerSubscription( subscriptionInfo = subscriptionInfo, - commitAwareHandler = handler + commitAwareHandler = handler, + expectedAccountGeneration = expectedAccountGeneration ) } @@ -575,33 +692,49 @@ class NostrRelayManager internal constructor( private fun registerSubscription( subscriptionInfo: SubscriptionInfo, ordinaryHandler: ((NostrEvent) -> Unit)? = null, - commitAwareHandler: ((NostrEvent) -> Boolean)? = null - ): String { + commitAwareHandler: ((NostrEvent) -> Boolean)? = null, + expectedAccountGeneration: Long + ): Boolean { require((ordinaryHandler == null) != (commitAwareHandler == null)) + val generation = expectedAccountGeneration + var registered = false runNetworkAction(subscriptionInfo.liveLocationToken) { - activeSubscriptions[subscriptionInfo.id] = subscriptionInfo - if (commitAwareHandler != null) { - commitAwareMessageHandlers[subscriptionInfo.id] = commitAwareHandler - messageHandlers.remove(subscriptionInfo.id) - } else { - messageHandlers[subscriptionInfo.id] = requireNotNull(ordinaryHandler) - commitAwareMessageHandlers.remove(subscriptionInfo.id) + synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) return@synchronized + activeSubscriptions[subscriptionInfo.id] = subscriptionInfo + if (commitAwareHandler != null) { + commitAwareMessageHandlers[subscriptionInfo.id] = commitAwareHandler + messageHandlers.remove(subscriptionInfo.id) + } else { + messageHandlers[subscriptionInfo.id] = requireNotNull(ordinaryHandler) + commitAwareMessageHandlers.remove(subscriptionInfo.id) + } + registered = true + } + if (registered) { + sendSubscriptionToRelays(subscriptionInfo, generation) } - sendSubscriptionToRelays(subscriptionInfo) } - return subscriptionInfo.id + return registered } /** * Send a subscription to the appropriate relays */ - private fun sendSubscriptionToRelays(subscriptionInfo: SubscriptionInfo) { - if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return + private fun sendSubscriptionToRelays( + subscriptionInfo: SubscriptionInfo, + generation: Long + ) { + if (!isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(subscriptionInfo.liveLocationToken) + ) return val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter)) val message = gson.toJson(request, NostrRequest::class.java) scope.launch { - if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return@launch + if (!isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(subscriptionInfo.liveLocationToken) + ) return@launch val targetRelays = subscriptionInfo.targetRelayUrls?.toList() ?: connections.keys.toList() targetRelays.forEach { relayUrl -> @@ -610,11 +743,15 @@ class NostrRelayManager internal constructor( try { var success = false runNetworkAction(subscriptionInfo.liveLocationToken) { - success = webSocket.send(message) - if (success) { - val currentSubs = subscriptions[relayUrl] ?: emptySet() - subscriptions[relayUrl] = - currentSubs + subscriptionInfo.id + synchronized(accountGenerationLock) { + if (isCurrentAccountGeneration(generation)) { + success = webSocket.send(message) + if (success) { + val currentSubs = subscriptions[relayUrl] ?: emptySet() + subscriptions[relayUrl] = + currentSubs + subscriptionInfo.id + } + } } } if (!success) { @@ -636,10 +773,18 @@ class NostrRelayManager internal constructor( * Unsubscribe from a subscription */ fun unsubscribe(id: String) { + val generation = accountGeneration.get() // Remove from persistent tracking - val subscriptionInfo = activeSubscriptions.remove(id) - messageHandlers.remove(id) - commitAwareMessageHandlers.remove(id) + val subscriptionInfo = synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) { + null + } else { + activeSubscriptions.remove(id).also { + messageHandlers.remove(id) + commitAwareMessageHandlers.remove(id) + } + } + } if (subscriptionInfo == null) { return @@ -648,7 +793,7 @@ class NostrRelayManager internal constructor( if (subscriptionInfo.liveLocationToken != null && !isNetworkActionAllowed(subscriptionInfo.liveLocationToken) ) { - closeSubscriptionsOnConnectedRelays(setOf(id)) + closeSubscriptionsOnConnectedRelays(setOf(id), generation) subscriptions.replaceAll { _, ids -> ids - id } return } @@ -657,8 +802,9 @@ class NostrRelayManager internal constructor( val message = gson.toJson(request, NostrRequest::class.java) scope.launch { + if (!isCurrentAccountGeneration(generation)) return@launch if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) { - closeSubscriptionsOnConnectedRelays(setOf(id)) + closeSubscriptionsOnConnectedRelays(setOf(id), generation) subscriptions.replaceAll { _, ids -> ids - id } return@launch } @@ -667,9 +813,15 @@ class NostrRelayManager internal constructor( if (currentSubs?.contains(id) == true) { try { runNetworkAction(subscriptionInfo.liveLocationToken) { - webSocket.send(message) + synchronized(accountGenerationLock) { + if (isCurrentAccountGeneration(generation)) { + webSocket.send(message) + } + } + } + if (isCurrentAccountGeneration(generation)) { + subscriptions[relayUrl] = currentSubs - id } - subscriptions[relayUrl] = currentSubs - id } catch (e: Exception) { Log.e(TAG, "Failed to unsubscribe from relay") } @@ -682,6 +834,8 @@ class NostrRelayManager internal constructor( * Manually retry connection to a specific relay */ fun retryConnection(relayUrl: String) { + val generation = accountGeneration.get() + if (!isCurrentAccountGeneration(generation)) return val relay = relaysList.find { it.url == relayUrl } ?: return val liveToken = liveLocationRelayTokens[relayUrl] ?.takeIf { relayUrl !in nonLiveRelayUrls } @@ -697,7 +851,9 @@ class NostrRelayManager internal constructor( // Attempt immediate reconnection scope.launch { - connectToRelay(relayUrl, liveToken) + if (isCurrentAccountGeneration(generation)) { + connectToRelay(relayUrl, liveToken, generation) + } } } @@ -724,9 +880,13 @@ class NostrRelayManager internal constructor( * Useful for ensuring subscription consistency after network issues */ fun reestablishAllSubscriptions() { + val generation = accountGeneration.get() scope.launch { + if (!isCurrentAccountGeneration(generation)) return@launch connections.forEach { (relayUrl, webSocket) -> - restoreSubscriptionsForRelay(relayUrl, webSocket) + if (isCurrentAccountGeneration(generation)) { + restoreSubscriptionsForRelay(relayUrl, webSocket, generation) + } } } } @@ -756,6 +916,105 @@ class NostrRelayManager internal constructor( Log.e(TAG, "Failed to clear subscriptions: ${e.message}") } } + + /** + * Discard every process-local relay artifact that belongs to the old + * account. Unlike a normal disconnect, panic/quit must never replay queued + * kind-1059 gift wraps after identity replacement. + */ + fun discardForAccountReset(): Long { + val resetToken = beginAccountReset() + discardForAccountReset(resetToken) + return resetToken + } + + /** + * Clear old-account state only while this caller still owns the reset. + * Reset operations are serialized so a stale reset cannot clear state after + * a newer reset has already reopened the replacement account. + */ + fun discardForAccountReset(resetToken: Long): Boolean = + synchronized(accountResetOperationLock) resetOperation@{ + synchronized(accountGenerationLock) { + if (!accountResetBlocked || + accountGeneration.get() != resetToken + ) return@resetOperation false + } + + // Invalidate every already-launched send/connect/listener/reconnect job + // before clearing state or closing sockets. Account-scoped handler + // jobs use their own cancel-and-mutation barrier. + synchronized(messageQueueLock) { + messageQueue.clear() + } + liveLocationConnectionJobs.forEach(Job::cancel) + liveLocationConnectionJobs.clear() + pendingGiftWrapIDs.clear() + disconnect() + clearAllSubscriptions() + liveGeohashTokens.clear() + liveLocationRelayTokens.clear() + nonLiveRelayUrls.clear() + true + } + + /** Refuse new account work and invalidate all previously captured generations. */ + fun beginAccountReset(): Long = + synchronized(accountGenerationLock) { + accountResetBlocked = true + accountGeneration.incrementAndGet() + } + + /** Permit fresh relay work only when this caller owns the latest reset. */ + fun completeAccountReset(resetToken: Long): Boolean = + synchronized(accountResetOperationLock) resetOperation@{ + synchronized(accountGenerationLock) { + if (!accountResetBlocked || + accountGeneration.get() != resetToken + ) return@resetOperation false + // Tokens captured while the reset was blocked must never become + // valid work for the replacement account. + accountGeneration.incrementAndGet() + accountResetBlocked = false + true + } + } + + internal fun queuedEventCountForTesting(): Int = + synchronized(messageQueueLock) { messageQueue.size } + + internal fun pendingGiftWrapCountForTesting(): Int = pendingGiftWrapIDs.size + + internal fun accountGenerationForTesting(): Long = accountGeneration.get() + + internal fun captureAccountGeneration(): Long = accountGeneration.get() + + internal fun registerPendingGiftWrap( + id: String, + expectedAccountGeneration: Long + ): Boolean = synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(expectedAccountGeneration)) { + false + } else { + pendingGiftWrapIDs.add(id) + true + } + } + + internal fun isAccountGenerationCurrent(generation: Long): Boolean = + isCurrentAccountGeneration(generation) + + private fun isCurrentAccountGeneration(generation: Long): Boolean = + !accountResetBlocked && accountGeneration.get() == generation + + private inline fun withAccountCallback( + generation: Long, + rejected: T, + callback: () -> T + ): T { + if (!isCurrentAccountGeneration(generation)) return rejected + return callback() + } /** * Get detailed status for all relays @@ -837,12 +1096,15 @@ class NostrRelayManager internal constructor( /** * Start periodic subscription validation to ensure robustness */ - private fun startSubscriptionValidation() { - stopSubscriptionValidation() // Stop any existing validation - - subscriptionValidationJob = scope.launch { + private fun startSubscriptionValidation(generation: Long) { + synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) return + stopSubscriptionValidation() // Stop any existing validation + + subscriptionValidationJob = scope.launch { while (isActive) { delay(SUBSCRIPTION_VALIDATION_INTERVAL) + if (!isCurrentAccountGeneration(generation)) return@launch try { val report = validateSubscriptionConsistency() @@ -860,7 +1122,11 @@ class NostrRelayManager internal constructor( val missingSubs = expectedSubs - currentSubs if (missingSubs.isNotEmpty()) { Log.i(TAG, "Auto-repairing ${missingSubs.size} missing subscriptions") - restoreSubscriptionsForRelay(relayUrl, webSocket) + restoreSubscriptionsForRelay( + relayUrl, + webSocket, + generation + ) } } } @@ -869,6 +1135,7 @@ class NostrRelayManager internal constructor( } } } + } } @@ -884,11 +1151,14 @@ class NostrRelayManager internal constructor( private suspend fun connectToRelay( urlString: String, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + generation: Long = accountGeneration.get() ) { val connectionToken = liveLocationToken ?.takeIf { urlString !in nonLiveRelayUrls } - if (!isNetworkActionAllowed(connectionToken)) return + if (!isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(connectionToken) + ) return // Skip if we already have a connection if (connections.containsKey(urlString)) { return @@ -900,16 +1170,25 @@ class NostrRelayManager internal constructor( .build() runNetworkAction(connectionToken) { - val webSocket = httpClient.newWebSocket( - request, - RelayWebSocketListener(urlString, connectionToken) - ) - connections[urlString] = webSocket + synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) { + return@synchronized + } + val webSocket = httpClient.newWebSocket( + request, + RelayWebSocketListener(urlString, connectionToken, generation) + ) + if (isCurrentAccountGeneration(generation)) { + connections[urlString] = webSocket + } else { + webSocket.cancel() + } + } } } catch (e: Exception) { Log.e(TAG, "Failed to create WebSocket connection") - handleDisconnection(urlString, e, liveLocationToken) + handleDisconnection(urlString, e, liveLocationToken, generation) } } @@ -917,16 +1196,23 @@ class NostrRelayManager internal constructor( event: NostrEvent, webSocket: WebSocket, relayUrl: String, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + accountGeneration: Long = this.accountGeneration.get() ): Boolean { - if (!isNetworkActionAllowed(liveLocationToken)) return false + if (!isCurrentAccountGeneration(accountGeneration) || + !isNetworkActionAllowed(liveLocationToken) + ) return false return try { val request = NostrRequest.Event(event) val message = gson.toJson(request, NostrRequest::class.java) var success = false runNetworkAction(liveLocationToken) { - success = webSocket.send(message) + synchronized(accountGenerationLock) { + if (isCurrentAccountGeneration(accountGeneration)) { + success = webSocket.send(message) + } + } } if (success) { // Update relay stats @@ -944,7 +1230,12 @@ class NostrRelayManager internal constructor( } } - private fun handleMessage(message: String, relayUrl: String) { + private fun handleMessage( + message: String, + relayUrl: String, + generation: Long + ) { + if (!isCurrentAccountGeneration(generation)) return try { val jsonElement = JsonParser.parseString(message) if (!jsonElement.isJsonArray) { @@ -956,49 +1247,65 @@ class NostrRelayManager internal constructor( when (response) { is NostrResponse.Event -> { - // Update relay stats - val relay = relaysList.find { it.url == relayUrl } - relay?.let { it.messagesReceived += 1 } - updateRelaysList() - - // CLIENT-SIDE FILTER ENFORCEMENT: Ensure this event matches the subscription's filter - val subscriptionInfo = activeSubscriptions[response.subscriptionId] - ?: return + val dispatch = synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) return + val relay = relaysList.find { it.url == relayUrl } + relay?.let { it.messagesReceived += 1 } + updateRelaysList() + val subscriptionInfo = + activeSubscriptions[response.subscriptionId] + ?: return + EventDispatch( + subscriptionInfo = subscriptionInfo, + commitAwareHandler = + commitAwareMessageHandlers[response.subscriptionId], + ordinaryHandler = + messageHandlers[response.subscriptionId] + ) + } + val subscriptionInfo = dispatch.subscriptionInfo if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return - subscriptionInfo.let { subInfo -> - val matches = try { subInfo.filter.matches(response.event) } catch (e: Exception) { true } - if (!matches) { - // Do NOT call deduplicator here to allow the correct subscription to process it later - return - } + val matches = try { + subscriptionInfo.filter.matches(response.event) + } catch (_: Exception) { + true + } + if (!matches) { + // Do not deduplicate an event that another subscription may accept. + return } - val commitAwareHandler = - commitAwareMessageHandlers[response.subscriptionId] + val commitAwareHandler = dispatch.commitAwareHandler if (commitAwareHandler != null) { scope.launch { - eventDeduplicator.processEventAfterSuccess(response.event) { event -> - commitAwareHandler(event) + withAccountCallback(generation, false) { + eventDeduplicator.processEventAfterSuccess(response.event) { event -> + isCurrentAccountGeneration(generation) && + commitAwareHandler(event) + } } } return } - // DEDUPLICATION: Check if we've already processed this event - eventDeduplicator.processEvent(response.event) { event -> - // Call handler for new events only - val handler = messageHandlers[response.subscriptionId] - if (handler != null) { - scope.launch(Dispatchers.Main) { - if (isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) { - handler(event) + val handler = dispatch.ordinaryHandler + if (handler != null) { + withAccountCallback(generation, Unit) { + eventDeduplicator.processEvent(response.event) { event -> + scope.launch(Dispatchers.Main) { + withAccountCallback(generation, Unit) { + if (isNetworkActionAllowed( + subscriptionInfo.liveLocationToken + )) { + handler(event) + } + } } } - } else { - Log.w(TAG, "⚠️ No handler for Nostr subscription") } + } else { + Log.w(TAG, "⚠️ No handler for Nostr subscription") } - } is NostrResponse.EndOfStoredEvents -> { @@ -1006,15 +1313,31 @@ class NostrRelayManager internal constructor( } is NostrResponse.Ok -> { - val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId) - confirmedPublishes[response.eventId]?.let { tracker -> - if (isNip20ConfirmedSuccess(response.accepted, response.message)) { - completeConfirmedPublish(response.eventId, tracker, accepted = true) - } else if (tracker.awaitingRelayUrls.remove(relayUrl) && - tracker.awaitingRelayUrls.isEmpty() - ) { - completeConfirmedPublish(response.eventId, tracker, accepted = false) + var completion: Pair? = null + val wasGiftWrap = synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) return + val pending = pendingGiftWrapIDs.remove(response.eventId) + confirmedPublishes[response.eventId]?.let { tracker -> + if (isNip20ConfirmedSuccess( + response.accepted, + response.message + ) + ) { + completion = tracker to true + } else if (tracker.awaitingRelayUrls.remove(relayUrl) && + tracker.awaitingRelayUrls.isEmpty() + ) { + completion = tracker to false + } } + pending + } + completion?.let { (tracker, accepted) -> + completeConfirmedPublish( + response.eventId, + tracker, + accepted + ) } if (!isNip20ConfirmedSuccess(response.accepted, response.message)) { val level = if (wasGiftWrap) Log.WARN else Log.ERROR @@ -1038,62 +1361,85 @@ class NostrRelayManager internal constructor( private fun handleDisconnection( relayUrl: String, error: Throwable, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + generation: Long = accountGeneration.get(), + webSocket: WebSocket? = null ) { - val connectionToken = liveLocationToken - ?.takeIf { relayUrl !in nonLiveRelayUrls } - connections.remove(relayUrl) - confirmedPublishes.entries.toList().forEach { (eventId, tracker) -> - if (tracker.awaitingRelayUrls.remove(relayUrl) && - tracker.awaitingRelayUrls.isEmpty() - ) { - completeConfirmedPublish(eventId, tracker, accepted = false) + val outcome = synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) return + if (webSocket != null && !connections.remove(relayUrl, webSocket)) { + return } - } - // NOTE: Don't remove subscriptions here - keep them for restoration on reconnection - // subscriptions.remove(relayUrl) // REMOVED - this was causing subscription loss - - updateRelayStatus(relayUrl, false, error) - if (!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 - } - - // Calculate backoff interval - val backoffInterval = min( - INITIAL_BACKOFF_INTERVAL * BACKOFF_MULTIPLIER.pow(relay.reconnectAttempts - 1.0), - MAX_BACKOFF_INTERVAL.toDouble() - ).toLong() - - relay.nextReconnectTime = System.currentTimeMillis() + backoffInterval - - Log.d(TAG, "Scheduling Nostr relay reconnection") - - // Schedule reconnection - scope.launch { - delay(backoffInterval) + val connectionToken = liveLocationToken + ?.takeIf { relayUrl !in nonLiveRelayUrls } + val confirmedCompletions = confirmedPublishes.entries + .toList() + .mapNotNull { (eventId, tracker) -> + if (tracker.awaitingRelayUrls.remove(relayUrl) && + tracker.awaitingRelayUrls.isEmpty() + ) { + eventId to tracker + } else { + null + } + } + + // Keep subscriptions for restoration on reconnection. + updateRelayStatus(relayUrl, false, error) + + var reconnectDelayMs: Long? = null if (isNetworkActionAllowed(connectionToken)) { - connectToRelay(relayUrl, connectionToken) + val errorMessage = error.message?.lowercase() ?: "" + val dnsFailure = + errorMessage.contains("hostname could not be found") || + errorMessage.contains("dns") || + errorMessage.contains("unable to resolve host") + if (dnsFailure) { + Log.w(TAG, "Nostr relay DNS failure; not retrying") + } else { + val relay = relaysList.find { it.url == relayUrl } + if (relay != null) { + relay.reconnectAttempts++ + if (relay.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + Log.w(TAG, "Max Nostr relay reconnection attempts reached") + } else { + val delayMs = min( + INITIAL_BACKOFF_INTERVAL * + BACKOFF_MULTIPLIER.pow( + relay.reconnectAttempts - 1.0 + ), + MAX_BACKOFF_INTERVAL.toDouble() + ).toLong() + reconnectDelayMs = delayMs + relay.nextReconnectTime = + System.currentTimeMillis() + delayMs + Log.d(TAG, "Scheduling Nostr relay reconnection") + } + } + } + } + DisconnectionOutcome( + confirmedCompletions = confirmedCompletions, + reconnectDelayMs = reconnectDelayMs, + connectionToken = connectionToken + ) + } + + outcome.confirmedCompletions.forEach { (eventId, tracker) -> + completeConfirmedPublish(eventId, tracker, accepted = false) + } + outcome.reconnectDelayMs?.let { delayMs -> + scope.launch { + delay(delayMs) + if (isCurrentAccountGeneration(generation) && + isNetworkActionAllowed(outcome.connectionToken) + ) { + connectToRelay( + relayUrl, + outcome.connectionToken, + generation + ) + } } } } @@ -1132,10 +1478,16 @@ class NostrRelayManager internal constructor( /** * Restore all active subscriptions for a specific relay that just reconnected */ - private fun restoreSubscriptionsForRelay(relayUrl: String, webSocket: WebSocket) { + private fun restoreSubscriptionsForRelay( + relayUrl: String, + webSocket: WebSocket, + generation: Long + ) { + if (!isCurrentAccountGeneration(generation)) return val subscriptionsToRestore = activeSubscriptions.values.filter { subscriptionInfo -> // Include subscription if it targets all relays or specifically targets this relay - isNetworkActionAllowed(subscriptionInfo.liveLocationToken) && + isCurrentAccountGeneration(generation) && + isNetworkActionAllowed(subscriptionInfo.liveLocationToken) && (subscriptionInfo.targetRelayUrls == null || subscriptionInfo.targetRelayUrls.contains(relayUrl)) } @@ -1151,11 +1503,15 @@ class NostrRelayManager internal constructor( var success = false runNetworkAction(subscriptionInfo.liveLocationToken) { - success = webSocket.send(message) - if (success) { - val currentSubs = subscriptions[relayUrl] ?: emptySet() - subscriptions[relayUrl] = - currentSubs + subscriptionInfo.id + synchronized(accountGenerationLock) { + if (isCurrentAccountGeneration(generation)) { + success = webSocket.send(message) + if (success) { + val currentSubs = subscriptions[relayUrl] ?: emptySet() + subscriptions[relayUrl] = + currentSubs + subscriptionInfo.id + } + } } } if (!success) { @@ -1172,43 +1528,57 @@ class NostrRelayManager internal constructor( */ private inner class RelayWebSocketListener( private val relayUrl: String, - private val liveLocationToken: Long? + private val liveLocationToken: Long?, + private val generation: Long ) : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { - if (!isNetworkActionAllowed(liveLocationToken)) { - connections.remove(relayUrl) - webSocket.cancel() - return + synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(liveLocationToken) || + connections[relayUrl] !== webSocket + ) { + connections.remove(relayUrl, webSocket) + webSocket.cancel() + return + } + updateRelayStatus(relayUrl, true) } - updateRelayStatus(relayUrl, true) - runCatching { ndrConnectionAvailableHandler?.invoke() } - .onFailure { Log.w(TAG, "NDR reconnect callback failed") } - - // Restore all active subscriptions for this relay - restoreSubscriptionsForRelay(relayUrl, webSocket) - - // Process any queued messages for this relay - synchronized(messageQueueLock) { - val iterator = messageQueue.iterator() - while (iterator.hasNext()) { - val queued = iterator.next() - if (relayUrl in queued.targetRelays && - isNetworkActionAllowed(queued.liveLocationToken) - ) { - sendToRelay( - queued.event, - webSocket, - relayUrl, - queued.liveLocationToken - ) + val callbackAccepted = withAccountCallback(generation, false) { + runCatching { ndrConnectionAvailableHandler?.invoke() } + .onFailure { Log.w(TAG, "NDR reconnect callback failed") } + true + } + if (!callbackAccepted) return + synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) return + restoreSubscriptionsForRelay(relayUrl, webSocket, generation) + + // Process any queued messages for this relay + synchronized(messageQueueLock) { + val iterator = messageQueue.iterator() + while (iterator.hasNext()) { + val queued = iterator.next() + if (queued.accountGeneration == generation && + isCurrentAccountGeneration(generation) && + relayUrl in queued.targetRelays && + isNetworkActionAllowed(queued.liveLocationToken) + ) { + sendToRelay( + queued.event, + webSocket, + relayUrl, + queued.liveLocationToken, + generation + ) + } } } } } override fun onMessage(webSocket: WebSocket, text: String) { - handleMessage(text, relayUrl) + handleMessage(text, relayUrl, generation) } override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { @@ -1216,13 +1586,33 @@ class NostrRelayManager internal constructor( } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + if (!isCurrentAccountGeneration(generation)) { + connections.remove(relayUrl, webSocket) + return + } val error = Exception("WebSocket closed: $code $reason") - handleDisconnection(relayUrl, error, liveLocationToken) + handleDisconnection( + relayUrl, + error, + liveLocationToken, + generation, + webSocket + ) } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + if (!isCurrentAccountGeneration(generation)) { + connections.remove(relayUrl, webSocket) + return + } Log.e(TAG, "Nostr WebSocket failure") - handleDisconnection(relayUrl, t, liveLocationToken) + handleDisconnection( + relayUrl, + t, + liveLocationToken, + generation, + webSocket + ) } } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt index 4b8365a1..66e9cf1e 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt @@ -4,7 +4,6 @@ import android.app.Application import android.util.Log import com.bitchat.android.geohash.LiveLocationPrivacyGate import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch /** * NostrSubscriptionManager @@ -12,20 +11,32 @@ import kotlinx.coroutines.launch */ class NostrSubscriptionManager( private val application: Application, + @Suppress("UNUSED_PARAMETER") private val scope: CoroutineScope ) { companion object { private const val TAG = "NostrSubscriptionManager" } private val relayManager get() = NostrRelayManager.getInstance(application) - fun connect() = scope.launch { runCatching { relayManager.connect() }.onFailure { Log.e(TAG, "connect failed: ${it.message}") } } - fun disconnect() = scope.launch { runCatching { relayManager.disconnect() }.onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } } + fun connect() { + runCatching { relayManager.connect() } + .onFailure { Log.e(TAG, "connect failed: ${it.message}") } + } + + fun disconnect() { + runCatching { relayManager.disconnect() } + .onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } + } fun subscribeGiftWraps(pubkey: String, sinceMs: Long, id: String, handler: (NostrEvent) -> Unit) { - scope.launch { - val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs) - relayManager.subscribe(filter, id, handler) - } + val generation = relayManager.captureAccountGeneration() + val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs) + relayManager.subscribe( + filter = filter, + id = id, + handler = handler, + expectedAccountGeneration = generation + ) } /** Subscribe to geohash chat messages only (kind 20000) — low-volume, kept alive in background. */ @@ -37,21 +48,21 @@ class NostrSubscriptionManager( handler: (NostrEvent) -> Unit, liveLocationToken: Long? = null ) { - scope.launch { - if (liveLocationToken != null && - !LiveLocationPrivacyGate.accepts(liveLocationToken) - ) return@launch - val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit) - relayManager.subscribeForGeohash( - geohash, - filter, - id, - handler, - includeDefaults = false, - nRelays = 5, - liveLocationToken = liveLocationToken - ) - } + if (liveLocationToken != null && + !LiveLocationPrivacyGate.accepts(liveLocationToken) + ) return + val generation = relayManager.captureAccountGeneration() + val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit) + relayManager.subscribeForGeohash( + geohash, + filter, + id, + handler, + includeDefaults = false, + nRelays = 5, + liveLocationToken = liveLocationToken, + expectedAccountGeneration = generation + ) } /** Subscribe to geohash presence heartbeats only (kind 20001) — high-volume, paused in background. */ @@ -63,22 +74,24 @@ class NostrSubscriptionManager( handler: (NostrEvent) -> Unit, liveLocationToken: Long? = null ) { - scope.launch { - if (liveLocationToken != null && - !LiveLocationPrivacyGate.accepts(liveLocationToken) - ) return@launch - val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit) - relayManager.subscribeForGeohash( - geohash, - filter, - id, - handler, - includeDefaults = false, - nRelays = 5, - liveLocationToken = liveLocationToken - ) - } + if (liveLocationToken != null && + !LiveLocationPrivacyGate.accepts(liveLocationToken) + ) return + val generation = relayManager.captureAccountGeneration() + val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit) + relayManager.subscribeForGeohash( + geohash, + filter, + id, + handler, + includeDefaults = false, + nRelays = 5, + liveLocationToken = liveLocationToken, + expectedAccountGeneration = generation + ) } - fun unsubscribe(id: String) { scope.launch { runCatching { relayManager.unsubscribe(id) } } } + fun unsubscribe(id: String) { + runCatching { relayManager.unsubscribe(id) } + } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt index 3402e380..84fd2a14 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt @@ -11,13 +11,48 @@ import com.bitchat.android.services.ContactIdentityResolver import kotlinx.coroutines.* import java.util.* import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean + +enum class NostrSendAdmission { + /** The legacy relay handoff or durable pairwise NDR state now owns delivery. */ + ADMITTED, + + /** No transport accepted the message yet, but unchanged input may succeed later. */ + RETRYABLE, + + /** The payload or recipient is invalid and cannot succeed unchanged. */ + TERMINAL_FAILED +} + +internal enum class NdrSendDisposition { + ADMITTED, + LEGACY_FALLBACK, + RETRYABLE +} + +internal fun ndrSendDisposition( + result: NdrSendResult, + ndrRequired: Boolean = false, + rebindBlocked: Boolean = false, + pairwiseOnly: Boolean = false +): NdrSendDisposition = when { + result == NdrSendResult.SENT -> NdrSendDisposition.ADMITTED + rebindBlocked -> NdrSendDisposition.RETRYABLE + result == NdrSendResult.NO_SESSION && !ndrRequired && !pairwiseOnly -> + NdrSendDisposition.LEGACY_FALLBACK + else -> NdrSendDisposition.RETRYABLE +} internal fun shouldUseLegacyNostrFallback( result: NdrSendResult, ndrRequired: Boolean = false, rebindBlocked: Boolean = false ): Boolean = - result == NdrSendResult.NO_SESSION && !ndrRequired && !rebindBlocked + ndrSendDisposition( + result = result, + ndrRequired = ndrRequired, + rebindBlocked = rebindBlocked + ) == NdrSendDisposition.LEGACY_FALLBACK internal fun isLegacyNostrAllowedWhenNdrDisabled( ndrRequired: Boolean, @@ -35,7 +70,11 @@ private data class NdrRecipientResolution( */ class NostrTransport( private val context: Context, - var senderPeerID: String = "" + var senderPeerID: String = "", + private val transportScope: CoroutineScope = + CoroutineScope(Dispatchers.IO + SupervisorJob()), + private val relayManager: NostrRelayManager = + NostrRelayManager.getInstance(context) ) { companion object { @@ -44,6 +83,8 @@ class NostrTransport( @Volatile private var INSTANCE: NostrTransport? = null + + fun tryGetInstance(): NostrTransport? = INSTANCE fun getInstance(context: Context): NostrTransport { return INSTANCE ?: synchronized(this) { @@ -53,125 +94,244 @@ class NostrTransport( } // Throttle READ receipts to avoid relay rate limits (like iOS) + private data class AccountToken( + val transportEpoch: Long, + val relayGeneration: Long + ) + private data class QueuedRead( val receipt: ReadReceipt, - val peerID: String + val peerID: String, + val sequence: Long, + val accountToken: AccountToken ) private val readQueue = ConcurrentLinkedQueue() - private var isSendingReadAcks = false - private val transportScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val accountStateLock = Any() + private var transportAccountEpoch = 0L + private var accountResetBlocked = false + private var nextReadSequence = 0L + private var activeReadSequence: Long? = null private val ndrService by lazy { NdrNostrService.getInstance(context) } // MARK: - Transport Interface Methods val myPeerID: String get() = senderPeerID + + private fun captureAccountToken(): AccountToken? = synchronized(accountStateLock) { + if (accountResetBlocked) { + null + } else { + AccountToken( + transportEpoch = transportAccountEpoch, + relayGeneration = relayManager.captureAccountGeneration() + ) + } + } + + private fun isAccountTokenCurrent(token: AccountToken): Boolean = + synchronized(accountStateLock) { + !accountResetBlocked && + token.transportEpoch == transportAccountEpoch && + relayManager.isAccountGenerationCurrent(token.relayGeneration) + } + + /** + * Start an account-lifetime barrier. Already-launched work keeps its captured + * token and is refused at the final relay handoff; throttled receipts are + * discarded immediately. + */ + fun discardForAccountReset(): Long = + synchronized(accountStateLock) { + accountResetBlocked = true + transportAccountEpoch += 1 + readQueue.clear() + activeReadSequence = null + transportAccountEpoch + } + + /** + * Allow fresh work only when the caller still owns the latest reset. + * A later panic/quit must not be reopened by an older reset finishing late. + */ + fun completeAccountReset(resetToken: Long): Boolean = + synchronized(accountStateLock) { + if (resetToken != transportAccountEpoch) return@synchronized false + accountResetBlocked = false + true + } + + internal fun queuedReadCountForTesting(): Int = readQueue.size + + internal fun activeReadCountForTesting(): Int = synchronized(accountStateLock) { + if (activeReadSequence == null) 0 else 1 + } fun sendPrivateMessage( content: String, to: String, recipientNickname: String, messageID: String, - expiresAtSeconds: ULong? = null + expiresAtSeconds: ULong? = null, + completion: (NostrSendAdmission) -> Unit = {} ) { - 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 - } - val ndrRecipient = resolveNdrRecipient(to, recipientHex) + val accountToken = captureAccountToken() + if (accountToken == null) { + runCatching { completion(NostrSendAdmission.RETRYABLE) } + .onFailure { Log.w(TAG, "Nostr private-message admission callback failed") } + return + } + val completed = AtomicBoolean(false) + fun completeOnce(admission: NostrSendAdmission) { + if (!completed.compareAndSet(false, true)) return + runCatching { completion(admission) } + .onFailure { Log.w(TAG, "Nostr private-message admission callback failed") } + } - 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( + val job = transportScope.launch { + val admission = try { + prepareAndSendPrivateMessage( content = content, + to = to, messageID = messageID, - recipientPeerID = recipientPeerIDForEmbed, - senderPeerID = senderPeerID + expiresAtSeconds = expiresAtSeconds, + accountToken = accountToken ) - - - if (embedded == null) { - Log.e(TAG, "NostrTransport: failed to embed PM packet") - return@launch - } - - sendWrappedMessage( - content = embedded, - fallbackRecipientHex = recipientHex, - senderIdentity = senderIdentity, - ndrRecipient = ndrRecipient, - expiresAtSeconds = expiresAtSeconds - ) - } catch (e: Exception) { Log.e(TAG, "Failed to send private message via Nostr: ${e.message}") + NostrSendAdmission.RETRYABLE + } + completeOnce(admission) + } + job.invokeOnCompletion { cause -> + if (cause != null) { + completeOnce(NostrSendAdmission.RETRYABLE) } } } + + private fun prepareAndSendPrivateMessage( + content: String, + to: String, + messageID: String, + expiresAtSeconds: ULong?, + accountToken: AccountToken + ): NostrSendAdmission { + if (!isAccountTokenCurrent(accountToken)) { + return NostrSendAdmission.RETRYABLE + } + if (expiresAtSeconds != null && + expiresAtSeconds <= (System.currentTimeMillis() / 1_000L).toULong() + ) { + Log.e(TAG, "NostrTransport: refusing an already-expired private message") + return NostrSendAdmission.TERMINAL_FAILED + } + val recipientNostrPubkey = resolveNostrPublicKey(to) + if (recipientNostrPubkey == null) { + Log.w(TAG, "No Nostr public key found for peerID: $to") + return NostrSendAdmission.RETRYABLE + } + + val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context) + if (senderIdentity == null) { + Log.e(TAG, "No Nostr identity available") + return NostrSendAdmission.RETRYABLE + } + + val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey) + if (recipientHex == null) { + Log.e(TAG, "NostrTransport: recipient key is not a valid Nostr pubkey") + return NostrSendAdmission.TERMINAL_FAILED + } + val ndrRecipient = resolveNdrRecipient(to, recipientHex) + + 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 NostrSendAdmission.RETRYABLE + } + 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 NostrSendAdmission.TERMINAL_FAILED + } + + return sendWrappedMessage( + content = embedded, + fallbackRecipientHex = recipientHex, + senderIdentity = senderIdentity, + ndrRecipient = ndrRecipient, + expiresAtSeconds = expiresAtSeconds, + accountToken = accountToken + ) + } fun sendReadReceipt(receipt: ReadReceipt, to: String) { + val accountToken = captureAccountToken() ?: return // Enqueue and process with throttling to avoid relay rate limits - readQueue.offer(QueuedRead(receipt, to)) + val queuedRead = synchronized(accountStateLock) { + if (!isAccountTokenCurrent(accountToken)) { + null + } else { + QueuedRead( + receipt = receipt, + peerID = to, + sequence = ++nextReadSequence, + accountToken = accountToken + ).also(readQueue::offer) + } + } ?: return + if (!isAccountTokenCurrent(queuedRead.accountToken)) return processReadQueueIfNeeded() } private fun processReadQueueIfNeeded() { - if (isSendingReadAcks) return - if (readQueue.isEmpty()) return - - isSendingReadAcks = true - sendNextReadAck() + val item = synchronized(accountStateLock) { + if (accountResetBlocked || activeReadSequence != null) return + var next = readQueue.poll() + while (next != null && !isAccountTokenCurrent(next.accountToken)) { + next = readQueue.poll() + } + next?.also { activeReadSequence = it.sequence } + } ?: return + sendReadAck(item) } - private fun sendNextReadAck() { - val item = readQueue.poll() - if (item == null) { - isSendingReadAcks = false - return - } - + private fun sendReadAck(item: QueuedRead) { transportScope.launch { try { + if (!isAccountTokenCurrent(item.accountToken)) { + finishReadAck(item) + return@launch + } val recipientNostrPubkey = resolveNostrPublicKey(item.peerID) if (recipientNostrPubkey == null) { Log.w(TAG, "No Nostr public key found for read receipt to: ${item.peerID}") - scheduleNextReadAck() + finishReadAck(item) return@launch } val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context) if (senderIdentity == null) { Log.e(TAG, "No Nostr identity available for read receipt") - scheduleNextReadAck() + finishReadAck(item) return@launch } val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey) if (recipientHex == null) { - scheduleNextReadAck() + finishReadAck(item) return@launch } val ndrRecipient = resolveNdrRecipient(item.peerID, recipientHex) @@ -185,7 +345,7 @@ class NostrTransport( if (ack == null) { Log.e(TAG, "NostrTransport: failed to embed READ ack") - scheduleNextReadAck() + finishReadAck(item) return@launch } @@ -193,29 +353,35 @@ class NostrTransport( content = ack, fallbackRecipientHex = recipientHex, senderIdentity = senderIdentity, - ndrRecipient = ndrRecipient + ndrRecipient = ndrRecipient, + accountToken = item.accountToken ) - scheduleNextReadAck() + finishReadAck(item) } catch (e: Exception) { Log.e(TAG, "Failed to send read receipt via Nostr: ${e.message}") - scheduleNextReadAck() + finishReadAck(item) } } } - private fun scheduleNextReadAck() { + private fun finishReadAck(item: QueuedRead) { transportScope.launch { delay(READ_ACK_INTERVAL) - isSendingReadAcks = false + synchronized(accountStateLock) { + if (activeReadSequence != item.sequence) return@launch + activeReadSequence = null + } processReadQueueIfNeeded() } } fun sendFavoriteNotification(to: String, isFavorite: Boolean) { + val accountToken = captureAccountToken() ?: return transportScope.launch { try { + if (!isAccountTokenCurrent(accountToken)) return@launch val recipientNostrPubkey = resolveNostrPublicKey(to) if (recipientNostrPubkey == null) { @@ -253,7 +419,8 @@ class NostrTransport( content = embedded, fallbackRecipientHex = recipientHex, senderIdentity = senderIdentity, - ndrRecipient = ndrRecipient + ndrRecipient = ndrRecipient, + accountToken = accountToken ) } catch (e: Exception) { @@ -263,8 +430,10 @@ class NostrTransport( } fun sendDeliveryAck(messageID: String, to: String) { + val accountToken = captureAccountToken() ?: return transportScope.launch { try { + if (!isAccountTokenCurrent(accountToken)) return@launch val recipientNostrPubkey = resolveNostrPublicKey(to) if (recipientNostrPubkey == null) { @@ -300,7 +469,8 @@ class NostrTransport( content = ack, fallbackRecipientHex = recipientHex, senderIdentity = senderIdentity, - ndrRecipient = ndrRecipient + ndrRecipient = ndrRecipient, + accountToken = accountToken ) } catch (e: Exception) { @@ -316,8 +486,10 @@ class NostrTransport( toRecipientHex: String, fromIdentity: NostrIdentity ) { + val accountToken = captureAccountToken() ?: return transportScope.launch { try { + if (!isAccountTokenCurrent(accountToken)) return@launch val embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient( type = NoisePayloadType.DELIVERED, messageID = messageID, @@ -332,11 +504,7 @@ class NostrTransport( senderIdentity = fromIdentity ) - // Register pending gift wrap for deduplication and send all - giftWraps.forEach { event -> - NostrRelayManager.registerPendingGiftWrap(event.id) - NostrRelayManager.getInstance(context).sendEvent(event) - } + sendLegacyGiftWraps(giftWraps, accountToken) } catch (e: Exception) { Log.e(TAG, "Failed to send geohash delivery ack: ${e.message}") @@ -349,8 +517,10 @@ class NostrTransport( toRecipientHex: String, fromIdentity: NostrIdentity ) { + val accountToken = captureAccountToken() ?: return transportScope.launch { try { + if (!isAccountTokenCurrent(accountToken)) return@launch val embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient( type = NoisePayloadType.READ_RECEIPT, messageID = messageID, @@ -365,11 +535,7 @@ class NostrTransport( senderIdentity = fromIdentity ) - // Register pending gift wrap for deduplication and send all - giftWraps.forEach { event -> - NostrRelayManager.registerPendingGiftWrap(event.id) - NostrRelayManager.getInstance(context).sendEvent(event) - } + sendLegacyGiftWraps(giftWraps, accountToken) } catch (e: Exception) { Log.e(TAG, "Failed to send geohash read receipt: ${e.message}") @@ -385,6 +551,7 @@ class NostrTransport( messageID: String, sourceGeohash: String? = null ) { + val accountToken = captureAccountToken() ?: return // Use provided geohash or derive from current location val geohash = sourceGeohash ?: run { val selected = try { @@ -406,6 +573,7 @@ class NostrTransport( transportScope.launch { try { + if (!isAccountTokenCurrent(accountToken)) return@launch if (toRecipientHex.isEmpty()) return@launch // Build embedded BitChat packet without recipient peer ID @@ -424,10 +592,7 @@ class NostrTransport( senderIdentity = fromIdentity ) - giftWraps.forEach { event -> - NostrRelayManager.registerPendingGiftWrap(event.id) - NostrRelayManager.getInstance(context).sendEvent(event) - } + sendLegacyGiftWraps(giftWraps, accountToken) } catch (e: Exception) { Log.e(TAG, "Failed to send geohash private message: ${e.message}") } @@ -445,34 +610,43 @@ class NostrTransport( ndrRequired = false, rebindBlocked = false ), - expiresAtSeconds: ULong? = null - ): Boolean { + expiresAtSeconds: ULong? = null, + accountToken: AccountToken + ): NostrSendAdmission { + if (!isAccountTokenCurrent(accountToken)) { + return NostrSendAdmission.RETRYABLE + } if (ndrRecipient.rebindBlocked) { Log.e(TAG, "NostrTransport: recipient rebind is quarantined") - return false + return NostrSendAdmission.RETRYABLE } if (NdrFeatureGate.isEnabled()) { - ndrService.configureIfNeeded(senderIdentity) + val configured = ndrService.configureIfNeeded(senderIdentity) { + isAccountTokenCurrent(accountToken) + } + if (!configured) { + return NostrSendAdmission.RETRYABLE + } val sendResult = ndrService.sendIfPossible( text = content, peerPubkeyHex = ndrRecipient.peerPubkeyHex, - expiresAtSeconds = expiresAtSeconds + expiresAtSeconds = expiresAtSeconds, + accountGuard = { isAccountTokenCurrent(accountToken) } ) - if (sendResult == NdrSendResult.SENT) { - return true - } - if (expiresAtSeconds != null && sendResult == NdrSendResult.NO_SESSION) { - Log.e(TAG, "NostrTransport: expiring message requires a pairwise session") - return false - } - if (!shouldUseLegacyNostrFallback( + when ( + ndrSendDisposition( result = sendResult, ndrRequired = ndrRecipient.ndrRequired, - rebindBlocked = ndrRecipient.rebindBlocked + rebindBlocked = ndrRecipient.rebindBlocked, + pairwiseOnly = expiresAtSeconds != null ) ) { - Log.e(TAG, "NostrTransport: pairwise send failed; refusing legacy downgrade") - return false + NdrSendDisposition.ADMITTED -> return NostrSendAdmission.ADMITTED + NdrSendDisposition.RETRYABLE -> { + Log.e(TAG, "NostrTransport: pairwise send not admitted; refusing legacy downgrade") + return NostrSendAdmission.RETRYABLE + } + NdrSendDisposition.LEGACY_FALLBACK -> Unit } } else if (expiresAtSeconds != null || !isLegacyNostrAllowedWhenNdrDisabled( @@ -481,18 +655,39 @@ class NostrTransport( ) ) { Log.e(TAG, "NostrTransport: pairwise transport is required") - return false + return NostrSendAdmission.RETRYABLE } - NostrProtocol.createPrivateMessage( + val events = NostrProtocol.createPrivateMessage( content = content, recipientPubkey = fallbackRecipientHex, senderIdentity = senderIdentity - ).forEach { event -> - NostrRelayManager.registerPendingGiftWrap(event.id) - NostrRelayManager.getInstance(context).sendEvent(event) + ) + if (events.isEmpty()) { + Log.e(TAG, "NostrTransport: failed to create legacy gift wrap") + return NostrSendAdmission.RETRYABLE + } + return if (sendLegacyGiftWraps(events, accountToken)) { + NostrSendAdmission.ADMITTED + } else { + NostrSendAdmission.RETRYABLE + } + } + + private fun sendLegacyGiftWraps( + events: List, + accountToken: AccountToken + ): Boolean = synchronized(accountStateLock) { + if (!isAccountTokenCurrent(accountToken)) return@synchronized false + events.all { event -> + relayManager.registerPendingGiftWrap( + event.id, + accountToken.relayGeneration + ) && relayManager.sendEvent( + event = event, + expectedAccountGeneration = accountToken.relayGeneration + ) } - return false } private fun resolveNdrRecipient( diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt index f4ca9f74..89b86a6d 100644 --- a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt @@ -9,13 +9,11 @@ import com.bitchat.android.net.TorMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull -import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicBoolean /** * Coordinates a full application shutdown: @@ -27,15 +25,9 @@ import java.util.concurrent.atomic.AtomicLong */ object AppShutdownCoordinator { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - private val shutdownToken = AtomicLong(0L) - @Volatile - private var shutdownJob: Job? = null + private val shutdownGate = ShutdownGate() - fun cancelPendingShutdown() { - shutdownToken.incrementAndGet() - shutdownJob?.cancel() - shutdownJob = null - } + fun isShutdownCommitted(): Boolean = shutdownGate.isCommitted() fun requestFullShutdownAndKill( app: Application, @@ -44,51 +36,84 @@ object AppShutdownCoordinator { stopForeground: () -> Unit, stopService: () -> Unit ) { - val token = shutdownToken.incrementAndGet() - shutdownJob?.cancel() - val job = scope.launch { - // Signal UI to finish gracefully before we kill the process - try { - val intent = android.content.Intent(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) - .setPackage(app.packageName) - app.sendBroadcast(intent, com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH) - } catch (_: Exception) { } + if (!shutdownGate.commit()) return - // Stop mesh (best-effort) - try { mesh?.stopServices() } catch (_: Exception) { } - - // Stop Tor temporarily (do not change user setting) - val torProvider = ArtiTorManager.getInstance() - val torStop = async { - try { torProvider.applyMode(app, TorMode.OFF) } catch (_: Exception) { } + val terminated = AtomicBoolean(false) + val terminateProcess = { + if (terminated.compareAndSet(false, true)) { + try { stopService() } catch (_: Exception) { } + try { Process.killProcess(Process.myPid()) } catch (_: Exception) { } + try { System.exit(0) } catch (_: Exception) { } } - - // Clear AppState in-memory store - try { com.bitchat.android.services.AppStateStore.clear() } catch (_: Exception) { } - - // Stop foreground and clear notification - try { stopForeground() } catch (_: Exception) { } - try { notificationManager.cancel(10001) } catch (_: Exception) { } - - // Wait up to 5 seconds for shutdown tasks - withTimeoutOrNull(5000) { - try { torStop.await() } catch (_: Exception) { } - delay(100) - } - - // Stop the service itself - if (!isActive || shutdownToken.get() != token) return@launch - try { stopService() } catch (_: Exception) { } - - // Hard kill the app process - if (!isActive || shutdownToken.get() != token) return@launch - try { Process.killProcess(Process.myPid()) } catch (_: Exception) { } - try { System.exit(0) } catch (_: Exception) { } } - shutdownJob = job - job.invokeOnCompletion { - if (shutdownJob === job) { - shutdownJob = null + scope.launch { + delay(5_000) + terminateProcess() + } + scope.launch { + try { + // Quit is an account-lifetime boundary, not a transient service + // pause: no queued plaintext or relay event may survive it. + try { + com.bitchat.android.nostr.NostrTransport + .tryGetInstance() + ?.discardForAccountReset() + } catch (_: Exception) { } + try { + com.bitchat.android.services.MessageRouter + .tryGetInstance() + ?.discardForAccountReset() + } catch (_: Exception) { } + try { + com.bitchat.android.nostr.NostrInboundAccountLifecycle + .invalidate() + } catch (_: Exception) { } + val relayManager = runCatching { + com.bitchat.android.nostr.NostrRelayManager.getInstance(app) + }.getOrNull() + val relayResetToken = runCatching { + relayManager?.beginAccountReset() + }.getOrNull() + try { + com.bitchat.android.nostr.NdrNostrService + .getInstance(app) + .shutdownForProcessExit() + } catch (_: Exception) { } + if (relayResetToken != null) { + runCatching { + relayManager?.discardForAccountReset(relayResetToken) + } + } + + // Signal UI to finish gracefully before we kill the process + try { + val intent = android.content.Intent(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) + .setPackage(app.packageName) + app.sendBroadcast(intent, com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH) + } catch (_: Exception) { } + + // Stop mesh (best-effort) + try { mesh?.stopServices() } catch (_: Exception) { } + + // Stop Tor temporarily (do not change user setting) + val torProvider = ArtiTorManager.getInstance() + val torStop = async { + try { torProvider.applyMode(app, TorMode.OFF) } catch (_: Exception) { } + } + + // Clear AppState in-memory store + try { com.bitchat.android.services.AppStateStore.clear() } catch (_: Exception) { } + + // Stop foreground and clear notification + try { stopForeground() } catch (_: Exception) { } + try { notificationManager.cancel(10001) } catch (_: Exception) { } + + withTimeoutOrNull(5_000) { + try { torStop.await() } catch (_: Exception) { } + delay(100) + } + } finally { + terminateProcess() } } } diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt index d2fda746..79dad672 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -118,9 +118,14 @@ class MeshForegroundService : Service() { override fun onCreate() { super.onCreate() + if (AppShutdownCoordinator.isShutdownCommitted()) { + stopSelf() + return + } if (!com.bitchat.android.nostr.NdrPanicStartupRecovery .isNetworkStartupAllowed() ) { + discardOldAccountNetworkWork() stopSelf() return } @@ -140,15 +145,17 @@ class MeshForegroundService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (!com.bitchat.android.nostr.NdrPanicStartupRecovery - .isNetworkStartupAllowed() - ) { + if (AppShutdownCoordinator.isShutdownCommitted()) { + isShuttingDown = true stopSelf() return START_NOT_STICKY } - if (isShuttingDown && intent?.action == ACTION_START) { - AppShutdownCoordinator.cancelPendingShutdown() - isShuttingDown = false + if (!com.bitchat.android.nostr.NdrPanicStartupRecovery + .isNetworkStartupAllowed() + ) { + discardOldAccountNetworkWork() + stopSelf() + return START_NOT_STICKY } if (isShuttingDown && intent?.action != ACTION_QUIT) { return START_NOT_STICKY @@ -393,6 +400,13 @@ class MeshForegroundService : Service() { } override fun onDestroy() { + // Service teardown is normally transient: pause retries but preserve + // the outbox for a later service rebind. Panic/quit discard explicitly. + try { + com.bitchat.android.services.MessageRouter + .tryGetInstance() + ?.stopOutboxScheduler() + } catch (_: Exception) { } updateJob?.cancel() updateJob = null // Cancel the service coroutine scope to prevent leaks @@ -405,5 +419,39 @@ class MeshForegroundService : Service() { super.onDestroy() } + private fun discardOldAccountNetworkWork() { + try { + com.bitchat.android.nostr.NostrTransport + .tryGetInstance() + ?.discardForAccountReset() + } catch (_: Exception) { } + try { + com.bitchat.android.services.MessageRouter + .tryGetInstance() + ?.discardForAccountReset() + } catch (_: Exception) { } + try { + com.bitchat.android.nostr.NostrInboundAccountLifecycle + .invalidate() + } catch (_: Exception) { } + val relayManager = runCatching { + com.bitchat.android.nostr.NostrRelayManager + .getInstance(applicationContext) + }.getOrNull() + val relayResetToken = runCatching { + relayManager?.beginAccountReset() + }.getOrNull() + try { + com.bitchat.android.nostr.NdrNostrService + .getInstance(applicationContext) + .shutdownForProcessExit() + } catch (_: Exception) { } + if (relayResetToken != null) { + runCatching { + relayManager?.discardForAccountReset(relayResetToken) + } + } + } + override fun onBind(intent: Intent?): IBinder? = null } diff --git a/app/src/main/java/com/bitchat/android/service/ShutdownGate.kt b/app/src/main/java/com/bitchat/android/service/ShutdownGate.kt new file mode 100644 index 00000000..d53a3f3d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/ShutdownGate.kt @@ -0,0 +1,21 @@ +package com.bitchat.android.service + +/** + * Makes a destructive application quit irreversible once committed. + */ +internal class ShutdownGate { + private val lock = Any() + private var committed = false + + fun commit(): Boolean = + synchronized(lock) { + if (committed) { + false + } else { + committed = true + true + } + } + + fun isCommitted(): Boolean = synchronized(lock) { committed } +} diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index c7971eb8..bbc9965b 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -126,8 +126,18 @@ object AppStateStore { val idx = list.indexOfFirst { it.id == messageID } if (idx >= 0) { val current = list[idx].deliveryStatus - // Do not downgrade (e.g., Read -> Delivered) - if (statusPriority(status) >= statusPriority(current)) { + val acceptsLocalFailure = status is DeliveryStatus.Failed && + (current == null || + current is DeliveryStatus.Sending || + current is DeliveryStatus.Failed) + val rejectsLateFailure = status is DeliveryStatus.Failed && + !acceptsLocalFailure + // Locally terminal/expired work may move Sending -> Failed, + // but a late failure must not overwrite Sent/Delivered/Read. + if (!rejectsLateFailure && + (acceptsLocalFailure || + statusPriority(status) >= statusPriority(current)) + ) { list[idx] = list[idx].copy(deliveryStatus = status) map[peer] = list changed = true diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt index a09fa31e..9dddcbea 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -5,6 +5,7 @@ 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.NostrSendAdmission import com.bitchat.android.nostr.NostrTransport import com.bitchat.android.util.AppConstants import kotlinx.coroutines.CoroutineScope @@ -19,14 +20,36 @@ import java.util.concurrent.ConcurrentHashMap /** * Routes messages between local mesh transports and Nostr, matching iOS behavior. */ -class MessageRouter private constructor( +internal fun interface NostrPrivateMessageSender { + fun sendPrivateMessage( + content: String, + to: String, + recipientNickname: String, + messageID: String, + completion: (NostrSendAdmission) -> Unit + ) +} + +class MessageRouter internal constructor( private val context: Context, private var mesh: MeshService, - private val nostr: NostrTransport + private val nostr: NostrTransport, + private val privateNostrSender: NostrPrivateMessageSender = + NostrPrivateMessageSender { content, to, recipientNickname, messageID, completion -> + nostr.sendPrivateMessage( + content = content, + to = to, + recipientNickname = recipientNickname, + messageID = messageID, + completion = completion + ) + }, + private val canSendViaNostrOverride: ((String) -> Boolean)? = null ) { enum class RouteResult { MESH, NOSTR, + NOSTR_PENDING, QUEUED, DROPPED } @@ -43,6 +66,12 @@ class MessageRouter private constructor( val nextHandshakeAttemptAtMs: Long ) + private data class InFlightNostrAttempt( + val messageID: String, + val generation: Long, + val outboxEpoch: Long + ) + companion object { private const val TAG = "MessageRouter" private const val OUTBOX_TICK_MS = AppConstants.Router.OUTBOX_TICK_MS @@ -86,6 +115,13 @@ class MessageRouter private constructor( // Per-conversation handshake retry state for queued messages private val retryState = ConcurrentHashMap() + // A Nostr/NDR ratchet send must have one owner. Only one queued message per + // conversation may cross the asynchronous transport boundary at a time. + private val inFlightNostrAttempts = mutableMapOf() + private var nextNostrAttemptGeneration = 0L + private var outboxEpoch = 0L + private var accountResetBlocked = false + private val schedulerScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private var schedulerJob: kotlinx.coroutines.Job? = null @@ -94,6 +130,8 @@ class MessageRouter private constructor( // Called with the messageID of queued messages that expired or were evicted var onMessageExpired: ((String) -> Unit)? = null + var onMessageAdmitted: ((String) -> Unit)? = null + var onMessageFailed: ((String, String) -> Unit)? = null init { startOutboxScheduler() @@ -110,7 +148,12 @@ class MessageRouter private constructor( } } + @Synchronized fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String): RouteResult { + if (accountResetBlocked) { + notifyFailed(messageID, "Account reset in progress") + return RouteResult.DROPPED + } val resolution = ContactDirectory.resolve(toPeerID) val conversationID = resolution.conversationID val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) } @@ -134,18 +177,24 @@ class MessageRouter private constructor( 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 + enqueue( + conversationID, + QueuedMessage(content, recipientNickname, messageID, clock()) + ) + flushOutboxFor(conversationID) + return RouteResult.NOSTR_PENDING } 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) } + if (hasMesh) kickHandshake(conversationID, meshTarget, immediate = true) return RouteResult.QUEUED } } + @Synchronized fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) { + if (accountResetBlocked) return val resolution = ContactDirectory.resolve(toPeerID) val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) } val nostrTarget = resolution.noiseKeyHex ?: toPeerID @@ -158,7 +207,9 @@ class MessageRouter private constructor( } } + @Synchronized fun sendDeliveryAck(messageID: String, toPeerID: String) { + if (accountResetBlocked) return // 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)) { @@ -175,7 +226,9 @@ class MessageRouter private constructor( } } + @Synchronized fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) { + if (accountResetBlocked) 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)) { @@ -194,31 +247,179 @@ class MessageRouter private constructor( @Synchronized fun flushOutboxFor(peerID: String) { val conversationID = ContactDirectory.canonicalConversationId(peerID) - val queued = outbox[conversationID] ?: outbox[peerID] ?: return + val queued = canonicalizeQueueKey(conversationID, peerID) ?: return if (queued.isEmpty()) return + if (findInFlightAttempt(conversationID, peerID, queued) != null) 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)) { + val resolution = ContactDirectory.resolve(conversationID) + val meshTarget = resolution.meshPeerID + val nostrTarget = resolution.noiseKeyHex ?: conversationID + if (meshTarget != null && isReady(mesh, meshTarget)) { + queued.forEach { entry -> 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() } + queued.clear() + } else if (canSendViaNostr(nostrTarget)) { + startNostrAttempt( + conversationID = conversationID, + target = nostrTarget, + entry = queued.first() + ) + return } if (queued.isEmpty()) { - outbox.remove(conversationID, queued) - outbox.remove(peerID, queued) - retryState.remove(conversationID) - retryState.remove(peerID) + removeEmptyQueue(conversationID, peerID, queued) } } + @Synchronized + private fun startNostrAttempt( + conversationID: String, + target: String, + entry: QueuedMessage + ) { + val queued = outbox[conversationID] ?: return + if (findInFlightAttempt(conversationID, conversationID, queued) != null) return + val attempt = InFlightNostrAttempt( + messageID = entry.messageID, + generation = ++nextNostrAttemptGeneration, + outboxEpoch = outboxEpoch + ) + inFlightNostrAttempts[conversationID] = attempt + privateNostrSender.sendPrivateMessage( + entry.content, + target, + entry.nickname, + entry.messageID + ) { admission -> + finishNostrAttempt(conversationID, attempt, admission) + } + } + + @Synchronized + private fun finishNostrAttempt( + conversationID: String, + attempt: InFlightNostrAttempt, + admission: NostrSendAdmission + ) { + if (attempt.outboxEpoch != outboxEpoch) return + val attemptEntry = inFlightNostrAttempts.entries + .firstOrNull { it.value === attempt } + ?: return + inFlightNostrAttempts.remove(attemptEntry.key, attempt) + val queueKey = outbox.entries + .firstOrNull { (_, queue) -> + queue.any { it.messageID == attempt.messageID } + } + ?.key + ?: conversationID + val currentConversationID = + ContactDirectory.canonicalConversationId(queueKey) + if (queueKey != currentConversationID) { + canonicalizeQueueKey(currentConversationID, queueKey) + } + + when (admission) { + NostrSendAdmission.ADMITTED -> { + removeQueuedMessage(currentConversationID, attempt.messageID) + notifyAdmitted(attempt.messageID) + flushOutboxFor(currentConversationID) + } + + NostrSendAdmission.RETRYABLE -> Unit + + NostrSendAdmission.TERMINAL_FAILED -> { + removeQueuedMessage(currentConversationID, attempt.messageID) + notifyFailed(attempt.messageID, "Message could not be sent") + flushOutboxFor(currentConversationID) + } + } + } + + private fun removeQueuedMessage(conversationID: String, messageID: String) { + val queued = outbox[conversationID] ?: return + queued.removeAll { it.messageID == messageID } + if (queued.isEmpty()) { + removeEmptyQueue(conversationID, conversationID, queued) + } + } + + private fun canonicalizeQueueKey( + conversationID: String, + peerID: String + ): MutableList? { + val canonical = outbox[conversationID] + val alias = outbox[peerID] + val queue = when { + canonical == null && alias != null -> { + outbox[conversationID] = alias + if (peerID != conversationID) { + outbox.remove(peerID, alias) + } + alias + } + + canonical != null && alias != null && canonical !== alias -> { + canonical.addAll(alias) + canonical.sortBy(QueuedMessage::enqueuedAtMs) + outbox.remove(peerID, alias) + canonical + } + + else -> canonical + } + if (queue != null) { + migrateInFlightOwnership(conversationID, peerID, queue) + } + return queue + } + + private fun findInFlightAttempt( + conversationID: String, + peerID: String, + queue: List + ): Map.Entry? { + val queuedMessageIDs = queue.asSequence() + .map(QueuedMessage::messageID) + .toSet() + return inFlightNostrAttempts.entries.firstOrNull { (key, attempt) -> + key == conversationID || + key == peerID || + ContactDirectory.canonicalConversationId(key) == conversationID || + attempt.messageID in queuedMessageIDs + } + } + + private fun migrateInFlightOwnership( + conversationID: String, + peerID: String, + queue: List + ) { + val matches = inFlightNostrAttempts.entries.filter { (key, attempt) -> + key == peerID || + ContactDirectory.canonicalConversationId(key) == conversationID || + queue.any { it.messageID == attempt.messageID } + } + if (matches.size != 1 || inFlightNostrAttempts.containsKey(conversationID)) { + return + } + val match = matches.single() + if (inFlightNostrAttempts.remove(match.key, match.value)) { + inFlightNostrAttempts[conversationID] = match.value + } + } + + private fun removeEmptyQueue( + conversationID: String, + peerID: String, + queued: MutableList + ) { + 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) } @@ -227,9 +428,21 @@ class MessageRouter private constructor( @Synchronized private fun enqueue(conversationID: String, entry: QueuedMessage) { val queue = outbox.getOrPut(conversationID) { mutableListOf() } + if (queue.any { it.messageID == entry.messageID }) return queue.add(entry) while (queue.size > OUTBOX_MAX_PER_PEER) { - val evicted = queue.removeAt(0) + val inFlightMessageIDs = inFlightNostrAttempts + .filter { (key, attempt) -> + key == conversationID || + ContactDirectory.canonicalConversationId(key) == conversationID || + queue.any { it.messageID == attempt.messageID } + } + .values + .mapTo(mutableSetOf(), InFlightNostrAttempt::messageID) + val evictionIndex = + queue.indexOfFirst { it.messageID !in inFlightMessageIDs } + if (evictionIndex < 0) break + val evicted = queue.removeAt(evictionIndex) Log.w(TAG, "Outbox full for ${conversationID.take(16)}…; evicting oldest msg_id=${evicted.messageID.take(8)}…") notifyExpired(evicted.messageID) } @@ -239,6 +452,14 @@ class MessageRouter private constructor( try { onMessageExpired?.invoke(messageID) } catch (_: Exception) { } } + private fun notifyAdmitted(messageID: String) { + try { onMessageAdmitted?.invoke(messageID) } catch (_: Exception) { } + } + + private fun notifyFailed(messageID: String, reason: String) { + try { onMessageFailed?.invoke(messageID, reason) } catch (_: Exception) { } + } + /** * Initiate a Noise handshake for a conversation with queued messages, applying * exponential backoff between attempts. [immediate] resets the backoff (peer just @@ -285,6 +506,32 @@ class MessageRouter private constructor( schedulerJob = null } + /** Panic/reset must invalidate callbacks from the previous account and drop plaintext. */ + @Synchronized + fun discardForAccountReset(): Long { + stopOutboxScheduler() + accountResetBlocked = true + outboxEpoch += 1 + inFlightNostrAttempts.clear() + outbox.clear() + retryState.clear() + return outboxEpoch + } + + @Synchronized + fun completeAccountReset(resetToken: Long): Boolean { + if (resetToken != outboxEpoch) return false + accountResetBlocked = false + startOutboxScheduler() + return true + } + + internal val queuedMessageCount: Int + @Synchronized get() = outbox.values.sumOf { it.size } + + internal val inFlightNostrAttemptCount: Int + @Synchronized get() = inFlightNostrAttempts.size + internal val isSchedulerRunning: Boolean get() = schedulerJob?.isActive == true /** @@ -319,10 +566,20 @@ class MessageRouter private constructor( private fun expireOldEntries(conversationID: String, nowMs: Long) { val queued = outbox[conversationID] ?: return + val inFlightMessageIDs = inFlightNostrAttempts + .filter { (key, attempt) -> + key == conversationID || + ContactDirectory.canonicalConversationId(key) == conversationID || + queued.any { it.messageID == attempt.messageID } + } + .values + .mapTo(mutableSetOf(), InFlightNostrAttempt::messageID) val iterator = queued.iterator() while (iterator.hasNext()) { val entry = iterator.next() - if (nowMs - entry.enqueuedAtMs > OUTBOX_MESSAGE_TTL_MS) { + if (entry.messageID !in inFlightMessageIDs && + 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) @@ -335,6 +592,7 @@ class MessageRouter private constructor( } private fun canSendViaNostr(peerID: String): Boolean { + canSendViaNostrOverride?.let { return it(peerID) } return try { val resolution = ContactDirectory.resolve(peerID) if (resolution.isMutualFavorite && resolution.nostrPubkey != null) return true diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index ac0f2db6..ddd6dde4 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -375,14 +375,30 @@ class ChatViewModel( loadAndInitialize() ContactDirectory.initialize(getApplication()) { mesh } com.bitchat.android.services.AppStateStore.canonicalizePrivateChats() - // Mark queued private messages as failed when the router gives up on them + // Reflect asynchronous router admission/failure into the local echo. try { - com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh).onMessageExpired = { messageID -> + val router = com.bitchat.android.services.MessageRouter.getInstance( + getApplication(), + mesh + ) + router.onMessageExpired = { messageID -> messageManager.updateMessageDeliveryStatus( messageID, com.bitchat.android.model.DeliveryStatus.Failed("Message expired before delivery") ) } + router.onMessageAdmitted = { messageID -> + messageManager.updateMessageDeliveryStatus( + messageID, + com.bitchat.android.model.DeliveryStatus.Sent + ) + } + router.onMessageFailed = { messageID, reason -> + messageManager.updateMessageDeliveryStatus( + messageID, + com.bitchat.android.model.DeliveryStatus.Failed(reason) + ) + } } catch (_: Exception) { } // Hydrate UI state from process-wide AppStateStore to survive Activity recreation viewModelScope.launch { @@ -718,6 +734,8 @@ class ChatViewModel( ) { messageContent, peerID, recipientNicknameParam, messageId -> val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh) val route = router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId) + // Geohash DMs retain their legacy fire-and-forget admission semantics. + // Standard Nostr/NDR sends are marked Sent only by onMessageAdmitted. if (route == com.bitchat.android.services.MessageRouter.RouteResult.NOSTR) { messageManager.updateMessageDeliveryStatus(messageId, com.bitchat.android.model.DeliveryStatus.Sent) } @@ -1364,20 +1382,35 @@ class ChatViewModel( fun panicClearAllData() { Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data") + // Freeze transport admission before erasing router plaintext so a + // concurrent UI send cannot create fresh work in the reset window. + val resetTransport = com.bitchat.android.nostr.NostrTransport + .getInstance(getApplication()) + val transportResetToken = resetTransport.discardForAccountReset() + val resetRouter = com.bitchat.android.services.MessageRouter + .getInstance(getApplication(), mesh) + val routerResetToken = resetRouter.discardForAccountReset() + geohashViewModel.invalidateDoubleRatchetAccount() try { com.bitchat.android.geohash.LocationChannelManager .getInstance(getApplication()) .disableLocationServices() } catch (_: Exception) { } + val resetRelay = com.bitchat.android.nostr.NostrRelayManager + .getInstance(getApplication()) + val relayResetToken = resetRelay.beginAccountReset() // A pending one-shot downgrade confirmation must not survive panic or // become actionable against the fresh post-wipe identity. mediaSendingManager.clearPendingPrivateMediaConsent() - geohashViewModel.invalidateDoubleRatchetAccount() val ndrResetSucceeded = ndrService.resetForPanic() if (!ndrResetSucceeded) { Log.e(TAG, "NDR storage wipe was incomplete; NDR remains disabled for this process") } + // NDR reset is synchronized and therefore quiesces any native send that + // entered before panic. Advance the relay generation only afterwards, + // then clear every event produced by that old runtime. + resetRelay.discardForAccountReset(relayResetToken) ndrBootstrapAttemptMs.clear() ndrNoiseHandshakeAttemptMs.clear() ndrInviteRetries.cancelAll() @@ -1453,14 +1486,10 @@ class ChatViewModel( locationManager.clearPersistedChannel() } catch (_: Exception) { } - geohashViewModel.panicReset() + geohashViewModel.panicReset(reinitialize = false) } catch (e: Exception) { Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}") } - if (ndrResetSucceeded && NdrFeatureGate.isEnabled()) { - // GeohashViewModel.panicReset() recreates the account identity and - // reinstalls the decrypted-message callback through initialize(). - } // Reset nickname val newNickname = "anon${Random.nextInt(1000, 9999)}" @@ -1469,6 +1498,24 @@ class ChatViewModel( // Recreate mesh service with fresh identity recreateMeshServiceAfterPanic() + try { + resetTransport.senderPeerID = mesh.myPeerID + val transportReopened = + resetTransport.completeAccountReset(transportResetToken) + val routerReopened = + resetRouter.completeAccountReset(routerResetToken) + val relayReopened = + resetRelay.completeAccountReset(relayResetToken) + if (!transportReopened || !routerReopened || !relayReopened) { + Log.w(TAG, "A newer account reset superseded panic reinitialization") + return + } + geohashViewModel.initialize() + mesh.startServices() + mesh.sendBroadcastAnnounce() + } catch (e: Exception) { + Log.e(TAG, "Failed to reopen network transports after panic: ${e.message}") + } Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}") } @@ -1492,10 +1539,6 @@ class ChatViewModel( unifiedMeshService = freshUnifiedMeshService mesh.delegate = this - // Restart mesh operations with new identity - mesh.startServices() - mesh.sendBroadcastAnnounce() - Log.d( TAG, "✅ Mesh service recreated. Old peerID: $oldPeerID, New peerID: ${mesh.myPeerID}" diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 4fdfa601..da236e89 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -113,13 +113,16 @@ class GeohashViewModel( val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication()) if (identity != null) { // Configure the singleton only for the account identity, never a derived geohash key. - dmHandler.configureDoubleRatchet(identity) + val accountEpoch = dmHandler.configureAccount(identity) // Use global chat-messages only for full account DMs (mesh context). For geohash DMs, subscribe per-geohash below. subscriptionManager.subscribeGiftWraps( pubkey = identity.publicKeyHex, sinceMs = System.currentTimeMillis() - 172800000L, id = "chat-messages", - handler = { event -> dmHandler.onGiftWrap(event, "", identity) } // geohash="" means global account DM (not geohash identity) + handler = { event -> + // geohash="" means global account DM (not geohash identity). + dmHandler.onGiftWrap(event, "", identity, accountEpoch) + } ) } try { @@ -194,7 +197,8 @@ class GeohashViewModel( } } - fun panicReset() { + fun panicReset(reinitialize: Boolean = true) { + dmHandler.invalidateAccount() repo.clearAll() GeohashAliasRegistry.clear() GeohashConversationRegistry.clear() @@ -209,11 +213,17 @@ class GeohashViewModel( globalPresenceJob?.cancel() globalPresenceJob = null try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {} - initialize() + // Install the replacement account key while relay/transport admission is + // still blocked. The caller may defer network initialization until the + // rest of the account identity has been recreated. + try { NostrIdentityBridge.getCurrentNostrIdentity(getApplication()) } catch (_: Exception) {} + if (reinitialize) { + initialize() + } } fun invalidateDoubleRatchetAccount() { - dmHandler.invalidateDoubleRatchetAccount() + dmHandler.invalidateAccount() } private suspend fun broadcastLiveLocationPresence(geohash: String) { @@ -249,8 +259,19 @@ class GeohashViewModel( } fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) { - viewModelScope.launch { + val accountEpoch = dmHandler.currentAccountEpoch() ?: return + val accountContext = + com.bitchat.android.nostr.NostrInboundAccountLifecycle + .contextFor(accountEpoch) + ?: return + val relayManager = NostrRelayManager.getInstance(getApplication()) + val relayGeneration = relayManager.captureAccountGeneration() + viewModelScope.launch(Dispatchers.Default + accountContext.receiveJob) { try { + if (!com.bitchat.android.nostr.NostrInboundAccountLifecycle + .isCurrent(accountEpoch) || + !relayManager.isAccountGenerationCurrent(relayGeneration) + ) return@launch val canUseChannel = locationChannelManager ?.canUseSelectedLocationChannel(channel) == true if (!canUseChannel) { @@ -274,11 +295,22 @@ class GeohashViewModel( channel = "#${channel.geohash}", powDifficulty = if (pow.enabled) pow.difficulty else null ) - messageManager.addChannelMessage("geo:${channel.geohash}", localMsg) + val localEchoAdded = + com.bitchat.android.nostr.NostrInboundAccountLifecycle + .runIfCurrent(accountEpoch) { + messageManager.addChannelMessage( + "geo:${channel.geohash}", + localMsg + ) + } + if (!localEchoAdded) return@launch val identity = NostrIdentityBridge.deriveIdentity( forGeohash = channel.geohash, context = getApplication() ) + if (!com.bitchat.android.nostr.NostrInboundAccountLifecycle + .isCurrent(accountEpoch) + ) return@launch val teleported = locationChannelManager?.teleported?.value ?: state.isTeleported.value val event = NostrProtocol.createEphemeralGeohashEvent( @@ -288,13 +320,13 @@ class GeohashViewModel( nickname, teleported ) - val relayManager = NostrRelayManager.getInstance(getApplication()) relayManager.sendEventToGeohash( event, channel.geohash, includeDefaults = false, nRelays = 5, - liveLocationToken = liveLocationToken + liveLocationToken = liveLocationToken, + expectedAccountGeneration = relayGeneration ) } catch (e: Exception) { Log.e(TAG, "Failed to send geohash message: ${e.message}") @@ -515,13 +547,16 @@ class GeohashViewModel( geohash: String, liveLocationToken: Long? ) { + val accountEpoch = dmHandler.currentAccountEpoch() ?: return val subId = "geohash-${UUID.randomUUID()}"; currentGeohashMsgSubId = subId subscriptionManager.subscribeGeohashMessages( geohash = geohash, sinceMs = System.currentTimeMillis() - 3600000L, limit = 200, id = subId, - handler = { event -> geohashMessageHandler.onEvent(event, geohash) }, + handler = { event -> + geohashMessageHandler.onEvent(event, geohash, accountEpoch) + }, liveLocationToken = liveLocationToken ) } @@ -535,13 +570,16 @@ class GeohashViewModel( geohash: String, liveLocationToken: Long? ) { + val accountEpoch = dmHandler.currentAccountEpoch() ?: return val subId = "geohash-presence-${UUID.randomUUID()}"; currentGeohashPresenceSubId = subId subscriptionManager.subscribeGeohashPresence( geohash = geohash, sinceMs = System.currentTimeMillis() - 3600000L, limit = 200, id = subId, - handler = { event -> geohashMessageHandler.onEvent(event, geohash) }, + handler = { event -> + geohashMessageHandler.onEvent(event, geohash, accountEpoch) + }, liveLocationToken = liveLocationToken ) } @@ -552,6 +590,11 @@ class GeohashViewModel( */ private fun subscribeChannelDM(geohash: String) { val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication()) + val accountEpoch = dmHandler.currentAccountEpoch() + if (accountEpoch == null) { + Log.w(TAG, "Skipping geohash DM subscription without an active account epoch") + return + } val dmSubId = "geo-dm-${UUID.randomUUID()}" currentDmSubId = dmSubId currentDmGeohash = geohash @@ -559,7 +602,9 @@ class GeohashViewModel( pubkey = dmIdentity.publicKeyHex, sinceMs = System.currentTimeMillis() - 172800000L, id = dmSubId, - handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) } + handler = { event -> + dmHandler.onGiftWrap(event, geohash, dmIdentity, accountEpoch) + } ) // Also register alias in global registry for routing convenience GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex) @@ -630,6 +675,7 @@ class GeohashViewModel( } private fun performSubscribeSampling(geohash: String) { + val accountEpoch = dmHandler.currentAccountEpoch() ?: return val subscriptionId = samplingSubscriptionIds.getOrPut(geohash) { "sampling-${UUID.randomUUID()}" } @@ -642,7 +688,9 @@ class GeohashViewModel( sinceMs = System.currentTimeMillis() - 86400000L, limit = 200, id = subscriptionId, - handler = { event -> geohashMessageHandler.onEvent(event, geohash) } + handler = { event -> + geohashMessageHandler.onEvent(event, geohash, accountEpoch) + } ) } @@ -662,7 +710,13 @@ class GeohashViewModel( sinceMs = System.currentTimeMillis() - 86400000L, limit = 200, id = subscriptionId, - handler = { event -> geohashMessageHandler.onEvent(event, geohash) }, + handler = { event -> + geohashMessageHandler.onEvent( + event, + geohash, + accountEpoch + ) + }, liveLocationToken = token ) } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index 0c7b64b2..a7285aea 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -238,6 +238,15 @@ class MessageManager(private val state: ChatState) { } private fun chooseStatus(old: DeliveryStatus?, new: DeliveryStatus): DeliveryStatus? { + if (new is DeliveryStatus.Failed) { + // A locally queued message may fail before any transport admits it. + // Never let a late local failure overwrite evidence of handoff/delivery. + return if (old == null || old is DeliveryStatus.Sending || old is DeliveryStatus.Failed) { + new + } else { + old + } + } // Never downgrade (e.g., Read -> Delivered). Keep the higher priority. return if (statusPriority(new) >= statusPriority(old)) new else old } diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt index 46578a4f..d969d185 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt @@ -1,6 +1,7 @@ package com.bitchat.android.nostr import com.bitchat.android.model.NdrFeatureGate +import kotlinx.coroutines.Job import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -18,14 +19,47 @@ class NdrNostrServiceTest { @Before fun enableNdrForTest() { + NostrInboundAccountLifecycle.invalidate() NdrFeatureGate.setEnabledForTests(true) } @After fun resetNdrGate() { + NostrInboundAccountLifecycle.invalidate() NdrFeatureGate.setEnabledForTests(false) } + @Test + fun processExitInvalidatesAccountWideReceiveWork() { + val parentJob = Job() + val accountContext = + NostrInboundAccountLifecycle.begin(localPubkey, parentJob) + val service = service(runtimeFactory = FakeNdrRuntimeFactory(FakeNdrPairwiseRuntime())) + + assertTrue(NostrInboundAccountLifecycle.isCurrent(accountContext.epoch)) + assertTrue(accountContext.receiveJob.isActive) + + service.shutdownForProcessExit() + + assertFalse(NostrInboundAccountLifecycle.isCurrent(accountContext.epoch)) + assertTrue(accountContext.receiveJob.isCancelled) + parentJob.cancel() + } + + @Test + fun processExitCannotReopenPairwiseRuntime() { + val factory = FakeNdrRuntimeFactory(FakeNdrPairwiseRuntime()) + val service = service(runtimeFactory = factory) + val identity = testIdentity() + assertTrue(service.configureIfNeeded(identity)) + + service.shutdownForProcessExit() + + assertFalse(service.configureIfNeeded(identity)) + assertFalse(service.retirePeerForMaintenance(identity, peerPubkey)) + assertEquals(1, factory.createdCount) + } + @Test fun disabledRolloutGateRefusesToCreateRuntime() { NdrFeatureGate.setEnabledForTests(false) @@ -902,6 +936,27 @@ class NdrNostrServiceTest { assertTrue(relay.sentEvents.isEmpty()) } + @Test + fun postCommitMarkerFailureStillReportsDurableNdrAdmission() { + val activePeers = mutableSetOf() + val runtime = FakeNdrPairwiseRuntime(activePeers) + val markers = InMemoryMarkerStore() + val service = service( + runtimeFactory = FakeNdrRuntimeFactory(runtime), + markerStore = markers + ) + service.configureIfNeeded(testIdentity()) + activePeers += peerPubkey + markers.markFailure = java.io.IOException("marker storage unavailable") + + val result = service.sendIfPossible("admitted-once", peerPubkey) + + assertEquals(NdrSendResult.SENT, result) + assertEquals(listOf(peerPubkey), runtime.sendTextCalls) + assertTrue(runtime.sendTextCompleted) + assertTrue(runtime.destroyed) + } + @Test fun persistedHalfReadyPairwiseSessionNeverFallsBackAfterRestart() { val markers = InMemoryMarkerStore() @@ -1100,6 +1155,38 @@ class NdrNostrServiceTest { ) } + @Test + fun ndrAdmissionDispositionRetriesEveryProtectedOrUnreadyState() { + assertEquals( + NdrSendDisposition.ADMITTED, + ndrSendDisposition(NdrSendResult.SENT) + ) + assertEquals( + NdrSendDisposition.LEGACY_FALLBACK, + ndrSendDisposition(NdrSendResult.NO_SESSION) + ) + assertEquals( + NdrSendDisposition.RETRYABLE, + ndrSendDisposition(NdrSendResult.NO_SESSION, ndrRequired = true) + ) + assertEquals( + NdrSendDisposition.RETRYABLE, + ndrSendDisposition(NdrSendResult.NO_SESSION, pairwiseOnly = true) + ) + assertEquals( + NdrSendDisposition.RETRYABLE, + ndrSendDisposition(NdrSendResult.FAILED) + ) + assertEquals( + NdrSendDisposition.RETRYABLE, + ndrSendDisposition( + NdrSendResult.NO_SESSION, + ndrRequired = true, + rebindBlocked = true + ) + ) + } + private fun service( relayManager: FakeRelayManager = FakeRelayManager(), runtimeFactory: NdrPairwiseRuntimeFactory, diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt index 9528595a..5953931e 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt @@ -1,6 +1,7 @@ package com.bitchat.android.nostr import android.os.Build +import com.bitchat.android.model.NdrFeatureGate import com.bitchat.android.services.AppStateStore import com.bitchat.android.services.SeenMessageStore import com.bitchat.android.ui.ChatState @@ -23,6 +24,7 @@ import kotlinx.coroutines.test.setMain import kotlinx.coroutines.withTimeout import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -32,6 +34,9 @@ import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean @RunWith(RobolectricTestRunner::class) @Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) @@ -44,11 +49,15 @@ class NostrDirectMessageHandlerTest { fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + NdrFeatureGate.setEnabledForTests(false) + NostrInboundAccountLifecycle.invalidate() AppStateStore.clear() } @After fun tearDown() { + NostrInboundAccountLifecycle.invalidate() + NdrFeatureGate.setEnabledForTests(false) AppStateStore.clear() scope.cancel() Dispatchers.resetMain() @@ -115,9 +124,10 @@ class NostrDirectMessageHandlerTest { giftWrapCreatedAt = now - 86_400 ) - handler.onGiftWrap(first, "", recipient) + val accountEpoch = handler.configureAccount(recipient) + handler.onGiftWrap(first, "", recipient, accountEpoch) waitForMessage(state, firstId) - handler.onGiftWrap(second, "", recipient) + handler.onGiftWrap(second, "", recipient, accountEpoch) waitForMessage(state, secondId) val messages = state.getPrivateChatsValue().values.single() @@ -170,7 +180,8 @@ class NostrDirectMessageHandlerTest { giftWrapCreatedAt = now - 5 ) - handler.onGiftWrap(giftWrap, "", recipient) + val accountEpoch = handler.configureAccount(recipient) + handler.onGiftWrap(giftWrap, "", recipient, accountEpoch) kotlinx.coroutines.runBlocking { assertEquals(sender.publicKeyHex, withTimeout(5_000) { policyChecked.await() }) @@ -179,6 +190,99 @@ class NostrDirectMessageHandlerTest { assertEquals(0, state.getPrivateChatsValue().values.flatten().size) } + @Test + fun `account invalidation prevents an in-flight legacy gift wrap from restoring chat state`() { + val application = RuntimeEnvironment.getApplication() + val state = ChatState(scope).apply { setNickname("recipient") } + val dataManager = DataManager(application) + val privateChatManager = PrivateChatManager( + state = state, + messageManager = MessageManager(state), + dataManager = dataManager, + noiseSessionDelegate = mock() + ) + val seenStore = mock() + whenever(seenStore.hasDelivered(any())).thenReturn(true) + whenever(seenStore.hasBeenReadLocally(any())).thenReturn(false) + val policyEntered = CountDownLatch(1) + val releasePolicy = CountDownLatch(1) + val blockFirstPolicyCheck = AtomicBoolean(true) + val handler = NostrDirectMessageHandler( + application = application, + state = state, + privateChatManager = privateChatManager, + meshDelegateHandler = mock(), + scope = scope, + repo = GeohashRepository(application, state, dataManager), + dataManager = dataManager, + seenStoreProvider = { seenStore }, + legacyNostrInboundAllowed = { + if (blockFirstPolicyCheck.compareAndSet(true, false)) { + policyEntered.countDown() + releasePolicy.await(5, TimeUnit.SECONDS) + } + true + } + ) + val sender = NostrIdentity.generate() + val recipient = NostrIdentity.generate() + val now = (System.currentTimeMillis() / 1000).toInt() + val staleGiftWrap = privateMessageGiftWrap( + content = requireNotNull( + NostrEmbeddedBitChat.encodePMForNostrNoRecipient( + content = "must-not-return", + messageID = "stale-legacy", + senderPeerID = "0011223344556677" + ) + ), + sender = sender, + recipient = recipient, + rumorCreatedAt = now - 60, + giftWrapCreatedAt = now - 5 + ) + val freshGiftWrap = privateMessageGiftWrap( + content = requireNotNull( + NostrEmbeddedBitChat.encodePMForNostrNoRecipient( + content = "fresh", + messageID = "fresh-legacy", + senderPeerID = "0011223344556677" + ) + ), + sender = sender, + recipient = recipient, + rumorCreatedAt = now - 30, + giftWrapCreatedAt = now - 4 + ) + + val staleEpoch = handler.configureAccount(recipient) + val staleJob = requireNotNull( + handler.onGiftWrap(staleGiftWrap, "", recipient, staleEpoch) + ) + try { + assertTrue(policyEntered.await(5, TimeUnit.SECONDS)) + handler.invalidateAccount() + } finally { + releasePolicy.countDown() + } + kotlinx.coroutines.runBlocking { + withTimeout(5_000) { staleJob.join() } + } + assertEquals(0, state.getPrivateChatsValue().values.flatten().size) + + val freshEpoch = handler.configureAccount(recipient) + val freshJob = requireNotNull( + handler.onGiftWrap(freshGiftWrap, "", recipient, freshEpoch) + ) + kotlinx.coroutines.runBlocking { + withTimeout(5_000) { freshJob.join() } + } + waitForMessage(state, "fresh-legacy") + assertEquals( + listOf("fresh-legacy"), + state.getPrivateChatsValue().values.flatten().map { it.id } + ) + } + private fun waitForMessage(state: ChatState, messageId: String) { kotlinx.coroutines.runBlocking { withTimeout(5_000) { diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt index 7bb4aa30..a51e109b 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt @@ -12,6 +12,8 @@ class NostrRelayManagerLifecycleSmokeTest { @Test fun `disconnected manager maintains subscription and empty publish invariants locally`() { val manager = NostrRelayManager.shared + val setupReset = manager.discardForAccountReset() + assertTrue(manager.completeAccountReset(setupReset)) manager.disconnect() manager.clearAllSubscriptions() @@ -38,6 +40,114 @@ class NostrRelayManagerLifecycleSmokeTest { assertFalse(manager.isConnected.value) } + @Test + fun `account reset advances generation and discards queued legacy gift wraps`() { + val manager = NostrRelayManager.shared + val setupReset = manager.discardForAccountReset() + assertTrue(manager.completeAccountReset(setupReset)) + val oldGeneration = manager.accountGenerationForTesting() + val event = signedEvent() + assertTrue(manager.registerPendingGiftWrap(event.id, oldGeneration)) + manager.sendEvent(event, relayUrls = emptyList()) + + assertEquals(1, manager.queuedEventCountForTesting()) + assertEquals(1, manager.pendingGiftWrapCountForTesting()) + + val resetToken = manager.discardForAccountReset() + + assertEquals(oldGeneration + 1, manager.accountGenerationForTesting()) + assertFalse(manager.isAccountGenerationCurrent(oldGeneration)) + assertFalse( + manager.isAccountGenerationCurrent( + manager.accountGenerationForTesting() + ) + ) + assertEquals(0, manager.queuedEventCountForTesting()) + assertEquals(0, manager.pendingGiftWrapCountForTesting()) + assertTrue(manager.completeAccountReset(resetToken)) + } + + @Test + fun `new relay work is rejected during reset and admitted after completion`() { + val manager = NostrRelayManager.shared + val resetToken = manager.discardForAccountReset() + val event = signedEvent() + + assertFalse(manager.sendEvent(event, relayUrls = emptyList())) + assertEquals(0, manager.queuedEventCountForTesting()) + + assertTrue(manager.completeAccountReset(resetToken)) + assertTrue(manager.sendEvent(event, relayUrls = emptyList())) + assertEquals(1, manager.queuedEventCountForTesting()) + + val cleanupReset = manager.discardForAccountReset() + assertTrue(manager.completeAccountReset(cleanupReset)) + } + + @Test + fun `stale reset cannot clear or reopen a newer relay reset`() { + val manager = NostrRelayManager.shared + val firstReset = manager.beginAccountReset() + val secondReset = manager.beginAccountReset() + val event = signedEvent() + + assertFalse(manager.discardForAccountReset(firstReset)) + assertFalse(manager.completeAccountReset(firstReset)) + assertFalse(manager.sendEvent(event, relayUrls = emptyList())) + assertEquals(0, manager.queuedEventCountForTesting()) + + assertTrue(manager.discardForAccountReset(secondReset)) + assertTrue(manager.completeAccountReset(secondReset)) + assertTrue(manager.sendEvent(event, relayUrls = emptyList())) + + val cleanupReset = manager.discardForAccountReset() + assertTrue(manager.completeAccountReset(cleanupReset)) + } + + @Test + fun `stale geohash wrapper cannot recapture a replacement generation`() { + val manager = NostrRelayManager.shared + val setupReset = manager.discardForAccountReset() + assertTrue(manager.completeAccountReset(setupReset)) + val oldGeneration = manager.captureAccountGeneration() + val replacementReset = manager.discardForAccountReset() + assertTrue(manager.completeAccountReset(replacementReset)) + val event = signedEvent() + + manager.sendEventToGeohash( + event = event, + geohash = "u4pruydq", + expectedAccountGeneration = oldGeneration + ) + manager.subscribe( + filter = NostrFilter(kinds = listOf(NostrKind.TEXT_NOTE)), + id = "stale-wrapper", + handler = {}, + targetRelayUrls = emptyList(), + expectedAccountGeneration = oldGeneration + ) + + assertEquals(0, manager.queuedEventCountForTesting()) + assertFalse(manager.getActiveSubscriptions().containsKey("stale-wrapper")) + + val cleanupReset = manager.discardForAccountReset() + assertTrue(manager.completeAccountReset(cleanupReset)) + } + + @Test + fun `generation captured during reset never becomes replacement work`() { + val manager = NostrRelayManager.shared + val resetToken = manager.beginAccountReset() + val parkedGeneration = manager.captureAccountGeneration() + + assertTrue(manager.discardForAccountReset(resetToken)) + assertTrue(manager.completeAccountReset(resetToken)) + assertFalse(manager.isAccountGenerationCurrent(parkedGeneration)) + + val cleanupReset = manager.discardForAccountReset() + assertTrue(manager.completeAccountReset(cleanupReset)) + } + private fun signedEvent(): NostrEvent { val privateKey = "0".repeat(63) + "1" return NostrEvent( diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySubscriptionRaceTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySubscriptionRaceTest.kt index 3388af50..e84a7bd1 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySubscriptionRaceTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySubscriptionRaceTest.kt @@ -9,6 +9,7 @@ import okhttp3.WebSocket import okio.ByteString import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -93,6 +94,26 @@ class NostrRelaySubscriptionRaceTest { assertFalse(accepted ?: true) } + @Test + fun ndrAdapterRejectsSubscriptionDuringAccountReset() { + val manager = NostrRelayManager( + CoroutineScope(Dispatchers.Unconfined + SupervisorJob()), + NostrEventDeduplicator(maxCapacity = 8) + ) + val adapter = BitchatNdrRelayAdapter(manager) + val resetToken = manager.beginAccountReset() + + assertThrows(IllegalStateException::class.java) { + adapter.subscribe( + filter = NostrFilter(kinds = listOf(1060)), + id = "blocked-ndr" + ) { true } + } + + assertTrue(manager.discardForAccountReset(resetToken)) + assertTrue(manager.completeAccountReset(resetToken)) + } + @Suppress("UNCHECKED_CAST") private fun installConnection( manager: NostrRelayManager, @@ -114,13 +135,15 @@ class NostrRelaySubscriptionRaceTest { val method = NostrRelayManager::class.java.getDeclaredMethod( "handleMessage", String::class.java, - String::class.java + String::class.java, + Long::class.javaPrimitiveType ) method.isAccessible = true method.invoke( manager, """["EVENT","$subscriptionId",${event.toJsonString()}]""", - RELAY_URL + RELAY_URL, + manager.captureAccountGeneration() ) } diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrTransportAdmissionTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrTransportAdmissionTest.kt new file mode 100644 index 00000000..abdc2d76 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrTransportAdmissionTest.kt @@ -0,0 +1,141 @@ +package com.bitchat.android.nostr + +import android.os.Build +import com.bitchat.android.model.ReadReceipt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +@OptIn(ExperimentalCoroutinesApi::class) +class NostrTransportAdmissionTest { + @Test + fun `cancelled transport scope completes admission exactly once as retryable`() { + val job = Job().apply { cancel() } + val transport = NostrTransport( + context = RuntimeEnvironment.getApplication(), + transportScope = CoroutineScope(Dispatchers.Unconfined + job) + ) + val admissions = mutableListOf() + + transport.sendPrivateMessage( + content = "hello", + to = "aa".repeat(32), + recipientNickname = "peer", + messageID = "cancelled", + completion = admissions::add + ) + + assertEquals(listOf(NostrSendAdmission.RETRYABLE), admissions) + } + + @Test + fun `already expired payload is terminal before identity or relay work`() { + val transport = NostrTransport( + context = RuntimeEnvironment.getApplication(), + transportScope = CoroutineScope(Dispatchers.Unconfined + Job()) + ) + val admissions = mutableListOf() + + transport.sendPrivateMessage( + content = "expired", + to = "aa".repeat(32), + recipientNickname = "peer", + messageID = "expired", + expiresAtSeconds = 0uL, + completion = admissions::add + ) + + assertEquals(listOf(NostrSendAdmission.TERMINAL_FAILED), admissions) + } + + @Test + fun `invocation before relay reset cannot enqueue when coroutine resumes after reset`() { + val dispatcher = StandardTestDispatcher() + val scope = TestScope(dispatcher) + val relayManager = NostrRelayManager(scope) + val setupReset = relayManager.discardForAccountReset() + relayManager.completeAccountReset(setupReset) + val oldGeneration = relayManager.accountGenerationForTesting() + val transport = NostrTransport( + context = RuntimeEnvironment.getApplication(), + transportScope = scope, + relayManager = relayManager + ) + + transport.sendDeliveryAckGeohash( + messageID = "old-account-message", + toRecipientHex = "22".repeat(32), + fromIdentity = NostrIdentity.fromPrivateKey("11".repeat(32)) + ) + val resetToken = relayManager.discardForAccountReset() + scope.advanceUntilIdle() + + assertEquals(oldGeneration + 1, relayManager.accountGenerationForTesting()) + assertEquals(0, relayManager.queuedEventCountForTesting()) + assertEquals(0, relayManager.pendingGiftWrapCountForTesting()) + relayManager.completeAccountReset(resetToken) + } + + @Test + fun `account reset clears throttled reads and invalidates their delayed work`() { + val dispatcher = StandardTestDispatcher() + val scope = TestScope(dispatcher) + val relayManager = NostrRelayManager(scope) + val setupReset = relayManager.discardForAccountReset() + relayManager.completeAccountReset(setupReset) + val transport = NostrTransport( + context = RuntimeEnvironment.getApplication(), + transportScope = scope, + relayManager = relayManager + ) + + transport.sendReadReceipt(ReadReceipt("first"), "aa".repeat(32)) + transport.sendReadReceipt(ReadReceipt("second"), "aa".repeat(32)) + assertEquals(1, transport.activeReadCountForTesting()) + assertEquals(1, transport.queuedReadCountForTesting()) + + transport.discardForAccountReset() + val resetToken = relayManager.discardForAccountReset() + scope.advanceUntilIdle() + + assertEquals(0, transport.activeReadCountForTesting()) + assertEquals(0, transport.queuedReadCountForTesting()) + assertEquals(0, relayManager.queuedEventCountForTesting()) + relayManager.completeAccountReset(resetToken) + } + + @Test + fun `stale transport reset cannot reopen admission`() { + val transport = NostrTransport( + context = RuntimeEnvironment.getApplication(), + transportScope = CoroutineScope(Dispatchers.Unconfined + Job()) + ) + val firstReset = transport.discardForAccountReset() + val secondReset = transport.discardForAccountReset() + val admissions = mutableListOf() + + assertEquals(false, transport.completeAccountReset(firstReset)) + transport.sendPrivateMessage( + content = "blocked", + to = "aa".repeat(32), + recipientNickname = "peer", + messageID = "stale-reset", + completion = admissions::add + ) + assertEquals(listOf(NostrSendAdmission.RETRYABLE), admissions) + + assertEquals(true, transport.completeAccountReset(secondReset)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/service/ShutdownGateTest.kt b/app/src/test/kotlin/com/bitchat/android/service/ShutdownGateTest.kt new file mode 100644 index 00000000..c79a17a0 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/service/ShutdownGateTest.kt @@ -0,0 +1,24 @@ +package com.bitchat.android.service + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ShutdownGateTest { + @Test + fun `shutdown is uncommitted initially`() { + val gate = ShutdownGate() + + assertFalse(gate.isCommitted()) + } + + @Test + fun `committed shutdown is irreversible and idempotent`() { + val gate = ShutdownGate() + + assertTrue(gate.commit()) + assertFalse(gate.commit()) + + assertTrue(gate.isCommitted()) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt index a70d4fa9..1c04745e 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -172,4 +172,40 @@ class AppStateStoreTest { .deliveryStatus assertTrue(status is DeliveryStatus.Read) } + + @Test + fun `local terminal failure replaces Sending but not admitted or delivered states`() { + val statuses = listOf( + DeliveryStatus.Sending, + DeliveryStatus.Sent, + DeliveryStatus.Delivered("peer-a", Date(2)), + DeliveryStatus.Read("peer-a", Date(3)) + ) + statuses.forEachIndexed { index, status -> + AppStateStore.addPrivateMessage( + "peer-a", + BitchatMessage( + id = "status-$index", + sender = "bob", + content = "hello", + timestamp = Date(1), + isPrivate = true, + deliveryStatus = status + ) + ) + AppStateStore.updatePrivateMessageStatus( + "status-$index", + DeliveryStatus.Failed("local terminal failure") + ) + } + + val resulting = AppStateStore.privateMessages.value + .values + .flatten() + .associate { it.id to it.deliveryStatus } + assertTrue(resulting["status-0"] is DeliveryStatus.Failed) + assertTrue(resulting["status-1"] is DeliveryStatus.Sent) + assertTrue(resulting["status-2"] is DeliveryStatus.Delivered) + assertTrue(resulting["status-3"] is DeliveryStatus.Read) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt b/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt index 48cbe2da..624ac75a 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt @@ -5,6 +5,8 @@ import android.os.Build import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.mesh.MeshService import com.bitchat.android.mesh.PeerInfo +import com.bitchat.android.nostr.NostrSendAdmission +import com.bitchat.android.nostr.NostrTransport import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -35,9 +37,22 @@ class MessageRouterTest { private val noiseKey = ByteArray(32) { 0x0B } private lateinit var mesh: MeshService + private lateinit var nostr: NostrTransport private lateinit var router: MessageRouter + private lateinit var identityManager: SecureIdentityStateManager private var fakeTime = 1_000_000L private val expired = mutableListOf() + private val admitted = mutableListOf() + private val failed = mutableListOf() + private val pendingNostrSends = mutableListOf() + private var nostrAvailable = false + + private data class PendingNostrSend( + val content: String, + val peerID: String, + val messageID: String, + val completion: (NostrSendAdmission) -> Unit + ) @Before fun setup() { @@ -46,10 +61,11 @@ class MessageRouterTest { "message-router-test-${UUID.randomUUID()}", Context.MODE_PRIVATE ) - val identityManager = SecureIdentityStateManager(prefs, testOnly = true) + identityManager = SecureIdentityStateManager(prefs, testOnly = true) ContactDirectory.identityManagerProvider = { identityManager } mesh = mock() + nostr = mock() whenever(mesh.myPeerID).thenReturn(myPeerID) whenever(mesh.getPeerNicknames()).thenReturn(mapOf(peerID to "peer")) @@ -59,10 +75,34 @@ class MessageRouterTest { MessageRouter.resetForTesting() fakeTime = 1_000_000L expired.clear() + admitted.clear() + failed.clear() + pendingNostrSends.clear() + nostrAvailable = false - router = MessageRouter.getInstance(context, mesh) + router = MessageRouter( + context = context, + mesh = mesh, + nostr = nostr, + privateNostrSender = NostrPrivateMessageSender { + content, + target, + _, + messageID, + completion -> + pendingNostrSends += PendingNostrSend( + content = content, + peerID = target, + messageID = messageID, + completion = completion + ) + }, + canSendViaNostrOverride = { nostrAvailable } + ) router.clock = { fakeTime } router.onMessageExpired = { expired.add(it) } + router.onMessageAdmitted = { admitted.add(it) } + router.onMessageFailed = { messageID, _ -> failed.add(messageID) } } @After @@ -174,6 +214,274 @@ class MessageRouterTest { verify(mesh, never()).initiateNoiseHandshake(any()) } + @Test + fun `retryable Nostr refusal remains queued until exactly one durable admission`() { + peerOffline() + nostrAvailable = true + + assertEquals( + MessageRouter.RouteResult.NOSTR_PENDING, + router.sendPrivate("hello", peerID, "peer", "msg-ndr") + ) + assertEquals(listOf("msg-ndr"), pendingNostrSends.map { it.messageID }) + assertTrue(admitted.isEmpty()) + + pendingNostrSends.single().completion(NostrSendAdmission.RETRYABLE) + router.tickOutbox() + assertEquals(listOf("msg-ndr", "msg-ndr"), pendingNostrSends.map { it.messageID }) + assertTrue(admitted.isEmpty()) + + val admittedAttempt = pendingNostrSends.last() + admittedAttempt.completion(NostrSendAdmission.ADMITTED) + admittedAttempt.completion(NostrSendAdmission.ADMITTED) + router.tickOutbox() + + assertEquals(listOf("msg-ndr"), admitted) + assertEquals(2, pendingNostrSends.size) + assertTrue(failed.isEmpty()) + verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull()) + } + + @Test + fun `one Nostr message per conversation is in flight and queued order is preserved`() { + peerOffline() + nostrAvailable = true + + router.sendPrivate("first", peerID, "peer", "msg-1") + router.sendPrivate("second", peerID, "peer", "msg-2") + router.tickOutbox() + + assertEquals(listOf("msg-1"), pendingNostrSends.map { it.messageID }) + + pendingNostrSends[0].completion(NostrSendAdmission.ADMITTED) + assertEquals(listOf("msg-1", "msg-2"), pendingNostrSends.map { it.messageID }) + + pendingNostrSends[1].completion(NostrSendAdmission.ADMITTED) + assertEquals(listOf("msg-1", "msg-2"), admitted) + } + + @Test + fun `mesh and duplicate session callbacks cannot race an in-flight Nostr copy`() { + peerOffline() + nostrAvailable = true + router.sendPrivate("hello", peerID, "peer", "msg-race") + + peerReady() + router.onSessionEstablished(peerID) + router.onSessionEstablished(peerID) + + assertEquals(1, pendingNostrSends.size) + verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull()) + + pendingNostrSends.single().completion(NostrSendAdmission.RETRYABLE) + router.onSessionEstablished(peerID) + router.onSessionEstablished(peerID) + + verify(mesh, times(1)).sendPrivateMessage("hello", peerID, "peer", "msg-race") + assertEquals(1, pendingNostrSends.size) + } + + @Test + fun `terminal Nostr failure removes the entry and reports failure once`() { + peerOffline() + nostrAvailable = true + router.sendPrivate("invalid", peerID, "peer", "msg-failed") + + val attempt = pendingNostrSends.single() + attempt.completion(NostrSendAdmission.TERMINAL_FAILED) + attempt.completion(NostrSendAdmission.TERMINAL_FAILED) + router.tickOutbox() + + assertEquals(listOf("msg-failed"), failed) + assertTrue(admitted.isEmpty()) + assertEquals(1, pendingNostrSends.size) + } + + @Test + fun `duplicate session established callbacks flush a queued mesh message once`() { + peerOffline() + router.sendPrivate("hello", peerID, "peer", "msg-once") + + peerReady() + router.onSessionEstablished(peerID) + router.onSessionEstablished(peerID) + + verify(mesh, times(1)).sendPrivateMessage("hello", peerID, "peer", "msg-once") + } + + @Test + fun `late result from an earlier Nostr attempt cannot complete its replacement`() { + peerOffline() + nostrAvailable = true + router.sendPrivate("hello", peerID, "peer", "msg-stale") + + val first = pendingNostrSends.single() + first.completion(NostrSendAdmission.RETRYABLE) + router.tickOutbox() + val second = pendingNostrSends.last() + + first.completion(NostrSendAdmission.ADMITTED) + assertTrue(admitted.isEmpty()) + assertEquals(1, router.queuedMessageCount) + assertEquals(1, router.inFlightNostrAttemptCount) + + second.completion(NostrSendAdmission.ADMITTED) + assertEquals(listOf("msg-stale"), admitted) + assertEquals(0, router.queuedMessageCount) + assertEquals(0, router.inFlightNostrAttemptCount) + } + + @Test + fun `alias convergence migrates in-flight ownership without duplicate Nostr send`() { + whenever(mesh.getPeerInfo(peerID)).thenReturn(null) + nostrAvailable = true + router.sendPrivate("hello", peerID, "peer", "msg-alias") + assertEquals(1, pendingNostrSends.size) + + identityManager.cachePeerNoiseKey( + peerID, + ContactIdentityResolver.noiseKeyHex(noiseKey) + ) + router.flushOutboxFor(peerID) + router.tickOutbox() + + assertEquals(1, pendingNostrSends.size) + pendingNostrSends.single().completion(NostrSendAdmission.ADMITTED) + assertEquals(listOf("msg-alias"), admitted) + assertEquals(0, router.queuedMessageCount) + assertEquals(0, router.inFlightNostrAttemptCount) + } + + @Test + fun `synchronous Nostr admission is safe and leaves no in-flight token`() { + peerOffline() + val synchronous = MessageRouter( + context = RuntimeEnvironment.getApplication(), + mesh = mesh, + nostr = nostr, + privateNostrSender = NostrPrivateMessageSender { + _, + _, + _, + _, + completion -> + completion(NostrSendAdmission.ADMITTED) + }, + canSendViaNostrOverride = { true } + ) + val synchronousAdmissions = mutableListOf() + synchronous.onMessageAdmitted = synchronousAdmissions::add + + synchronous.sendPrivate("first", peerID, "peer", "sync-1") + synchronous.sendPrivate("second", peerID, "peer", "sync-2") + + assertEquals(listOf("sync-1", "sync-2"), synchronousAdmissions) + assertEquals(0, synchronous.queuedMessageCount) + assertEquals(0, synchronous.inFlightNostrAttemptCount) + } + + @Test + fun `TTL and cap never evict an in-flight Nostr message`() { + peerOffline() + nostrAvailable = true + router.sendPrivate("first", peerID, "peer", "msg-0") + repeat(100) { index -> + router.sendPrivate("queued-$index", peerID, "peer", "msg-${index + 1}") + } + + assertEquals(listOf("msg-1"), expired) + assertEquals("msg-0", pendingNostrSends.single().messageID) + assertEquals(100, router.queuedMessageCount) + + fakeTime += 86_400_001L + router.tickOutbox() + + assertFalse("msg-0" in expired) + assertEquals(1, router.queuedMessageCount) + assertEquals(1, router.inFlightNostrAttemptCount) + + pendingNostrSends.single().completion(NostrSendAdmission.ADMITTED) + assertEquals(listOf("msg-0"), admitted) + assertEquals(0, router.queuedMessageCount) + } + + @Test + fun `account reset discards plaintext and ignores every late callback`() { + peerOffline() + nostrAvailable = true + router.sendPrivate("old identity", peerID, "peer", "msg-old-account") + val oldAttempt = pendingNostrSends.single() + + router.discardForAccountReset() + + assertEquals(0, router.queuedMessageCount) + assertEquals(0, router.inFlightNostrAttemptCount) + assertEquals( + MessageRouter.RouteResult.DROPPED, + router.sendPrivate( + "must not survive reset", + peerID, + "peer", + "msg-reset-window" + ) + ) + assertEquals(0, router.queuedMessageCount) + oldAttempt.completion(NostrSendAdmission.ADMITTED) + oldAttempt.completion(NostrSendAdmission.RETRYABLE) + oldAttempt.completion(NostrSendAdmission.TERMINAL_FAILED) + + assertTrue(admitted.isEmpty()) + assertEquals(listOf("msg-reset-window"), failed) + router.tickOutbox() + assertEquals(1, pendingNostrSends.size) + } + + @Test + fun `stale router reset cannot reopen a newer reset`() { + peerOffline() + val firstReset = router.discardForAccountReset() + val secondReset = router.discardForAccountReset() + + assertFalse(router.completeAccountReset(firstReset)) + assertEquals( + MessageRouter.RouteResult.DROPPED, + router.sendPrivate( + "blocked", + peerID, + "peer", + "msg-stale-reset" + ) + ) + + assertTrue(router.completeAccountReset(secondReset)) + assertEquals( + MessageRouter.RouteResult.QUEUED, + router.sendPrivate( + "fresh", + peerID, + "peer", + "msg-current-reset" + ) + ) + } + + @Test + fun `normal scheduler stop preserves queued plaintext and in-flight ownership`() { + peerOffline() + nostrAvailable = true + router.sendPrivate("keep across service stop", peerID, "peer", "msg-pause") + val attempt = pendingNostrSends.single() + + router.stopOutboxScheduler() + + assertEquals(1, router.queuedMessageCount) + assertEquals(1, router.inFlightNostrAttemptCount) + attempt.completion(NostrSendAdmission.ADMITTED) + assertEquals(listOf("msg-pause"), admitted) + assertEquals(0, router.queuedMessageCount) + assertEquals(0, router.inFlightNostrAttemptCount) + } + @Test fun `scheduler stops with the mesh service and restarts on rebind`() { MessageRouter.disableSchedulerForTesting = false diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MessageManagerDeliveryStatusTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MessageManagerDeliveryStatusTest.kt new file mode 100644 index 00000000..4c91a3c8 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/MessageManagerDeliveryStatusTest.kt @@ -0,0 +1,70 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.services.AppStateStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Date + +class MessageManagerDeliveryStatusTest { + private lateinit var state: ChatState + private lateinit var manager: MessageManager + + @Before + fun setUp() { + AppStateStore.clear() + state = ChatState(CoroutineScope(Dispatchers.Unconfined + SupervisorJob())) + manager = MessageManager(state) + } + + @After + fun tearDown() { + AppStateStore.clear() + } + + @Test + fun `local failure replaces Sending but never downgrades admitted delivery evidence`() { + val statuses = listOf( + DeliveryStatus.Sending, + DeliveryStatus.Sent, + DeliveryStatus.Delivered("peer", Date(2)), + DeliveryStatus.Read("peer", Date(3)) + ) + state.setPrivateChats( + mapOf( + "peer" to statuses.mapIndexed { index, status -> + BitchatMessage( + id = "message-$index", + sender = "me", + content = "hello", + timestamp = Date(1), + isPrivate = true, + deliveryStatus = status + ) + } + ) + ) + + statuses.indices.forEach { index -> + manager.updateMessageDeliveryStatus( + "message-$index", + DeliveryStatus.Failed("local terminal failure") + ) + } + + val resulting = state.getPrivateChatsValue() + .values + .flatten() + .associate { it.id to it.deliveryStatus } + assertTrue(resulting["message-0"] is DeliveryStatus.Failed) + assertTrue(resulting["message-1"] is DeliveryStatus.Sent) + assertTrue(resulting["message-2"] is DeliveryStatus.Delivered) + assertTrue(resulting["message-3"] is DeliveryStatus.Read) + } +}