mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 18:45:22 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acc5bdf5ec | ||
|
|
fcd82c28c6 | ||
|
|
4e51c83914 | ||
|
|
d9884eaf87 | ||
|
|
a486644b7f | ||
|
|
5273f13512 | ||
|
|
e79bcf531b | ||
|
|
3e5043f875 | ||
|
|
11cd5527ed | ||
|
|
cf1bfdac6b | ||
|
|
b63a595b04 |
@@ -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 */; };
|
||||||
@@ -205,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>"; };
|
||||||
@@ -483,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 */,
|
||||||
@@ -519,6 +526,7 @@
|
|||||||
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */,
|
||||||
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
|
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
|
||||||
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
|
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
|
||||||
047502B82E560F690083520F /* RelayController.swift */,
|
047502B82E560F690083520F /* RelayController.swift */,
|
||||||
@@ -771,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 */,
|
||||||
@@ -830,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 */,
|
||||||
@@ -866,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 */,
|
||||||
@@ -888,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 */,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -40,23 +40,23 @@ extension Data {
|
|||||||
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))
|
||||||
}
|
}
|
||||||
@@ -113,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 |
|
||||||
@@ -137,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 {
|
||||||
@@ -220,4 +220,3 @@ extension Data {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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(),
|
||||||
@@ -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,
|
||||||
@@ -800,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,
|
||||||
@@ -854,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,
|
||||||
@@ -904,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,
|
||||||
@@ -1247,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])
|
||||||
@@ -1263,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 {
|
||||||
@@ -1436,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
|
||||||
@@ -1460,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 {
|
||||||
@@ -1565,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1666,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,
|
||||||
@@ -1822,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,
|
||||||
@@ -1856,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,
|
||||||
@@ -1893,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,
|
||||||
@@ -1960,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() {
|
||||||
@@ -2072,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2454,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2855,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -89,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
|
||||||
}
|
}
|
||||||
@@ -101,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) {
|
||||||
@@ -112,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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -383,6 +383,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
@Published var bluetoothAlertMessage = ""
|
@Published var bluetoothAlertMessage = ""
|
||||||
@Published var bluetoothState: CBManagerState = .unknown
|
@Published var bluetoothState: CBManagerState = .unknown
|
||||||
|
|
||||||
|
// Presentation state for privacy gating
|
||||||
|
@Published var isLocationChannelsSheetPresented: Bool = false
|
||||||
|
@Published var isAppInfoPresented: Bool = false
|
||||||
|
@Published var showScreenshotPrivacyWarning: Bool = false
|
||||||
|
|
||||||
// Messages are naturally ephemeral - no persistent storage
|
// Messages are naturally ephemeral - no persistent storage
|
||||||
// Persist mesh public timeline across channel switches
|
// Persist mesh public timeline across channel switches
|
||||||
private var meshTimeline: [BitchatMessage] = []
|
private var meshTimeline: [BitchatMessage] = []
|
||||||
@@ -487,6 +492,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
|
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
|
||||||
// Route receipts from PrivateChatManager through MessageRouter
|
// Route receipts from PrivateChatManager through MessageRouter
|
||||||
self.privateChatManager.messageRouter = self.messageRouter
|
self.privateChatManager.messageRouter = self.messageRouter
|
||||||
|
// Allow UnifiedPeerService to route favorite notifications via mesh/Nostr
|
||||||
|
self.unifiedPeerService.messageRouter = self.messageRouter
|
||||||
self.autocompleteService = AutocompleteService()
|
self.autocompleteService = AutocompleteService()
|
||||||
|
|
||||||
// Wire up dependencies
|
// Wire up dependencies
|
||||||
@@ -586,6 +593,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if connected {
|
if connected {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.resubscribeCurrentGeohash()
|
self.resubscribeCurrentGeohash()
|
||||||
|
// Re-init sampling for regional + bookmarked geohashes after reconnect
|
||||||
|
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||||
|
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
||||||
|
let union = Array(Set(regional).union(bookmarks))
|
||||||
|
self.beginGeohashSampling(for: union)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -611,21 +623,41 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
|
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Background: keep sampling nearby geohashes for notifications even when sheet is closed
|
// Background: keep sampling nearby geohashes + bookmarks for notifications even when sheet is closed
|
||||||
LocationChannelManager.shared.$availableChannels
|
LocationChannelManager.shared.$availableChannels
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] channels in
|
.sink { [weak self] channels in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let ghs = channels.map { $0.geohash }
|
let regional = channels.map { $0.geohash }
|
||||||
|
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
||||||
|
let union = Array(Set(regional).union(bookmarks))
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.beginGeohashSampling(for: ghs)
|
self.beginGeohashSampling(for: union)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
// Kick off initial sampling if we already have channels
|
|
||||||
if !LocationChannelManager.shared.availableChannels.isEmpty {
|
// Also observe bookmark changes to update sampling
|
||||||
let ghs = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
GeohashBookmarksStore.shared.$bookmarks
|
||||||
Task { @MainActor in self.beginGeohashSampling(for: ghs) }
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] bookmarks in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||||
|
let union = Array(Set(regional).union(bookmarks))
|
||||||
|
Task { @MainActor in
|
||||||
|
self.beginGeohashSampling(for: union)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Kick off initial sampling if we have regional channels or bookmarks
|
||||||
|
do {
|
||||||
|
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||||
|
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
||||||
|
let union = Array(Set(regional).union(bookmarks))
|
||||||
|
if !union.isEmpty {
|
||||||
|
Task { @MainActor in self.beginGeohashSampling(for: union) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Refresh channels once when authorized to seed sampling
|
// Refresh channels once when authorized to seed sampling
|
||||||
LocationChannelManager.shared.$permissionState
|
LocationChannelManager.shared.$permissionState
|
||||||
@@ -1382,6 +1414,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
switch channel {
|
switch channel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
messages = meshTimeline
|
messages = meshTimeline
|
||||||
|
// Debug: log if any empty messages are present
|
||||||
|
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
|
||||||
|
if emptyMesh > 0 {
|
||||||
|
SecureLogger.log("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: SecureLogger.session, level: .debug)
|
||||||
|
}
|
||||||
stopGeoParticipantsTimer()
|
stopGeoParticipantsTimer()
|
||||||
geohashPeople = []
|
geohashPeople = []
|
||||||
teleportedGeo.removeAll()
|
teleportedGeo.removeAll()
|
||||||
@@ -1389,13 +1426,26 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Sanitize existing timeline (filter any prior empty-content entries)
|
// Sanitize existing timeline (filter any prior empty-content entries)
|
||||||
var arr = geoTimelines[ch.geohash] ?? []
|
var arr = geoTimelines[ch.geohash] ?? []
|
||||||
arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||||
// Ensure chronological order when returning to a geohash
|
// Deduplicate by ID while preserving order (from oldest to newest)
|
||||||
if arr.count > 1 {
|
if arr.count > 1 {
|
||||||
arr.sort { $0.timestamp < $1.timestamp }
|
var seen = Set<String>()
|
||||||
|
var dedup: [BitchatMessage] = []
|
||||||
|
for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
||||||
|
if !seen.contains(m.id) {
|
||||||
|
dedup.append(m)
|
||||||
|
seen.insert(m.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
arr = dedup
|
||||||
}
|
}
|
||||||
// Persist the cleaned/sorted timeline for this geohash
|
// Persist the cleaned/sorted timeline for this geohash
|
||||||
geoTimelines[ch.geohash] = arr
|
geoTimelines[ch.geohash] = arr
|
||||||
messages = arr
|
messages = arr
|
||||||
|
// Debug: log if any empty messages are present post-sanitize
|
||||||
|
let emptyGeo = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
|
||||||
|
if emptyGeo > 0 {
|
||||||
|
SecureLogger.log("RenderGuard: geohash \(ch.geohash) timeline has \(emptyGeo) empty messages after sanitize", category: SecureLogger.session, level: .debug)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Unsubscribe previous
|
// Unsubscribe previous
|
||||||
if let sub = geoSubscriptionID {
|
if let sub = geoSubscriptionID {
|
||||||
@@ -1838,7 +1888,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
if Date().timeIntervalSince(eventTime) > 30 { return }
|
if Date().timeIntervalSince(eventTime) > 30 { return }
|
||||||
// Foreground policy: allow if it's a different geohash than the one currently open
|
// Foreground policy: allow if it's a different geohash than the one currently open
|
||||||
// Suppress only when app is active AND we're already in this same geohash channel
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
if UIApplication.shared.applicationState == .active {
|
if UIApplication.shared.applicationState == .active {
|
||||||
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
|
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
|
||||||
@@ -1861,6 +1910,30 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}()
|
}()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.lastGeoNotificationAt[gh] = now
|
self.lastGeoNotificationAt[gh] = now
|
||||||
|
// Pre-populate the target geohash timeline so the triggering message appears when user opens it
|
||||||
|
var arr = self.geoTimelines[gh] ?? []
|
||||||
|
let senderSuffix = String(event.pubkey.suffix(4))
|
||||||
|
let nick = self.geoNicknames[event.pubkey.lowercased()]
|
||||||
|
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
|
||||||
|
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
|
let mentions = self.parseMentions(from: content)
|
||||||
|
let msg = BitchatMessage(
|
||||||
|
id: event.id,
|
||||||
|
sender: senderName,
|
||||||
|
content: content,
|
||||||
|
timestamp: ts,
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: false,
|
||||||
|
recipientNickname: nil,
|
||||||
|
senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
|
||||||
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
|
)
|
||||||
|
if !arr.contains(where: { $0.id == msg.id }) {
|
||||||
|
arr.append(msg)
|
||||||
|
if arr.count > self.geoTimelineCap { arr = Array(arr.suffix(self.geoTimelineCap)) }
|
||||||
|
self.geoTimelines[gh] = arr
|
||||||
|
}
|
||||||
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2540,6 +2613,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@objc private func userDidTakeScreenshot() {
|
@objc private func userDidTakeScreenshot() {
|
||||||
|
// Respect privacy: do not broadcast screenshots taken from non-chat sheets
|
||||||
|
if isLocationChannelsSheetPresented {
|
||||||
|
// Show a warning about sharing location screenshots publicly
|
||||||
|
showScreenshotPrivacyWarning = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isAppInfoPresented {
|
||||||
|
// Silently ignore screenshots of app info
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Send screenshot notification based on current context
|
// Send screenshot notification based on current context
|
||||||
let screenshotMessage = "* \(nickname) took a screenshot *"
|
let screenshotMessage = "* \(nickname) took a screenshot *"
|
||||||
|
|
||||||
@@ -2559,7 +2643,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show local notification immediately as system message
|
// Show local notification immediately as system message (only in chat)
|
||||||
let localNotification = BitchatMessage(
|
let localNotification = BitchatMessage(
|
||||||
sender: "system",
|
sender: "system",
|
||||||
content: "you took a screenshot",
|
content: "you took a screenshot",
|
||||||
@@ -2610,7 +2694,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Show local notification immediately as system message
|
// Show local notification immediately as system message (only in chat)
|
||||||
let localNotification = BitchatMessage(
|
let localNotification = BitchatMessage(
|
||||||
sender: "system",
|
sender: "system",
|
||||||
content: "you took a screenshot",
|
content: "you took a screenshot",
|
||||||
@@ -2995,8 +3079,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
|
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
|
||||||
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
|
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
|
||||||
|
|
||||||
let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
let nsContent = contentText as NSString
|
||||||
let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
let nsLen = nsContent.length
|
||||||
|
let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
|
||||||
|
let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
|
||||||
|
|
||||||
// Combine and sort all matches
|
// Combine and sort all matches
|
||||||
var allMatches: [(range: NSRange, type: String)] = []
|
var allMatches: [(range: NSRange, type: String)] = []
|
||||||
@@ -3013,12 +3099,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
for (matchRange, matchType) in allMatches {
|
for (matchRange, matchType) in allMatches {
|
||||||
// Add text before the match
|
// Add text before the match
|
||||||
if let range = Range(matchRange, in: contentText) {
|
if let range = Range(matchRange, in: contentText) {
|
||||||
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
if lastEndIndex < range.lowerBound {
|
||||||
if !beforeText.isEmpty {
|
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
||||||
var normalStyle = AttributeContainer()
|
if !beforeText.isEmpty {
|
||||||
normalStyle.font = .system(size: 14, design: .monospaced)
|
var normalStyle = AttributeContainer()
|
||||||
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
normalStyle.font = .system(size: 14, design: .monospaced)
|
||||||
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
||||||
|
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the match with appropriate styling
|
// Add the match with appropriate styling
|
||||||
@@ -3036,7 +3124,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
||||||
|
|
||||||
lastEndIndex = range.upperBound
|
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3113,9 +3201,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
// For extremely long content, render as plain text to avoid heavy regex/layout work,
|
// For extremely long content, render as plain text to avoid heavy regex/layout work,
|
||||||
// unless the content includes Cashu tokens we want to chip-render below
|
// unless the content includes Cashu tokens we want to chip-render below
|
||||||
|
// Compute NSString-backed length for regex/nsrange correctness with multi-byte characters
|
||||||
|
let nsContent = content as NSString
|
||||||
|
let nsLen = nsContent.length
|
||||||
let containsCashuEarly: Bool = {
|
let containsCashuEarly: Bool = {
|
||||||
let rx = Regexes.quickCashuPresence
|
let rx = Regexes.quickCashuPresence
|
||||||
return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: content.count)) > 0
|
return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
|
||||||
}()
|
}()
|
||||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
||||||
var plainStyle = AttributeContainer()
|
var plainStyle = AttributeContainer()
|
||||||
@@ -3133,8 +3224,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let lnurlRegex = Regexes.lnurl
|
let lnurlRegex = Regexes.lnurl
|
||||||
let lightningSchemeRegex = Regexes.lightningScheme
|
let lightningSchemeRegex = Regexes.lightningScheme
|
||||||
let detector = Regexes.linkDetector
|
let detector = Regexes.linkDetector
|
||||||
|
|
||||||
let nsLen = content.count
|
|
||||||
let hasMentionsHint = content.contains("@")
|
let hasMentionsHint = content.contains("@")
|
||||||
let hasHashtagsHint = content.contains("#")
|
let hasHashtagsHint = content.contains("#")
|
||||||
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
|
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
|
||||||
@@ -3214,17 +3303,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
for (range, type) in allMatches {
|
for (range, type) in allMatches {
|
||||||
// Add text before match
|
// Add text before match
|
||||||
if let nsRange = Range(range, in: content) {
|
if let nsRange = Range(range, in: content) {
|
||||||
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
|
if lastEnd < nsRange.lowerBound {
|
||||||
if !beforeText.isEmpty {
|
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
|
||||||
var beforeStyle = AttributeContainer()
|
if !beforeText.isEmpty {
|
||||||
beforeStyle.foregroundColor = baseColor
|
var beforeStyle = AttributeContainer()
|
||||||
beforeStyle.font = isSelf
|
beforeStyle.foregroundColor = baseColor
|
||||||
? .system(size: 14, weight: .bold, design: .monospaced)
|
beforeStyle.font = isSelf
|
||||||
: .system(size: 14, design: .monospaced)
|
? .system(size: 14, weight: .bold, design: .monospaced)
|
||||||
if isMentioned {
|
: .system(size: 14, design: .monospaced)
|
||||||
beforeStyle.font = beforeStyle.font?.bold()
|
if isMentioned {
|
||||||
|
beforeStyle.font = beforeStyle.font?.bold()
|
||||||
|
}
|
||||||
|
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
||||||
}
|
}
|
||||||
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add styled match
|
// Add styled match
|
||||||
@@ -3332,7 +3423,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
result.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
result.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lastEnd = nsRange.upperBound
|
// Advance lastEnd safely in case of overlaps
|
||||||
|
if lastEnd < nsRange.upperBound {
|
||||||
|
lastEnd = nsRange.upperBound
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3430,19 +3524,23 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Regular expression to find @mentions
|
// Regular expression to find @mentions
|
||||||
let pattern = "@([\\p{L}0-9_]+)"
|
let pattern = "@([\\p{L}0-9_]+)"
|
||||||
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
||||||
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
let nsContent = contentText as NSString
|
||||||
|
let nsLen = nsContent.length
|
||||||
|
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
|
||||||
|
|
||||||
var lastEndIndex = contentText.startIndex
|
var lastEndIndex = contentText.startIndex
|
||||||
|
|
||||||
for match in matches {
|
for match in matches {
|
||||||
// Add text before the mention
|
// Add text before the mention
|
||||||
if let range = Range(match.range(at: 0), in: contentText) {
|
if let range = Range(match.range(at: 0), in: contentText) {
|
||||||
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
if lastEndIndex < range.lowerBound {
|
||||||
if !beforeText.isEmpty {
|
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
||||||
var normalStyle = AttributeContainer()
|
if !beforeText.isEmpty {
|
||||||
normalStyle.font = .system(size: 14, design: .monospaced)
|
var normalStyle = AttributeContainer()
|
||||||
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
normalStyle.font = .system(size: 14, design: .monospaced)
|
||||||
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
||||||
|
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the mention with highlight
|
// Add the mention with highlight
|
||||||
@@ -3452,7 +3550,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
mentionStyle.foregroundColor = Color.orange
|
mentionStyle.foregroundColor = Color.orange
|
||||||
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
|
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
|
||||||
|
|
||||||
lastEndIndex = range.upperBound
|
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4529,14 +4627,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
|
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have new mesh peers we haven't seen recently
|
// Rising-edge only: previously zero peers, now > 0 peers
|
||||||
let currentPeerSet = Set(meshPeers)
|
let currentPeerSet = Set(meshPeers)
|
||||||
let newPeers = currentPeerSet.subtracting(self.recentlySeenPeers)
|
let hadNone = self.recentlySeenPeers.isEmpty
|
||||||
// Send notification if:
|
if meshPeers.count > 0 && hadNone && !self.hasNotifiedNetworkAvailable {
|
||||||
// 1. We have mesh peers (not just Nostr-only)
|
|
||||||
// 2. There are new peers we haven't seen (rising-edge)
|
|
||||||
// 3. We haven't already notified since the last sustained-empty period
|
|
||||||
if meshPeers.count > 0 && !newPeers.isEmpty && !self.hasNotifiedNetworkAvailable {
|
|
||||||
self.hasNotifiedNetworkAvailable = true
|
self.hasNotifiedNetworkAvailable = true
|
||||||
self.lastNetworkNotificationTime = Date()
|
self.lastNetworkNotificationTime = Date()
|
||||||
self.recentlySeenPeers = currentPeerSet
|
self.recentlySeenPeers = currentPeerSet
|
||||||
@@ -4545,16 +4639,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
category: SecureLogger.session, level: .info)
|
category: SecureLogger.session, level: .info)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No peers - schedule a graceful reset to avoid refiring on brief drops
|
// No peers — immediately reset to allow next rising-edge to notify
|
||||||
if self.networkResetTimer == nil {
|
self.hasNotifiedNetworkAvailable = false
|
||||||
self.networkResetTimer = Timer.scheduledTimer(withTimeInterval: self.networkResetGraceSeconds, repeats: false) { [weak self] _ in
|
self.recentlySeenPeers.removeAll()
|
||||||
guard let self = self else { return }
|
if self.networkResetTimer != nil {
|
||||||
self.hasNotifiedNetworkAvailable = false
|
self.networkResetTimer?.invalidate()
|
||||||
self.recentlySeenPeers.removeAll()
|
self.networkResetTimer = nil
|
||||||
self.networkResetTimer = nil
|
|
||||||
SecureLogger.log("⏳ Mesh empty for \(Int(self.networkResetGraceSeconds))s — reset network notification state", category: SecureLogger.session, level: .debug)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
SecureLogger.log("⏳ Mesh empty — reset network notification state", category: SecureLogger.session, level: .debug)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register ephemeral sessions for all connected peers
|
// Register ephemeral sessions for all connected peers
|
||||||
@@ -4656,7 +4748,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames
|
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames
|
||||||
let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
|
let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
|
||||||
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
||||||
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
let nsContent = content as NSString
|
||||||
|
let nsLen = nsContent.length
|
||||||
|
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
|
||||||
|
|
||||||
var mentions: [String] = []
|
var mentions: [String] = []
|
||||||
let peerNicknames = meshService.getPeerNicknames()
|
let peerNicknames = meshService.getPeerNicknames()
|
||||||
@@ -5723,9 +5817,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if isGeo && finalMessage.sender != "system" {
|
if isGeo && finalMessage.sender != "system" {
|
||||||
if let gh = currentGeohash {
|
if let gh = currentGeohash {
|
||||||
var arr = geoTimelines[gh] ?? []
|
var arr = geoTimelines[gh] ?? []
|
||||||
arr.append(finalMessage)
|
// Dedup by message ID before appending to per-geohash timeline
|
||||||
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
if !arr.contains(where: { $0.id == finalMessage.id }) {
|
||||||
geoTimelines[gh] = arr
|
arr.append(finalMessage)
|
||||||
|
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
||||||
|
geoTimelines[gh] = arr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5740,11 +5837,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
guard channelMatches else { return }
|
guard channelMatches else { return }
|
||||||
|
|
||||||
|
// Removed background nudge notification for generic "new chats!"
|
||||||
|
|
||||||
|
// Append via batching buffer (skip empty content) with simple dedup by ID
|
||||||
// Append via batching buffer (skip empty content)
|
|
||||||
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
enqueuePublic(finalMessage)
|
if !messages.contains(where: { $0.id == finalMessage.id }) {
|
||||||
|
enqueuePublic(finalMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1118,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 = {
|
||||||
@@ -1172,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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user