mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 18:25:19 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acc5bdf5ec | ||
|
|
fcd82c28c6 | ||
|
|
4e51c83914 | ||
|
|
d9884eaf87 | ||
|
|
a486644b7f | ||
|
|
5273f13512 | ||
|
|
e79bcf531b | ||
|
|
3e5043f875 | ||
|
|
11cd5527ed | ||
|
|
cf1bfdac6b | ||
|
|
b63a595b04 | ||
|
|
d7b7f1f673 | ||
|
|
7aa3622349 |
@@ -2,7 +2,7 @@
|
||||
|
||||
## bitchat
|
||||
|
||||
A decentralized peer-to-peer messaging app that works over Bluetooth mesh networks. No internet required, no servers, no phone numbers. It's the side-groupchat.
|
||||
A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers. It's the side-groupchat.
|
||||
|
||||
[bitchat.free](http://bitchat.free)
|
||||
|
||||
@@ -11,50 +11,99 @@ A decentralized peer-to-peer messaging app that works over Bluetooth mesh networ
|
||||
> [!WARNING]
|
||||
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||
|
||||
|
||||
## License
|
||||
|
||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- **Dual Transport Architecture**: Bluetooth mesh for offline + Nostr protocol for internet-based messaging
|
||||
- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over global Nostr relays
|
||||
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org)
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||
- **Universal App**: Native support for iOS and macOS
|
||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||
- **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking
|
||||
|
||||
|
||||
## [Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat)
|
||||
|
||||
### Binary Protocol
|
||||
bitchat uses an efficient binary protocol optimized for Bluetooth LE:
|
||||
- Compact packet format with 1-byte type field
|
||||
- TTL-based message routing (max 7 hops)
|
||||
- Automatic fragmentation for large messages
|
||||
- Message deduplication via unique IDs
|
||||
BitChat uses a **hybrid messaging architecture** with two complementary transport layers:
|
||||
|
||||
### Mesh Networking
|
||||
- Each device acts as both client and peripheral
|
||||
- Automatic peer discovery and connection management
|
||||
- Adaptive duty cycling for battery optimization
|
||||
### Bluetooth Mesh Network (Offline)
|
||||
|
||||
- **Local Communication**: Direct peer-to-peer within Bluetooth range
|
||||
- **Multi-hop Relay**: Messages route through nearby devices (max 7 hops)
|
||||
- **No Internet Required**: Works completely offline in disaster scenarios
|
||||
- **Noise Protocol Encryption**: End-to-end encryption with forward secrecy
|
||||
- **Binary Protocol**: Compact packet format optimized for Bluetooth LE constraints
|
||||
- **Automatic Discovery**: Peer discovery and connection management
|
||||
- **Adaptive Power**: Battery-optimized duty cycling
|
||||
|
||||
### Nostr Protocol (Internet)
|
||||
|
||||
- **Global Reach**: Connect with users worldwide via internet relays
|
||||
- **Location Channels**: Geographic chat rooms using geohash coordinates
|
||||
- **290+ Relay Network**: Distributed across the globe for reliability
|
||||
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
|
||||
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
|
||||
|
||||
### Channel Types
|
||||
|
||||
#### `mesh #bluetooth`
|
||||
|
||||
- **Transport**: Bluetooth Low Energy mesh network
|
||||
- **Scope**: Local devices within multi-hop range
|
||||
- **Internet**: Not required
|
||||
- **Use Case**: Offline communication, protests, disasters, remote areas
|
||||
|
||||
#### Location Channels (`block #dr5rsj7`, `neighborhood #dr5rs`, `country #dr`)
|
||||
|
||||
- **Transport**: Nostr protocol over internet
|
||||
- **Scope**: Geographic areas defined by geohash precision
|
||||
- `block` (7 chars): City block level
|
||||
- `neighborhood` (6 chars): District/neighborhood
|
||||
- `city` (5 chars): City level
|
||||
- `province` (4 chars): State/province
|
||||
- `region` (2 chars): Country/large region
|
||||
- **Internet**: Required (connects to Nostr relays)
|
||||
- **Use Case**: Location-based community chat, local events, regional discussions
|
||||
|
||||
### Direct Message Routing
|
||||
|
||||
Private messages use **intelligent transport selection**:
|
||||
|
||||
1. **Bluetooth First** (preferred when available)
|
||||
|
||||
- Direct connection with established Noise session
|
||||
- Fastest and most private option
|
||||
|
||||
2. **Nostr Fallback** (when Bluetooth unavailable)
|
||||
|
||||
- Uses recipient's Nostr public key
|
||||
- NIP-17 gift-wrapping for privacy
|
||||
- Routes through global relay network
|
||||
|
||||
3. **Smart Queuing** (when neither available)
|
||||
- Messages queued until transport becomes available
|
||||
- Automatic delivery when connection established
|
||||
|
||||
For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md).
|
||||
|
||||
|
||||
## Setup
|
||||
|
||||
### Option 1: Using XcodeGen (Recommended)
|
||||
|
||||
1. Install XcodeGen if you haven't already:
|
||||
|
||||
```bash
|
||||
brew install xcodegen
|
||||
```
|
||||
|
||||
2. Generate the Xcode project:
|
||||
|
||||
```bash
|
||||
cd bitchat
|
||||
xcodegen generate
|
||||
@@ -68,6 +117,7 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
||||
### Option 2: Using Swift Package Manager
|
||||
|
||||
1. Open the project in Xcode:
|
||||
|
||||
```bash
|
||||
cd bitchat
|
||||
open Package.swift
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
048A4BE72E5CCCC300162C4A /* 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 */; };
|
||||
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 */; };
|
||||
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -483,6 +489,7 @@
|
||||
C3D98EB3E1B455E321F519F4 /* bitchatTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
|
||||
D69A18D27F9A565FD6041E12 /* Info.plist */,
|
||||
047502912E547ACC0083520F /* LocationChannelsTests.swift */,
|
||||
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
|
||||
@@ -519,6 +526,7 @@
|
||||
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */,
|
||||
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
|
||||
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
|
||||
047502B82E560F690083520F /* RelayController.swift */,
|
||||
@@ -771,6 +779,7 @@
|
||||
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
||||
AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||
8C1AB0F2D48207E0755DA91A /* NoiseProtocol.swift in Sources */,
|
||||
048A4C282E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
|
||||
049BD3AC2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
|
||||
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */,
|
||||
8A14ADADF5CD7A79919CB655 /* NoiseSession.swift in Sources */,
|
||||
@@ -830,6 +839,7 @@
|
||||
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
||||
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */,
|
||||
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
|
||||
049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
|
||||
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */,
|
||||
92D1CF17DF88EA298F6E5E8E /* NoiseSession.swift in Sources */,
|
||||
@@ -866,6 +876,7 @@
|
||||
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
||||
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
|
||||
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
||||
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
|
||||
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
|
||||
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */,
|
||||
968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */,
|
||||
@@ -888,6 +899,7 @@
|
||||
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
||||
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
|
||||
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
||||
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
|
||||
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
|
||||
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */,
|
||||
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */,
|
||||
|
||||
@@ -173,6 +173,14 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle deeplink (e.g., geohash activity)
|
||||
if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) {
|
||||
#if os(iOS)
|
||||
DispatchQueue.main.async { UIApplication.shared.open(url) }
|
||||
#else
|
||||
DispatchQueue.main.async { NSWorkspace.shared.open(url) }
|
||||
#endif
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
}
|
||||
@@ -192,6 +200,15 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Suppress geohash activity notification if we're already in that geohash channel
|
||||
if identifier.hasPrefix("geo-activity-"),
|
||||
let deep = userInfo["deeplink"] as? String,
|
||||
let gh = deep.components(separatedBy: "/").last {
|
||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Show notification in all other cases
|
||||
completionHandler([.banner, .sound])
|
||||
|
||||
@@ -44,8 +44,15 @@ class NostrRelayManager: ObservableObject {
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// 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 encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
// Exponential backoff configuration
|
||||
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
||||
@@ -59,6 +66,8 @@ class NostrRelayManager: ObservableObject {
|
||||
init() {
|
||||
// Initialize with default relays
|
||||
self.relays = Self.defaultRelays.map { Relay(url: $0) }
|
||||
// Deterministic JSON shape for outbound requests
|
||||
self.encoder.outputFormatting = .sortedKeys
|
||||
}
|
||||
|
||||
/// Connect to all configured relays
|
||||
@@ -96,15 +105,56 @@ class NostrRelayManager: ObservableObject {
|
||||
let targetRelays = relayUrls ?? Self.defaultRelays
|
||||
ensureConnections(to: targetRelays)
|
||||
|
||||
// Add to queue for reliability
|
||||
messageQueueLock.lock()
|
||||
messageQueue.append((event, targetRelays))
|
||||
messageQueueLock.unlock()
|
||||
|
||||
// Attempt immediate send
|
||||
// Attempt immediate send to relays with active connections; queue the rest
|
||||
var stillPending = Set<String>()
|
||||
for relayUrl in targetRelays {
|
||||
if let connection = connections[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])
|
||||
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = .sortedKeys // For consistent output
|
||||
let message = try encoder.encode(req)
|
||||
guard let messageString = String(data: message, encoding: .utf8) else {
|
||||
SecureLogger.log("❌ Failed to encode subscription request", category: SecureLogger.session, level: .error)
|
||||
@@ -175,7 +223,7 @@ class NostrRelayManager: ObservableObject {
|
||||
messageHandlers.removeValue(forKey: id)
|
||||
|
||||
let req = NostrRequest.close(id: id)
|
||||
let message = try? JSONEncoder().encode(req)
|
||||
let message = try? encoder.encode(req)
|
||||
|
||||
guard let messageData = message,
|
||||
let messageString = String(data: messageData, encoding: .utf8) else { return }
|
||||
@@ -242,14 +290,21 @@ class NostrRelayManager: ObservableObject {
|
||||
case .success(let message):
|
||||
switch message {
|
||||
case .string(let text):
|
||||
Task { @MainActor in
|
||||
self.handleMessage(text, from: relayUrl)
|
||||
// Parse off-main to reduce UI jank, then hop back for state updates
|
||||
Task.detached(priority: .utility) {
|
||||
guard let parsed = parseInboundMessage(text) else { return }
|
||||
await MainActor.run {
|
||||
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
|
||||
}
|
||||
}
|
||||
case .data(let data):
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
Task { @MainActor in
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@unknown default:
|
||||
break
|
||||
@@ -268,79 +323,42 @@ class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMessage(_ message: String, from relayUrl: String) {
|
||||
guard let data = message.data(using: .utf8) else { return }
|
||||
// Parsed inbound message type (off-main)
|
||||
// Note: declared at file scope below to avoid MainActor isolation inside this class
|
||||
// and keep parsing off the main actor.
|
||||
|
||||
do {
|
||||
// Try to decode as an array first
|
||||
if let array = try JSONSerialization.jsonObject(with: data) as? [Any],
|
||||
array.count >= 2,
|
||||
let type = array[0] as? String {
|
||||
|
||||
// Received message from relay
|
||||
|
||||
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
|
||||
}
|
||||
// Handle parsed message on MainActor (state updates and handlers)
|
||||
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
|
||||
switch parsed {
|
||||
case .event(let subId, let event):
|
||||
if event.kind != 1059 {
|
||||
SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.log("Failed to parse Nostr message: \(error)", category: SecureLogger.session, level: .error)
|
||||
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||
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)
|
||||
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = .sortedKeys
|
||||
let data = try encoder.encode(req)
|
||||
let message = String(data: data, encoding: .utf8) ?? ""
|
||||
|
||||
@@ -389,6 +405,10 @@ class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
updateConnectionStatus()
|
||||
// If we just connected to this relay, flush any queued sends targeting it
|
||||
if isConnected {
|
||||
flushMessageQueue(for: url)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
enum NostrRequest: Encodable {
|
||||
|
||||
@@ -40,23 +40,23 @@ extension Data {
|
||||
extension Data {
|
||||
// MARK: Writing
|
||||
|
||||
mutating func appendUInt8(_ value: UInt8) {
|
||||
@inlinable mutating func appendUInt8(_ value: UInt8) {
|
||||
self.append(value)
|
||||
}
|
||||
|
||||
mutating func appendUInt16(_ value: UInt16) {
|
||||
@inlinable mutating func appendUInt16(_ value: UInt16) {
|
||||
self.append(UInt8((value >> 8) & 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 >> 16) & 0xFF))
|
||||
self.append(UInt8((value >> 8) & 0xFF))
|
||||
self.append(UInt8(value & 0xFF))
|
||||
}
|
||||
|
||||
mutating func appendUInt64(_ value: UInt64) {
|
||||
@inlinable mutating func appendUInt64(_ value: UInt64) {
|
||||
for i in (0..<8).reversed() {
|
||||
self.append(UInt8((value >> (i * 8)) & 0xFF))
|
||||
}
|
||||
@@ -113,21 +113,21 @@ extension Data {
|
||||
|
||||
// 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 }
|
||||
let value = self[offset]
|
||||
offset += 1
|
||||
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 }
|
||||
let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1])
|
||||
offset += 2
|
||||
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 }
|
||||
let value = UInt32(self[offset]) << 24 |
|
||||
UInt32(self[offset + 1]) << 16 |
|
||||
@@ -137,7 +137,7 @@ extension Data {
|
||||
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 }
|
||||
var value: UInt64 = 0
|
||||
for i in 0..<8 {
|
||||
@@ -220,4 +220,3 @@ extension Data {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,11 @@ struct BinaryProtocol {
|
||||
}
|
||||
|
||||
// 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.type)
|
||||
data.append(packet.ttl)
|
||||
@@ -222,135 +227,105 @@ struct BinaryProtocol {
|
||||
|
||||
// Core decoding implementation used by decode(_:) with and without padding removal
|
||||
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
||||
// Minimum size check: header + senderID
|
||||
guard raw.count >= headerSize + senderIDSize else {
|
||||
return nil
|
||||
}
|
||||
// Minimum size: header + senderID
|
||||
guard raw.count >= headerSize + senderIDSize else { return nil }
|
||||
|
||||
// Convert to array for safer indexed access
|
||||
let dataArray = Array(raw)
|
||||
var offset = 0
|
||||
|
||||
// Header parsing with bounds checks
|
||||
guard offset < dataArray.count else { return nil }
|
||||
let version = dataArray[offset]; offset += 1
|
||||
|
||||
// Check if version is 1 (only supported version)
|
||||
guard version == 1 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard offset < dataArray.count else { return nil }
|
||||
let type = dataArray[offset]; offset += 1
|
||||
|
||||
guard offset < dataArray.count else { return nil }
|
||||
let ttl = dataArray[offset]; offset += 1
|
||||
|
||||
// Timestamp - need 8 bytes
|
||||
guard offset + 8 <= dataArray.count else { return nil }
|
||||
let timestampData = Data(dataArray[offset..<offset+8])
|
||||
let timestamp = timestampData.reduce(0) { result, byte in
|
||||
(result << 8) | UInt64(byte)
|
||||
}
|
||||
offset += 8
|
||||
|
||||
// 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
|
||||
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
||||
guard let base = buf.baseAddress else { return nil }
|
||||
var offset = 0
|
||||
func require(_ n: Int) -> Bool { offset + n <= buf.count }
|
||||
// Read single byte
|
||||
func read8() -> UInt8? {
|
||||
guard require(1) else { return nil }
|
||||
let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
|
||||
offset += 1
|
||||
return v
|
||||
}
|
||||
// Read big-endian 16-bit
|
||||
func read16() -> UInt16? {
|
||||
guard require(2) else { return nil }
|
||||
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||
let v = (UInt16(p[0]) << 8) | UInt16(p[1])
|
||||
offset += 2
|
||||
return v
|
||||
}
|
||||
// Copy N bytes into Data
|
||||
func readData(_ n: Int) -> Data? {
|
||||
guard require(n) else { return nil }
|
||||
let ptr = base.advanced(by: offset)
|
||||
let d = Data(bytes: ptr, count: n)
|
||||
offset += n
|
||||
return d
|
||||
}
|
||||
|
||||
let compressedPayload = Data(dataArray[offset..<offset+compressedPayloadSize])
|
||||
offset += compressedPayloadSize
|
||||
// Version
|
||||
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
|
||||
guard let decompressedPayload = CompressionUtil.decompress(compressedPayload, originalSize: originalSize) else {
|
||||
return nil
|
||||
// Timestamp 8 bytes BE
|
||||
guard require(8) else { 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
|
||||
guard decompressedPayload.count == originalSize else {
|
||||
return nil
|
||||
// Flags
|
||||
guard let flags = read8() else { 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
|
||||
} else {
|
||||
// Uncompressed payload
|
||||
guard Int(payloadLength) >= 0 && offset + Int(payloadLength) <= dataArray.count else {
|
||||
return nil
|
||||
// Payload
|
||||
let payload: Data
|
||||
if isCompressed {
|
||||
// Need original size (2 bytes)
|
||||
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
|
||||
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? {
|
||||
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
|
||||
guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil }
|
||||
@@ -88,6 +90,7 @@ struct PrivateMessagePacket {
|
||||
|
||||
func encode() -> Data? {
|
||||
var data = Data()
|
||||
data.reserveCapacity(2 + min(messageID.count, 255) + 2 + min(content.count, 255))
|
||||
|
||||
// TLV for messageID
|
||||
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()
|
||||
|
||||
// 5. Fragment Reassembly (necessary for messages > MTU)
|
||||
private var incomingFragments: [String: [Int: Data]] = [:]
|
||||
private var fragmentMetadata: [String: (type: UInt8, total: Int, timestamp: Date)] = [:]
|
||||
private struct FragmentKey: Hashable { let sender: UInt64; let id: UInt64 }
|
||||
private var incomingFragments: [FragmentKey: [Int: Data]] = [:]
|
||||
private var fragmentMetadata: [FragmentKey: (type: UInt8, total: Int, timestamp: Date)] = [:]
|
||||
// Backoff for peripherals that recently timed out connecting
|
||||
private var recentConnectTimeouts: [String: Date] = [:] // Peripheral UUID -> last timeout
|
||||
|
||||
@@ -88,6 +89,7 @@ final class BLEService: NSObject {
|
||||
var myPeerID: String = ""
|
||||
var myNickname: String = "anon"
|
||||
private let noiseService = NoiseEncryptionService()
|
||||
private var myPeerIDData: Data = Data()
|
||||
|
||||
// MARK: - Advertising Privacy
|
||||
// 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)
|
||||
} else {
|
||||
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)
|
||||
let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars
|
||||
self.myPeerID = String(fingerprint.prefix(16))
|
||||
self.myPeerIDData = Data(hexString: myPeerID) ?? Data()
|
||||
|
||||
// Set queue key for identification
|
||||
messageQueue.setSpecific(key: messageQueueKey, value: ())
|
||||
@@ -374,7 +400,7 @@ final class BLEService: NSObject {
|
||||
maintenanceTimer = timer
|
||||
|
||||
// Publish initial empty state
|
||||
publishFullPeerData()
|
||||
requestPeerDataPublish()
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String) {
|
||||
@@ -463,7 +489,7 @@ final class BLEService: NSObject {
|
||||
// Send leave message synchronously to ensure delivery
|
||||
let leavePacket = BitchatPacket(
|
||||
type: MessageType.leave.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
senderID: myPeerIDData,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data(),
|
||||
@@ -591,7 +617,7 @@ final class BLEService: NSObject {
|
||||
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
senderID: myPeerIDData,
|
||||
recipientID: Data(hexString: peerID),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encrypted,
|
||||
@@ -643,7 +669,7 @@ final class BLEService: NSObject {
|
||||
let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
senderID: myPeerIDData,
|
||||
recipientID: Data(hexString: peerID),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encrypted,
|
||||
@@ -800,7 +826,7 @@ final class BLEService: NSObject {
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
senderID: myPeerIDData,
|
||||
recipientID: recipientData,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encrypted,
|
||||
@@ -854,7 +880,7 @@ final class BLEService: NSObject {
|
||||
// Send handshake init
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseHandshake.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
senderID: myPeerIDData,
|
||||
recipientID: Data(hexString: peerID),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: handshakeData,
|
||||
@@ -904,7 +930,7 @@ final class BLEService: NSObject {
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
senderID: myPeerIDData,
|
||||
recipientID: Data(hexString: peerID),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encrypted,
|
||||
@@ -1247,8 +1273,11 @@ final class BLEService: NSObject {
|
||||
// Minimum header: 8 bytes ID + 2 index + 2 total + 1 type
|
||||
guard packet.payload.count >= 13 else { return }
|
||||
|
||||
let senderHex = packet.senderID.hexEncodedString()
|
||||
let fragmentID = packet.payload[0..<8].map { String(format: "%02x", $0) }.joined()
|
||||
// Compute compact fragment key (sender: 8 bytes, id: 8 bytes), big-endian
|
||||
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
|
||||
let idxHi = UInt16(packet.payload[8])
|
||||
let idxLo = UInt16(packet.payload[9])
|
||||
@@ -1263,7 +1292,7 @@ final class BLEService: NSObject {
|
||||
guard total > 0 && index >= 0 && index < total else { return }
|
||||
|
||||
// Store fragment
|
||||
let key = "\(senderHex):\(fragmentID)"
|
||||
let key = FragmentKey(sender: senderU64, id: fragU64)
|
||||
if incomingFragments[key] == nil {
|
||||
// Cap in-flight assemblies to prevent memory/battery blowups
|
||||
if incomingFragments.count >= maxInFlightAssemblies {
|
||||
@@ -1436,6 +1465,20 @@ final class BLEService: NSObject {
|
||||
|
||||
// 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
|
||||
var isNewPeer = false
|
||||
var isReconnectedPeer = false
|
||||
@@ -1460,21 +1503,8 @@ final class BLEService: NSObject {
|
||||
isNewPeer = (existingPeer == nil)
|
||||
isReconnectedPeer = wasDisconnected
|
||||
|
||||
// Verify packet signature using the announced signing public key
|
||||
var verified = false
|
||||
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
|
||||
}
|
||||
// Use precomputed verification result
|
||||
let verified = verifiedAnnounce
|
||||
|
||||
// Require verified announce; ignore otherwise (no backward compatibility)
|
||||
if !verified {
|
||||
@@ -1565,7 +1595,7 @@ final class BLEService: NSObject {
|
||||
self.delegate?.didConnectToPeer(peerID)
|
||||
}
|
||||
|
||||
self.publishFullPeerData()
|
||||
self.requestPeerDataPublish()
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
|
||||
@@ -1666,7 +1696,7 @@ final class BLEService: NSObject {
|
||||
// Send response
|
||||
let responsePacket = BitchatPacket(
|
||||
type: MessageType.noiseHandshake.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
senderID: myPeerIDData,
|
||||
recipientID: Data(hexString: peerID),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: response,
|
||||
@@ -1822,7 +1852,7 @@ final class BLEService: NSObject {
|
||||
// Create packet with signature using the noise private key
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
senderID: myPeerIDData,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -1856,7 +1886,7 @@ final class BLEService: NSObject {
|
||||
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
senderID: myPeerIDData,
|
||||
recipientID: Data(hexString: peerID),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encrypted,
|
||||
@@ -1893,7 +1923,7 @@ final class BLEService: NSObject {
|
||||
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
senderID: myPeerIDData,
|
||||
recipientID: Data(hexString: peerID),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
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
|
||||
|
||||
private func performMaintenance() {
|
||||
@@ -2072,7 +2124,7 @@ final class BLEService: NSObject {
|
||||
self.delegate?.didDisconnectFromPeer(peerID)
|
||||
}
|
||||
// Publish snapshots so UnifiedPeerService updates connection/reachability icons
|
||||
self.publishFullPeerData()
|
||||
self.requestPeerDataPublish()
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
}
|
||||
@@ -2454,7 +2506,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
||||
if let peerID = peerID {
|
||||
self.notifyPeerDisconnectedDebounced(peerID)
|
||||
}
|
||||
self.publishFullPeerData()
|
||||
self.requestPeerDataPublish()
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
}
|
||||
@@ -2855,7 +2907,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
|
||||
self.notifyPeerDisconnectedDebounced(peerID)
|
||||
// Publish snapshots so UnifiedPeerService can refresh icons promptly
|
||||
self.publishFullPeerData()
|
||||
self.requestPeerDataPublish()
|
||||
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
|
||||
}
|
||||
@@ -50,10 +50,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
teleportedSet = Set(arr)
|
||||
}
|
||||
// Initialize teleported flag from persisted state if a location channel is selected
|
||||
if case .location(let ch) = selectedChannel {
|
||||
teleported = teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
// Do not eagerly mark teleported on startup; wait for location to compute regional set.
|
||||
// This avoids showing teleported for in-region channels during cold start.
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
@@ -61,6 +59,15 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
updatePermissionState(from: status)
|
||||
// If we don't have location authorization at startup, fall back to persisted teleport state
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
break // will compute from location
|
||||
default:
|
||||
if case .location(let ch) = selectedChannel {
|
||||
teleported = teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
@@ -92,23 +99,30 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin periodic one-shot location refreshes while a selector UI is visible.
|
||||
/// Begin continuous, distance-filtered updates while the channel sheet is visible.
|
||||
/// Uses a 21m filter (configurable) to only refresh on meaningful movement.
|
||||
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
||||
guard permissionState == .authorized else { return }
|
||||
// Switch to a lightweight periodic one-shot request (polling) while the sheet is open
|
||||
// Stop any previous polling timer
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
self?.requestOneShotLocation()
|
||||
}
|
||||
// Kick off immediately
|
||||
refreshTimer = nil
|
||||
// Tighten accuracy and distance filter for live view
|
||||
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
|
||||
// Start continuous updates
|
||||
cl.startUpdatingLocation()
|
||||
// Request an immediate fix to populate UI without waiting for movement
|
||||
requestOneShotLocation()
|
||||
}
|
||||
|
||||
/// Stop periodic refreshes when selector UI is dismissed.
|
||||
/// Stop continuous refreshes when selector UI is dismissed.
|
||||
func endLiveRefresh() {
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
cl.stopUpdatingLocation()
|
||||
// Restore more relaxed defaults for background/idle state
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||
}
|
||||
|
||||
func select(_ channel: ChannelID) {
|
||||
@@ -122,7 +136,21 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
case .mesh:
|
||||
self.teleported = false
|
||||
case .location(let ch):
|
||||
self.teleported = self.teleportedSet.contains(ch.geohash)
|
||||
// If this geohash is in our current regional set, do NOT mark teleported.
|
||||
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
if inRegional {
|
||||
self.teleported = false
|
||||
// Clear persisted teleport for this geohash to keep future selections clean
|
||||
if self.teleportedSet.contains(ch.geohash) {
|
||||
self.teleportedSet.remove(ch.geohash)
|
||||
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fall back to persisted mark (set by deep link or manual teleport)
|
||||
self.teleported = self.teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,14 +223,25 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
}
|
||||
Task { @MainActor in
|
||||
self.availableChannels = result
|
||||
// Recompute teleported status based on persisted state OR current location vs selected channel
|
||||
// Recompute teleported status based on whether the selected geohash is in our regional set
|
||||
switch self.selectedChannel {
|
||||
case .mesh:
|
||||
self.teleported = false
|
||||
case .location(let ch):
|
||||
let persisted = self.teleportedSet.contains(ch.geohash)
|
||||
let currentGH = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: ch.level.precision)
|
||||
self.teleported = persisted || (currentGH != ch.geohash)
|
||||
// Membership check using freshly computed regional channels; avoids precision/rename drift
|
||||
let inRegional = result.contains { $0.geohash == ch.geohash }
|
||||
if inRegional {
|
||||
self.teleported = false
|
||||
// Clear persisted teleport flag if present
|
||||
if self.teleportedSet.contains(ch.geohash) {
|
||||
self.teleportedSet.remove(ch.geohash)
|
||||
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.teleported = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,10 +89,19 @@ final class MessageRouter {
|
||||
|
||||
// MARK: - Outbox Management
|
||||
private func canSendViaNostr(peerID: String) -> Bool {
|
||||
guard let noiseKey = Data(hexString: peerID) else { return false }
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
fav.peerNostrPublicKey != nil {
|
||||
return true
|
||||
// Two forms are supported:
|
||||
// - 64-hex Noise public key (32 bytes)
|
||||
// - 16-hex short peer ID (derived from Noise pubkey)
|
||||
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
|
||||
}
|
||||
@@ -101,6 +110,7 @@ final class MessageRouter {
|
||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||
SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||
for (content, nickname, messageID) in queued {
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
@@ -112,14 +122,19 @@ final class MessageRouter {
|
||||
category: SecureLogger.session, level: .debug)
|
||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
} 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)
|
||||
outbox[peerID]?.removeAll()
|
||||
// Persist only items we could not send
|
||||
if remaining.isEmpty {
|
||||
outbox.removeValue(forKey: peerID)
|
||||
} else {
|
||||
outbox[peerID] = remaining
|
||||
}
|
||||
}
|
||||
|
||||
func flushAllOutbox() {
|
||||
for key in outbox.keys { flushOutbox(for: key) }
|
||||
for key in Array(outbox.keys) { flushOutbox(for: key) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,15 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
// Geohash public chat notification with deep link to a specific geohash
|
||||
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
||||
let title = "\(titlePrefix)\(geohash)"
|
||||
let identifier = "geo-activity-\(geohash)-\(Date().timeIntervalSince1970)"
|
||||
let deeplink = "bitchat://geohash/\(geohash)"
|
||||
let userInfo: [String: Any] = ["deeplink": deeplink]
|
||||
sendLocalNotification(title: title, body: bodyPreview, identifier: identifier, userInfo: userInfo)
|
||||
}
|
||||
|
||||
func sendNetworkAvailableNotification(peerCount: Int) {
|
||||
let title = "👥 bitchatters nearby!"
|
||||
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
||||
|
||||
@@ -100,8 +100,14 @@ enum TransportConfig {
|
||||
|
||||
// Location
|
||||
static let locationDistanceFilterMeters: Double = 1000
|
||||
// Live (channel sheet open) distance threshold for meaningful updates
|
||||
static let locationDistanceFilterLiveMeters: Double = 21.0
|
||||
static let locationLiveRefreshInterval: TimeInterval = 5.0
|
||||
|
||||
// Notifications (geohash)
|
||||
static let uiGeoNotifyCooldownSeconds: TimeInterval = 60.0
|
||||
static let uiGeoNotifySnippetMaxLen: Int = 80
|
||||
|
||||
// Nostr geohash
|
||||
static let nostrGeohashInitialLookbackSeconds: TimeInterval = 3600
|
||||
static let nostrGeohashInitialLimit: Int = 200
|
||||
@@ -169,6 +175,10 @@ enum TransportConfig {
|
||||
// UI color tuning
|
||||
static let uiColorHueAvoidanceDelta: Double = 0.05
|
||||
static let uiColorHueOffset: Double = 0.12
|
||||
// Peer list palette
|
||||
static let uiPeerPaletteSlots: Int = 36
|
||||
static let uiPeerPaletteRingBrightnessDeltaLight: Double = 0.07
|
||||
static let uiPeerPaletteRingBrightnessDeltaDark: Double = -0.07
|
||||
|
||||
// UI windowing (infinite scroll)
|
||||
static let uiWindowInitialCountPublic: Int = 300
|
||||
|
||||
@@ -27,6 +27,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
private var peerIndex: [String: BitchatPeer] = [:]
|
||||
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
|
||||
private let meshService: Transport
|
||||
weak var messageRouter: MessageRouter?
|
||||
private let favoritesService = FavoritesPersistenceService.shared
|
||||
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))",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Send favorite notification to the peer
|
||||
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
||||
// Send favorite notification to the peer via router (mesh or Nostr)
|
||||
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
|
||||
updatePeers()
|
||||
|
||||
@@ -9,6 +9,7 @@ final class MessageDeduplicator {
|
||||
}
|
||||
|
||||
private var entries: [Entry] = []
|
||||
private var head: Int = 0
|
||||
private var lookup = Set<String>()
|
||||
private let lock = NSLock()
|
||||
private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes
|
||||
@@ -28,10 +29,18 @@ final class MessageDeduplicator {
|
||||
entries.append(Entry(messageID: messageID, timestamp: Date()))
|
||||
lookup.insert(messageID)
|
||||
|
||||
if entries.count > maxCount {
|
||||
let toRemove = entries.prefix(100)
|
||||
toRemove.forEach { lookup.remove($0.messageID) }
|
||||
entries.removeFirst(100)
|
||||
// Soft-cap and advance head by a chunk to avoid O(n) shifting
|
||||
if (entries.count - head) > maxCount {
|
||||
let removeCount = min(100, entries.count - head)
|
||||
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
|
||||
@@ -61,6 +70,7 @@ final class MessageDeduplicator {
|
||||
defer { lock.unlock() }
|
||||
|
||||
entries.removeAll()
|
||||
head = 0
|
||||
lookup.removeAll()
|
||||
}
|
||||
|
||||
@@ -78,9 +88,13 @@ final class MessageDeduplicator {
|
||||
|
||||
private func cleanupOldEntries() {
|
||||
let cutoff = Date().addingTimeInterval(-maxAge)
|
||||
while let first = entries.first, first.timestamp < cutoff {
|
||||
lookup.remove(first.messageID)
|
||||
entries.removeFirst()
|
||||
while head < entries.count, entries[head].timestamp < cutoff {
|
||||
lookup.remove(entries[head].messageID)
|
||||
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
|
||||
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) {
|
||||
guard shouldLog(level) else { return }
|
||||
let location = formatLocation(file: file, line: line, function: function)
|
||||
let sanitized = sanitize("\(location) \(message)")
|
||||
let sanitized = sanitize("\(location) \(message())")
|
||||
|
||||
#if DEBUG
|
||||
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
||||
@@ -157,10 +157,10 @@ class SecureLogger {
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let location = formatLocation(file: file, line: line, function: function)
|
||||
let sanitized = sanitize(context)
|
||||
let sanitized = sanitize(context())
|
||||
let errorDesc = sanitize(error.localizedDescription)
|
||||
|
||||
#if DEBUG
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ struct ContentView: View {
|
||||
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||
@State private var messageText = ""
|
||||
@State private var textFieldSelection: NSRange? = nil
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
@@ -164,6 +165,8 @@ struct ContentView: View {
|
||||
}
|
||||
.sheet(isPresented: $showAppInfo) {
|
||||
AppInfoView()
|
||||
.onAppear { viewModel.isAppInfoPresented = true }
|
||||
.onDisappear { viewModel.isAppInfoPresented = false }
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { viewModel.showingFingerprintFor != nil },
|
||||
@@ -278,8 +281,10 @@ struct ContentView: View {
|
||||
}
|
||||
}()
|
||||
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
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Check if current user is mentioned
|
||||
@@ -459,7 +464,13 @@ struct ContentView: View {
|
||||
}
|
||||
let level = levelForLength(gh.count)
|
||||
let ch = GeohashChannel(level: level, geohash: gh)
|
||||
LocationChannelManager.shared.markTeleported(for: gh, true)
|
||||
// Do not mark teleported when opening a geohash that is in our regional set.
|
||||
// If availableChannels is empty (e.g., cold start), defer marking and let
|
||||
// LocationChannelManager compute teleported based on actual location.
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
|
||||
LocationChannelManager.shared.markTeleported(for: gh, true)
|
||||
}
|
||||
LocationChannelManager.shared.select(ChannelID.location(ch))
|
||||
}
|
||||
.onTapGesture(count: 3) {
|
||||
@@ -1112,6 +1123,15 @@ struct ContentView: View {
|
||||
.buttonStyle(.plain)
|
||||
.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 '#'
|
||||
Button(action: { showLocationChannelsSheet = true }) {
|
||||
let badgeText: String = {
|
||||
@@ -1166,6 +1186,13 @@ struct ContentView: View {
|
||||
.padding(.horizontal, 12)
|
||||
.sheet(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))
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import AppKit
|
||||
struct LocationChannelsSheet: View {
|
||||
@Binding var isPresented: Bool
|
||||
@ObservedObject private var manager = LocationChannelManager.shared
|
||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State private var customGeohash: String = ""
|
||||
@@ -83,29 +84,22 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
// Begin periodic refresh while sheet is open
|
||||
manager.beginLiveRefresh()
|
||||
// Begin multi-channel sampling for counts
|
||||
let ghs = manager.availableChannels.map { $0.geohash }
|
||||
viewModel.beginGeohashSampling(for: ghs)
|
||||
// Geohash sampling is now managed by ChatViewModel globally
|
||||
}
|
||||
.onDisappear {
|
||||
manager.endLiveRefresh()
|
||||
viewModel.endGeohashSampling()
|
||||
}
|
||||
.onChange(of: manager.permissionState) { newValue in
|
||||
if newValue == LocationChannelManager.PermissionState.authorized {
|
||||
manager.refreshChannels()
|
||||
}
|
||||
}
|
||||
.onChange(of: manager.availableChannels) { newValue in
|
||||
// Keep sampling list in sync with available channels as they refresh live
|
||||
let ghs = newValue.map { $0.geohash }
|
||||
viewModel.beginGeohashSampling(for: ghs)
|
||||
}
|
||||
.onChange(of: manager.availableChannels) { _ in }
|
||||
}
|
||||
|
||||
private var channelList: some View {
|
||||
List {
|
||||
// Mesh option first
|
||||
// Mesh option first (no bookmark)
|
||||
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||||
manager.select(ChannelID.mesh)
|
||||
isPresented = false
|
||||
@@ -119,7 +113,21 @@ struct LocationChannelsSheet: View {
|
||||
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||||
let subtitlePrefix = "#\(channel.geohash) • \(coverage)"
|
||||
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.
|
||||
manager.markTeleported(for: channel.geohash, false)
|
||||
manager.select(ChannelID.location(channel))
|
||||
@@ -195,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
|
||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||
Button(action: {
|
||||
@@ -227,14 +277,24 @@ struct LocationChannelsSheet: View {
|
||||
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 {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
// Render title with smaller font for trailing count in parentheses
|
||||
let parts = splitTitleAndCount(title)
|
||||
HStack(spacing: 4) {
|
||||
Text(parts.base)
|
||||
@ViewBuilder
|
||||
private func channelRow(
|
||||
title: String,
|
||||
subtitlePrefix: String,
|
||||
subtitleName: String? = nil,
|
||||
subtitleNameBold: Bool = false,
|
||||
isSelected: Bool,
|
||||
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))
|
||||
.fontWeight(titleBold ? .bold : .regular)
|
||||
.foregroundColor(titleColor ?? Color.primary)
|
||||
@@ -244,20 +304,17 @@ struct LocationChannelsSheet: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
Text(subtitlePrefix)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.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)
|
||||
}
|
||||
let subtitleFull: String = {
|
||||
if let name = subtitleName, !name.isEmpty {
|
||||
return subtitlePrefix + " • " + name
|
||||
}
|
||||
return subtitlePrefix
|
||||
}()
|
||||
Text(subtitleFull)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
Spacer()
|
||||
if isSelected {
|
||||
@@ -265,11 +322,11 @@ struct LocationChannelsSheet: View {
|
||||
.font(.system(size: 16, design: .monospaced))
|
||||
.foregroundColor(standardGreen)
|
||||
}
|
||||
trailingAccessory()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: action)
|
||||
}
|
||||
|
||||
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
|
||||
@@ -289,20 +346,28 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
|
||||
private func meshCount() -> Int {
|
||||
// Count mesh-connected OR mesh-reachable peers (exclude self)
|
||||
let myID = viewModel.meshService.myPeerID
|
||||
return viewModel.allPeers.reduce(0) { acc, peer in
|
||||
if peer.id != myID && peer.isConnected { return acc + 1 }
|
||||
if peer.id != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
|
||||
return acc
|
||||
}
|
||||
}
|
||||
|
||||
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 noun = count == 1 ? "person" : "people"
|
||||
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 {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
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