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)]" } } -