Compare commits

..
Author SHA1 Message Date
jack acc5bdf5ec Fix: move ParsedInbound + parseInboundMessage to file scope (non-isolated) to satisfy Swift 6; update detached tasks to call free function 2025-09-05 11:07:11 +02:00
jack fcd82c28c6 Fix: Swift 6 concurrency - avoid capturing self in detached tasks; deliver parsed Nostr messages via MainActor singleton 2025-09-05 10:57:01 +02:00
jack 4e51c83914 Nostr: parse inbound messages off-main, then update state on main; BLE: debounce peer snapshot publishing to reduce churn 2025-09-04 23:52:05 +02:00
jack d9884eaf87 Compression: revert to zlib per request (compatibility) 2025-09-04 23:18:26 +02:00
jack a486644b7f Perf: reduce hot‑path overhead (logger autoclosure, zero‑copy BinaryProtocol.decode, prealloc encoders) 2025-09-04 23:17:00 +02:00
5273f13512 Fix critical routing and buffer issues\n\n- Preserve unsent items in MessageRouter outbox (no data loss)\n- Drain and bound Nostr send queue; flush on relay connect\n- Cap BLE pending write buffers per peripheral to avoid OOM\n- Enable Nostr fallback for short peer IDs via favorites mapping\n- Route favorite notifications via MessageRouter (mesh/Nostr) (#559)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-04 21:26:58 +02:00
e79bcf531b Feature/screenshot privacy warning (#541)
* Screenshot privacy: warn on location sheet; gate screenshot broadcasts to chat only\n\n- Track sheet presentation in ChatViewModel\n- Show privacy alert when screenshot taken on location channels\n- Ignore screenshots for App Info and Location sheet for broadcast\n- ContentView wires sheet onAppear/onDisappear and presents alert\n- Fix location sheet subtitle wrapping: render as single Text to avoid orphaned bullets

* Fix duplicate messages after channel switch\n\n- Dedup on per-geohash append (skip if ID exists)\n- Dedup and sort timeline by timestamp when switching into a geohash\n- Keep content/layout unchanged; add lightweight debug logs for empty rows

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-28 14:37:17 +02:00
jack 3e5043f875 Harden text formatting against Unicode range mismatches
- Use NSString.length for all NSRange regex scans
- Guard string slicing when lastEnd > match.lowerBound
- Advance lastEnd safely to avoid backtracking overlaps
- Apply to formatMessageContent, formatMessage, formatMessageAsText, parseMentions
2025-08-28 09:22:34 +01:00
jack 11cd5527ed Fix crash in formatMessageAsText: use NSString length for NSRanges and guard slicing before matches to avoid invalid ranges with multi-byte content 2025-08-28 09:18:11 +01:00
cf1bfdac6b Geohash Bookmarks + Rising‑Edge Notifications + Friendly Names (#538)
* Add geohash bookmarks: persistence, sampling integration, and UI\n\n- Add GeohashBookmarksStore with UserDefaults persistence and toggle API\n- Sample union of regional + bookmarked geohashes for activity notifications\n- LocationChannelsSheet: bookmark icons on nearby rows and a Bookmarked section\n- Header toolbar: toggle bookmark for current geohash\n- Tests: GeohashBookmarksStoreTests for normalization and persistence\n\nRationale: Bookmarked geohashes are always sampled for new activity notifications and quickly selectable from the sheet.

* Notifications: remove background 'new chats!' nudge; geohash activity only on zero→alive; mesh 'nearby' only on zero→alive\n\n- Geohash sampling: notify only when previous participant count in last 5m was zero, with 30s freshness gate\n- Suppress old sampled events after (re)subscribe\n- Remove channel inactivity nudge\n- Mesh: reset rising-edge gate immediately when peer list goes empty (strict zero→alive)

* Geohash notifications: ensure triggering message appears\n\n- On zero→alive sampling, pre-populate geoTimelines[gh] with the triggering message\n- Dedup on per-geohash timeline and UI messages by message ID to avoid duplicates after subscribe\n- Keeps participant update, block/self checks, and cooldown intact

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-28 09:10:15 +01:00
b63a595b04 Fix geohash UX: rising-edge notify, correct teleport, sort timeline (#537)
- Remove "new chats!" background nudge; notify only when a regional geohash goes from 0 participants to activity, with content preview and cooldown.\n- Do not mark teleported when selected geohash is in regional channel list; clear persisted teleport when in-region; avoid self teleported state from historical tags; deep links don’t mark teleported for regional geohashes or during cold start.\n- Sort per-geohash timelines chronologically on re-entry and trim whitespace-only content to prevent blank lines.\n- Trim inbound public content and ignore whitespace-only sends.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-28 00:08:01 +01:00
26 changed files with 1322 additions and 985 deletions
+16 -22
View File
@@ -32,6 +32,10 @@
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4C282E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */; };
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */; };
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */; };
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */; };
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; }; 049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; };
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; }; 049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; };
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; }; 049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; };
@@ -56,12 +60,6 @@
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; }; 049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B12E51F319001A566B /* NostrTransport.swift */; }; 049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B12E51F319001A566B /* NostrTransport.swift */; };
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; }; 049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
ABCD00022E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */; };
ABCD00032E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */; };
ABCD00052E5CCCC300162C4C /* NostrInboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */; };
ABCD00062E5CCCC300162C4D /* NostrInboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */; };
ABCD00082E5CCCC300162C4E /* NostrOutboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */; };
ABCD00092E5CCCC300162C4F /* NostrOutboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */; };
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; }; 0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; };
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; }; 0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; }; 10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
@@ -211,6 +209,8 @@
047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; }; 047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; };
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; }; 047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; }; 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; };
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = "<group>"; };
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = "<group>"; };
049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; }; 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; };
049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; }; 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; };
049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; }; 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; };
@@ -219,9 +219,6 @@
049BD39E2E51DBF4001A566B /* Packets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Packets.swift; sourceTree = "<group>"; }; 049BD39E2E51DBF4001A566B /* Packets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Packets.swift; sourceTree = "<group>"; };
049BD39F2E51DBF4001A566B /* PeerID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerID.swift; sourceTree = "<group>"; }; 049BD39F2E51DBF4001A566B /* PeerID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerID.swift; sourceTree = "<group>"; };
049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplicator.swift; sourceTree = "<group>"; }; 049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplicator.swift; sourceTree = "<group>"; };
ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadReceiptTracker.swift; sourceTree = "<group>"; };
ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrInboxService.swift; sourceTree = "<group>"; };
ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrOutboxService.swift; sourceTree = "<group>"; };
049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerIDResolver.swift; sourceTree = "<group>"; }; 049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerIDResolver.swift; sourceTree = "<group>"; };
049BD3AD2E51ED60001A566B /* Transport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transport.swift; sourceTree = "<group>"; }; 049BD3AD2E51ED60001A566B /* Transport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transport.swift; sourceTree = "<group>"; };
049BD3B02E51F319001A566B /* MessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRouter.swift; sourceTree = "<group>"; }; 049BD3B02E51F319001A566B /* MessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRouter.swift; sourceTree = "<group>"; };
@@ -492,6 +489,7 @@
C3D98EB3E1B455E321F519F4 /* bitchatTests */ = { C3D98EB3E1B455E321F519F4 /* bitchatTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
D69A18D27F9A565FD6041E12 /* Info.plist */, D69A18D27F9A565FD6041E12 /* Info.plist */,
047502912E547ACC0083520F /* LocationChannelsTests.swift */, 047502912E547ACC0083520F /* LocationChannelsTests.swift */,
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */, C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
@@ -528,10 +526,8 @@
D98A3186D7E4C72E35BDF7FE /* Services */ = { D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */,
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */, 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */,
ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */,
ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */,
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */, AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
047502B82E560F690083520F /* RelayController.swift */, 047502B82E560F690083520F /* RelayController.swift */,
0475028B2E54171C0083520F /* LocationChannelManager.swift */, 0475028B2E54171C0083520F /* LocationChannelManager.swift */,
@@ -754,11 +750,8 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */, 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
ABCD00022E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */, 1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
ABCD00052E5CCCC300162C4C /* NostrInboxService.swift in Sources */,
ABCD00082E5CCCC300162C4E /* NostrOutboxService.swift in Sources */,
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */, AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */, AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */, A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
@@ -786,6 +779,7 @@
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */, 0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */,
AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */, AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */,
8C1AB0F2D48207E0755DA91A /* NoiseProtocol.swift in Sources */, 8C1AB0F2D48207E0755DA91A /* NoiseProtocol.swift in Sources */,
048A4C282E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
049BD3AC2E51E38E001A566B /* PeerIDResolver.swift in Sources */, 049BD3AC2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */, D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */,
8A14ADADF5CD7A79919CB655 /* NoiseSession.swift in Sources */, 8A14ADADF5CD7A79919CB655 /* NoiseSession.swift in Sources */,
@@ -816,11 +810,8 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */, 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
ABCD00032E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */, 1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
ABCD00062E5CCCC300162C4D /* NostrInboxService.swift in Sources */,
ABCD00092E5CCCC300162C4F /* NostrOutboxService.swift in Sources */,
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */, AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */, AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */, A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
@@ -848,6 +839,7 @@
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */, 0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */, 6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */,
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */, A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */,
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */, 049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */, 9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */,
92D1CF17DF88EA298F6E5E8E /* NoiseSession.swift in Sources */, 92D1CF17DF88EA298F6E5E8E /* NoiseSession.swift in Sources */,
@@ -884,6 +876,7 @@
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */, 047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */, D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */, 047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */, 6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */, 765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */,
968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */, 968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */,
@@ -906,6 +899,7 @@
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */, 047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */, 8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */, 047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */, 3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */, BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */,
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */, EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */,
@@ -310,7 +310,7 @@ class SecureIdentityStateManager {
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] { func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
queue.sync { queue.sync {
// Defensive: ensure hex and correct length // Defensive: ensure hex and correct length
guard PeerIDResolver.isShortID(peerID) else { return [] } guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) } return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
} }
} }
+153 -88
View File
@@ -44,8 +44,15 @@ class NostrRelayManager: ObservableObject {
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
// Message queue for reliability // Message queue for reliability
private var messageQueue: [(event: NostrEvent, relayUrls: [String])] = [] // Pending sends held only for relays that are not yet connected.
private struct PendingSend {
var event: NostrEvent
var pendingRelays: Set<String>
}
private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock() private let messageQueueLock = NSLock()
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
// Exponential backoff configuration // Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
@@ -59,6 +66,8 @@ class NostrRelayManager: ObservableObject {
init() { init() {
// Initialize with default relays // Initialize with default relays
self.relays = Self.defaultRelays.map { Relay(url: $0) } self.relays = Self.defaultRelays.map { Relay(url: $0) }
// Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys
} }
/// Connect to all configured relays /// Connect to all configured relays
@@ -96,15 +105,56 @@ class NostrRelayManager: ObservableObject {
let targetRelays = relayUrls ?? Self.defaultRelays let targetRelays = relayUrls ?? Self.defaultRelays
ensureConnections(to: targetRelays) ensureConnections(to: targetRelays)
// Add to queue for reliability // Attempt immediate send to relays with active connections; queue the rest
messageQueueLock.lock() var stillPending = Set<String>()
messageQueue.append((event, targetRelays))
messageQueueLock.unlock()
// Attempt immediate send
for relayUrl in targetRelays { for relayUrl in targetRelays {
if let connection = connections[relayUrl] { if let connection = connections[relayUrl] {
sendToRelay(event: event, connection: connection, relayUrl: relayUrl) sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
} else {
stillPending.insert(relayUrl)
}
}
if !stillPending.isEmpty {
messageQueueLock.lock()
messageQueue.append(PendingSend(event: event, pendingRelays: stillPending))
messageQueueLock.unlock()
}
}
/// Try to flush any queued messages for relays that are now connected.
private func flushMessageQueue(for relayUrl: String? = nil) {
messageQueueLock.lock()
defer { messageQueueLock.unlock() }
guard !messageQueue.isEmpty else { return }
if let target = relayUrl {
// Flush only for a specific relay
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
if item.pendingRelays.contains(target), let conn = connections[target] {
sendToRelay(event: item.event, connection: conn, relayUrl: target)
item.pendingRelays.remove(target)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: i)
} else {
messageQueue[i] = item
}
}
}
} else {
// Flush for any relays that now have connections
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
for url in item.pendingRelays {
if let conn = connections[url] {
sendToRelay(event: item.event, connection: conn, relayUrl: url)
item.pendingRelays.remove(url)
}
}
if item.pendingRelays.isEmpty {
messageQueue.remove(at: i)
} else {
messageQueue[i] = item
}
} }
} }
} }
@@ -124,8 +174,6 @@ class NostrRelayManager: ObservableObject {
let req = NostrRequest.subscribe(id: id, filters: [filter]) let req = NostrRequest.subscribe(id: id, filters: [filter])
do { do {
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys // For consistent output
let message = try encoder.encode(req) let message = try encoder.encode(req)
guard let messageString = String(data: message, encoding: .utf8) else { guard let messageString = String(data: message, encoding: .utf8) else {
SecureLogger.log("❌ Failed to encode subscription request", category: SecureLogger.session, level: .error) SecureLogger.log("❌ Failed to encode subscription request", category: SecureLogger.session, level: .error)
@@ -175,7 +223,7 @@ class NostrRelayManager: ObservableObject {
messageHandlers.removeValue(forKey: id) messageHandlers.removeValue(forKey: id)
let req = NostrRequest.close(id: id) let req = NostrRequest.close(id: id)
let message = try? JSONEncoder().encode(req) let message = try? encoder.encode(req)
guard let messageData = message, guard let messageData = message,
let messageString = String(data: messageData, encoding: .utf8) else { return } let messageString = String(data: messageData, encoding: .utf8) else { return }
@@ -242,14 +290,21 @@ class NostrRelayManager: ObservableObject {
case .success(let message): case .success(let message):
switch message { switch message {
case .string(let text): case .string(let text):
Task { @MainActor in // Parse off-main to reduce UI jank, then hop back for state updates
self.handleMessage(text, from: relayUrl) Task.detached(priority: .utility) {
guard let parsed = parseInboundMessage(text) else { return }
await MainActor.run {
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
}
} }
case .data(let data): case .data(let data):
if let text = String(data: data, encoding: .utf8) { if let text = String(data: data, encoding: .utf8) {
Task { @MainActor in Task.detached(priority: .utility) {
self.handleMessage(text, from: relayUrl) guard let parsed = parseInboundMessage(text) else { return }
} await MainActor.run {
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
}
}
} }
@unknown default: @unknown default:
break break
@@ -268,79 +323,42 @@ class NostrRelayManager: ObservableObject {
} }
} }
private func handleMessage(_ message: String, from relayUrl: String) { // Parsed inbound message type (off-main)
guard let data = message.data(using: .utf8) else { return } // Note: declared at file scope below to avoid MainActor isolation inside this class
// and keep parsing off the main actor.
do { // Handle parsed message on MainActor (state updates and handlers)
// Try to decode as an array first private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
if let array = try JSONSerialization.jsonObject(with: data) as? [Any], switch parsed {
array.count >= 2, case .event(let subId, let event):
let type = array[0] as? String { if event.kind != 1059 {
SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
// Received message from relay category: SecureLogger.session, level: .debug)
switch type {
case "EVENT":
if array.count >= 3,
let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any] {
let event = try NostrEvent(from: eventDict)
// Only log non-gift-wrap events to reduce noise
if event.kind != 1059 {
SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
category: SecureLogger.session, level: .debug)
}
DispatchQueue.main.async {
// Update relay stats
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
self.relays[index].messagesReceived += 1
}
// Call handler
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
SecureLogger.log("⚠️ No handler for subscription \(subId)",
category: SecureLogger.session, level: .warning)
}
}
}
case "EOSE":
if array.count >= 2 {
// End of stored events
}
case "OK":
if array.count >= 3,
let eventId = array[1] as? String,
let success = array[2] as? Bool {
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
if success {
_ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.log("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)",
category: SecureLogger.session, level: .debug)
} else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)",
category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
}
}
case "NOTICE":
if array.count >= 2 {
// Server notice received
}
default:
break // Unknown message type
}
} }
} catch { if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
SecureLogger.log("Failed to parse Nostr message: \(error)", category: SecureLogger.session, level: .error) self.relays[index].messagesReceived += 1
}
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
SecureLogger.log("⚠️ No handler for subscription \(subId)",
category: SecureLogger.session, level: .warning)
}
case .eose:
// No-op for now
break
case .ok(let eventId, let success, let reason):
if success {
_ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.log("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)",
category: SecureLogger.session, level: .debug)
} else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)",
category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
}
case .notice:
break
} }
} }
@@ -348,8 +366,6 @@ class NostrRelayManager: ObservableObject {
let req = NostrRequest.event(event) let req = NostrRequest.event(event)
do { do {
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
let data = try encoder.encode(req) let data = try encoder.encode(req)
let message = String(data: data, encoding: .utf8) ?? "" let message = String(data: data, encoding: .utf8) ?? ""
@@ -389,6 +405,10 @@ class NostrRelayManager: ObservableObject {
} }
} }
updateConnectionStatus() updateConnectionStatus()
// If we just connected to this relay, flush any queued sends targeting it
if isConnected {
flushMessageQueue(for: url)
}
} }
private func updateConnectionStatus() { private func updateConnectionStatus() {
@@ -494,6 +514,51 @@ class NostrRelayManager: ObservableObject {
} }
} }
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
private enum ParsedInbound {
case event(subId: String, event: NostrEvent)
case ok(eventId: String, success: Bool, reason: String)
case eose(subscriptionId: String)
case notice(String)
}
// Off-main JSON parse to avoid UI jank; pure function, not actor-isolated
private func parseInboundMessage(_ message: String) -> ParsedInbound? {
guard let data = message.data(using: .utf8) else { return nil }
do {
if let array = try JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 2,
let type = array[0] as? String {
switch type {
case "EVENT":
if array.count >= 3,
let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any] {
let event = try NostrEvent(from: eventDict)
return .event(subId: subId, event: event)
}
case "EOSE":
if let subId = array[1] as? String { return .eose(subscriptionId: subId) }
case "OK":
if array.count >= 3,
let eventId = array[1] as? String,
let success = array[2] as? Bool {
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
return .ok(eventId: eventId, success: success, reason: reason)
}
case "NOTICE":
if array.count >= 2, let msg = array[1] as? String { return .notice(msg) }
default:
return nil
}
}
} catch {
// Ignore
}
return nil
}
// MARK: - Nostr Protocol Types // MARK: - Nostr Protocol Types
enum NostrRequest: Encodable { enum NostrRequest: Encodable {
+8 -19
View File
@@ -6,7 +6,6 @@
// //
import Foundation import Foundation
import CryptoKit
// MARK: - Hex Encoding/Decoding // MARK: - Hex Encoding/Decoding
@@ -36,38 +35,28 @@ extension Data {
} }
} }
// MARK: - Fingerprint Helpers
extension Data {
/// SHA-256 over data, hex-encoded (lowercase)
func sha256Fingerprint() -> String {
let hash = SHA256.hash(data: self)
return hash.map { String(format: "%02x", $0) }.joined()
}
}
// MARK: - Binary Encoding Utilities // MARK: - Binary Encoding Utilities
extension Data { extension Data {
// MARK: Writing // MARK: Writing
mutating func appendUInt8(_ value: UInt8) { @inlinable mutating func appendUInt8(_ value: UInt8) {
self.append(value) self.append(value)
} }
mutating func appendUInt16(_ value: UInt16) { @inlinable mutating func appendUInt16(_ value: UInt16) {
self.append(UInt8((value >> 8) & 0xFF)) self.append(UInt8((value >> 8) & 0xFF))
self.append(UInt8(value & 0xFF)) self.append(UInt8(value & 0xFF))
} }
mutating func appendUInt32(_ value: UInt32) { @inlinable mutating func appendUInt32(_ value: UInt32) {
self.append(UInt8((value >> 24) & 0xFF)) self.append(UInt8((value >> 24) & 0xFF))
self.append(UInt8((value >> 16) & 0xFF)) self.append(UInt8((value >> 16) & 0xFF))
self.append(UInt8((value >> 8) & 0xFF)) self.append(UInt8((value >> 8) & 0xFF))
self.append(UInt8(value & 0xFF)) self.append(UInt8(value & 0xFF))
} }
mutating func appendUInt64(_ value: UInt64) { @inlinable mutating func appendUInt64(_ value: UInt64) {
for i in (0..<8).reversed() { for i in (0..<8).reversed() {
self.append(UInt8((value >> (i * 8)) & 0xFF)) self.append(UInt8((value >> (i * 8)) & 0xFF))
} }
@@ -124,21 +113,21 @@ extension Data {
// MARK: Reading // MARK: Reading
func readUInt8(at offset: inout Int) -> UInt8? { @inlinable func readUInt8(at offset: inout Int) -> UInt8? {
guard offset >= 0 && offset < self.count else { return nil } guard offset >= 0 && offset < self.count else { return nil }
let value = self[offset] let value = self[offset]
offset += 1 offset += 1
return value return value
} }
func readUInt16(at offset: inout Int) -> UInt16? { @inlinable func readUInt16(at offset: inout Int) -> UInt16? {
guard offset + 2 <= self.count else { return nil } guard offset + 2 <= self.count else { return nil }
let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1]) let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1])
offset += 2 offset += 2
return value return value
} }
func readUInt32(at offset: inout Int) -> UInt32? { @inlinable func readUInt32(at offset: inout Int) -> UInt32? {
guard offset + 4 <= self.count else { return nil } guard offset + 4 <= self.count else { return nil }
let value = UInt32(self[offset]) << 24 | let value = UInt32(self[offset]) << 24 |
UInt32(self[offset + 1]) << 16 | UInt32(self[offset + 1]) << 16 |
@@ -148,7 +137,7 @@ extension Data {
return value return value
} }
func readUInt64(at offset: inout Int) -> UInt64? { @inlinable func readUInt64(at offset: inout Int) -> UInt64? {
guard offset + 8 <= self.count else { return nil } guard offset + 8 <= self.count else { return nil }
var value: UInt64 = 0 var value: UInt64 = 0
for i in 0..<8 { for i in 0..<8 {
+94 -119
View File
@@ -139,6 +139,11 @@ struct BinaryProtocol {
} }
// Header // Header
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
data.reserveCapacity(estimated)
data.append(packet.version) data.append(packet.version)
data.append(packet.type) data.append(packet.type)
data.append(packet.ttl) data.append(packet.ttl)
@@ -222,135 +227,105 @@ struct BinaryProtocol {
// Core decoding implementation used by decode(_:) with and without padding removal // Core decoding implementation used by decode(_:) with and without padding removal
private static func decodeCore(_ raw: Data) -> BitchatPacket? { private static func decodeCore(_ raw: Data) -> BitchatPacket? {
// Minimum size check: header + senderID // Minimum size: header + senderID
guard raw.count >= headerSize + senderIDSize else { guard raw.count >= headerSize + senderIDSize else { return nil }
return nil
}
// Convert to array for safer indexed access return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
let dataArray = Array(raw) guard let base = buf.baseAddress else { return nil }
var offset = 0 var offset = 0
func require(_ n: Int) -> Bool { offset + n <= buf.count }
// Header parsing with bounds checks // Read single byte
guard offset < dataArray.count else { return nil } func read8() -> UInt8? {
let version = dataArray[offset]; offset += 1 guard require(1) else { return nil }
let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
// Check if version is 1 (only supported version) offset += 1
guard version == 1 else { return v
return nil }
} // Read big-endian 16-bit
func read16() -> UInt16? {
guard offset < dataArray.count else { return nil } guard require(2) else { return nil }
let type = dataArray[offset]; offset += 1 let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
let v = (UInt16(p[0]) << 8) | UInt16(p[1])
guard offset < dataArray.count else { return nil } offset += 2
let ttl = dataArray[offset]; offset += 1 return v
}
// Timestamp - need 8 bytes // Copy N bytes into Data
guard offset + 8 <= dataArray.count else { return nil } func readData(_ n: Int) -> Data? {
let timestampData = Data(dataArray[offset..<offset+8]) guard require(n) else { return nil }
let timestamp = timestampData.reduce(0) { result, byte in let ptr = base.advanced(by: offset)
(result << 8) | UInt64(byte) let d = Data(bytes: ptr, count: n)
} offset += n
offset += 8 return d
// Flags
guard offset < dataArray.count else { return nil }
let flags = dataArray[offset]; offset += 1
let hasRecipient = (flags & Flags.hasRecipient) != 0
let hasSignature = (flags & Flags.hasSignature) != 0
let isCompressed = (flags & Flags.isCompressed) != 0
// Payload length - need 2 bytes
guard offset + 2 <= dataArray.count else { return nil }
let payloadLengthData = Data(dataArray[offset..<offset+2])
let payloadLength = payloadLengthData.reduce(0) { result, byte in
(result << 8) | UInt16(byte)
}
offset += 2
// Validate payloadLength is reasonable (prevent integer overflow)
guard payloadLength <= 65535 else { return nil }
// SenderID - need 8 bytes
guard offset + senderIDSize <= dataArray.count else { return nil }
let senderID = Data(dataArray[offset..<offset+senderIDSize])
offset += senderIDSize
// RecipientID if present
var recipientID: Data?
if hasRecipient {
guard offset + recipientIDSize <= dataArray.count else { return nil }
recipientID = Data(dataArray[offset..<offset+recipientIDSize])
offset += recipientIDSize
}
// Payload handling with comprehensive bounds checking
let payload: Data
if isCompressed {
// Compressed payload needs at least 2 bytes for original size
guard Int(payloadLength) >= 2 else { return nil }
// Check we have enough data for the original size prefix
guard offset + 2 <= dataArray.count else { return nil }
let originalSizeData = Data(dataArray[offset..<offset+2])
let originalSize = Int(originalSizeData.reduce(0) { result, byte in
(result << 8) | UInt16(byte)
})
offset += 2
// Validate original size is reasonable
guard originalSize >= 0 && originalSize <= 1048576 else { return nil } // Max 1MB
// Check we have enough data for the compressed payload
let compressedPayloadSize = Int(payloadLength) - 2
guard compressedPayloadSize >= 0 && offset + compressedPayloadSize <= dataArray.count else {
return nil
} }
let compressedPayload = Data(dataArray[offset..<offset+compressedPayloadSize]) // Version
offset += compressedPayloadSize guard let version = read8(), version == 1 else { return nil }
guard let type = read8() else { return nil }
guard let ttl = read8() else { return nil }
// Decompress with error handling // Timestamp 8 bytes BE
guard let decompressedPayload = CompressionUtil.decompress(compressedPayload, originalSize: originalSize) else { guard require(8) else { return nil }
return nil var ts: UInt64 = 0
for _ in 0..<8 {
guard let b = read8() else { return nil }
ts = (ts << 8) | UInt64(b)
} }
// Verify decompressed size matches expected // Flags
guard decompressedPayload.count == originalSize else { guard let flags = read8() else { return nil }
return nil let hasRecipient = (flags & Flags.hasRecipient) != 0
let hasSignature = (flags & Flags.hasSignature) != 0
let isCompressed = (flags & Flags.isCompressed) != 0
// Payload length
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
// SenderID
guard let senderID = readData(senderIDSize) else { return nil }
// Recipient
var recipientID: Data? = nil
if hasRecipient {
recipientID = readData(recipientIDSize)
if recipientID == nil { return nil }
} }
payload = decompressedPayload // Payload
} else { let payload: Data
// Uncompressed payload if isCompressed {
guard Int(payloadLength) >= 0 && offset + Int(payloadLength) <= dataArray.count else { // Need original size (2 bytes)
return nil guard let origSize16 = read16() else { return nil }
let originalSize = Int(origSize16)
guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil }
let compSize = Int(payloadLen) - 2
guard compSize >= 0, let compressed = readData(compSize) else { return nil }
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
decompressed.count == originalSize else { return nil }
payload = decompressed
} else {
guard let p = readData(Int(payloadLen)) else { return nil }
payload = p
} }
payload = Data(dataArray[offset..<offset+Int(payloadLength)])
offset += Int(payloadLength) // Signature
var signature: Data? = nil
if hasSignature {
signature = readData(signatureSize)
if signature == nil { return nil }
}
guard offset <= buf.count else { return nil }
return BitchatPacket(
type: type,
senderID: senderID,
recipientID: recipientID,
timestamp: ts,
payload: payload,
signature: signature,
ttl: ttl
)
} }
// Signature if present
var signature: Data?
if hasSignature {
guard offset + signatureSize <= dataArray.count else { return nil }
signature = Data(dataArray[offset..<offset+signatureSize])
offset += signatureSize
}
// Final validation: ensure we haven't gone past the end
guard offset <= dataArray.count else { return nil }
return BitchatPacket(
type: type,
senderID: senderID,
recipientID: recipientID,
timestamp: timestamp,
payload: payload,
signature: signature,
ttl: ttl
)
} }
} }
+24
View File
@@ -87,4 +87,28 @@ enum Geohash {
let lon = (lonInterval.0 + lonInterval.1) / 2 let lon = (lonInterval.0 + lonInterval.1) / 2
return (lat, lon) return (lat, lon)
} }
/// Decodes a geohash into its latitude and longitude bounds.
/// - Parameter geohash: Base32 geohash string.
/// - Returns: (latMin, latMax, lonMin, lonMax)
static func decodeBounds(_ geohash: String) -> (latMin: Double, latMax: Double, lonMin: Double, lonMax: Double) {
var latInterval: (Double, Double) = (-90.0, 90.0)
var lonInterval: (Double, Double) = (-180.0, 180.0)
var isEven = true
for ch in geohash.lowercased() {
guard let cd = base32Map[ch] else { continue }
for mask in [16, 8, 4, 2, 1] {
if isEven {
let mid = (lonInterval.0 + lonInterval.1) / 2
if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid }
} else {
let mid = (latInterval.0 + latInterval.1) / 2
if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid }
}
isEven.toggle()
}
}
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
}
} }
+3
View File
@@ -15,6 +15,8 @@ struct AnnouncementPacket {
func encode() -> Data? { func encode() -> Data? {
var data = Data() var data = Data()
// Reserve: TLVs for nickname (2 + n), noise key (2 + 32), signing key (2 + 32)
data.reserveCapacity(2 + min(nickname.count, 255) + 2 + noisePublicKey.count + 2 + signingPublicKey.count)
// TLV for nickname // TLV for nickname
guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil } guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil }
@@ -88,6 +90,7 @@ struct PrivateMessagePacket {
func encode() -> Data? { func encode() -> Data? {
var data = Data() var data = Data()
data.reserveCapacity(2 + min(messageID.count, 255) + 2 + min(content.count, 255))
// TLV for messageID // TLV for messageID
guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil } guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil }
+92 -39
View File
@@ -63,8 +63,9 @@ final class BLEService: NSObject {
private let messageDeduplicator = MessageDeduplicator() private let messageDeduplicator = MessageDeduplicator()
// 5. Fragment Reassembly (necessary for messages > MTU) // 5. Fragment Reassembly (necessary for messages > MTU)
private var incomingFragments: [String: [Int: Data]] = [:] private struct FragmentKey: Hashable { let sender: UInt64; let id: UInt64 }
private var fragmentMetadata: [String: (type: UInt8, total: Int, timestamp: Date)] = [:] private var incomingFragments: [FragmentKey: [Int: Data]] = [:]
private var fragmentMetadata: [FragmentKey: (type: UInt8, total: Int, timestamp: Date)] = [:]
// Backoff for peripherals that recently timed out connecting // Backoff for peripherals that recently timed out connecting
private var recentConnectTimeouts: [String: Date] = [:] // Peripheral UUID -> last timeout private var recentConnectTimeouts: [String: Date] = [:] // Peripheral UUID -> last timeout
@@ -88,6 +89,7 @@ final class BLEService: NSObject {
var myPeerID: String = "" var myPeerID: String = ""
var myNickname: String = "anon" var myNickname: String = "anon"
private let noiseService = NoiseEncryptionService() private let noiseService = NoiseEncryptionService()
private var myPeerIDData: Data = Data()
// MARK: - Advertising Privacy // MARK: - Advertising Privacy
// No Local Name by default for maximum privacy. No rotating alias. // No Local Name by default for maximum privacy. No rotating alias.
@@ -223,7 +225,30 @@ final class BLEService: NSObject {
peripheral.writeValue(data, for: characteristic, type: .withoutResponse) peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
} else { } else {
self.collectionsQueue.async(flags: .barrier) { self.collectionsQueue.async(flags: .barrier) {
self.pendingPeripheralWrites[uuid, default: []].append(data) var queue = self.pendingPeripheralWrites[uuid] ?? []
let capBytes = TransportConfig.blePendingWriteBufferCapBytes
let newSize = data.count
// If single chunk exceeds cap, drop it immediately
if newSize > capBytes {
SecureLogger.log("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)",
category: SecureLogger.session, level: .warning)
} else {
// Append and trim from the front to respect cap
var total = queue.reduce(0) { $0 + $1.count }
queue.append(data)
total += newSize
if total > capBytes {
var removedBytes = 0
while total > capBytes && !queue.isEmpty {
let removed = queue.removeFirst()
removedBytes += removed.count
total -= removed.count
}
SecureLogger.log("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B",
category: SecureLogger.session, level: .warning)
}
self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue
}
} }
} }
} }
@@ -308,6 +333,7 @@ final class BLEService: NSObject {
// Derive stable peer ID from Noise static public key fingerprint (first 8 bytes 16 hex chars) // Derive stable peer ID from Noise static public key fingerprint (first 8 bytes 16 hex chars)
let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars
self.myPeerID = String(fingerprint.prefix(16)) self.myPeerID = String(fingerprint.prefix(16))
self.myPeerIDData = Data(hexString: myPeerID) ?? Data()
// Set queue key for identification // Set queue key for identification
messageQueue.setSpecific(key: messageQueueKey, value: ()) messageQueue.setSpecific(key: messageQueueKey, value: ())
@@ -374,7 +400,7 @@ final class BLEService: NSObject {
maintenanceTimer = timer maintenanceTimer = timer
// Publish initial empty state // Publish initial empty state
publishFullPeerData() requestPeerDataPublish()
} }
func setNickname(_ nickname: String) { func setNickname(_ nickname: String) {
@@ -463,7 +489,7 @@ final class BLEService: NSObject {
// Send leave message synchronously to ensure delivery // Send leave message synchronously to ensure delivery
let leavePacket = BitchatPacket( let leavePacket = BitchatPacket(
type: MessageType.leave.rawValue, type: MessageType.leave.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: nil, recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(), payload: Data(),
@@ -512,7 +538,7 @@ final class BLEService: NSObject {
func isPeerConnected(_ peerID: String) -> Bool { func isPeerConnected(_ peerID: String) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys // Accept both 16-hex short IDs and 64-hex Noise keys
let shortID: String = { let shortID: String = {
if PeerIDResolver.isNoiseKeyHex(peerID), let key = Data(hexString: peerID) { if peerID.count == 64, let key = Data(hexString: peerID) {
return PeerIDUtils.derivePeerID(fromPublicKey: key) return PeerIDUtils.derivePeerID(fromPublicKey: key)
} }
return peerID return peerID
@@ -523,7 +549,7 @@ final class BLEService: NSObject {
func isPeerReachable(_ peerID: String) -> Bool { func isPeerReachable(_ peerID: String) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys // Accept both 16-hex short IDs and 64-hex Noise keys
let shortID: String = { let shortID: String = {
if PeerIDResolver.isNoiseKeyHex(peerID), let key = Data(hexString: peerID) { if peerID.count == 64, let key = Data(hexString: peerID) {
return PeerIDUtils.derivePeerID(fromPublicKey: key) return PeerIDUtils.derivePeerID(fromPublicKey: key)
} }
return peerID return peerID
@@ -591,7 +617,7 @@ final class BLEService: NSObject {
let encrypted = try noiseService.encrypt(payload, for: peerID) let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
@@ -643,7 +669,7 @@ final class BLEService: NSObject {
let encrypted = try noiseService.encrypt(typedPayload, for: peerID) let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
@@ -664,7 +690,8 @@ final class BLEService: NSObject {
return collectionsQueue.sync { return collectionsQueue.sync {
if let publicKey = peers[peerID]?.noisePublicKey { if let publicKey = peers[peerID]?.noisePublicKey {
// Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key) // Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key)
return publicKey.sha256Fingerprint() let hash = SHA256.hash(data: publicKey)
return hash.map { String(format: "%02x", $0) }.joined()
} }
return nil return nil
} }
@@ -799,7 +826,7 @@ final class BLEService: NSObject {
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: recipientData, recipientID: recipientData,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
@@ -853,7 +880,7 @@ final class BLEService: NSObject {
// Send handshake init // Send handshake init
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseHandshake.rawValue, type: MessageType.noiseHandshake.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: handshakeData, payload: handshakeData,
@@ -903,7 +930,7 @@ final class BLEService: NSObject {
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
@@ -1246,8 +1273,11 @@ final class BLEService: NSObject {
// Minimum header: 8 bytes ID + 2 index + 2 total + 1 type // Minimum header: 8 bytes ID + 2 index + 2 total + 1 type
guard packet.payload.count >= 13 else { return } guard packet.payload.count >= 13 else { return }
let senderHex = packet.senderID.hexEncodedString() // Compute compact fragment key (sender: 8 bytes, id: 8 bytes), big-endian
let fragmentID = packet.payload[0..<8].map { String(format: "%02x", $0) }.joined() var senderU64: UInt64 = 0
for b in packet.senderID.prefix(8) { senderU64 = (senderU64 << 8) | UInt64(b) }
var fragU64: UInt64 = 0
for b in packet.payload.prefix(8) { fragU64 = (fragU64 << 8) | UInt64(b) }
// Parse big-endian UInt16 safely without alignment assumptions // Parse big-endian UInt16 safely without alignment assumptions
let idxHi = UInt16(packet.payload[8]) let idxHi = UInt16(packet.payload[8])
let idxLo = UInt16(packet.payload[9]) let idxLo = UInt16(packet.payload[9])
@@ -1262,7 +1292,7 @@ final class BLEService: NSObject {
guard total > 0 && index >= 0 && index < total else { return } guard total > 0 && index >= 0 && index < total else { return }
// Store fragment // Store fragment
let key = "\(senderHex):\(fragmentID)" let key = FragmentKey(sender: senderU64, id: fragU64)
if incomingFragments[key] == nil { if incomingFragments[key] == nil {
// Cap in-flight assemblies to prevent memory/battery blowups // Cap in-flight assemblies to prevent memory/battery blowups
if incomingFragments.count >= maxInFlightAssemblies { if incomingFragments.count >= maxInFlightAssemblies {
@@ -1435,6 +1465,20 @@ final class BLEService: NSObject {
// Suppress announce logs to reduce noise // Suppress announce logs to reduce noise
// Precompute signature verification outside barrier to reduce contention
let existingPeerForVerify = collectionsQueue.sync { peers[peerID] }
var verifiedAnnounce = false
if packet.signature != nil {
verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey)
if !verifiedAnnounce {
SecureLogger.log("⚠️ Signature verification for announce failed \(peerID.prefix(8))", category: SecureLogger.security, level: .warning)
}
}
if let existingKey = existingPeerForVerify?.noisePublicKey, existingKey != announcement.noisePublicKey {
SecureLogger.log("⚠️ Announce key mismatch for \(peerID.prefix(8))… — keeping unverified", category: SecureLogger.security, level: .warning)
verifiedAnnounce = false
}
// Track if this is a new or reconnected peer // Track if this is a new or reconnected peer
var isNewPeer = false var isNewPeer = false
var isReconnectedPeer = false var isReconnectedPeer = false
@@ -1459,21 +1503,8 @@ final class BLEService: NSObject {
isNewPeer = (existingPeer == nil) isNewPeer = (existingPeer == nil)
isReconnectedPeer = wasDisconnected isReconnectedPeer = wasDisconnected
// Verify packet signature using the announced signing public key // Use precomputed verification result
var verified = false let verified = verifiedAnnounce
if packet.signature != nil {
// Verify that the packet was signed by the signing private key corresponding to the announced signing public key
verified = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey)
if !verified {
SecureLogger.log("⚠️ Signature verification for announce failed \(peerID.prefix(8))", category: SecureLogger.security, level: .warning)
}
}
// If existing peer has a different noise public key, do not consider this verified
if let existing = existingPeer, let existingKey = existing.noisePublicKey, existingKey != announcement.noisePublicKey {
SecureLogger.log("⚠️ Announce key mismatch for \(peerID.prefix(8))… — keeping unverified", category: SecureLogger.security, level: .warning)
verified = false
}
// Require verified announce; ignore otherwise (no backward compatibility) // Require verified announce; ignore otherwise (no backward compatibility)
if !verified { if !verified {
@@ -1564,7 +1595,7 @@ final class BLEService: NSObject {
self.delegate?.didConnectToPeer(peerID) self.delegate?.didConnectToPeer(peerID)
} }
self.publishFullPeerData() self.requestPeerDataPublish()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
@@ -1665,7 +1696,7 @@ final class BLEService: NSObject {
// Send response // Send response
let responsePacket = BitchatPacket( let responsePacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue, type: MessageType.noiseHandshake.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: response, payload: response,
@@ -1821,7 +1852,7 @@ final class BLEService: NSObject {
// Create packet with signature using the noise private key // Create packet with signature using the noise private key
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.announce.rawValue, type: MessageType.announce.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: nil, recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
@@ -1855,7 +1886,7 @@ final class BLEService: NSObject {
let encrypted = try noiseService.encrypt(payload, for: peerID) let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
@@ -1892,7 +1923,7 @@ final class BLEService: NSObject {
let encrypted = try noiseService.encrypt(payload, for: peerID) let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
@@ -1959,6 +1990,28 @@ final class BLEService: NSObject {
} }
} }
// Debounced publish to coalesce rapid changes
private var lastPeerPublishAt: Date = .distantPast
private var peerPublishPending: Bool = false
private let peerPublishMinInterval: TimeInterval = 0.1
private func requestPeerDataPublish() {
let now = Date()
let elapsed = now.timeIntervalSince(lastPeerPublishAt)
if elapsed >= peerPublishMinInterval {
lastPeerPublishAt = now
publishFullPeerData()
} else if !peerPublishPending {
peerPublishPending = true
let delay = peerPublishMinInterval - elapsed
messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self = self else { return }
self.lastPeerPublishAt = Date()
self.peerPublishPending = false
self.publishFullPeerData()
}
}
}
// MARK: - Consolidated Maintenance // MARK: - Consolidated Maintenance
private func performMaintenance() { private func performMaintenance() {
@@ -2071,7 +2124,7 @@ final class BLEService: NSObject {
self.delegate?.didDisconnectFromPeer(peerID) self.delegate?.didDisconnectFromPeer(peerID)
} }
// Publish snapshots so UnifiedPeerService updates connection/reachability icons // Publish snapshots so UnifiedPeerService updates connection/reachability icons
self.publishFullPeerData() self.requestPeerDataPublish()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
} }
@@ -2453,7 +2506,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
if let peerID = peerID { if let peerID = peerID {
self.notifyPeerDisconnectedDebounced(peerID) self.notifyPeerDisconnectedDebounced(peerID)
} }
self.publishFullPeerData() self.requestPeerDataPublish()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
} }
@@ -2854,7 +2907,7 @@ extension BLEService: CBPeripheralManagerDelegate {
self.notifyPeerDisconnectedDebounced(peerID) self.notifyPeerDisconnectedDebounced(peerID)
// Publish snapshots so UnifiedPeerService can refresh icons promptly // Publish snapshots so UnifiedPeerService can refresh icons promptly
self.publishFullPeerData() self.requestPeerDataPublish()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
} }
@@ -181,7 +181,7 @@ class FavoritesPersistenceService: ObservableObject {
/// Falls back to scanning favorites and matching on derived peer ID. /// Falls back to scanning favorites and matching on derived peer ID.
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? { func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
// Quick sanity: peerID should be 16 hex chars (8 bytes) // Quick sanity: peerID should be 16 hex chars (8 bytes)
guard PeerIDResolver.isShortID(peerID) else { return nil } guard peerID.count == 16 else { return nil }
for (pubkey, rel) in favorites { for (pubkey, rel) in favorites {
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey) let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
if derived == peerID { return rel } if derived == peerID { return rel }
@@ -0,0 +1,227 @@
import Foundation
import Combine
#if os(iOS) || os(macOS)
import CoreLocation
#endif
/// Stores a user-maintained list of bookmarked geohash channels.
/// - Persistence: UserDefaults (JSON string array)
/// - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
final class GeohashBookmarksStore: ObservableObject {
static let shared = GeohashBookmarksStore()
@Published private(set) var bookmarks: [String] = []
@Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name
private let storeKey = "locationChannel.bookmarks"
private let namesStoreKey = "locationChannel.bookmarkNames"
private var membership: Set<String> = []
#if os(iOS) || os(macOS)
private let geocoder = CLGeocoder()
private var resolving: Set<String> = []
#endif
private init() {
load()
}
// MARK: - Public API
func isBookmarked(_ geohash: String) -> Bool {
return membership.contains(Self.normalize(geohash))
}
func toggle(_ geohash: String) {
let gh = Self.normalize(geohash)
if membership.contains(gh) {
remove(gh)
} else {
add(gh)
}
}
func add(_ geohash: String) {
let gh = Self.normalize(geohash)
guard !gh.isEmpty else { return }
guard !membership.contains(gh) else { return }
bookmarks.insert(gh, at: 0)
membership.insert(gh)
persist()
// Resolve and persist a friendly name once when added
resolveNameIfNeeded(for: gh)
}
func remove(_ geohash: String) {
let gh = Self.normalize(geohash)
guard membership.contains(gh) else { return }
if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) }
membership.remove(gh)
// Clean up stored name to avoid stale cache growth
if bookmarkNames.removeValue(forKey: gh) != nil {
persistNames()
}
persist()
}
// MARK: - Persistence
private func load() {
guard let data = UserDefaults.standard.data(forKey: storeKey) else { return }
if let arr = try? JSONDecoder().decode([String].self, from: data) {
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
var seen = Set<String>()
var list: [String] = []
for raw in arr {
let gh = Self.normalize(raw)
guard !gh.isEmpty else { continue }
if !seen.contains(gh) {
seen.insert(gh)
list.append(gh)
}
}
bookmarks = list
membership = seen
}
// Load any saved names
if let namesData = UserDefaults.standard.data(forKey: namesStoreKey),
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
bookmarkNames = dict
}
}
private func persist() {
if let data = try? JSONEncoder().encode(bookmarks) {
UserDefaults.standard.set(data, forKey: storeKey)
}
}
private func persistNames() {
if let data = try? JSONEncoder().encode(bookmarkNames) {
UserDefaults.standard.set(data, forKey: namesStoreKey)
}
}
// MARK: - Helpers
private static func normalize(_ s: String) -> String {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
return s
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.replacingOccurrences(of: "#", with: "")
.filter { allowed.contains($0) }
}
// MARK: - Name Resolution
/// Attempt to resolve and persist a friendly place name for a bookmarked geohash.
func resolveNameIfNeeded(for geohash: String) {
let gh = Self.normalize(geohash)
guard !gh.isEmpty else { return }
if bookmarkNames[gh] != nil { return }
#if os(iOS) || os(macOS)
if resolving.contains(gh) { return }
resolving.insert(gh)
// For very coarse geohashes, sample multiple points to capture multiple admin areas
if gh.count <= 2 {
let b = Geohash.decodeBounds(gh)
let pts: [CLLocation] = [
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), // center
CLLocation(latitude: b.latMin, longitude: b.lonMin),
CLLocation(latitude: b.latMin, longitude: b.lonMax),
CLLocation(latitude: b.latMax, longitude: b.lonMin),
CLLocation(latitude: b.latMax, longitude: b.lonMax)
]
resolveCompositeAdminName(geohash: gh, points: pts)
} else {
let center = Geohash.decodeCenter(gh)
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
guard let self = self else { return }
defer { self.resolving.remove(gh) }
if let pm = placemarks?.first {
let name = Self.nameForGeohashLength(gh.count, from: pm)
if let name = name, !name.isEmpty {
DispatchQueue.main.async {
self.bookmarkNames[gh] = name
self.persistNames()
}
}
}
}
}
#endif
}
#if os(iOS) || os(macOS)
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
var uniqueAdmins = OrderedSet<String>()
var idx = 0
func step() {
if idx >= points.count {
// Compose up to 2 names joined by ' and '
let finalName: String? = {
let names = uniqueAdmins.array
if names.count >= 2 { return names[0] + " and " + names[1] }
return names.first
}()
if let finalName = finalName, !finalName.isEmpty {
DispatchQueue.main.async {
self.bookmarkNames[gh] = finalName
self.persistNames()
}
}
self.resolving.remove(gh)
return
}
let loc = points[idx]
idx += 1
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
guard self != nil else { return }
if let pm = placemarks?.first {
if let admin = pm.administrativeArea, !admin.isEmpty {
uniqueAdmins.insert(admin)
} else if let country = pm.country, !country.isEmpty {
uniqueAdmins.insert(country)
}
}
// Proceed to next point
step()
}
}
step()
}
// Minimal ordered-set for stable joining
private struct OrderedSet<Element: Hashable> {
private var set: Set<Element> = []
private(set) var array: [Element] = []
mutating func insert(_ element: Element) {
if set.insert(element).inserted { array.append(element) }
}
}
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
switch len {
case 0...2:
// Prefer administrative area if available at this coarse level
return pm.administrativeArea ?? pm.country
case 3...4:
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
case 5:
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
case 6...7:
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
default:
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
}
}
#endif
#if DEBUG
/// Testing-only reset helper
func _resetForTesting() {
bookmarks.removeAll()
membership.removeAll()
bookmarkNames.removeAll()
persist()
persistNames()
}
#endif
}
+41 -9
View File
@@ -50,10 +50,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
let arr = try? JSONDecoder().decode([String].self, from: data) { let arr = try? JSONDecoder().decode([String].self, from: data) {
teleportedSet = Set(arr) teleportedSet = Set(arr)
} }
// Initialize teleported flag from persisted state if a location channel is selected // Do not eagerly mark teleported on startup; wait for location to compute regional set.
if case .location(let ch) = selectedChannel { // This avoids showing teleported for in-region channels during cold start.
teleported = teleportedSet.contains(ch.geohash)
}
let status: CLAuthorizationStatus let status: CLAuthorizationStatus
if #available(iOS 14.0, macOS 11.0, *) { if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus status = cl.authorizationStatus
@@ -61,6 +59,15 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
status = CLLocationManager.authorizationStatus() status = CLLocationManager.authorizationStatus()
} }
updatePermissionState(from: status) updatePermissionState(from: status)
// If we don't have location authorization at startup, fall back to persisted teleport state
switch status {
case .authorizedAlways, .authorizedWhenInUse, .authorized:
break // will compute from location
default:
if case .location(let ch) = selectedChannel {
teleported = teleportedSet.contains(ch.geohash)
}
}
} }
// MARK: - Public API // MARK: - Public API
@@ -129,7 +136,21 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
case .mesh: case .mesh:
self.teleported = false self.teleported = false
case .location(let ch): case .location(let ch):
self.teleported = self.teleportedSet.contains(ch.geohash) // If this geohash is in our current regional set, do NOT mark teleported.
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
if inRegional {
self.teleported = false
// Clear persisted teleport for this geohash to keep future selections clean
if self.teleportedSet.contains(ch.geohash) {
self.teleportedSet.remove(ch.geohash)
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
}
}
} else {
// Fall back to persisted mark (set by deep link or manual teleport)
self.teleported = self.teleportedSet.contains(ch.geohash)
}
} }
} }
} }
@@ -202,14 +223,25 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
} }
Task { @MainActor in Task { @MainActor in
self.availableChannels = result self.availableChannels = result
// Recompute teleported status based on persisted state OR current location vs selected channel // Recompute teleported status based on whether the selected geohash is in our regional set
switch self.selectedChannel { switch self.selectedChannel {
case .mesh: case .mesh:
self.teleported = false self.teleported = false
case .location(let ch): case .location(let ch):
let persisted = self.teleportedSet.contains(ch.geohash) // Membership check using freshly computed regional channels; avoids precision/rename drift
let currentGH = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: ch.level.precision) let inRegional = result.contains { $0.geohash == ch.geohash }
self.teleported = persisted || (currentGH != ch.geohash) if inRegional {
self.teleported = false
// Clear persisted teleport flag if present
if self.teleportedSet.contains(ch.geohash) {
self.teleportedSet.remove(ch.geohash)
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
}
}
} else {
self.teleported = true
}
} }
} }
} }
+27 -11
View File
@@ -4,12 +4,13 @@ import Foundation
@MainActor @MainActor
final class MessageRouter { final class MessageRouter {
private let mesh: Transport private let mesh: Transport
private let nostr: NostrOutboxService private let nostr: NostrTransport
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
init(mesh: Transport, nostr: NostrOutboxService) { init(mesh: Transport, nostr: NostrTransport) {
self.mesh = mesh self.mesh = mesh
self.nostr = nostr self.nostr = nostr
self.nostr.senderPeerID = mesh.myPeerID
// Observe favorites changes to learn Nostr mapping and flush queued messages // Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
@@ -45,7 +46,7 @@ final class MessageRouter {
} else if canSendViaNostr(peerID: peerID) { } else if canSendViaNostr(peerID: peerID) {
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
nostr.sendPrivate(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else { } else {
// Queue for later (when mesh connects or Nostr mapping appears) // Queue for later (when mesh connects or Nostr mapping appears)
if outbox[peerID] == nil { outbox[peerID] = [] } if outbox[peerID] == nil { outbox[peerID] = [] }
@@ -88,10 +89,19 @@ final class MessageRouter {
// MARK: - Outbox Management // MARK: - Outbox Management
private func canSendViaNostr(peerID: String) -> Bool { private func canSendViaNostr(peerID: String) -> Bool {
guard let noiseKey = Data(hexString: peerID) else { return false } // Two forms are supported:
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), // - 64-hex Noise public key (32 bytes)
fav.peerNostrPublicKey != nil { // - 16-hex short peer ID (derived from Noise pubkey)
return true if peerID.count == 64, let noiseKey = Data(hexString: peerID) {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
fav.peerNostrPublicKey != nil {
return true
}
} else if peerID.count == 16 {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
fav.peerNostrPublicKey != nil {
return true
}
} }
return false return false
} }
@@ -100,6 +110,7 @@ final class MessageRouter {
guard let queued = outbox[peerID], !queued.isEmpty else { return } guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
var remaining: [(content: String, nickname: String, messageID: String)] = []
// Prefer mesh if connected; else try Nostr if mapping exists // Prefer mesh if connected; else try Nostr if mapping exists
for (content, nickname, messageID) in queued { for (content, nickname, messageID) in queued {
if mesh.isPeerReachable(peerID) { if mesh.isPeerReachable(peerID) {
@@ -111,14 +122,19 @@ final class MessageRouter {
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else { } else {
continue // Keep unsent items queued
remaining.append((content, nickname, messageID))
} }
} }
// Remove all flushed items (remaining ones, if any, will be re-queued on next call) // Persist only items we could not send
outbox[peerID]?.removeAll() if remaining.isEmpty {
outbox.removeValue(forKey: peerID)
} else {
outbox[peerID] = remaining
}
} }
func flushAllOutbox() { func flushAllOutbox() {
for key in outbox.keys { flushOutbox(for: key) } for key in Array(outbox.keys) { flushOutbox(for: key) }
} }
} }
@@ -548,7 +548,8 @@ class NoiseEncryptionService {
} }
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String { private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
return publicKey.rawRepresentation.sha256Fingerprint() let hash = SHA256.hash(data: publicKey.rawRepresentation)
return hash.map { String(format: "%02x", $0) }.joined()
} }
// MARK: - Session Maintenance // MARK: - Session Maintenance
-90
View File
@@ -1,90 +0,0 @@
//
// NostrInboxService.swift
// bitchat
//
// Centralizes Nostr subscribe/unsubscribe and event de-duplication for inbox flows.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
@MainActor
final class NostrInboxService {
private var processedIDs: Set<String> = []
private var order: [String] = []
private let cap: Int
init(capacity: Int = TransportConfig.uiProcessedNostrEventsCap) {
self.cap = capacity
}
private func track(_ id: String) -> Bool {
if processedIDs.contains(id) { return false }
processedIDs.insert(id)
order.append(id)
if order.count > cap {
let overflow = order.count - cap
for _ in 0..<overflow {
if let oldest = order.first {
order.removeFirst()
processedIDs.remove(oldest)
}
}
}
return true
}
func reset() {
processedIDs.removeAll()
order.removeAll()
}
// Subscribe to public geohash ephemeral events (kind 20000)
@discardableResult
func subscribeGeohashEphemeral(
geohash: String,
lookbackSeconds: TimeInterval,
limit: Int,
relayCount: Int,
subID: String? = nil,
handler: @escaping (NostrEvent) -> Void
) -> String {
let id = subID ?? "geo-\(geohash)"
let filter = NostrFilter.geohashEphemeral(
geohash,
since: Date().addingTimeInterval(-lookbackSeconds),
limit: limit
)
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: relayCount)
NostrRelayManager.shared.subscribe(filter: filter, id: id, relayUrls: relays) { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
guard self.track(event.id) else { return }
handler(event)
}
return id
}
// Subscribe to gift wraps (DMs) for a given pubkey
@discardableResult
func subscribeGiftWrapsFor(
pubkeyHex: String,
lookbackSeconds: TimeInterval,
subID: String? = nil,
handler: @escaping (NostrEvent) -> Void
) -> String {
let id = subID ?? "dm-\(pubkeyHex.prefix(8))"
let filter = NostrFilter.giftWrapsFor(pubkey: pubkeyHex, since: Date().addingTimeInterval(-lookbackSeconds))
NostrRelayManager.shared.subscribe(filter: filter, id: id) { [weak self] event in
guard let self = self else { return }
guard self.track(event.id) else { return }
handler(event)
}
return id
}
func unsubscribe(id: String) {
NostrRelayManager.shared.unsubscribe(id: id)
}
}
-50
View File
@@ -1,50 +0,0 @@
//
// NostrOutboxService.swift
// bitchat
//
// Centralized Nostr outbox for sending PMs and ACKs.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
@MainActor
final class NostrOutboxService {
private let transport: NostrTransport
init(meshService: Transport) {
let t = NostrTransport()
t.senderPeerID = meshService.myPeerID
self.transport = t
}
func sendGeohashPM(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
transport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: identity, messageID: messageID)
}
func sendGeohashDeliveredAck(messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
transport.sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity)
}
func sendGeohashReadAck(messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
transport.sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity)
}
// MARK: - Generic outbox via favorites mapping
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
transport.sendReadReceipt(receipt, to: peerID)
}
func sendDeliveryAck(for messageID: String, to peerID: String) {
transport.sendDeliveryAck(for: messageID, to: peerID)
}
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
}
}
+1 -1
View File
@@ -167,7 +167,7 @@ final class NostrTransport: Transport {
let npub = fav.peerNostrPublicKey { let npub = fav.peerNostrPublicKey {
return npub return npub
} }
if PeerIDResolver.isShortID(peerID), if peerID.count == 16,
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
let npub = fav.peerNostrPublicKey { let npub = fav.peerNostrPublicKey {
return npub return npub
+6 -7
View File
@@ -16,15 +16,14 @@ class PrivateChatManager: ObservableObject {
@Published var unreadMessages: Set<String> = [] @Published var unreadMessages: Set<String> = []
private var selectedPeerFingerprint: String? = nil private var selectedPeerFingerprint: String? = nil
private var readReceiptTracker: ReadReceiptTracker? var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
weak var meshService: Transport? weak var meshService: Transport?
// Route acks/receipts via MessageRouter (chooses mesh or Nostr) // Route acks/receipts via MessageRouter (chooses mesh or Nostr)
weak var messageRouter: MessageRouter? weak var messageRouter: MessageRouter?
init(meshService: Transport? = nil, readReceiptTracker: ReadReceiptTracker? = nil) { init(meshService: Transport? = nil) {
self.meshService = meshService self.meshService = meshService
self.readReceiptTracker = readReceiptTracker
} }
// Cap for messages stored per private chat // Cap for messages stored per private chat
@@ -122,7 +121,7 @@ class PrivateChatManager: ObservableObject {
unreadMessages.insert(senderPeerID) unreadMessages.insert(senderPeerID)
// Avoid notifying for messages already marked as read (dup/resubscribe cases) // Avoid notifying for messages already marked as read (dup/resubscribe cases)
if !(readReceiptTracker?.contains(message.id) ?? false) { if !sentReadReceipts.contains(message.id) {
NotificationService.shared.sendPrivateMessageNotification( NotificationService.shared.sendPrivateMessageNotification(
from: message.sender, from: message.sender,
message: message.content, message: message.content,
@@ -161,7 +160,7 @@ class PrivateChatManager: ObservableObject {
// Send read receipts for unread messages that haven't been sent yet // Send read receipts for unread messages that haven't been sent yet
if let messages = privateChats[peerID] { if let messages = privateChats[peerID] {
for message in messages { for message in messages {
if message.senderPeerID == peerID && !message.isRelay && !(readReceiptTracker?.contains(message.id) ?? false) { if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
sendReadReceipt(for: message) sendReadReceipt(for: message)
} }
} }
@@ -213,12 +212,12 @@ class PrivateChatManager: ObservableObject {
// MARK: - Private Methods // MARK: - Private Methods
private func sendReadReceipt(for message: BitchatMessage) { private func sendReadReceipt(for message: BitchatMessage) {
guard !(readReceiptTracker?.contains(message.id) ?? false), guard !sentReadReceipts.contains(message.id),
let senderPeerID = message.senderPeerID else { let senderPeerID = message.senderPeerID else {
return return
} }
readReceiptTracker?.insert(message.id) sentReadReceipts.insert(message.id)
// Create read receipt using the simplified method // Create read receipt using the simplified method
let receipt = ReadReceipt( let receipt = ReadReceipt(
-77
View File
@@ -1,77 +0,0 @@
//
// ReadReceiptTracker.swift
// bitchat
//
// Centralized tracker for sent read receipts with simple persistence.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
final class ReadReceiptTracker {
private let defaults: UserDefaults
private let key = "sentReadReceipts"
private let queue = DispatchQueue(label: "chat.bitchat.readreceipts", attributes: .concurrent)
private var set: Set<String> = []
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
if let data = defaults.data(forKey: key),
let arr = try? JSONDecoder().decode([String].self, from: data) {
self.set = Set(arr)
}
}
func contains(_ id: String) -> Bool {
queue.sync { set.contains(id) }
}
func insert(_ id: String) {
queue.async(flags: .barrier) {
if self.set.insert(id).inserted { self.persist() }
}
}
func insert<S: Sequence>(_ ids: S) where S.Element == String {
queue.async(flags: .barrier) {
var changed = false
for id in ids { changed = self.set.insert(id).inserted || changed }
if changed { self.persist() }
}
}
func remove(_ id: String) {
queue.async(flags: .barrier) {
if self.set.remove(id) != nil { self.persist() }
}
}
func removeAll() {
queue.async(flags: .barrier) {
if !self.set.isEmpty { self.set.removeAll(); self.persist() }
}
}
/// Keep only IDs present in the allow-list; useful for pruning stale entries.
func prune(toAllowedIDs allowed: Set<String>) {
queue.async(flags: .barrier) {
let newSet = self.set.intersection(allowed)
if newSet.count != self.set.count { self.set = newSet; self.persist() }
}
}
/// Snapshot current set for read-only operations (avoid long-lived copies in hot paths)
func snapshot() -> Set<String> { queue.sync { set } }
private func persist() {
let arr = Array(set)
if let data = try? JSONEncoder().encode(arr) {
defaults.set(data, forKey: key)
} else {
SecureLogger.log("❌ Failed to encode read receipts for persistence",
category: SecureLogger.session, level: .error)
}
}
}
+15 -3
View File
@@ -27,6 +27,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
private var peerIndex: [String: BitchatPeer] = [:] private var peerIndex: [String: BitchatPeer] = [:]
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
private let meshService: Transport private let meshService: Transport
weak var messageRouter: MessageRouter?
private let favoritesService = FavoritesPersistenceService.shared private let favoritesService = FavoritesPersistenceService.shared
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -330,8 +331,13 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))", SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
// Send favorite notification to the peer // Send favorite notification to the peer via router (mesh or Nostr)
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite) if let router = messageRouter {
router.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
} else {
// Fallback to mesh-only if router not yet wired
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
}
// Force update of peers to reflect the change // Force update of peers to reflect the change
updatePeers() updatePeers()
@@ -412,4 +418,10 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Helper Extensions // MARK: - Helper Extensions
// Moved sha256Fingerprint() to BinaryEncodingUtils for reuse extension Data {
func sha256Fingerprint() -> String {
// Implementation matches existing fingerprint generation in NoiseEncryptionService
let hash = SHA256.hash(data: self)
return hash.map { String(format: "%02x", $0) }.joined()
}
}
+21 -7
View File
@@ -9,6 +9,7 @@ final class MessageDeduplicator {
} }
private var entries: [Entry] = [] private var entries: [Entry] = []
private var head: Int = 0
private var lookup = Set<String>() private var lookup = Set<String>()
private let lock = NSLock() private let lock = NSLock()
private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes
@@ -28,10 +29,18 @@ final class MessageDeduplicator {
entries.append(Entry(messageID: messageID, timestamp: Date())) entries.append(Entry(messageID: messageID, timestamp: Date()))
lookup.insert(messageID) lookup.insert(messageID)
if entries.count > maxCount { // Soft-cap and advance head by a chunk to avoid O(n) shifting
let toRemove = entries.prefix(100) if (entries.count - head) > maxCount {
toRemove.forEach { lookup.remove($0.messageID) } let removeCount = min(100, entries.count - head)
entries.removeFirst(100) for i in head..<(head + removeCount) {
lookup.remove(entries[i].messageID)
}
head += removeCount
// Periodically compact to reclaim memory
if head > entries.count / 2 {
entries.removeFirst(head)
head = 0
}
} }
return false return false
@@ -61,6 +70,7 @@ final class MessageDeduplicator {
defer { lock.unlock() } defer { lock.unlock() }
entries.removeAll() entries.removeAll()
head = 0
lookup.removeAll() lookup.removeAll()
} }
@@ -78,9 +88,13 @@ final class MessageDeduplicator {
private func cleanupOldEntries() { private func cleanupOldEntries() {
let cutoff = Date().addingTimeInterval(-maxAge) let cutoff = Date().addingTimeInterval(-maxAge)
while let first = entries.first, first.timestamp < cutoff { while head < entries.count, entries[head].timestamp < cutoff {
lookup.remove(first.messageID) lookup.remove(entries[head].messageID)
entries.removeFirst() head += 1
}
if head > 0 && head > entries.count / 2 {
entries.removeFirst(head)
head = 0
} }
} }
} }
+4 -4
View File
@@ -140,11 +140,11 @@ class SecureLogger {
} }
/// Log general messages with automatic sensitive data filtering /// Log general messages with automatic sensitive data filtering
static func log(_ message: String, category: OSLog = noise, level: LogLevel = .debug, static func log(_ message: @autoclosure () -> String, category: OSLog = noise, level: LogLevel = .debug,
file: String = #file, line: Int = #line, function: String = #function) { file: String = #file, line: Int = #line, function: String = #function) {
guard shouldLog(level) else { return } guard shouldLog(level) else { return }
let location = formatLocation(file: file, line: line, function: function) let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize("\(location) \(message)") let sanitized = sanitize("\(location) \(message())")
#if DEBUG #if DEBUG
os_log("%{public}@", log: category, type: level.osLogType, sanitized) os_log("%{public}@", log: category, type: level.osLogType, sanitized)
@@ -157,10 +157,10 @@ class SecureLogger {
} }
/// Log errors with context /// Log errors with context
static func logError(_ error: Error, context: String, category: OSLog = noise, static func logError(_ error: Error, context: @autoclosure () -> String, category: OSLog = noise,
file: String = #file, line: Int = #line, function: String = #function) { file: String = #file, line: Int = #line, function: String = #function) {
let location = formatLocation(file: file, line: line, function: function) let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize(context) let sanitized = sanitize(context())
let errorDesc = sanitize(error.localizedDescription) let errorDesc = sanitize(error.localizedDescription)
#if DEBUG #if DEBUG
File diff suppressed because it is too large Load Diff
+33 -6
View File
@@ -24,6 +24,7 @@ struct ContentView: View {
@EnvironmentObject var viewModel: ChatViewModel @EnvironmentObject var viewModel: ChatViewModel
@ObservedObject private var locationManager = LocationChannelManager.shared @ObservedObject private var locationManager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@State private var messageText = "" @State private var messageText = ""
@State private var textFieldSelection: NSRange? = nil @State private var textFieldSelection: NSRange? = nil
@FocusState private var isTextFieldFocused: Bool @FocusState private var isTextFieldFocused: Bool
@@ -164,6 +165,8 @@ struct ContentView: View {
} }
.sheet(isPresented: $showAppInfo) { .sheet(isPresented: $showAppInfo) {
AppInfoView() AppInfoView()
.onAppear { viewModel.isAppInfoPresented = true }
.onDisappear { viewModel.isAppInfoPresented = false }
} }
.sheet(isPresented: Binding( .sheet(isPresented: Binding(
get: { viewModel.showingFingerprintFor != nil }, get: { viewModel.showingFingerprintFor != nil },
@@ -278,8 +281,10 @@ struct ContentView: View {
} }
}() }()
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) } let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
// Filter out empty/whitespace-only messages to avoid blank rows
let filteredItems = items.filter { !$0.message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
ForEach(items, id: \.uiID) { item in ForEach(filteredItems, id: \.uiID) { item in
let message = item.message let message = item.message
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Check if current user is mentioned // Check if current user is mentioned
@@ -459,7 +464,13 @@ struct ContentView: View {
} }
let level = levelForLength(gh.count) let level = levelForLength(gh.count)
let ch = GeohashChannel(level: level, geohash: gh) let ch = GeohashChannel(level: level, geohash: gh)
LocationChannelManager.shared.markTeleported(for: gh, true) // Do not mark teleported when opening a geohash that is in our regional set.
// If availableChannels is empty (e.g., cold start), defer marking and let
// LocationChannelManager compute teleported based on actual location.
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
LocationChannelManager.shared.markTeleported(for: gh, true)
}
LocationChannelManager.shared.select(ChannelID.location(ch)) LocationChannelManager.shared.select(ChannelID.location(ch))
} }
.onTapGesture(count: 3) { .onTapGesture(count: 3) {
@@ -1112,6 +1123,15 @@ struct ContentView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Open unread private chat") .accessibilityLabel("Open unread private chat")
} }
// Bookmark toggle for current geohash (not shown for mesh)
if case .location(let ch) = locationManager.selectedChannel {
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
.font(.system(size: 12))
}
.buttonStyle(.plain)
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
}
// Location channels button '#' // Location channels button '#'
Button(action: { showLocationChannelsSheet = true }) { Button(action: { showLocationChannelsSheet = true }) {
let badgeText: String = { let badgeText: String = {
@@ -1166,6 +1186,13 @@ struct ContentView: View {
.padding(.horizontal, 12) .padding(.horizontal, 12)
.sheet(isPresented: $showLocationChannelsSheet) { .sheet(isPresented: $showLocationChannelsSheet) {
LocationChannelsSheet(isPresented: $showLocationChannelsSheet) LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
}
.alert("heads up", isPresented: $viewModel.showScreenshotPrivacyWarning) {
Button("ok", role: .cancel) {}
} message: {
Text("screenshots of location channels will reveal your location. think before sharing publicly.")
} }
.background(backgroundColor.opacity(0.95)) .background(backgroundColor.opacity(0.95))
} }
@@ -1182,7 +1209,7 @@ struct ContentView: View {
private func privateHeaderContent(for privatePeerID: String) -> some View { private func privateHeaderContent(for privatePeerID: String) -> some View {
// Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only. // Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only.
let headerPeerID: String = { let headerPeerID: String = {
if PeerIDResolver.isNoiseKeyHex(privatePeerID) { if privatePeerID.count == 64 {
// Map stable Noise key to short ID if we know it (even if not directly connected) // Map stable Noise key to short ID if we know it (even if not directly connected)
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { return short } if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { return short }
} }
@@ -1207,14 +1234,14 @@ struct ContentView: View {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()), if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
!fav.peerNickname.isEmpty { return fav.peerNickname } !fav.peerNickname.isEmpty { return fav.peerNickname }
// Fallback: resolve from persisted social identity via fingerprint mapping // Fallback: resolve from persisted social identity via fingerprint mapping
if PeerIDResolver.isShortID(headerPeerID) { if headerPeerID.count == 16 {
let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(headerPeerID) let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
if let id = candidates.first, if let id = candidates.first,
let social = SecureIdentityStateManager.shared.getSocialIdentity(for: id.fingerprint) { let social = SecureIdentityStateManager.shared.getSocialIdentity(for: id.fingerprint) {
if let pet = social.localPetname, !pet.isEmpty { return pet } if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname } if !social.claimedNickname.isEmpty { return social.claimedNickname }
} }
} else if PeerIDResolver.isNoiseKeyHex(headerPeerID), let keyData = Data(hexString: headerPeerID) { } else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
let fp = keyData.sha256Fingerprint() let fp = keyData.sha256Fingerprint()
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) { if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
if let pet = social.localPetname, !pet.isEmpty { return pet } if let pet = social.localPetname, !pet.isEmpty { return pet }
@@ -1298,7 +1325,7 @@ struct ContentView: View {
if !privatePeerID.hasPrefix("nostr_") { if !privatePeerID.hasPrefix("nostr_") {
// Use short peer ID if available for encryption status (sessions keyed by short ID) // Use short peer ID if available for encryption status (sessions keyed by short ID)
let statusPeerID: String = { let statusPeerID: String = {
if PeerIDResolver.isNoiseKeyHex(privatePeerID), let short = viewModel.getShortIDForNoiseKey(privatePeerID) { if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
return short return short
} }
return headerPeerID return headerPeerID
+2 -2
View File
@@ -43,14 +43,14 @@ struct FingerprintView: View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
// Prefer short mesh ID for session/encryption status // Prefer short mesh ID for session/encryption status
let statusPeerID: String = { let statusPeerID: String = {
if PeerIDResolver.isNoiseKeyHex(peerID), let short = viewModel.getShortIDForNoiseKey(peerID) { return short } if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
return peerID return peerID
}() }()
// Resolve a friendly name // Resolve a friendly name
let peerNickname: String = { let peerNickname: String = {
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName } if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name } if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
if PeerIDResolver.isNoiseKeyHex(peerID), let data = Data(hexString: peerID) { if peerID.count == 64, let data = Data(hexString: peerID) {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname } if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
let fp = data.sha256Fingerprint() let fp = data.sha256Fingerprint()
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) { if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
+98 -27
View File
@@ -8,6 +8,7 @@ import AppKit
struct LocationChannelsSheet: View { struct LocationChannelsSheet: View {
@Binding var isPresented: Bool @Binding var isPresented: Bool
@ObservedObject private var manager = LocationChannelManager.shared @ObservedObject private var manager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@EnvironmentObject var viewModel: ChatViewModel @EnvironmentObject var viewModel: ChatViewModel
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@State private var customGeohash: String = "" @State private var customGeohash: String = ""
@@ -98,7 +99,7 @@ struct LocationChannelsSheet: View {
private var channelList: some View { private var channelList: some View {
List { List {
// Mesh option first // Mesh option first (no bookmark)
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) { channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
manager.select(ChannelID.mesh) manager.select(ChannelID.mesh)
isPresented = false isPresented = false
@@ -112,7 +113,21 @@ struct LocationChannelsSheet: View {
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 } let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
let subtitlePrefix = "#\(channel.geohash)\(coverage)" let subtitlePrefix = "#\(channel.geohash)\(coverage)"
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0 let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
channelRow(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, isSelected: isSelected(channel), titleBold: highlight) { channelRow(
title: geohashTitleWithCount(for: channel),
subtitlePrefix: subtitlePrefix,
subtitleName: namePart,
isSelected: isSelected(channel),
titleBold: highlight,
trailingAccessory: {
Button(action: { bookmarks.toggle(channel.geohash) }) {
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
.font(.system(size: 14))
}
.buttonStyle(.plain)
.padding(.leading, 8)
}
) {
// Selecting a suggested nearby channel is not a teleport. Persist this. // Selecting a suggested nearby channel is not a teleport. Persist this.
manager.markTeleported(for: channel.geohash, false) manager.markTeleported(for: channel.geohash, false)
manager.select(ChannelID.location(channel)) manager.select(ChannelID.location(channel))
@@ -188,6 +203,48 @@ struct LocationChannelsSheet: View {
} }
} }
// Bookmarked geohashes
if !bookmarks.bookmarks.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text("bookmarked")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
}
.listRowSeparator(.hidden)
ForEach(bookmarks.bookmarks, id: \.self) { gh in
let level = levelForLength(gh.count)
let channel = GeohashChannel(level: level, geohash: gh)
let coverage = coverageString(forPrecision: gh.count)
let subtitle = "#\(gh)\(coverage)"
let name = bookmarks.bookmarkNames[gh]
channelRow(
title: geohashHashTitleWithCount(gh),
subtitlePrefix: subtitle,
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
isSelected: isSelected(channel),
trailingAccessory: {
Button(action: { bookmarks.toggle(gh) }) {
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
.font(.system(size: 14))
}
.buttonStyle(.plain)
.padding(.leading, 8)
}
) {
// For bookmarked selection, mark teleported based on regional membership
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
if !inRegional && !manager.availableChannels.isEmpty {
manager.markTeleported(for: gh, true)
} else {
manager.markTeleported(for: gh, false)
}
manager.select(ChannelID.location(channel))
isPresented = false
}
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
}
}
// Footer action inside the list // Footer action inside the list
if manager.permissionState == LocationChannelManager.PermissionState.authorized { if manager.permissionState == LocationChannelManager.PermissionState.authorized {
Button(action: { Button(action: {
@@ -220,14 +277,24 @@ struct LocationChannelsSheet: View {
return false return false
} }
private func channelRow(title: String, subtitlePrefix: String, subtitleName: String? = nil, subtitleNameBold: Bool = false, isSelected: Bool, titleColor: Color? = nil, titleBold: Bool = false, action: @escaping () -> Void) -> some View { @ViewBuilder
Button(action: action) { private func channelRow(
HStack { title: String,
VStack(alignment: .leading) { subtitlePrefix: String,
// Render title with smaller font for trailing count in parentheses subtitleName: String? = nil,
let parts = splitTitleAndCount(title) subtitleNameBold: Bool = false,
HStack(spacing: 4) { isSelected: Bool,
Text(parts.base) titleColor: Color? = nil,
titleBold: Bool = false,
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
action: @escaping () -> Void
) -> some View {
HStack(alignment: .center, spacing: 8) {
VStack(alignment: .leading) {
// Render title with smaller font for trailing count in parentheses
let parts = splitTitleAndCount(title)
HStack(spacing: 4) {
Text(parts.base)
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.fontWeight(titleBold ? .bold : .regular) .fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? Color.primary) .foregroundColor(titleColor ?? Color.primary)
@@ -237,20 +304,17 @@ struct LocationChannelsSheet: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
HStack(spacing: 0) { let subtitleFull: String = {
Text(subtitlePrefix) if let name = subtitleName, !name.isEmpty {
.font(.system(size: 12, design: .monospaced)) return subtitlePrefix + "" + name
.foregroundColor(.secondary)
if let name = subtitleName {
Text("")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Text(name)
.font(.system(size: 12, design: .monospaced))
.fontWeight(subtitleNameBold ? .bold : .regular)
.foregroundColor(.secondary)
}
} }
return subtitlePrefix
}()
Text(subtitleFull)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
} }
Spacer() Spacer()
if isSelected { if isSelected {
@@ -258,11 +322,11 @@ struct LocationChannelsSheet: View {
.font(.system(size: 16, design: .monospaced)) .font(.system(size: 16, design: .monospaced))
.foregroundColor(standardGreen) .foregroundColor(standardGreen)
} }
trailingAccessory()
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle()) .contentShape(Rectangle())
} .onTapGesture(perform: action)
.buttonStyle(.plain)
} }
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]" // Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
@@ -291,12 +355,19 @@ struct LocationChannelsSheet: View {
} }
private func geohashTitleWithCount(for channel: GeohashChannel) -> String { private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
// Use ViewModel's 5-minute activity counts; may be 0 for non-selected channels // Main list: keep level labels (block/neighborhood/city/province/region)
let count = viewModel.geohashParticipantCount(for: channel.geohash) let count = viewModel.geohashParticipantCount(for: channel.geohash)
let noun = count == 1 ? "person" : "people" let noun = count == 1 ? "person" : "people"
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]" return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
} }
private func geohashHashTitleWithCount(_ geohash: String) -> String {
// Bookmarked list: show the #geohash as the main label
let count = viewModel.geohashParticipantCount(for: geohash)
let noun = count == 1 ? "person" : "people"
return "#\(geohash) [\(count) \(noun)]"
}
private func validateGeohash(_ s: String) -> Bool { private func validateGeohash(_ s: String) -> Bool {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard !s.isEmpty, s.count <= 12 else { return false } guard !s.isEmpty, s.count <= 12 else { return false }
@@ -0,0 +1,51 @@
import XCTest
@testable import bitchat
final class GeohashBookmarksStoreTests: XCTestCase {
let storeKey = "locationChannel.bookmarks"
override func setUp() {
super.setUp()
// Clear persisted state before each test
UserDefaults.standard.removeObject(forKey: storeKey)
GeohashBookmarksStore.shared._resetForTesting()
}
override func tearDown() {
// Clean after each test
UserDefaults.standard.removeObject(forKey: storeKey)
GeohashBookmarksStore.shared._resetForTesting()
super.tearDown()
}
func testToggleAndNormalize() {
let store = GeohashBookmarksStore.shared
// Start clean
XCTAssertTrue(store.bookmarks.isEmpty)
// Add with mixed case and hash prefix
store.toggle("#U4PRUY")
XCTAssertTrue(store.isBookmarked("u4pruy"))
XCTAssertEqual(store.bookmarks.first, "u4pruy")
// Toggling again removes
store.toggle("u4pruy")
XCTAssertFalse(store.isBookmarked("u4pruy"))
XCTAssertTrue(store.bookmarks.isEmpty)
}
func testPersistenceWritten() throws {
let store = GeohashBookmarksStore.shared
store.toggle("ezs42")
store.toggle("u4pruy")
// Verify persisted JSON contains both (order not enforced here)
guard let data = UserDefaults.standard.data(forKey: storeKey) else {
XCTFail("No persisted data found")
return
}
let arr = try JSONDecoder().decode([String].self, from: data)
XCTAssertTrue(arr.contains("ezs42"))
XCTAssertTrue(arr.contains("u4pruy"))
}
}