From dd6b624cae3a90f9c1113da0e6f762022e380b81 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:44:02 +0200 Subject: [PATCH] Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1 (#1418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1 Post-merge review of PRs #1400–#1417 (push-to-talk, mesh bridging, DM store-and-forward, empty-mesh liveliness, geo-notes). Fixes the confirmed, well-scoped findings; deeper architectural/security items are tracked separately. - PTT hot-mic leak: releasing the mic during VoiceCaptureSession.start()'s 150ms retry pause left the mic live and streaming for up to 120s, because cancel() no-op'd once `completed` was set. Bail after the sleep if the hold was released, and make cancel() always tear down a late-started capture. - Bridge courier depositDrop reported success and burned the dedup slot before the drop was actually published (evicted/compose-fail = lying 📦 "carried" with no retry). Only consume publishedDropKeys on durable accept; add BoundedIDSet.remove() to release evicted/failed slots (uses the dead dedupKey). - Blocked senders resurfaced via archived "heard here earlier" echoes, the one path that bypassed the live block filter — filter at seed time. - A late optimistic .sent clobbered the router's .carried state; extend ConversationStore.shouldSkipStatusUpdate to a full precedence guard (sending < sent < carried < delivered < read). - Read receipts were permanently burned when the router dropped them (marked sent then dropped). sendReadReceipt/routeReadReceipt now return Bool; only record as sent on a successful route, else retry on the next read scan. - MessageRouter.cleanupExpiredMessages() had no production caller, so DMs to a peer that never reconnects sat on .sending until relaunch — run it in the 120s bridge sweep. - Sightings tally now rolls over at midnight while idle; wave notification action localized across all 29 locales; bridged anon#tag uses suffix(4) like everything else; makeThrowawayIdentity delegates to NostrIdentity.generate(); .swiftlint.yml excludes .claude worktrees. - Add regression tests: carried→sent no-downgrade, carried→delivered upgrade, evicted pending drop stays retryable. - Bump MARKETING_VERSION to 1.7.1. Co-Authored-By: Claude Opus 4.8 * Fix CI: adjust delivery-status benchmark and drop now-dead addSystemMessage The stricter no-downgrade guard (delivered/carried never regress to sent) broke two things the earlier commit didn't catch locally (perf tests are skipped in the default run, and Periphery runs only in CI): - PerformanceBaselineTests delivery benchmarks alternated sent <-> delivered assuming both directions apply; the delivered -> sent half is now correctly skipped, so the pass measured 0 updates. Alternate two delivered timestamps instead — every update is real, no downgrade. - Routing the geoDM "not in a location channel" error into the thread removed the only caller of ChatPrivateConversationContext.addSystemMessage, leaving it (and its mock) dead per Periphery. Drop the protocol requirement, the mock impl, and the now-vacuous systemMessages.isEmpty assertions (the invariant is compile-time enforced: the context can no longer emit a public system line). Co-Authored-By: Claude Opus 4.8 * Address review findings: read-receipt dedup, carried-vs-sending, block-time echo purge - Read receipts: claim the receipt in sentReadReceipts synchronously before spawning the routing task (chat open runs two read scans in one MainActor stretch, so the async insert let every unread message route twice), and release the claim when the route fails so the retry-on-failed-route behavior is preserved. - Delivery status: extend the no-downgrade guard so the `.sending` stamp a pre-handshake resend emits can no longer clobber carried/delivered/read (the 📦 indicator survived `.sent` but not `.sending`). - Archived echoes: blocking a peer now purges their carried public messages from the gossip archive at block time (UnifiedPeerService and /block), while the fingerprint-to-peerID mapping is still known — the seed-time filter can't resolve offline non-favorite strangers and stays only as defense-in-depth. New Transport hook (default no-op) + GossipSyncManager.removePublicMessages with immediate persist. - Bridge courier: an envelope that can't encode within the drop size caps fails identically on every attempt; consume the dedup slot so the 120s retry sweep stops re-running Noise sealing on it. - MeshSightingsTracker: cache the day-key DateFormatter instead of building one per call. Tests: double-markAsRead dedup + failed-route retry, carried→sending no-downgrade matrix, block-time purge (manager + service wiring), oversize-drop slot consumption. Co-Authored-By: Claude Fable 5 * Also skip the sent→sending downgrade in the delivery-status guard Codex review follow-up: sendPrivateMessage without an established Noise session emits `.sending` asynchronously, so it can land after the message already reached `.sent` and visibly walk "Sent" back to "Sending...". Treat `.sending` as weaker than `.sent` too — the status was already truthful. `.failed` → `.sending` stays allowed so a retry after a real failure remains visible. Co-Authored-By: Claude Fable 5 * Retain Codable properties in Periphery scan (same fix as #1421) The noiseKey assign-only false positive fired persistently on this branch (twice, including a rerun) despite the baselined USR. Byte-identical to the fix on fix/announce-replay-link-steal so the branches merge cleanly in either order. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Opus 4.8 --- .periphery.baseline.json | 2 +- .periphery.yml | 7 + .swiftlint.yml | 1 + Configs/Release.xcconfig | 2 +- bitchat/App/ConversationStore.swift | 17 +- .../Features/voice/VoiceCaptureSession.swift | 17 +- bitchat/Localizable.xcstrings | 180 ++++++++++++++++++ bitchat/Nostr/NostrProtocol.swift | 2 +- bitchat/Services/BLE/BLEService.swift | 4 + bitchat/Services/CommandProcessor.swift | 3 + bitchat/Services/Gateway/BoundedIDSet.swift | 10 + .../Gateway/BridgeCourierService.swift | 64 ++++--- bitchat/Services/Gateway/BridgeService.swift | 2 +- .../Services/GeohashChatActivityTracker.swift | 6 - bitchat/Services/MeshSightingsTracker.swift | 18 +- bitchat/Services/MessageRouter.swift | 15 +- bitchat/Services/NotificationService.swift | 2 +- bitchat/Services/PrivateChatManager.swift | 18 +- bitchat/Services/Transport.swift | 5 + bitchat/Services/UnifiedPeerService.swift | 6 + bitchat/Sync/GossipSyncManager.swift | 17 ++ .../ViewModels/ChatLifecycleCoordinator.swift | 11 +- .../ChatPrivateConversationCoordinator.swift | 15 +- .../ChatViewModelBootstrapper.swift | 14 +- .../Views/Components/MeshEmptyStateView.swift | 6 +- ...ChatLifecycleCoordinatorContextTests.swift | 4 +- ...eConversationCoordinatorContextTests.swift | 12 +- .../ChatViewModelDeliveryStatusTests.swift | 99 ++++++++++ .../ChatViewModelExtensionsTests.swift | 8 +- bitchatTests/GossipSyncManagerTests.swift | 41 ++++ bitchatTests/Mocks/MockTransport.swift | 5 + .../PerformanceBaselineTests.swift | 13 +- .../Services/BridgeCourierServiceTests.swift | 42 ++++ .../Services/BridgeServiceTests.swift | 2 +- .../Services/PrivateChatManagerTests.swift | 70 +++++++ .../Services/UnifiedPeerServiceTests.swift | 9 +- .../BitFoundation/BitchatMessage.swift | 1 - .../BitLogger/Sources/SecureLogger.swift | 1 - 38 files changed, 670 insertions(+), 81 deletions(-) diff --git a/.periphery.baseline.json b/.periphery.baseline.json index 67219de2..0b826869 100644 --- a/.periphery.baseline.json +++ b/.periphery.baseline.json @@ -1 +1 @@ -{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat17PrekeyBundleStoreC12StoredBundleV8noiseKey10Foundation4DataVvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}} \ No newline at end of file +{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}} \ No newline at end of file diff --git a/.periphery.yml b/.periphery.yml index 865def40..fab8b45f 100644 --- a/.periphery.yml +++ b/.periphery.yml @@ -10,5 +10,12 @@ project: bitchat.xcodeproj schemes: - bitchat (macOS) retain_swift_ui_previews: true +# Codable properties are (de)serialized via synthesized conformances the +# indexer doesn't always attribute reads to: PrekeyBundleStore.StoredBundle +# .noiseKey flaked CI as "assign-only" even while read in loadFromDisk — +# and slipped past its baselined USR. Retaining Codable properties outright +# is deterministic; a truly-dead Codable field is a persisted-format change +# anyway, never a safe mechanical delete. +retain_codable_properties: true relative_results: true baseline: .periphery.baseline.json diff --git a/.swiftlint.yml b/.swiftlint.yml index e35a75d0..833662e9 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -2,6 +2,7 @@ # (CI checkouts are fresh, so this only matters in a working tree). excluded: - .build + - .claude - .swiftpm - .DerivedData - DerivedData diff --git a/Configs/Release.xcconfig b/Configs/Release.xcconfig index 0149960f..f018b882 100644 --- a/Configs/Release.xcconfig +++ b/Configs/Release.xcconfig @@ -1,4 +1,4 @@ -MARKETING_VERSION = 1.7.0 +MARKETING_VERSION = 1.7.1 CURRENT_PROJECT_VERSION = 1 IPHONEOS_DEPLOYMENT_TARGET = 16.0 diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift index c5195ae3..0a1b8cb8 100644 --- a/bitchat/App/ConversationStore.swift +++ b/bitchat/App/ConversationStore.swift @@ -225,8 +225,23 @@ final class Conversation: ObservableObject, Identifiable { guard let current else { return false } if current == new { return true } + // Never downgrade to a weaker delivery state. Ordering of certainty: + // sending < sent < carried < delivered < read. A late `.sent` write + // (e.g. the optimistic stamp after routing) must not clobber the + // `.carried` the router already set when it handed a copy to a + // courier/bridge, nor a `.delivered`/`.read` ack. Same for the + // `.sending` stamp a pre-handshake resend emits asynchronously: it + // can land after the message already reached `.sent`, and "Sent" was + // already truthful. (`.failed` → `.sending` stays allowed so a real + // failure retry is visible.) switch (current, new) { - case (.read, .delivered), (.read, .sent): + case (.read, .delivered), (.read, .carried), (.read, .sent), (.read, .sending): + return true + case (.delivered, .carried), (.delivered, .sent), (.delivered, .sending): + return true + case (.carried, .sent), (.carried, .sending): + return true + case (.sent, .sending): return true default: return false diff --git a/bitchat/Features/voice/VoiceCaptureSession.swift b/bitchat/Features/voice/VoiceCaptureSession.swift index 5a28b91c..556a37f3 100644 --- a/bitchat/Features/voice/VoiceCaptureSession.swift +++ b/bitchat/Features/voice/VoiceCaptureSession.swift @@ -124,6 +124,13 @@ final class PTTLiveVoiceSession: VoiceCaptureSession { // a short pause covers it (observed on iPhone field tests). SecureLogger.warning("PTT: capture start failed (\(error)) — retrying once after route settle", category: .session) try? await Task.sleep(nanoseconds: 150_000_000) + // The hold may have been released/canceled during the retry pause. + // Starting the mic now would leave it live and streaming after the + // user let go, so bail instead of opening a hot mic. + guard !completed else { + capture.cancel() + return + } try capture.start(outputURL: outputURL) } startDate = Date() @@ -156,10 +163,16 @@ final class PTTLiveVoiceSession: VoiceCaptureSession { } func cancel() async { - guard !completed else { return } + let alreadyCompleted = completed completed = true + // Always tear down the capture, even if a quick-release already marked + // us completed: the engine can start late (during start()'s retry + // pause), and only capture.cancel() stops the mic and deactivates the + // session. It is idempotent, so a redundant call is harmless. capture.cancel() - sendControlPacket(.canceled) + if !alreadyCompleted { + sendControlPacket(.canceled) + } } private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) { diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 89942914..797af0ef 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -1,6 +1,186 @@ { "sourceLanguage" : "en", "strings" : { + "notification.action.wave" : { + "comment" : "Title of the notification action button that sends a friendly wave back to a nearby person", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لوّح 👋" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "হাত নাড়ুন 👋" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "winken 👋" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "wave 👋" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "saludar 👋" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "kumaway 👋" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "saluer 👋" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לנופף 👋" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "हाथ हिलाएँ 👋" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "melambai 👋" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "saluta 👋" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "手を振る 👋" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "손 흔들기 👋" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "lambai 👋" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "हात हल्लाउनुहोस् 👋" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zwaaien 👋" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "pomachaj 👋" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "acenar 👋" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "acenar 👋" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "помахать 👋" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "vinka 👋" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "கை அசைக்கவும் 👋" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "โบกมือ 👋" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "el salla 👋" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "помахати 👋" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ہاتھ ہلائیں 👋" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "vẫy tay 👋" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "挥手 👋" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "揮手 👋" + } + } + } + }, "#%@" : { "comment" : "Non-localizable channel hashtag format used in code", "extractionState" : "manual", diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index bdcb60f5..39417883 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -325,7 +325,7 @@ struct NostrProtocol { ) throws -> NostrEvent { let tags = [ ["x", recipientTagHex], - ["expiration", String(Int(expiresAt.timeIntervalSince1970))], + ["expiration", String(Int(expiresAt.timeIntervalSince1970))] ] let event = NostrEvent( pubkey: senderIdentity.publicKeyHex, diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 19eb6a32..9b18ed77 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -1257,6 +1257,10 @@ final class BLEService: NSObject { // MARK: - Archived public messages ("heard here earlier") + func purgeArchivedPublicMessages(from peerID: PeerID) { + gossipSyncManager?.removePublicMessages(from: peerID) + } + func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) { guard let sync = gossipSyncManager else { Task { @MainActor in completion([]) } diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 9f125291..f2b007dd 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -365,6 +365,9 @@ final class CommandProcessor { ) identityManager.updateSocialIdentity(blockedIdentity) } + // Scrub their carried public messages now, while the peerID is + // resolvable, so they can't resurface as archived echoes. + meshService?.purgeArchivedPublicMessages(from: peerID) return .success(message: "blocked \(nickname). you will no longer receive messages from them") } // Mesh lookup failed; try geohash (Nostr) participant by display name diff --git a/bitchat/Services/Gateway/BoundedIDSet.swift b/bitchat/Services/Gateway/BoundedIDSet.swift index 8509c295..5cb61744 100644 --- a/bitchat/Services/Gateway/BoundedIDSet.swift +++ b/bitchat/Services/Gateway/BoundedIDSet.swift @@ -32,4 +32,14 @@ struct BoundedIDSet { } return true } + + /// Releases a previously inserted ID so it can be re-added later. Used + /// when a tentatively-recorded item is later abandoned (e.g. a queued + /// drop evicted before it was ever published) and must become retryable. + mutating func remove(_ id: String) { + guard members.remove(id) != nil else { return } + if let index = order.firstIndex(of: id) { + order.remove(at: index) + } + } } diff --git a/bitchat/Services/Gateway/BridgeCourierService.swift b/bitchat/Services/Gateway/BridgeCourierService.swift index 164249d0..8e777897 100644 --- a/bitchat/Services/Gateway/BridgeCourierService.swift +++ b/bitchat/Services/Gateway/BridgeCourierService.swift @@ -9,7 +9,6 @@ import BitFoundation import BitLogger import Foundation -import Security /// Courier delivery over the internet bridge: sealed courier envelopes are /// parked on relays as kind-1401 "drops" tagged with their rotating @@ -111,8 +110,21 @@ final class BridgeCourierService: ObservableObject { guard bridgeEnabled?() ?? false else { return false } guard !publishedDropKeys.contains(messageID) else { return false } guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else { return false } + // An envelope that can't encode within the drop size caps fails the + // same way on every attempt (size is a function of the content, not + // of the sealing); consume the dedup slot so the retry sweep stops + // re-running Noise sealing on a drop that can never ship. + guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else { + publishedDropKeys.insert(messageID) + return false + } + // Only consume the sender-side dedup slot once the drop is durably + // accepted (published, or safely queued for the next relay + // connection). If the compose fails, leave the slot open so the + // router's retry sweep can attempt a fresh deposit rather than + // marking the message "carried" and blocking retries forever. + guard publishDrop(envelope, messageID: messageID) else { return false } publishedDropKeys.insert(messageID) - publishDrop(envelope) return true } @@ -125,18 +137,27 @@ final class BridgeCourierService: ObservableObject { } } - private func publishDrop(_ envelope: CourierEnvelope) { + /// Publishes a drop, or queues it when relays are down. `messageID` is the + /// sender-side dedup key (nil for held/relayed envelopes we don't track); + /// it rides the pending queue so an evicted drop can release its slot. + /// Returns false only when the drop could not be made durable (bad + /// encode/expired/compose failure) so callers can keep it retryable. + @discardableResult + private func publishDrop(_ envelope: CourierEnvelope, messageID: String? = nil) -> Bool { guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes, - !envelope.isExpired else { return } + !envelope.isExpired else { return false } guard relaysConnected?() ?? false else { - pendingDrops.append((envelope, nil)) - if pendingDrops.count > Limits.maxPendingDrops { - pendingDrops.removeFirst(pendingDrops.count - Limits.maxPendingDrops) + pendingDrops.append((envelope, messageID)) + while pendingDrops.count > Limits.maxPendingDrops { + let evicted = pendingDrops.removeFirst() + // The oldest queued drop is being dropped before it ever + // published; release its dedup slot so it stays retryable. + if let key = evicted.dedupKey { publishedDropKeys.remove(key) } } - return + return true } - guard let identity = Self.makeThrowawayIdentity(), + guard let identity = try? NostrIdentity.generate(), let event = try? NostrProtocol.createCourierDropEvent( envelope: encoded, recipientTagHex: envelope.recipientTag.hexEncodedString(), @@ -144,10 +165,11 @@ final class BridgeCourierService: ObservableObject { senderIdentity: identity ) else { SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption) - return + return false } publishEvent?(event) SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))…", category: .session) + return true } /// Drops queued while relays were unreachable publish on reconnect. @@ -155,8 +177,10 @@ final class BridgeCourierService: ObservableObject { guard bridgeEnabled?() ?? false, relaysConnected?() ?? false, !pendingDrops.isEmpty else { return } let queued = pendingDrops pendingDrops.removeAll() - for item in queued { - publishDrop(item.envelope) + for item in queued where !publishDrop(item.envelope, messageID: item.dedupKey) { + // Compose failed with relays up: release the slot so the router's + // retry sweep can attempt a fresh deposit. + if let key = item.dedupKey { publishedDropKeys.remove(key) } } } @@ -267,18 +291,10 @@ final class BridgeCourierService: ObservableObject { // MARK: - Helpers - /// A fresh random Nostr identity for signing one drop. + /// A fresh random Nostr identity for signing one drop. Delegates to the + /// canonical generator (Schnorr key that can't fail validity) instead of + /// hand-rolling SecRandom + retry. static func makeThrowawayIdentity() -> NostrIdentity? { - for _ in 0..<10 { - var bytes = Data(count: 32) - let status = bytes.withUnsafeMutableBytes { ptr in - SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) - } - guard status == errSecSuccess else { return nil } - if let identity = try? NostrIdentity(privateKeyData: bytes) { - return identity - } - } - return nil + try? NostrIdentity.generate() } } diff --git a/bitchat/Services/Gateway/BridgeService.swift b/bitchat/Services/Gateway/BridgeService.swift index eb84944d..fed06020 100644 --- a/bitchat/Services/Gateway/BridgeService.swift +++ b/bitchat/Services/Gateway/BridgeService.swift @@ -652,7 +652,7 @@ final class BridgeService: ObservableObject { let messageID = (meshID?.trimmedOrNilIfEmpty) ?? event.id return .message(InboundBridgeMessage( messageID: messageID, - senderNickname: nickname?.trimmedOrNilIfEmpty ?? "anon#\(event.pubkey.prefix(4))", + senderNickname: nickname?.trimmedOrNilIfEmpty ?? "anon#\(event.pubkey.suffix(4))", senderPubkey: event.pubkey, content: content, timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at)) diff --git a/bitchat/Services/GeohashChatActivityTracker.swift b/bitchat/Services/GeohashChatActivityTracker.swift index 8d8f71a8..def9d59f 100644 --- a/bitchat/Services/GeohashChatActivityTracker.swift +++ b/bitchat/Services/GeohashChatActivityTracker.swift @@ -15,12 +15,6 @@ struct GeohashChatPreview: Equatable, Sendable { let senderName: String let content: String let timestamp: Date - - init(senderName: String, content: String, timestamp: Date) { - self.senderName = senderName - self.content = content - self.timestamp = timestamp - } } /// The liveliest nearby conversation, resolved against the user's regional diff --git a/bitchat/Services/MeshSightingsTracker.swift b/bitchat/Services/MeshSightingsTracker.swift index 1a5a9d80..12bd7255 100644 --- a/bitchat/Services/MeshSightingsTracker.swift +++ b/bitchat/Services/MeshSightingsTracker.swift @@ -51,6 +51,14 @@ final class MeshSightingsTracker: ObservableObject { defaults.set(Array(seenHashes), forKey: Keys.hashes) } + /// Re-evaluates the day boundary for the UI. `recordSighting` handles + /// rollover when peers are seen, but an idle app open across midnight + /// would otherwise keep showing yesterday's tally until the next sighting; + /// the empty-state view calls this on its periodic refresh tick. + func refreshForDisplay() { + rollOverIfNeeded() + } + func clear() { seenHashes.removeAll() todayCount = 0 @@ -76,8 +84,10 @@ final class MeshSightingsTracker: ObservableObject { defaults.set(today, forKey: Keys.dayKey) defaults.set(Self.randomSalt(), forKey: Keys.salt) defaults.removeObject(forKey: Keys.hashes) + defaults.removeObject(forKey: Keys.lastSeenAt) seenHashes.removeAll() todayCount = 0 + lastSightingAt = nil } private func saltedHash(_ value: String) -> String { @@ -92,12 +102,16 @@ final class MeshSightingsTracker: ObservableObject { return digest.finalize().map { String(format: "%02x", $0) }.joined() } - private static func dayKey(for date: Date) -> String { + private static let dayKeyFormatter: DateFormatter = { let formatter = DateFormatter() formatter.calendar = Calendar.current formatter.timeZone = TimeZone.current formatter.dateFormat = "yyyy-MM-dd" - return formatter.string(from: date) + return formatter + }() + + private static func dayKey(for date: Date) -> String { + dayKeyFormatter.string(from: date) } private static func randomSalt() -> Data { diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index f8474776..bfbba094 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -92,6 +92,10 @@ final class MessageRouter { bridgeSweepTask = Task { @MainActor [weak self] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + // Expire stale outbox entries in-session too — otherwise a DM + // to a peer that never reconnects sits on "sending" until the + // next relaunch instead of surfacing as failed. + self?.cleanupExpiredMessages() self?.retryBridgeCourierDeposits() } } @@ -354,13 +358,20 @@ final class MessageRouter { outboxStore?.wipe() } - func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + /// Returns true only when the receipt was handed to a reachable transport. + /// A false result means it was dropped (no route) and must NOT be recorded + /// as sent, or the sender's message would stay unread forever — the receipt + /// is retried on the next read scan (chat open / foreground / reconnect). + @discardableResult + func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool { if let transport = reachableTransport(for: peerID) { SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session) transport.sendReadReceipt(receipt, to: peerID) + return true } else if !transports.isEmpty { - SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))…", category: .session) + SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))… — leaving unsent for retry", category: .session) } + return false } func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { diff --git a/bitchat/Services/NotificationService.swift b/bitchat/Services/NotificationService.swift index cc75bcef..4b437798 100644 --- a/bitchat/Services/NotificationService.swift +++ b/bitchat/Services/NotificationService.swift @@ -153,7 +153,7 @@ final class NotificationService { private func registerCategories() { let wave = UNNotificationAction( identifier: Self.waveActionID, - title: "wave 👋", + title: String(localized: "notification.action.wave", comment: "Title of the notification action button that sends a friendly wave back to a nearby person"), options: [] ) let nearby = UNNotificationCategory( diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index 513acebb..977bdcb3 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -256,8 +256,6 @@ final class PrivateChatManager: ObservableObject { return } - sentReadReceipts.insert(message.id) - // Create read receipt using the simplified method let receipt = ReadReceipt( originalMessageID: message.id, @@ -268,11 +266,21 @@ final class PrivateChatManager: ObservableObject { // Route via MessageRouter to avoid handshakeRequired spam when session isn't established if let router = messageRouter { SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session) - Task { @MainActor in - router.sendReadReceipt(receipt, to: senderPeerID) + let messageID = message.id + // Claim the receipt synchronously so a second read scan in the + // same runloop pass (chat open triggers two) can't route a + // duplicate; release the claim on a failed route (no reachable + // transport) so a later read scan retries instead of permanently + // losing the receipt. + sentReadReceipts.insert(messageID) + Task { @MainActor [weak self] in + if !router.sendReadReceipt(receipt, to: senderPeerID) { + self?.sentReadReceipts.remove(messageID) + } } } else { - // Fallback: preserve previous behavior + // Fallback: preserve previous behavior (best-effort mesh send). + sentReadReceipts.insert(message.id) meshService?.sendReadReceipt(receipt, to: senderPeerID) } } diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 43ece9ea..35a794cb 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -216,6 +216,9 @@ protocol Transport: AnyObject { // this device is carrying for gossip sync, decoded for display as // "heard here earlier" timeline echoes. func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) + /// Drops any carried public messages from a (newly blocked) sender so + /// they can't resurface as archived echoes on a later launch. + func purgeArchivedPublicMessages(from peerID: PeerID) } /// A carried public mesh message from the store-and-forward window, decoded @@ -291,6 +294,8 @@ extension Transport { func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) { Task { @MainActor in completion([]) } } + + func purgeArchivedPublicMessages(from peerID: PeerID) {} } protocol TransportPeerEventsDelegate: AnyObject { diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index 84a4843f..f54523ec 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -275,6 +275,12 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { return nil } identityManager.setBlocked(fingerprint, isBlocked: blocked) + if blocked { + // Purge while the fingerprint↔peerID mapping is still known: the + // archived-echo seed filter can't resolve offline strangers, so + // scrub their carried messages now rather than at relaunch. + meshService.purgeArchivedPublicMessages(from: peerID) + } updatePeers() return fingerprint } diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index b619e75d..62f6394e 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -716,6 +716,23 @@ final class GossipSyncManager { } } + /// Block-time hygiene: drop the carried public messages from a blocked + /// sender and persist immediately, so nothing of theirs can resurface as + /// an archived echo on the next launch. Narrower than `removeState(for:)` + /// — the peer's announcement and in-flight fragments are untouched. + func removePublicMessages(from peerID: PeerID) { + queue.async { [weak self] in + guard let self else { return } + let countBefore = self.messages.packets.count + self.messages.remove { PeerID(hexData: $0.senderID) == peerID } + guard self.messages.packets.count != countBefore else { return } + self.archiveDirty = true + // Persist now rather than waiting for maintenance: a relaunch in + // the gap would restore the purged messages from disk. + self.persistArchiveIfDirty() + } + } + private func removeState(for peerID: PeerID) { // Deliberately keeps the peer's prekey bundle: bundles exist to reach // owners who left the mesh, and they age out on their own schedule. diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index 05831407..9b0fab0d 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -53,7 +53,8 @@ protocol ChatLifecycleContext: AnyObject { // MARK: Routing & receipts func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) - func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) + @discardableResult + func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) @@ -248,8 +249,12 @@ final class ChatLifecycleCoordinator { ? peerID : (context.unifiedPeer(for: peerID)?.peerID ?? peerID) - context.routeReadReceipt(receipt, to: recipientPeerID) - context.markReadReceiptSent(message.id) + // Only record the receipt as sent when it actually left via a + // reachable transport; a dropped receipt stays unmarked so the + // next read scan retries it instead of burning it forever. + if context.routeReadReceipt(receipt, to: recipientPeerID) { + context.markReadReceiptSent(message.id) + } } } diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index bfaaab90..0bfca931 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -83,14 +83,14 @@ protocol ChatPrivateConversationContext: AnyObject { // MARK: Routing & acknowledgements func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) - func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) + @discardableResult + func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) // MARK: System messages - func addSystemMessage(_ content: String) func addMeshOnlySystemMessage(_ content: String) /// Appends a local-only system line into a specific private thread — /// errors about a DM belong in that DM, not on the active timeline. @@ -161,7 +161,8 @@ extension ChatViewModel: ChatPrivateConversationContext { messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) } - func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + @discardableResult + func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool { messageRouter.sendReadReceipt(receipt, to: peerID) } @@ -329,8 +330,12 @@ final class ChatPrivateConversationCoordinator { func sendGeohashDM(_ content: String, to peerID: PeerID) { guard case .location(let channel) = context.activeChannel else { - context.addSystemMessage( - String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel") + // The failure happened inside a geoDM thread — surface it there, + // not on the public timeline (matches the sibling blocked/unknown + // errors routed into the thread by #1415). + context.addLocalPrivateSystemMessage( + String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel"), + to: peerID ) return } diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 4d761d8f..c331fe00 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -210,11 +210,19 @@ private extension ChatViewModelBootstrapper { DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiArchivedEchoLoadDelaySeconds) { [weak viewModel] in guard let viewModel else { return } viewModel.meshService.collectArchivedPublicMessages { [weak viewModel] allArchived in + guard let viewModel else { return } // A previous /clear dismissed everything heard up to its - // watermark; only newer archive entries come back. + // watermark; only newer archive entries come back. Blocking a + // peer purges their carried messages from the archive at + // block time (when the fingerprint↔peerID mapping is known); + // the filter here is defense-in-depth for entries that slip + // past the purge (e.g. re-synced from a nearby peer), and it + // only resolves connected peers or favorites. let clearedThrough = MeshEchoSettings.clearedThrough ?? .distantPast - let archived = allArchived.filter { $0.timestamp > clearedThrough } - guard let viewModel, !archived.isEmpty else { return } + let archived = allArchived.filter { + $0.timestamp > clearedThrough && !viewModel.isPeerBlocked($0.senderPeerID) + } + guard !archived.isEmpty else { return } // Seed only an untouched timeline: with live rows already // present (or after /clear) splicing history back in would // be wrong. diff --git a/bitchat/Views/Components/MeshEmptyStateView.swift b/bitchat/Views/Components/MeshEmptyStateView.swift index fad25b21..ef6e6219 100644 --- a/bitchat/Views/Components/MeshEmptyStateView.swift +++ b/bitchat/Views/Components/MeshEmptyStateView.swift @@ -100,7 +100,11 @@ struct MeshEmptyStateView: View { } } .frame(minHeight: compact ? 0 : fillHeight, alignment: .top) - .onReceive(refreshTimer) { _ in refreshTick += 1 } + .onReceive(refreshTimer) { _ in + refreshTick += 1 + // Roll the tally over if the local day changed while idle. + sightingsTracker.refreshForDisplay() + } } /// The radar with today's tally as its caption — the stat belongs to diff --git a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift index f8b0de66..1ed083a0 100644 --- a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift +++ b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift @@ -106,8 +106,10 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { routedPrivateMessages.append((content, peerID, recipientNickname)) } - func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + var routeReadReceiptResult = true + func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool { routedReadReceipts.append((receipt.originalMessageID, peerID)) + return routeReadReceiptResult } func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift index a92d1e9c..59516b9a 100644 --- a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift @@ -181,8 +181,10 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC routedPrivateMessages.append((content, peerID, messageID)) } - func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + var routeReadReceiptResult = true + func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool { routedReadReceipts.append((receipt.originalMessageID, peerID)) + return routeReadReceiptResult } func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { @@ -223,13 +225,8 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC } // System messages - private(set) var systemMessages: [String] = [] private(set) var meshOnlySystemMessages: [String] = [] - func addSystemMessage(_ content: String) { - systemMessages.append(content) - } - func addMeshOnlySystemMessage(_ content: String) { meshOnlySystemMessages.append(content) } @@ -657,7 +654,6 @@ struct ChatPrivateConversationCoordinatorContextTests { #expect(context.routedPrivateMessages.map(\.content) == ["hello bob"]) #expect(context.privateChats[peerID]?.first?.deliveryStatus == .sent) #expect(context.privateChats[peerID]?.first?.recipientNickname == "bob") - #expect(context.systemMessages.isEmpty) } /// Same as above, but the conversation is keyed by the SHORT mesh ID — @@ -683,7 +679,6 @@ struct ChatPrivateConversationCoordinatorContextTests { #expect(context.routedPrivateMessages.map(\.content) == ["hello again"]) #expect(context.privateChats[shortID]?.first?.deliveryStatus == .sent) #expect(context.privateChats[shortID]?.first?.recipientNickname == "bob") - #expect(context.systemMessages.isEmpty) } /// Field-found: pre-judging reachability here marked the message failed @@ -701,6 +696,5 @@ struct ChatPrivateConversationCoordinatorContextTests { #expect(context.routedPrivateMessages.map(\.content) == ["hello?"]) #expect(context.privateChats[peerID]?.first?.deliveryStatus == .sending) - #expect(context.systemMessages.isEmpty) } } diff --git a/bitchatTests/ChatViewModelDeliveryStatusTests.swift b/bitchatTests/ChatViewModelDeliveryStatusTests.swift index 7d68a3dc..f6d3d974 100644 --- a/bitchatTests/ChatViewModelDeliveryStatusTests.swift +++ b/bitchatTests/ChatViewModelDeliveryStatusTests.swift @@ -67,6 +67,105 @@ struct ChatViewModelDeliveryStatusTests { }()) } + @Test @MainActor + func deliveryStatus_noDowngrade_carriedToSent() async { + // Regression: the optimistic `.sent` stamp the send path writes after + // routing must not clobber the `.carried` the router already set when + // it handed a copy to a courier/bridge (store-and-forward), or the + // offline-favorite flow shows "sent" instead of 📦 carried. + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0102030405060708") + let messageID = "test-msg-carried" + + let message = BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Test message", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .carried + ) + viewModel.seedPrivateChat([message], for: peerID) + + viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent) + + let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus + #expect({ + if case .carried = currentStatus { return true } + return false + }()) + } + + @Test @MainActor + func deliveryStatus_noDowngrade_carriedToSending() async { + // Regression: a pre-handshake resend stamps `.sending`; it must not + // wipe the 📦 carried indicator (nor a delivered/read ack). + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0102030405060708") + let messageID = "test-msg-carried-sending" + + let message = BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Test message", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .carried + ) + viewModel.seedPrivateChat([message], for: peerID) + + viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sending) + + let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus + #expect({ + if case .carried = currentStatus { return true } + return false + }()) + + #expect(Conversation.shouldSkipStatusUpdate(current: .delivered(to: "Peer", at: Date()), new: .sending)) + #expect(Conversation.shouldSkipStatusUpdate(current: .read(by: "Peer", at: Date()), new: .sending)) + // A late async `.sending` (pre-handshake resend) must not visibly + // downgrade a truthful "Sent" either... + #expect(Conversation.shouldSkipStatusUpdate(current: .sent, new: .sending)) + // ...but a retry after a real failure stays visible. + #expect(!Conversation.shouldSkipStatusUpdate(current: .failed(reason: "no route"), new: .sending)) + } + + @Test @MainActor + func deliveryStatus_upgrade_carriedToDelivered() async { + // A delivery ack must still promote a carried message. + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0102030405060708") + let messageID = "test-msg-carried-delivered" + + let message = BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Test message", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .carried + ) + viewModel.seedPrivateChat([message], for: peerID) + + viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date())) + + let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus + #expect({ + if case .delivered = currentStatus { return true } + return false + }()) + } + @Test @MainActor func deliveryStatus_upgrade_sentToDelivered() async { let (viewModel, transport) = makeTestableViewModel() diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index dc0bdaa4..0097a368 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -761,9 +761,11 @@ struct ChatViewModelGeoDMTests { viewModel.sendGeohashDM("hello", to: convKey) - #expect(viewModel.privateChats[convKey] == nil) - #expect(viewModel.messages.count == 1) - #expect(viewModel.messages.last?.sender == "system") + // The failure is surfaced inside the geoDM thread, not on the public + // timeline (matches the sibling in-thread errors from #1415). + #expect(viewModel.messages.isEmpty) + #expect(viewModel.privateChats[convKey]?.count == 1) + #expect(viewModel.privateChats[convKey]?.last?.sender == "system") } @Test @MainActor diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index de1fa209..2d39ea88 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -91,6 +91,47 @@ struct GossipSyncManagerTests { #expect(manager._messageCount(for: PeerID(str: peerHex)) == 0) } + @Test func removePublicMessagesPurgesOnlyThatSender() throws { + // Block-time archive hygiene: purging a blocked sender's carried + // public messages must not touch other senders' messages or the + // blocked sender's announcement. + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: requestSyncManager) + let blockedHex = "00112233445566aa" + let otherHex = "00112233445566bb" + let blockedData = try #require(Data(hexString: blockedHex)) + let otherData = try #require(Data(hexString: otherHex)) + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + + manager.onPublicPacketSeen(BitchatPacket( + type: MessageType.announce.rawValue, + senderID: blockedData, + recipientID: nil, + timestamp: nowMs, + payload: Data(), + signature: nil, + ttl: 1 + )) + for (index, sender) in [blockedData, blockedData, otherData].enumerated() { + manager.onPublicPacketSeen(BitchatPacket( + type: MessageType.message.rawValue, + senderID: sender, + recipientID: nil, + timestamp: nowMs + UInt64(index), + payload: Data([UInt8(index)]), + signature: nil, + ttl: 1 + )) + } + #expect(manager._messageCount(for: PeerID(str: blockedHex)) == 2) + + manager.removePublicMessages(from: PeerID(str: blockedHex)) + + #expect(manager._messageCount(for: PeerID(str: blockedHex)) == 0) + #expect(manager._messageCount(for: PeerID(str: otherHex)) == 1) + #expect(manager._hasAnnouncement(for: PeerID(str: blockedHex))) + } + @Test func ignoresAnnounceOlderThanStaleTimeout() throws { var config = GossipSyncManager.Config() config.stalePeerTimeoutSeconds = 5 diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index 6c617249..62ced6c9 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -45,6 +45,7 @@ final class MockTransport: Transport { private(set) var emergencyDisconnectCallCount = 0 private(set) var broadcastAnnounceCallCount = 0 private(set) var triggeredHandshakes: [PeerID] = [] + private(set) var purgedArchivePeers: [PeerID] = [] // MARK: - Configurable Mock State @@ -107,6 +108,10 @@ final class MockTransport: Transport { triggeredHandshakes.append(peerID) } + func purgeArchivedPublicMessages(from peerID: PeerID) { + purgedArchivePeers.append(peerID) + } + // Noise identity wrappers backed by a mock-keychain encryption service // (mirrors the previous `getNoiseService()` placeholder behavior: a real // identity, but no peer sessions). Exposed so tests can assert against diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index 74246df2..2b038b88 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -225,8 +225,9 @@ final class PerformanceBaselineTests: XCTestCase { /// `ConversationStore`'s message-ID → conversation map: 2000 public /// (split mesh + geohash to stay under the per-conversation cap) + 50x40 /// private messages, 500 status updates per pass. Statuses alternate - /// sent <-> delivered so every call performs a real update (never the - /// skip path). + /// between two `delivered` timestamps so every call performs a real update + /// (never the skip path). A sent <-> delivered alternation would now hit + /// the store's no-downgrade guard on the delivered -> sent half. func testDeliveryStatusIncrementalUpdates() { let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40) let coordinator = ChatDeliveryCoordinator(context: context) @@ -234,12 +235,13 @@ final class PerformanceBaselineTests: XCTestCase { XCTAssertEqual(targetIDs.count, 500) let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) + let fixedDate2 = Date(timeIntervalSince1970: 1_700_000_001) var toggle = false var samples: [TimeInterval] = [] measure { toggle.toggle() - let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent + let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .delivered(to: "peer", at: fixedDate2) let start = Date() var updated = 0 for id in targetIDs where coordinator.updateMessageDeliveryStatus(id, status: status) { @@ -267,13 +269,16 @@ final class PerformanceBaselineTests: XCTestCase { let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250) XCTAssertEqual(targetIDs.count, 500) + // Alternate two delivered timestamps so every update is real; a + // sent <-> delivered swing would hit the no-downgrade guard. let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) + let fixedDate2 = Date(timeIntervalSince1970: 1_700_000_001) var toggle = false var samples: [TimeInterval] = [] measure { toggle.toggle() - let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent + let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .delivered(to: "peer", at: fixedDate2) let start = Date() var updated = 0 for id in targetIDs where store.setDeliveryStatus(status, forMessageID: id) { diff --git a/bitchatTests/Services/BridgeCourierServiceTests.swift b/bitchatTests/Services/BridgeCourierServiceTests.swift index 13bcbda0..ebd25bf1 100644 --- a/bitchatTests/Services/BridgeCourierServiceTests.swift +++ b/bitchatTests/Services/BridgeCourierServiceTests.swift @@ -133,6 +133,48 @@ struct BridgeCourierServiceTests { #expect(fixture.service.pendingDrops.isEmpty) } + @Test func evictedPendingDropStaysRetryable() { + // Regression: a drop queued while relays are down but then evicted + // (oldest-out at capacity) before it ever published must release its + // sender-side dedup slot, or the router marks it "carried" and can + // never re-deposit it. + let fixture = Fixture() + fixture.relaysConnected = false + let key = Fixture.randomKey() + fixture.sealResult = makeEnvelope(recipientKey: key) + + let firstID = UUID().uuidString + #expect(fixture.service.depositDrop(content: "0", messageID: firstID, recipientNoiseKey: key)) + // Fill past capacity so the first drop is evicted. + for i in 1...BridgeCourierService.Limits.maxPendingDrops { + fixture.service.depositDrop(content: "\(i)", messageID: UUID().uuidString, recipientNoiseKey: key) + } + #expect(fixture.service.pendingDrops.count == BridgeCourierService.Limits.maxPendingDrops) + + // The evicted first drop is deposit-able again (slot released). + #expect(fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key)) + } + + @Test func oversizeDropConsumesSlotInsteadOfChurning() { + // An envelope that encodes over the size cap fails identically on + // every attempt; the dedup slot must be consumed so the retry sweep + // doesn't re-run Noise sealing forever. + let fixture = Fixture() + let key = Fixture.randomKey() + fixture.sealResult = makeEnvelope( + recipientKey: key, + ciphertext: Data(repeating: 7, count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1) + ) + let messageID = UUID().uuidString + + #expect(!fixture.service.depositDrop(content: "big", messageID: messageID, recipientNoiseKey: key)) + #expect(fixture.publishedEvents.isEmpty) + + // The retry sweep must not seal the same payload again. + #expect(!fixture.service.depositDrop(content: "big", messageID: messageID, recipientNoiseKey: key)) + #expect(fixture.sealRequests.count == 1) + } + @Test func distinctDropsUseDistinctThrowawayKeys() { let fixture = Fixture() let keyA = Fixture.randomKey() diff --git a/bitchatTests/Services/BridgeServiceTests.swift b/bitchatTests/Services/BridgeServiceTests.swift index 0dc62552..31107310 100644 --- a/bitchatTests/Services/BridgeServiceTests.swift +++ b/bitchatTests/Services/BridgeServiceTests.swift @@ -494,7 +494,7 @@ struct BridgeServiceTests { "kind": event.kind, "tags": event.tags, "content": event.content + " (tampered)", - "sig": event.sig ?? "", + "sig": event.sig ?? "" ] let forged = try NostrEvent(from: dict) diff --git a/bitchatTests/Services/PrivateChatManagerTests.swift b/bitchatTests/Services/PrivateChatManagerTests.swift index a986570d..69b49aaf 100644 --- a/bitchatTests/Services/PrivateChatManagerTests.swift +++ b/bitchatTests/Services/PrivateChatManagerTests.swift @@ -108,6 +108,76 @@ struct PrivateChatManagerTests { #expect(transport.sentReadReceipts.first?.receipt.originalMessageID == "pm-fallback") } + @Test @MainActor + func markAsRead_calledTwiceSynchronously_routesOneReceiptPerMessage() async { + // Regression: opening a chat runs two read scans in the same + // synchronous MainActor stretch (beginPrivateChatSession and + // markPrivateMessagesAsRead). The receipt must be claimed before the + // routing task gets a chance to run, or both scans route a copy. + let transport = MockTransport() + let router = MessageRouter(transports: [transport]) + let (manager, store) = Self.makeManager(transport: transport) + manager.messageRouter = router + + let peerID = PeerID(str: "00000000000000DE") + transport.reachablePeers.insert(peerID) + + store.append( + BitchatMessage( + id: "pm-double", + sender: "Peer", + content: "Hi", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerID + ), + to: .directPeer(peerID) + ) + store.markUnread(.directPeer(peerID)) + + manager.markAsRead(from: peerID) + manager.markAsRead(from: peerID) + try? await Task.sleep(nanoseconds: 100_000_000) + + #expect(transport.sentReadReceipts.count == 1) + #expect(manager.sentReadReceipts.contains("pm-double")) + } + + @Test @MainActor + func markAsRead_failedRouteReleasesClaimForRetry() async { + // No reachable transport: the receipt is not sent, and the eager + // claim must be released so a later read scan retries. + let transport = MockTransport() + let router = MessageRouter(transports: [transport]) + let (manager, store) = Self.makeManager(transport: transport) + manager.messageRouter = router + + let peerID = PeerID(str: "00000000000000DF") + + store.append( + BitchatMessage( + id: "pm-unroutable", + sender: "Peer", + content: "Hi", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerID + ), + to: .directPeer(peerID) + ) + store.markUnread(.directPeer(peerID)) + + manager.markAsRead(from: peerID) + try? await Task.sleep(nanoseconds: 100_000_000) + + #expect(transport.sentReadReceipts.isEmpty) + #expect(!manager.sentReadReceipts.contains("pm-unroutable")) + } + @Test @MainActor func consolidateMessages_mergesStableNoiseKeyHistoryAndMarksUnread() async { let transport = MockTransport() diff --git a/bitchatTests/Services/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift index 17b30def..a81003ec 100644 --- a/bitchatTests/Services/UnifiedPeerServiceTests.swift +++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift @@ -53,17 +53,22 @@ struct UnifiedPeerServiceTests { let fingerprint = "fp-target" transport.peerFingerprints[peerID] = fingerprint - // Blocking resolves and persists by the peer's fingerprint. + // Blocking resolves and persists by the peer's fingerprint, and + // scrubs the peer's carried public messages from the gossip archive + // while the fingerprint↔peerID mapping is still known (the + // archived-echo seed filter can't resolve offline strangers). let resolved = service.setBlocked(peerID, blocked: true) #expect(resolved == fingerprint) #expect(identity.isBlocked(fingerprint: fingerprint)) #expect(service.isBlocked(peerID)) + #expect(transport.purgedArchivePeers == [peerID]) - // Unblocking clears it against the same identity. + // Unblocking clears it against the same identity, without purging. let unresolved = service.setBlocked(peerID, blocked: false) #expect(unresolved == fingerprint) #expect(!identity.isBlocked(fingerprint: fingerprint)) #expect(!service.isBlocked(peerID)) + #expect(transport.purgedArchivePeers == [peerID]) } // MARK: - Offline-favorite dedup (updatePeers phase 2) diff --git a/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift b/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift index 01cc0769..cf7039fa 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift @@ -367,4 +367,3 @@ extension BitchatMessage { Self.timestampFormatter.string(from: timestamp) } } - diff --git a/localPackages/BitLogger/Sources/SecureLogger.swift b/localPackages/BitLogger/Sources/SecureLogger.swift index e12fda0f..1a82f437 100644 --- a/localPackages/BitLogger/Sources/SecureLogger.swift +++ b/localPackages/BitLogger/Sources/SecureLogger.swift @@ -275,4 +275,3 @@ private extension SecureLogger { return "[\(timestamp)] [\(fileName):\(line) \(function)]" } } -