mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 23:25:20 +00:00
Refactor/ble nostr boundaries (#449)
* Refactor: move Nostr embedding and TLVs out of BLE; add NostrEmbeddedBitChat, Packets, PeerIDUtils; centralize MessageDeduplicator; update ChatViewModel to use new helpers; remove AI_CONTEXT.md and CLAUDE.md * Rename class to BLEService with compatibility alias; move mention parsing out of BLE; emit low-level BLE events to delegate; unify hex helpers; accept 64-hex in isPeerConnected; add PeerIDResolver * Project: rename file to BLEService.swift and update Xcode project; keep typealias SimplifiedBluetoothService = BLEService for compatibility * Remove SimplifiedBluetoothService alias; update app code to use BLEService explicitly * Tests: rename MockSimplifiedBluetoothService to MockBLEService; update typealiases and Xcode project * Docs: update comments to refer to BLEService (tests, protocol, noise service) * Tests: rename SimplifiedBluetoothServiceTests to BLEServiceTests; update project references and class names * Introduce Transport protocol; BLEService conforms; document delegate-only event pattern in BLEService; keep publishers internal for UnifiedPeerService * Adopt Transport end-to-end: add TransportPeerSnapshot + publishers; BLEService maps to Transport snapshots; UnifiedPeerService consumes Transport; ChatViewModel holds Transport * Fix Transport integration: replace getPeerFingerprint with getFingerprint(for:); update PrivateChatManager and CommandProcessor to use Transport; add BLEService.getFingerprint(for:); update PeerManager to use Transport * Refactor transport and BLE/Nostr layers; unify UI events; fix MainActor isolation - Rename SimplifiedBluetoothService to BLEService and slim responsibilities - Introduce Transport protocol and peerEventsDelegate for UI updates - Add NostrTransport and MessageRouter to route PM/read/favorite via BLE or Nostr - Centralize TLVs, PeerID utils, and MessageDeduplicator outside BLE - Update UnifiedPeerService and ChatViewModel to use Transport and delegate events - Fix MainActor isolation: route delegate calls via Task on MainActor; update notifyUI helper - Adjust related files and tests accordingly * BLEService: remove internal publishers; switch to delegate-only events - Drop legacy messages/peers/fullPeers publishers - Provide lightweight peerSnapshotSubject only to satisfy Transport - Rework publishFullPeerData to build snapshots from internal state and notify delegate + subject - Remove all peersPublisher.send call sites - Keep UnifiedPeerService on delegate updates exclusively * Remove inlined Nostr send helpers from ChatViewModel; route via MessageRouter - Replace direct Nostr sends (PM, ACKs, favorites) with MessageRouter - Add router method for delivery ACKs and implement NostrTransport.sendDeliveryAck - Simplify ChatViewModel favorite notification path to use router - Keep Nostr receive handling intact; reduce duplication * Fix ReadReceipt initializer usage in ChatViewModel (readerID + readerNickname) * Fix unused variable warning: replace shadowed 'nostrPubkey' bind with boolean check in ChatViewModel * Fix queued PM format: use TLV for pending messages after Noise handshake - Pending messages (including first-time favorite notifications) now use the same TLV encoding as normal sends - Ensures ChatViewModel can decode on first send, even if handshake completes after queuing --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -98,10 +98,10 @@ class PeerManager: ObservableObject {
|
||||
@Published var favorites: [BitchatPeer] = []
|
||||
@Published var mutualFavorites: [BitchatPeer] = []
|
||||
|
||||
private let meshService: SimplifiedBluetoothService
|
||||
private let meshService: Transport
|
||||
private let favoritesService = FavoritesPersistenceService.shared
|
||||
|
||||
init(meshService: SimplifiedBluetoothService) {
|
||||
init(meshService: Transport) {
|
||||
self.meshService = meshService
|
||||
updatePeers()
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - BitChat-over-Nostr Adapter
|
||||
|
||||
struct NostrEmbeddedBitChat {
|
||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
||||
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||
// TLV-encode the private message
|
||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||
guard let tlv = pm.encode() else { return nil }
|
||||
|
||||
// Prefix with NoisePayloadType
|
||||
var payload = Data([NoisePayloadType.privateMessage.rawValue])
|
||||
payload.append(tlv)
|
||||
|
||||
// Determine 8-byte recipient ID to embed
|
||||
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: recipientIDHex),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
|
||||
guard let data = packet.toBinaryData() else { return nil }
|
||||
return "bitchat1:" + base64URLEncode(data)
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
||||
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||
guard type == .delivered || type == .readReceipt else { return nil }
|
||||
|
||||
var payload = Data([type.rawValue])
|
||||
payload.append(Data(messageID.utf8))
|
||||
|
||||
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: recipientIDHex),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
|
||||
guard let data = packet.toBinaryData() else { return nil }
|
||||
return "bitchat1:" + base64URLEncode(data)
|
||||
}
|
||||
|
||||
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
|
||||
if let maybeData = Data(hexString: recipientPeerID) {
|
||||
if maybeData.count == 32 {
|
||||
// Treat as Noise static public key; derive peerID from fingerprint
|
||||
return PeerIDUtils.derivePeerID(fromPublicKey: maybeData)
|
||||
} else if maybeData.count == 8 {
|
||||
// Already an 8-byte peer ID
|
||||
return recipientPeerID
|
||||
}
|
||||
}
|
||||
// Fallback: return as-is (expecting 16 hex chars) – caller should pass a valid peer ID
|
||||
return recipientPeerID
|
||||
}
|
||||
|
||||
/// Base64url encode without padding
|
||||
private static func base64URLEncode(_ data: Data) -> String {
|
||||
let b64 = data.base64EncodedString()
|
||||
return b64
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
/// 1. **Creation**: Messages are created with type, content, and metadata
|
||||
/// 2. **Encoding**: Converted to binary format with proper field ordering
|
||||
/// 3. **Fragmentation**: Split if larger than BLE MTU (512 bytes)
|
||||
/// 4. **Transmission**: Sent via SimplifiedBluetoothService
|
||||
/// 4. **Transmission**: Sent via BLEService
|
||||
/// 5. **Routing**: Relayed by intermediate nodes (TTL decrements)
|
||||
/// 6. **Reassembly**: Fragments collected and reassembled
|
||||
/// 7. **Decoding**: Binary data parsed back to message objects
|
||||
@@ -461,6 +461,10 @@ protocol BitchatDelegate: AnyObject {
|
||||
func isFavorite(fingerprint: String) -> Bool
|
||||
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
||||
|
||||
// Low-level events for better separation of concerns
|
||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date)
|
||||
}
|
||||
|
||||
// Provide default implementation to make it effectively optional
|
||||
@@ -472,6 +476,14 @@ extension BitchatDelegate {
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
||||
// Default empty implementation
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Payload Helpers
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Protocol TLV Packets
|
||||
|
||||
struct AnnouncementPacket {
|
||||
let nickname: String
|
||||
let publicKey: Data
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case nickname = 0x01
|
||||
case noisePublicKey = 0x02
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
var data = Data()
|
||||
|
||||
// TLV for nickname
|
||||
guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil }
|
||||
data.append(TLVType.nickname.rawValue)
|
||||
data.append(UInt8(nicknameData.count))
|
||||
data.append(nicknameData)
|
||||
|
||||
// TLV for public key
|
||||
guard publicKey.count <= 255 else { return nil }
|
||||
data.append(TLVType.noisePublicKey.rawValue)
|
||||
data.append(UInt8(publicKey.count))
|
||||
data.append(publicKey)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
static func decode(from data: Data) -> AnnouncementPacket? {
|
||||
var offset = 0
|
||||
var nickname: String?
|
||||
var publicKey: Data?
|
||||
|
||||
while offset + 2 <= data.count {
|
||||
guard let type = TLVType(rawValue: data[offset]) else { return nil }
|
||||
offset += 1
|
||||
|
||||
let length = Int(data[offset])
|
||||
offset += 1
|
||||
|
||||
guard offset + length <= data.count else { return nil }
|
||||
let value = data[offset..<offset + length]
|
||||
offset += length
|
||||
|
||||
switch type {
|
||||
case .nickname:
|
||||
nickname = String(data: value, encoding: .utf8)
|
||||
case .noisePublicKey:
|
||||
publicKey = Data(value)
|
||||
}
|
||||
}
|
||||
|
||||
guard let nickname = nickname, let publicKey = publicKey else { return nil }
|
||||
return AnnouncementPacket(nickname: nickname, publicKey: publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
struct PrivateMessagePacket {
|
||||
let messageID: String
|
||||
let content: String
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case messageID = 0x00
|
||||
case content = 0x01
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
var data = Data()
|
||||
|
||||
// TLV for messageID
|
||||
guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil }
|
||||
data.append(TLVType.messageID.rawValue)
|
||||
data.append(UInt8(messageIDData.count))
|
||||
data.append(messageIDData)
|
||||
|
||||
// TLV for content
|
||||
guard let contentData = content.data(using: .utf8), contentData.count <= 255 else { return nil }
|
||||
data.append(TLVType.content.rawValue)
|
||||
data.append(UInt8(contentData.count))
|
||||
data.append(contentData)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
static func decode(from data: Data) -> PrivateMessagePacket? {
|
||||
var offset = 0
|
||||
var messageID: String?
|
||||
var content: String?
|
||||
|
||||
while offset + 2 <= data.count {
|
||||
guard let type = TLVType(rawValue: data[offset]) else { return nil }
|
||||
offset += 1
|
||||
|
||||
let length = Int(data[offset])
|
||||
offset += 1
|
||||
|
||||
guard offset + length <= data.count else { return nil }
|
||||
let value = data[offset..<offset + length]
|
||||
offset += length
|
||||
|
||||
switch type {
|
||||
case .messageID:
|
||||
messageID = String(data: value, encoding: .utf8)
|
||||
case .content:
|
||||
content = String(data: value, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
guard let messageID = messageID, let content = content else { return nil }
|
||||
return PrivateMessagePacket(messageID: messageID, content: content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Peer ID Utilities
|
||||
|
||||
struct PeerIDUtils {
|
||||
/// Derive the stable 16-hex peer ID from a Noise static public key
|
||||
static func derivePeerID(fromPublicKey publicKey: Data) -> String {
|
||||
let digest = SHA256.hash(data: publicKey)
|
||||
let hex = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return String(hex.prefix(16))
|
||||
}
|
||||
}
|
||||
|
||||
+136
-532
@@ -6,14 +6,19 @@ import CryptoKit
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Simplified Bluetooth Mesh Service - Core functionality only
|
||||
/// Target: <1500 lines (from 6470)
|
||||
final class SimplifiedBluetoothService: NSObject {
|
||||
/// BLEService — Bluetooth Mesh Transport
|
||||
/// - Emits events exclusively via `BitchatDelegate` for UI.
|
||||
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
|
||||
/// - A lightweight `peerSnapshotPublisher` is provided for non-UI services.
|
||||
final class BLEService: NSObject {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
#if DEBUG
|
||||
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5A") // testnet
|
||||
//static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet
|
||||
#else
|
||||
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet
|
||||
#endif
|
||||
static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
|
||||
|
||||
private let maxFragmentSize = 469 // 512 MTU - headers
|
||||
@@ -97,44 +102,38 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
private weak var maintenanceTimer: Timer? // Single timer for all maintenance tasks
|
||||
private var maintenanceCounter = 0 // Track maintenance cycles
|
||||
|
||||
// MARK: - Publishers
|
||||
|
||||
let messagesPublisher = PassthroughSubject<BitchatMessage, Never>()
|
||||
let peersPublisher = PassthroughSubject<[String: String], Never>() // Legacy - for backward compatibility
|
||||
|
||||
// NEW: Full peer data publisher for UnifiedPeerService
|
||||
struct PeerInfoSnapshot {
|
||||
let id: String
|
||||
let nickname: String
|
||||
let isConnected: Bool
|
||||
let noisePublicKey: Data?
|
||||
let lastSeen: Date
|
||||
// MARK: - Peer snapshots publisher (non-UI convenience)
|
||||
private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>()
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||
peerSnapshotSubject.eraseToAnyPublisher()
|
||||
}
|
||||
let fullPeersPublisher = CurrentValueSubject<[String: PeerInfoSnapshot], Never>([:])
|
||||
|
||||
// Helper to convert internal PeerInfo to public snapshot
|
||||
private func createPeerSnapshot(_ info: PeerInfo) -> PeerInfoSnapshot {
|
||||
PeerInfoSnapshot(
|
||||
id: info.id,
|
||||
nickname: info.nickname,
|
||||
isConnected: info.isConnected,
|
||||
noisePublicKey: info.noisePublicKey,
|
||||
lastSeen: info.lastSeen
|
||||
)
|
||||
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] {
|
||||
collectionsQueue.sync {
|
||||
peers.values.map { info in
|
||||
TransportPeerSnapshot(
|
||||
id: info.id,
|
||||
nickname: info.nickname,
|
||||
isConnected: info.isConnected,
|
||||
noisePublicKey: info.noisePublicKey,
|
||||
lastSeen: info.lastSeen
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delegate
|
||||
|
||||
weak var delegate: BitchatDelegate?
|
||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Notify UI on main thread (only if needed)
|
||||
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
|
||||
private func notifyUI(_ block: @escaping () -> Void) {
|
||||
if Thread.isMainThread {
|
||||
// Always hop onto the MainActor so calls to @MainActor delegates are safe
|
||||
Task { @MainActor in
|
||||
block()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: block)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +260,7 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
// Start BLE services if not already running
|
||||
if centralManager?.state == .poweredOn {
|
||||
centralManager?.scanForPeripherals(
|
||||
withServices: [SimplifiedBluetoothService.serviceUUID],
|
||||
withServices: [BLEService.serviceUUID],
|
||||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
|
||||
)
|
||||
}
|
||||
@@ -272,12 +271,18 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
}
|
||||
}
|
||||
|
||||
// Transport protocol conformance helper: simplified public message send
|
||||
func sendMessage(_ content: String, mentions: [String]) {
|
||||
// Delegate to the full API with default routing
|
||||
sendMessage(content, mentions: mentions, to: nil, messageID: nil, timestamp: nil)
|
||||
}
|
||||
|
||||
func stopServices() {
|
||||
// Send leave message synchronously to ensure delivery
|
||||
let leavePacket = BitchatPacket(
|
||||
type: MessageType.leave.rawValue,
|
||||
senderID: hexStringToData(myPeerID),
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data(),
|
||||
@@ -322,9 +327,14 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
}
|
||||
|
||||
func isPeerConnected(_ peerID: String) -> Bool {
|
||||
return collectionsQueue.sync {
|
||||
return peers[peerID]?.isConnected ?? false
|
||||
}
|
||||
// Accept both 16-hex short IDs and 64-hex Noise keys
|
||||
let shortID: String = {
|
||||
if peerID.count == 64, let key = Data(hexString: peerID) {
|
||||
return PeerIDUtils.derivePeerID(fromPublicKey: key)
|
||||
}
|
||||
return peerID
|
||||
}()
|
||||
return collectionsQueue.sync { peers[shortID]?.isConnected ?? false }
|
||||
}
|
||||
|
||||
func getPeerNicknames() -> [String: String] {
|
||||
@@ -376,8 +386,8 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
let encrypted = try noiseService.encrypt(receiptPayload, for: peerID)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: hexStringToData(myPeerID),
|
||||
recipientID: hexStringToData(peerID),
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: peerID),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encrypted,
|
||||
signature: nil,
|
||||
@@ -406,11 +416,14 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
func getPeerFingerprint(_ peerID: String) -> String? {
|
||||
return collectionsQueue.sync {
|
||||
if let publicKey = peers[peerID]?.noisePublicKey {
|
||||
return dataToHexString(publicKey)
|
||||
return publicKey.hexEncodedString()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Transport compatibility: generic naming
|
||||
|
||||
|
||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState {
|
||||
if noiseService.hasEstablishedSession(with: peerID) {
|
||||
@@ -531,15 +544,15 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: hexStringToData(myPeerID),
|
||||
recipientID: recipientData,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encrypted,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
recipientID: recipientData,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encrypted,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
// Call directly if already on messageQueue, otherwise dispatch
|
||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||
broadcastPacket(packet)
|
||||
@@ -587,8 +600,8 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
// Send handshake init
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseHandshake.rawValue,
|
||||
senderID: hexStringToData(myPeerID),
|
||||
recipientID: hexStringToData(peerID),
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: peerID),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: handshakeData,
|
||||
signature: nil,
|
||||
@@ -622,39 +635,43 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
|
||||
// Send each pending message directly (we know session is established)
|
||||
for (content, messageID) in messages {
|
||||
// Encrypt and send directly without checking session again
|
||||
do {
|
||||
// Create message payload with ID: [type byte] + [ID:xxxxx|content]
|
||||
// Use the same TLV format as normal sends to keep receiver decoding consistent
|
||||
let privateMessage = PrivateMessagePacket(messageID: messageID, content: content)
|
||||
guard let tlvData = privateMessage.encode() else {
|
||||
SecureLogger.log("Failed to encode pending private message TLV", category: SecureLogger.noise, level: .error)
|
||||
continue
|
||||
}
|
||||
|
||||
var messagePayload = Data([NoisePayloadType.privateMessage.rawValue])
|
||||
let messageWithID = "ID:\(messageID)|\(content)"
|
||||
messagePayload.append(contentsOf: messageWithID.utf8)
|
||||
|
||||
messagePayload.append(tlvData)
|
||||
|
||||
let encrypted = try noiseService.encrypt(messagePayload, for: peerID)
|
||||
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: hexStringToData(myPeerID),
|
||||
recipientID: hexStringToData(peerID),
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: peerID),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encrypted,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
|
||||
|
||||
// We're already on messageQueue from the callback
|
||||
broadcastPacket(packet)
|
||||
|
||||
|
||||
// Notify delegate that message was sent
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||
}
|
||||
|
||||
|
||||
SecureLogger.log("✅ Sent pending message \(messageID) to \(peerID) after handshake",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
} catch {
|
||||
SecureLogger.log("Failed to send pending message after handshake: \(error)",
|
||||
category: SecureLogger.noise, level: .error)
|
||||
|
||||
|
||||
// Notify delegate of failure
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .failed(reason: "Encryption failed"))
|
||||
@@ -691,7 +708,7 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
// Handshakes need broader delivery to establish encryption
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let recipientID = packet.recipientID {
|
||||
let recipientPeerID = dataToHexString(recipientID)
|
||||
let recipientPeerID = recipientID.hexEncodedString()
|
||||
var sentEncrypted = false
|
||||
|
||||
// Check routing availability (only log if there's an issue)
|
||||
@@ -756,7 +773,7 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
// 1. First try sending as central via writes to connected peripherals
|
||||
// This is the preferred path when we have direct peripheral connections
|
||||
for state in peripherals.values where state.isConnected {
|
||||
if let characteristic = state.characteristic {
|
||||
if let characteristic = state.characteristic {
|
||||
state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||
sentToPeripherals += 1
|
||||
}
|
||||
@@ -907,7 +924,7 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
|
||||
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: String) {
|
||||
// Deduplication (thread-safe)
|
||||
let senderID = dataToHexString(packet.senderID)
|
||||
let senderID = packet.senderID.hexEncodedString()
|
||||
// Include packet type in message ID to prevent collisions between different packet types
|
||||
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)"
|
||||
|
||||
@@ -983,7 +1000,7 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
|
||||
// Verify that the sender's derived ID from the announced public key matches the packet senderID
|
||||
// This helps detect relayed or spoofed announces. Only warn in release; assert in debug.
|
||||
let derivedFromKey = Self.derivePeerID(fromPublicKey: announcement.publicKey)
|
||||
let derivedFromKey = PeerIDUtils.derivePeerID(fromPublicKey: announcement.publicKey)
|
||||
if derivedFromKey != peerID {
|
||||
SecureLogger.log("⚠️ Announce sender mismatch: derived \(derivedFromKey.prefix(8))… vs packet \(peerID.prefix(8))…", category: SecureLogger.security, level: .warning)
|
||||
#if DEBUG
|
||||
@@ -1062,8 +1079,7 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
self.delegate?.didConnectToPeer(peerID)
|
||||
}
|
||||
|
||||
self.peersPublisher.send(self.getPeers())
|
||||
self.publishFullPeerData() // NEW: Publish full peer data
|
||||
self.publishFullPeerData()
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
|
||||
@@ -1081,29 +1097,7 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func parseMentions(from content: String) -> [String] {
|
||||
let pattern = "@([\\p{L}0-9_]+)"
|
||||
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
||||
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
||||
|
||||
var mentions: [String] = []
|
||||
|
||||
// Get all known nicknames including our own
|
||||
var allNicknames = Set(peers.values.map { $0.nickname })
|
||||
allNicknames.insert(myNickname)
|
||||
|
||||
for match in matches {
|
||||
if let range = Range(match.range(at: 1), in: content) {
|
||||
let mentionedName = String(content[range])
|
||||
// Check if this is a valid nickname
|
||||
if allNicknames.contains(mentionedName) {
|
||||
mentions.append(mentionedName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array(Set(mentions)) // Remove duplicates
|
||||
}
|
||||
// Mention parsing moved to ChatViewModel
|
||||
|
||||
private func handleMessage(_ packet: BitchatPacket, from peerID: String) {
|
||||
// Don't process our own messages
|
||||
@@ -1119,43 +1113,24 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
let senderNickname = peers[peerID]?.nickname ?? "Unknown"
|
||||
SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Parse mentions from the message content
|
||||
let mentions = parseMentions(from: content)
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: UUID().uuidString,
|
||||
sender: senderNickname,
|
||||
content: content,
|
||||
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
// Send on main thread (without capturing self strongly)
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
guard let self = self else { return }
|
||||
// Deliver to UI
|
||||
self.messagesPublisher.send(message)
|
||||
self.delegate?.didReceiveMessage(message)
|
||||
self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: String) {
|
||||
// Use NoiseEncryptionService for handshake processing
|
||||
if let recipientID = packet.recipientID,
|
||||
dataToHexString(recipientID) == myPeerID {
|
||||
recipientID.hexEncodedString() == myPeerID {
|
||||
// Handshake is for us
|
||||
do {
|
||||
if let response = try noiseService.processHandshakeMessage(from: peerID, message: packet.payload) {
|
||||
// Send response
|
||||
let responsePacket = BitchatPacket(
|
||||
type: MessageType.noiseHandshake.rawValue,
|
||||
senderID: hexStringToData(myPeerID),
|
||||
recipientID: hexStringToData(peerID),
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: peerID),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: response,
|
||||
signature: nil,
|
||||
@@ -1186,7 +1161,7 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
let recipientHex = dataToHexString(recipientID)
|
||||
let recipientHex = recipientID.hexEncodedString()
|
||||
if recipientHex != myPeerID {
|
||||
SecureLogger.log("🔐 Encrypted message not for me (for \(recipientHex), I am \(myPeerID))", category: SecureLogger.session, level: .debug)
|
||||
return
|
||||
@@ -1205,71 +1180,20 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
|
||||
switch NoisePayloadType(rawValue: payloadType) {
|
||||
case .privateMessage:
|
||||
// Try to decode as TLV first
|
||||
guard let privateMessage = PrivateMessagePacket.decode(from: Data(payloadData)) else {
|
||||
SecureLogger.log("⚠️ Failed to decode private message with TLV format",
|
||||
category: SecureLogger.noise, level: .warning)
|
||||
return
|
||||
}
|
||||
// Successfully decoded TLV format
|
||||
let messageID = privateMessage.messageID
|
||||
let messageContent = privateMessage.content
|
||||
|
||||
// Parse mentions even in private messages
|
||||
let mentions = parseMentions(from: messageContent)
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: peers[peerID]?.nickname ?? "Unknown",
|
||||
content: messageContent,
|
||||
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: myNickname,
|
||||
senderPeerID: peerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
SecureLogger.log("🔓 Decrypted TLV PM from \(message.sender): \(messageContent.prefix(30))...", category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Send on main thread
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
if let delegate = self?.delegate {
|
||||
SecureLogger.log("📨 Forwarding PM to ChatViewModel delegate", category: SecureLogger.session, level: .debug)
|
||||
delegate.didReceiveMessage(message)
|
||||
} else {
|
||||
SecureLogger.log("⚠️ Delegate is nil, cannot forward PM to ChatViewModel", category: SecureLogger.session, level: .warning)
|
||||
}
|
||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts)
|
||||
}
|
||||
|
||||
// Send delivery ACK
|
||||
sendDeliveryAck(for: messageID, to: peerID)
|
||||
|
||||
|
||||
case .delivered:
|
||||
// Handle delivery ACK
|
||||
guard let messageID = String(data: payloadData, encoding: .utf8) else { return }
|
||||
// Delivery ACK received - no need to log
|
||||
|
||||
// Update delivery status
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: peerID, at: Date()))
|
||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data(payloadData), timestamp: ts)
|
||||
}
|
||||
|
||||
case .readReceipt:
|
||||
// Handle read receipt
|
||||
guard let messageID = String(data: payloadData, encoding: .utf8) else { return }
|
||||
|
||||
SecureLogger.log("📖 Received READ receipt for message \(messageID) from \(peerID)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Update read status
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
let nickname = self?.peers[peerID]?.nickname ?? "Unknown"
|
||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .read(by: nickname, at: Date()))
|
||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
|
||||
}
|
||||
|
||||
default:
|
||||
SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning)
|
||||
}
|
||||
@@ -1299,7 +1223,6 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
// Get current peer list (after removal)
|
||||
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
|
||||
|
||||
self.peersPublisher.send(self.getPeers())
|
||||
self.delegate?.didDisconnectFromPeer(peerID)
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
@@ -1361,7 +1284,7 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func sendDeliveryAck(for messageID: String, to peerID: String) {
|
||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
||||
// Send encrypted delivery ACK
|
||||
guard noiseService.hasSession(with: peerID) else {
|
||||
SecureLogger.log("Cannot send ACK - no Noise session with \(peerID)", category: SecureLogger.noise, level: .warning)
|
||||
@@ -1376,8 +1299,8 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
let encrypted = try noiseService.encrypt(ackPayload, for: peerID)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: hexStringToData(myPeerID),
|
||||
recipientID: hexStringToData(peerID),
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: peerID),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encrypted,
|
||||
signature: nil,
|
||||
@@ -1400,14 +1323,25 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
// NEW: Publish full peer data to subscribers
|
||||
// NEW: Publish peer snapshots to subscribers and notify Transport delegates
|
||||
private func publishFullPeerData() {
|
||||
let snapshot = collectionsQueue.sync { () -> [String: PeerInfoSnapshot] in
|
||||
Dictionary(uniqueKeysWithValues: peers.map { (id, info) in
|
||||
(id, createPeerSnapshot(info))
|
||||
})
|
||||
let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync {
|
||||
peers.values.map { info in
|
||||
TransportPeerSnapshot(
|
||||
id: info.id,
|
||||
nickname: info.nickname,
|
||||
isConnected: info.isConnected,
|
||||
noisePublicKey: info.noisePublicKey,
|
||||
lastSeen: info.lastSeen
|
||||
)
|
||||
}
|
||||
}
|
||||
// Notify non-UI listeners
|
||||
peerSnapshotSubject.send(transportPeers)
|
||||
// Notify UI on MainActor via delegate
|
||||
Task { @MainActor [weak self] in
|
||||
self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers)
|
||||
}
|
||||
fullPeersPublisher.send(snapshot)
|
||||
}
|
||||
|
||||
// MARK: - Consolidated Maintenance
|
||||
@@ -1479,7 +1413,6 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
for peerID in disconnectedPeers {
|
||||
self.delegate?.didDisconnectFromPeer(peerID)
|
||||
}
|
||||
self.peersPublisher.send(self.getPeers())
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
}
|
||||
@@ -1505,7 +1438,7 @@ final class SimplifiedBluetoothService: NSObject {
|
||||
|
||||
// MARK: - CBCentralManagerDelegate
|
||||
|
||||
extension SimplifiedBluetoothService: CBCentralManagerDelegate {
|
||||
extension BLEService: CBCentralManagerDelegate {
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
if central.state == .poweredOn {
|
||||
// Start scanning - use allow duplicates for faster discovery when active
|
||||
@@ -1527,7 +1460,7 @@ extension SimplifiedBluetoothService: CBCentralManagerDelegate {
|
||||
#endif
|
||||
|
||||
central.scanForPeripherals(
|
||||
withServices: [SimplifiedBluetoothService.serviceUUID],
|
||||
withServices: [BLEService.serviceUUID],
|
||||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates]
|
||||
)
|
||||
|
||||
@@ -1630,7 +1563,7 @@ extension SimplifiedBluetoothService: CBCentralManagerDelegate {
|
||||
SecureLogger.log("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Discover services
|
||||
peripheral.discoverServices([SimplifiedBluetoothService.serviceUUID])
|
||||
peripheral.discoverServices([BLEService.serviceUUID])
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||||
@@ -1674,8 +1607,7 @@ extension SimplifiedBluetoothService: CBCentralManagerDelegate {
|
||||
if let peerID = peerID {
|
||||
self.delegate?.didDisconnectFromPeer(peerID)
|
||||
}
|
||||
self.peersPublisher.send(self.getPeers())
|
||||
self.publishFullPeerData() // NEW: Publish full peer data
|
||||
self.publishFullPeerData()
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
}
|
||||
@@ -1692,14 +1624,14 @@ extension SimplifiedBluetoothService: CBCentralManagerDelegate {
|
||||
|
||||
// MARK: - CBPeripheralDelegate
|
||||
|
||||
extension SimplifiedBluetoothService: CBPeripheralDelegate {
|
||||
extension BLEService: CBPeripheralDelegate {
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
if let error = error {
|
||||
SecureLogger.log("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: SecureLogger.session, level: .error)
|
||||
// Retry service discovery after a delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
guard peripheral.state == .connected else { return }
|
||||
peripheral.discoverServices([SimplifiedBluetoothService.serviceUUID])
|
||||
peripheral.discoverServices([BLEService.serviceUUID])
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1709,14 +1641,14 @@ extension SimplifiedBluetoothService: CBPeripheralDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
guard let service = services.first(where: { $0.uuid == SimplifiedBluetoothService.serviceUUID }) else {
|
||||
guard let service = services.first(where: { $0.uuid == BLEService.serviceUUID }) else {
|
||||
// Not a BitChat peer - disconnect
|
||||
centralManager?.cancelPeripheralConnection(peripheral)
|
||||
return
|
||||
}
|
||||
|
||||
// Discovering BLE characteristics
|
||||
peripheral.discoverCharacteristics([SimplifiedBluetoothService.characteristicUUID], for: service)
|
||||
peripheral.discoverCharacteristics([BLEService.characteristicUUID], for: service)
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||||
@@ -1725,7 +1657,7 @@ extension SimplifiedBluetoothService: CBPeripheralDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
guard let characteristic = service.characteristics?.first(where: { $0.uuid == SimplifiedBluetoothService.characteristicUUID }) else {
|
||||
guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else {
|
||||
SecureLogger.log("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: SecureLogger.session, level: .warning)
|
||||
return
|
||||
}
|
||||
@@ -1788,7 +1720,7 @@ extension SimplifiedBluetoothService: CBPeripheralDelegate {
|
||||
}
|
||||
|
||||
// Use the packet's senderID as the peer identifier
|
||||
let senderID = dataToHexString(packet.senderID)
|
||||
let senderID = packet.senderID.hexEncodedString()
|
||||
// Only log non-announce packets
|
||||
if packet.type != MessageType.announce.rawValue {
|
||||
SecureLogger.log("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: SecureLogger.session, level: .debug)
|
||||
@@ -1834,7 +1766,7 @@ extension SimplifiedBluetoothService: CBPeripheralDelegate {
|
||||
SecureLogger.log("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: SecureLogger.session, level: .warning)
|
||||
|
||||
// Check if our service was invalidated (peer app quit)
|
||||
let hasOurService = peripheral.services?.contains { $0.uuid == SimplifiedBluetoothService.serviceUUID } ?? false
|
||||
let hasOurService = peripheral.services?.contains { $0.uuid == BLEService.serviceUUID } ?? false
|
||||
|
||||
if !hasOurService {
|
||||
// Service is gone - disconnect
|
||||
@@ -1842,7 +1774,7 @@ extension SimplifiedBluetoothService: CBPeripheralDelegate {
|
||||
centralManager?.cancelPeripheralConnection(peripheral)
|
||||
} else {
|
||||
// Try to rediscover
|
||||
peripheral.discoverServices([SimplifiedBluetoothService.serviceUUID])
|
||||
peripheral.discoverServices([BLEService.serviceUUID])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1863,7 +1795,7 @@ extension SimplifiedBluetoothService: CBPeripheralDelegate {
|
||||
|
||||
// MARK: - CBPeripheralManagerDelegate
|
||||
|
||||
extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
|
||||
extension BLEService: CBPeripheralManagerDelegate {
|
||||
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
|
||||
SecureLogger.log("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: SecureLogger.session, level: .debug)
|
||||
|
||||
@@ -1873,14 +1805,14 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
|
||||
|
||||
// Create characteristic
|
||||
characteristic = CBMutableCharacteristic(
|
||||
type: SimplifiedBluetoothService.characteristicUUID,
|
||||
type: BLEService.characteristicUUID,
|
||||
properties: [.notify, .write, .writeWithoutResponse, .read],
|
||||
value: nil,
|
||||
permissions: [.readable, .writeable]
|
||||
)
|
||||
|
||||
// Create service
|
||||
let service = CBMutableService(type: SimplifiedBluetoothService.serviceUUID, primary: true)
|
||||
let service = CBMutableService(type: BLEService.serviceUUID, primary: true)
|
||||
service.characteristics = [characteristic!]
|
||||
|
||||
// Add service (advertising will start in didAdd delegate)
|
||||
@@ -1941,7 +1873,6 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
|
||||
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
|
||||
|
||||
self.delegate?.didDisconnectFromPeer(peerID)
|
||||
self.peersPublisher.send(self.getPeers())
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
}
|
||||
@@ -2019,7 +1950,7 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
|
||||
|
||||
if let packet = BinaryProtocol.decode(data) {
|
||||
// Use the packet's senderID as the peer identifier
|
||||
let senderID = dataToHexString(packet.senderID)
|
||||
let senderID = packet.senderID.hexEncodedString()
|
||||
// Only log non-announce packets
|
||||
if packet.type != MessageType.announce.rawValue {
|
||||
SecureLogger.log("📦 Decoded packet type: \(packet.type) from sender: \(senderID)", category: SecureLogger.session, level: .debug)
|
||||
@@ -2052,45 +1983,15 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
|
||||
SecureLogger.log("❌ Failed to decode packet from central, full data: \(data.map { String(format: "%02x", $0) }.joined(separator: " "))", category: SecureLogger.session, level: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Functions
|
||||
|
||||
private func hexStringToData(_ hex: String) -> Data {
|
||||
var data = Data()
|
||||
var tempID = hex
|
||||
while tempID.count >= 2 {
|
||||
let hexByte = String(tempID.prefix(2))
|
||||
if let byte = UInt8(hexByte, radix: 16) {
|
||||
data.append(byte)
|
||||
}
|
||||
tempID = String(tempID.dropFirst(2))
|
||||
}
|
||||
if tempID.count == 1 {
|
||||
if let byte = UInt8(tempID, radix: 16) {
|
||||
data.append(byte)
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private func dataToHexString(_ data: Data) -> String {
|
||||
return data.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Advertising Builders & Alias Rotation
|
||||
|
||||
extension SimplifiedBluetoothService {
|
||||
static func derivePeerID(fromPublicKey publicKey: Data) -> String {
|
||||
let digest = SHA256.hash(data: publicKey)
|
||||
let hex = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return String(hex.prefix(16))
|
||||
}
|
||||
|
||||
extension BLEService {
|
||||
private func buildAdvertisementData() -> [String: Any] {
|
||||
let data: [String: Any] = [
|
||||
CBAdvertisementDataServiceUUIDsKey: [SimplifiedBluetoothService.serviceUUID]
|
||||
CBAdvertisementDataServiceUUIDsKey: [BLEService.serviceUUID]
|
||||
]
|
||||
// No Local Name for privacy
|
||||
return data
|
||||
@@ -2098,300 +1999,3 @@ extension SimplifiedBluetoothService {
|
||||
|
||||
// No alias rotation or advertising restarts required.
|
||||
}
|
||||
|
||||
// MARK: - Nostr Embedding Helpers
|
||||
|
||||
extension SimplifiedBluetoothService {
|
||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message
|
||||
/// for transport over Nostr DMs. The payload is a plaintext typed NoisePayload
|
||||
/// (no inner Noise encryption; NIP-17 provides transport-layer E2E).
|
||||
func buildNostrEmbeddedPrivateMessageContent(content: String, to recipientPeerID: String, messageID: String) -> String? {
|
||||
// TLV-encode the private message
|
||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||
guard let tlv = pm.encode() else { return nil }
|
||||
|
||||
// Prefix with NoisePayloadType
|
||||
var payload = Data([NoisePayloadType.privateMessage.rawValue])
|
||||
payload.append(tlv)
|
||||
|
||||
// Build BitChat packet (noiseEncrypted type used as a typed envelope)
|
||||
// Determine correct 8-byte recipient ID (peerID) to embed
|
||||
let recipientIDHex: String = {
|
||||
if let maybeData = Data(hexString: recipientPeerID) {
|
||||
if maybeData.count == 32 {
|
||||
// Treat as Noise static public key; derive peerID from fingerprint
|
||||
return Self.derivePeerID(fromPublicKey: maybeData)
|
||||
} else if maybeData.count == 8 {
|
||||
// Already an 8-byte peer ID
|
||||
return recipientPeerID
|
||||
}
|
||||
}
|
||||
// Fallback (should not happen): use myPeerID to avoid dropping
|
||||
return recipientPeerID.count == 16 ? recipientPeerID : myPeerID
|
||||
}()
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: hexStringToData(myPeerID),
|
||||
recipientID: hexStringToData(recipientIDHex),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
|
||||
guard let data = packet.toBinaryData() else { return nil }
|
||||
return "bitchat1:" + Self.base64URLEncode(data)
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack
|
||||
/// for transport over Nostr DMs. Payload is plaintext typed NoisePayload.
|
||||
func buildNostrEmbeddedAckContent(type: NoisePayloadType, messageID: String, to recipientPeerID: String) -> String? {
|
||||
guard type == .delivered || type == .readReceipt else { return nil }
|
||||
|
||||
var payload = Data([type.rawValue])
|
||||
payload.append(Data(messageID.utf8))
|
||||
|
||||
// Determine correct 8-byte recipient ID (peerID) to embed
|
||||
let recipientIDHex: String = {
|
||||
if let maybeData = Data(hexString: recipientPeerID) {
|
||||
if maybeData.count == 32 {
|
||||
return Self.derivePeerID(fromPublicKey: maybeData)
|
||||
} else if maybeData.count == 8 {
|
||||
return recipientPeerID
|
||||
}
|
||||
}
|
||||
return recipientPeerID.count == 16 ? recipientPeerID : myPeerID
|
||||
}()
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: hexStringToData(myPeerID),
|
||||
recipientID: hexStringToData(recipientIDHex),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
|
||||
guard let data = packet.toBinaryData() else { return nil }
|
||||
return "bitchat1:" + Self.base64URLEncode(data)
|
||||
}
|
||||
|
||||
/// Base64url encode without padding
|
||||
private static func base64URLEncode(_ data: Data) -> String {
|
||||
let b64 = data.base64EncodedString()
|
||||
let urlSafe = b64
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
return urlSafe
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Deduplicator
|
||||
|
||||
/// Efficient message deduplication with time-based cleanup
|
||||
private class MessageDeduplicator {
|
||||
private struct Entry {
|
||||
let messageID: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
private var entries: [Entry] = []
|
||||
private var lookup = Set<String>()
|
||||
private let lock = NSLock()
|
||||
private let maxAge: TimeInterval = 300 // 5 minutes
|
||||
private let maxCount = 1000
|
||||
|
||||
/// Check if message is duplicate and add if not
|
||||
func isDuplicate(_ messageID: String) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
// Clean old entries first
|
||||
cleanupOldEntries()
|
||||
|
||||
if lookup.contains(messageID) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Add new entry
|
||||
entries.append(Entry(messageID: messageID, timestamp: Date()))
|
||||
lookup.insert(messageID)
|
||||
|
||||
// Efficient cleanup when over limit
|
||||
if entries.count > maxCount {
|
||||
// Remove oldest 100 entries
|
||||
let toRemove = entries.prefix(100)
|
||||
toRemove.forEach { lookup.remove($0.messageID) }
|
||||
entries.removeFirst(100)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/// Add an ID without checking (for announce-back tracking)
|
||||
func markProcessed(_ messageID: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
if !lookup.contains(messageID) {
|
||||
entries.append(Entry(messageID: messageID, timestamp: Date()))
|
||||
lookup.insert(messageID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if ID exists without adding
|
||||
func contains(_ messageID: String) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return lookup.contains(messageID)
|
||||
}
|
||||
|
||||
/// Clear all entries
|
||||
func reset() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
entries.removeAll()
|
||||
lookup.removeAll()
|
||||
}
|
||||
|
||||
/// Periodic cleanup (called from timer)
|
||||
func cleanup() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
cleanupOldEntries()
|
||||
|
||||
// Additional memory optimization if needed
|
||||
if entries.capacity > maxCount * 2 {
|
||||
entries.reserveCapacity(maxCount)
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupOldEntries() {
|
||||
let cutoff = Date().addingTimeInterval(-maxAge)
|
||||
|
||||
// Remove all entries older than cutoff
|
||||
while let first = entries.first, first.timestamp < cutoff {
|
||||
lookup.remove(first.messageID)
|
||||
entries.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Types
|
||||
|
||||
private struct AnnouncementPacket {
|
||||
let nickname: String
|
||||
let publicKey: Data
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case nickname = 0x01
|
||||
case noisePublicKey = 0x02
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
var data = Data()
|
||||
|
||||
// TLV for nickname
|
||||
guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil }
|
||||
data.append(TLVType.nickname.rawValue)
|
||||
data.append(UInt8(nicknameData.count))
|
||||
data.append(nicknameData)
|
||||
|
||||
// TLV for public key
|
||||
guard publicKey.count <= 255 else { return nil }
|
||||
data.append(TLVType.noisePublicKey.rawValue)
|
||||
data.append(UInt8(publicKey.count))
|
||||
data.append(publicKey)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
static func decode(from data: Data) -> AnnouncementPacket? {
|
||||
var offset = 0
|
||||
var nickname: String?
|
||||
var publicKey: Data?
|
||||
|
||||
while offset + 2 <= data.count {
|
||||
guard let type = TLVType(rawValue: data[offset]) else { return nil }
|
||||
offset += 1
|
||||
|
||||
let length = Int(data[offset])
|
||||
offset += 1
|
||||
|
||||
guard offset + length <= data.count else { return nil }
|
||||
let value = data[offset..<offset + length]
|
||||
offset += length
|
||||
|
||||
switch type {
|
||||
case .nickname:
|
||||
nickname = String(data: value, encoding: .utf8)
|
||||
case .noisePublicKey:
|
||||
publicKey = Data(value)
|
||||
}
|
||||
}
|
||||
|
||||
guard let nickname = nickname, let publicKey = publicKey else { return nil }
|
||||
return AnnouncementPacket(nickname: nickname, publicKey: publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PrivateMessagePacket {
|
||||
let messageID: String
|
||||
let content: String
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case messageID = 0x00
|
||||
case content = 0x01
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
var data = Data()
|
||||
|
||||
// TLV for messageID
|
||||
guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil }
|
||||
data.append(TLVType.messageID.rawValue)
|
||||
data.append(UInt8(messageIDData.count))
|
||||
data.append(messageIDData)
|
||||
|
||||
// TLV for content
|
||||
guard let contentData = content.data(using: .utf8), contentData.count <= 255 else { return nil }
|
||||
data.append(TLVType.content.rawValue)
|
||||
data.append(UInt8(contentData.count))
|
||||
data.append(contentData)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
static func decode(from data: Data) -> PrivateMessagePacket? {
|
||||
var offset = 0
|
||||
var messageID: String?
|
||||
var content: String?
|
||||
|
||||
while offset + 2 <= data.count {
|
||||
guard let type = TLVType(rawValue: data[offset]) else { return nil }
|
||||
offset += 1
|
||||
|
||||
let length = Int(data[offset])
|
||||
offset += 1
|
||||
|
||||
guard offset + length <= data.count else { return nil }
|
||||
let value = data[offset..<offset + length]
|
||||
offset += length
|
||||
|
||||
switch type {
|
||||
case .messageID:
|
||||
messageID = String(data: value, encoding: .utf8)
|
||||
case .content:
|
||||
content = String(data: value, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
guard let messageID = messageID, let content = content else { return nil }
|
||||
return PrivateMessagePacket(messageID: messageID, content: content)
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,9 @@ enum CommandResult {
|
||||
@MainActor
|
||||
class CommandProcessor {
|
||||
weak var chatViewModel: ChatViewModel?
|
||||
weak var meshService: SimplifiedBluetoothService?
|
||||
weak var meshService: Transport?
|
||||
|
||||
init(chatViewModel: ChatViewModel? = nil, meshService: SimplifiedBluetoothService? = nil) {
|
||||
init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.meshService = meshService
|
||||
}
|
||||
@@ -127,7 +127,7 @@ class CommandProcessor {
|
||||
}
|
||||
} else {
|
||||
// In public chat
|
||||
meshService?.sendMessage(emoteContent)
|
||||
meshService?.sendMessage(emoteContent, mentions: [])
|
||||
}
|
||||
|
||||
return .handled
|
||||
@@ -145,7 +145,7 @@ class CommandProcessor {
|
||||
var blockedNicknames: [String] = []
|
||||
if let peers = meshService?.getPeerNicknames() {
|
||||
for (peerID, nickname) in peers {
|
||||
if let fingerprint = meshService?.getPeerFingerprint(peerID),
|
||||
if let fingerprint = meshService?.getFingerprint(for: peerID),
|
||||
blockedUsers.contains(fingerprint) {
|
||||
blockedNicknames.append(nickname)
|
||||
}
|
||||
@@ -160,7 +160,7 @@ class CommandProcessor {
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getPeerFingerprint(peerID) else {
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) else {
|
||||
return .error(message: "cannot block \(nickname): not found or unable to verify identity")
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ class CommandProcessor {
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getPeerFingerprint(peerID) else {
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) else {
|
||||
return .error(message: "cannot unblock \(nickname): not found")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
/// Routes messages between BLE and Nostr transports
|
||||
@MainActor
|
||||
final class MessageRouter {
|
||||
private let mesh: Transport
|
||||
private let nostr: NostrTransport
|
||||
|
||||
init(mesh: Transport, nostr: NostrTransport) {
|
||||
self.mesh = mesh
|
||||
self.nostr = nostr
|
||||
self.nostr.senderPeerID = mesh.myPeerID
|
||||
}
|
||||
|
||||
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||
if mesh.isPeerConnected(peerID) {
|
||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
} else {
|
||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||
if mesh.isPeerConnected(peerID) {
|
||||
mesh.sendReadReceipt(receipt, to: peerID)
|
||||
} else {
|
||||
nostr.sendReadReceipt(receipt, to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
||||
if mesh.isPeerConnected(peerID) {
|
||||
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||
} else {
|
||||
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||
if mesh.isPeerConnected(peerID) {
|
||||
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
} else {
|
||||
nostr.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
///
|
||||
/// High-level encryption service that manages Noise Protocol sessions for secure
|
||||
/// peer-to-peer communication in BitChat. Acts as the bridge between the transport
|
||||
/// layer (SimplifiedBluetoothService) and the cryptographic layer (NoiseProtocol).
|
||||
/// layer (BLEService) and the cryptographic layer (NoiseProtocol).
|
||||
///
|
||||
/// ## Overview
|
||||
/// This service provides a simplified API for establishing and managing encrypted
|
||||
@@ -60,7 +60,7 @@
|
||||
/// ```
|
||||
///
|
||||
/// ## Integration Points
|
||||
/// - **SimplifiedBluetoothService**: Calls this service for all private messages
|
||||
/// - **BLEService**: Calls this service for all private messages
|
||||
/// - **ChatViewModel**: Monitors encryption status for UI indicators
|
||||
/// - **NoiseHandshakeCoordinator**: Prevents handshake race conditions
|
||||
/// - **KeychainManager**: Secure storage for identity keys
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
// Minimal Nostr transport conforming to Transport for offline sending
|
||||
final class NostrTransport: Transport {
|
||||
weak var delegate: BitchatDelegate?
|
||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||
Just([]).eraseToAnyPublisher()
|
||||
}
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
||||
|
||||
// Provide BLE short peer ID for BitChat embedding
|
||||
var senderPeerID: String = ""
|
||||
|
||||
var myPeerID: String { senderPeerID }
|
||||
var myNickname: String { "" }
|
||||
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
||||
|
||||
func startServices() { /* no-op */ }
|
||||
func stopServices() { /* no-op */ }
|
||||
func emergencyDisconnectAll() { /* no-op */ }
|
||||
|
||||
func isPeerConnected(_ peerID: String) -> Bool { false }
|
||||
func getPeerNicknames() -> [String : String] { [:] }
|
||||
|
||||
func getFingerprint(for peerID: String) -> String? { nil }
|
||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none }
|
||||
func triggerHandshake(with peerID: String) { /* no-op */ }
|
||||
func getNoiseService() -> NoiseEncryptionService { NoiseEncryptionService() }
|
||||
|
||||
// Public broadcast not supported over Nostr here
|
||||
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
|
||||
|
||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard let noiseKey = Data(hexString: peerID),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
let recipientNostrPubkey = fav.peerNostrPublicKey else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID),
|
||||
let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientNostrPubkey, senderIdentity: senderIdentity) else { return }
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||
Task { @MainActor in
|
||||
guard let noiseKey = Data(hexString: peerID),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
let recipientNostrPubkey = fav.peerNostrPublicKey else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: receipt.originalMessageID, recipientPeerID: peerID, senderPeerID: senderPeerID),
|
||||
let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientNostrPubkey, senderIdentity: senderIdentity) else { return }
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||
Task { @MainActor in
|
||||
guard let noiseKey = Data(hexString: peerID),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
let recipientNostrPubkey = fav.peerNostrPublicKey else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID),
|
||||
let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientNostrPubkey, senderIdentity: senderIdentity) else { return }
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
||||
Task { @MainActor in
|
||||
guard let noiseKey = Data(hexString: peerID),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
let recipientNostrPubkey = fav.peerNostrPublicKey else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID),
|
||||
let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientNostrPubkey, senderIdentity: senderIdentity) else { return }
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,9 @@ class PrivateChatManager: ObservableObject {
|
||||
private var selectedPeerFingerprint: String? = nil
|
||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||
|
||||
weak var meshService: SimplifiedBluetoothService?
|
||||
weak var meshService: Transport?
|
||||
|
||||
init(meshService: SimplifiedBluetoothService? = nil) {
|
||||
init(meshService: Transport? = nil) {
|
||||
self.meshService = meshService
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class PrivateChatManager: ObservableObject {
|
||||
selectedPeer = peerID
|
||||
|
||||
// Store fingerprint for persistence across reconnections
|
||||
if let fingerprint = meshService?.getPeerFingerprint(peerID) {
|
||||
if let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
selectedPeerFingerprint = fingerprint
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ class PrivateChatManager: ObservableObject {
|
||||
|
||||
// Find peer with matching fingerprint
|
||||
for (peerID, _) in peers {
|
||||
if meshService?.getPeerFingerprint(peerID) == fingerprint {
|
||||
if meshService?.getFingerprint(for: peerID) == fingerprint {
|
||||
selectedPeer = peerID
|
||||
break
|
||||
}
|
||||
@@ -186,4 +186,4 @@ class PrivateChatManager: ObservableObject {
|
||||
// Send through mesh service's read receipt method
|
||||
meshService?.sendReadReceipt(receipt, to: senderPeerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Abstract transport interface used by ChatViewModel and services.
|
||||
/// BLEService implements this protocol; a future Nostr transport can too.
|
||||
struct TransportPeerSnapshot {
|
||||
let id: String
|
||||
let nickname: String
|
||||
let isConnected: Bool
|
||||
let noisePublicKey: Data?
|
||||
let lastSeen: Date
|
||||
}
|
||||
|
||||
protocol Transport: AnyObject {
|
||||
// Peer events (preferred over publishers for UI)
|
||||
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
||||
// Event sink
|
||||
var delegate: BitchatDelegate? { get set }
|
||||
|
||||
// Identity
|
||||
var myPeerID: String { get }
|
||||
var myNickname: String { get }
|
||||
func setNickname(_ nickname: String)
|
||||
|
||||
// Lifecycle
|
||||
func startServices()
|
||||
func stopServices()
|
||||
func emergencyDisconnectAll()
|
||||
|
||||
// Connectivity and peers
|
||||
func isPeerConnected(_ peerID: String) -> Bool
|
||||
func getPeerNicknames() -> [String: String]
|
||||
|
||||
// Protocol utilities
|
||||
func getFingerprint(for peerID: String) -> String?
|
||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState
|
||||
func triggerHandshake(with peerID: String)
|
||||
func getNoiseService() -> NoiseEncryptionService
|
||||
|
||||
// Messaging
|
||||
func sendMessage(_ content: String, mentions: [String])
|
||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String)
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String)
|
||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool)
|
||||
func sendBroadcastAnnounce()
|
||||
func sendDeliveryAck(for messageID: String, to peerID: String)
|
||||
|
||||
// Peer snapshots (for non-UI services)
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
||||
}
|
||||
|
||||
protocol TransportPeerEventsDelegate: AnyObject {
|
||||
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
|
||||
}
|
||||
|
||||
extension BLEService: Transport {}
|
||||
@@ -13,7 +13,7 @@ import CryptoKit
|
||||
|
||||
/// Single source of truth for peer state, combining mesh connectivity and favorites
|
||||
@MainActor
|
||||
class UnifiedPeerService: ObservableObject {
|
||||
class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
// MARK: - Published Properties
|
||||
|
||||
@@ -26,13 +26,13 @@ class UnifiedPeerService: ObservableObject {
|
||||
|
||||
private var peerIndex: [String: BitchatPeer] = [:]
|
||||
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
|
||||
private let meshService: SimplifiedBluetoothService
|
||||
private let meshService: Transport
|
||||
private let favoritesService = FavoritesPersistenceService.shared
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(meshService: SimplifiedBluetoothService) {
|
||||
init(meshService: Transport) {
|
||||
self.meshService = meshService
|
||||
|
||||
// Subscribe to changes from both services
|
||||
@@ -47,14 +47,8 @@ class UnifiedPeerService: ObservableObject {
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupSubscriptions() {
|
||||
// Subscribe to mesh peer updates
|
||||
meshService.fullPeersPublisher
|
||||
.combineLatest(favoritesService.$favorites)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.updatePeers()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
// Subscribe to mesh peer updates via delegate (preferred over publishers)
|
||||
meshService.peerEventsDelegate = self
|
||||
|
||||
// Also listen for favorite change notifications
|
||||
NotificationCenter.default.publisher(for: .favoriteStatusChanged)
|
||||
@@ -64,11 +58,16 @@ class UnifiedPeerService: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// TransportPeerEventsDelegate
|
||||
func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot]) {
|
||||
updatePeers()
|
||||
}
|
||||
|
||||
// MARK: - Core Update Logic
|
||||
|
||||
private func updatePeers() {
|
||||
let meshPeers = meshService.fullPeersPublisher.value
|
||||
let meshPeers = meshService.currentPeerSnapshots()
|
||||
let favorites = favoritesService.favorites
|
||||
|
||||
var enrichedPeers: [BitchatPeer] = []
|
||||
@@ -76,11 +75,11 @@ class UnifiedPeerService: ObservableObject {
|
||||
var addedPeerIDs: Set<String> = []
|
||||
|
||||
// Phase 1: Add all connected mesh peers
|
||||
for (peerID, peerInfo) in meshPeers where peerInfo.isConnected {
|
||||
for peerInfo in meshPeers where peerInfo.isConnected {
|
||||
let peerID = peerInfo.id
|
||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||
|
||||
let peer = buildPeerFromMesh(
|
||||
peerID: peerID,
|
||||
peerInfo: peerInfo,
|
||||
favorites: favorites
|
||||
)
|
||||
@@ -162,12 +161,11 @@ class UnifiedPeerService: ObservableObject {
|
||||
// MARK: - Peer Building Helpers
|
||||
|
||||
private func buildPeerFromMesh(
|
||||
peerID: String,
|
||||
peerInfo: SimplifiedBluetoothService.PeerInfoSnapshot,
|
||||
peerInfo: TransportPeerSnapshot,
|
||||
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship]
|
||||
) -> BitchatPeer {
|
||||
var peer = BitchatPeer(
|
||||
id: peerID,
|
||||
id: peerInfo.id,
|
||||
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
||||
nickname: peerInfo.nickname,
|
||||
lastSeen: peerInfo.lastSeen,
|
||||
@@ -365,7 +363,7 @@ class UnifiedPeerService: ObservableObject {
|
||||
}
|
||||
|
||||
// Try to get from mesh service
|
||||
if let fingerprint = meshService.getPeerFingerprint(peerID) {
|
||||
if let fingerprint = meshService.getFingerprint(for: peerID) {
|
||||
fingerprintCache[peerID] = fingerprint
|
||||
return fingerprint
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Message Deduplicator (shared)
|
||||
|
||||
final class MessageDeduplicator {
|
||||
private struct Entry {
|
||||
let messageID: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
private var entries: [Entry] = []
|
||||
private var lookup = Set<String>()
|
||||
private let lock = NSLock()
|
||||
private let maxAge: TimeInterval = 300 // 5 minutes
|
||||
private let maxCount = 1000
|
||||
|
||||
/// Check if message is duplicate and add if not
|
||||
func isDuplicate(_ messageID: String) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
cleanupOldEntries()
|
||||
|
||||
if lookup.contains(messageID) {
|
||||
return true
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/// Add an ID without checking (for announce-back tracking)
|
||||
func markProcessed(_ messageID: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
if !lookup.contains(messageID) {
|
||||
entries.append(Entry(messageID: messageID, timestamp: Date()))
|
||||
lookup.insert(messageID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if ID exists without adding
|
||||
func contains(_ messageID: String) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return lookup.contains(messageID)
|
||||
}
|
||||
|
||||
/// Clear all entries
|
||||
func reset() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
entries.removeAll()
|
||||
lookup.removeAll()
|
||||
}
|
||||
|
||||
/// Periodic cleanup
|
||||
func cleanup() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
cleanupOldEntries()
|
||||
|
||||
if entries.capacity > maxCount * 2 {
|
||||
entries.reserveCapacity(maxCount)
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupOldEntries() {
|
||||
let cutoff = Date().addingTimeInterval(-maxAge)
|
||||
while let first = entries.first, first.timestamp < cutoff {
|
||||
lookup.remove(first.messageID)
|
||||
entries.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
struct PeerIDResolver {
|
||||
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
|
||||
static func toShortID(_ id: String) -> String {
|
||||
if id.count == 64, let data = Data(hexString: id) {
|
||||
return PeerIDUtils.derivePeerID(fromPublicKey: data)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
static func isShortID(_ id: String) -> Bool {
|
||||
return id.count == 16 && Data(hexString: id) != nil
|
||||
}
|
||||
|
||||
static func isNoiseKeyHex(_ id: String) -> Bool {
|
||||
return id.count == 64 && Data(hexString: id) != nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
///
|
||||
/// ## Architecture
|
||||
/// The ViewModel acts as:
|
||||
/// - **BitchatDelegate**: Receives messages and events from SimplifiedBluetoothService
|
||||
/// - **BitchatDelegate**: Receives messages and events from BLEService
|
||||
/// - **State Manager**: Maintains all UI-relevant state with @Published properties
|
||||
/// - **Command Processor**: Handles IRC-style commands (/msg, /who, etc.)
|
||||
/// - **Message Router**: Directs messages to appropriate chats (public/private)
|
||||
@@ -116,6 +116,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// MARK: - Service Delegates
|
||||
|
||||
private let commandProcessor: CommandProcessor
|
||||
private let messageRouter: MessageRouter
|
||||
private let privateChatManager: PrivateChatManager
|
||||
private let unifiedPeerService: UnifiedPeerService
|
||||
private let autocompleteService: AutocompleteService
|
||||
@@ -181,7 +182,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// MARK: - Services and Storage
|
||||
|
||||
var meshService = SimplifiedBluetoothService()
|
||||
var meshService: Transport = BLEService()
|
||||
private var nostrRelayManager: NostrRelayManager?
|
||||
// PeerManager replaced by UnifiedPeerService
|
||||
private var processedNostrEvents = Set<String>() // Simple deduplication
|
||||
@@ -274,6 +275,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
self.commandProcessor = CommandProcessor()
|
||||
self.privateChatManager = PrivateChatManager(meshService: meshService)
|
||||
self.unifiedPeerService = UnifiedPeerService(meshService: meshService)
|
||||
let nostrTransport = NostrTransport()
|
||||
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
|
||||
self.autocompleteService = AutocompleteService()
|
||||
|
||||
// Wire up dependencies
|
||||
@@ -857,14 +860,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Trigger UI update for sent message
|
||||
objectWillChange.send()
|
||||
|
||||
// Send via appropriate transport
|
||||
if isConnected {
|
||||
// Send via mesh
|
||||
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID)
|
||||
} else if isMutualFavorite && hasNostrKey,
|
||||
let recipientNostrPubkey = favoriteStatus?.peerNostrPublicKey {
|
||||
// Mutual favorite offline - send via Nostr
|
||||
sendViaNostr(content, to: recipientNostrPubkey, recipientPeerID: peerID, messageId: messageID)
|
||||
// Send via appropriate transport (BLE if connected, else Nostr when possible)
|
||||
if isConnected || (isMutualFavorite && hasNostrKey) {
|
||||
messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID)
|
||||
} else {
|
||||
// Update delivery status to failed
|
||||
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
@@ -1304,6 +1302,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc private func userDidTakeScreenshot() {
|
||||
// Send screenshot notification based on current context
|
||||
let screenshotMessage = "* \(nickname) took a screenshot *"
|
||||
@@ -1317,7 +1316,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
switch sessionState {
|
||||
case .established:
|
||||
// Send the message directly without going through sendPrivateMessage to avoid local echo
|
||||
meshService.sendPrivateMessage(screenshotMessage, to: peerID, recipientNickname: peerNickname, messageID: UUID().uuidString)
|
||||
messageRouter.sendPrivate(screenshotMessage, to: peerID, recipientNickname: peerNickname, messageID: UUID().uuidString)
|
||||
default:
|
||||
// Don't send screenshot notification if no session exists
|
||||
SecureLogger.log("Skipping screenshot notification to \(peerID) - no established session", category: SecureLogger.security, level: .debug)
|
||||
@@ -1414,7 +1413,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// The radical simplification plan says to accept occasional loss
|
||||
} else if meshService.getPeerNicknames()[actualPeerID] != nil {
|
||||
// Use mesh for connected peers (default behavior)
|
||||
meshService.sendReadReceipt(receipt, to: actualPeerID)
|
||||
messageRouter.sendReadReceipt(receipt, to: actualPeerID)
|
||||
} else {
|
||||
// Skip read receipts for offline peers - fire and forget principle
|
||||
}
|
||||
@@ -1447,7 +1446,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
|
||||
// Send Nostr read ACKs if peer has Nostr capability
|
||||
if let nostrPubkey = peerNostrPubkey {
|
||||
if peerNostrPubkey != nil {
|
||||
// Check messages under both ephemeral peer ID and stable Noise key
|
||||
let messagesToAck = getPrivateChatMessages(for: peerID)
|
||||
|
||||
@@ -1459,7 +1458,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if !sentReadReceipts.contains(message.id) {
|
||||
// Use stable Noise key hex if available; else fall back to peerID
|
||||
let recipPeer = (Data(hexString: peerID) != nil) ? peerID : (unifiedPeerService.getPeer(by: peerID)?.noisePublicKey.hexEncodedString() ?? peerID)
|
||||
sendNostrAcknowledgment(messageId: message.id, to: nostrPubkey, type: "READ", recipientPeerID: recipPeer)
|
||||
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
|
||||
messageRouter.sendReadReceipt(receipt, to: recipPeer)
|
||||
sentReadReceipts.insert(message.id)
|
||||
}
|
||||
}
|
||||
@@ -2324,6 +2324,74 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
sendHapticFeedback(for: message)
|
||||
}
|
||||
}
|
||||
|
||||
// Low-level BLE events
|
||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||
Task { @MainActor in
|
||||
switch type {
|
||||
case .privateMessage:
|
||||
guard let pm = PrivateMessagePacket.decode(from: payload) else { return }
|
||||
let senderName = unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown"
|
||||
let pmMentions = parseMentions(from: pm.content)
|
||||
let msg = BitchatMessage(
|
||||
id: pm.messageID,
|
||||
sender: senderName,
|
||||
content: pm.content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: nickname,
|
||||
senderPeerID: peerID,
|
||||
mentions: pmMentions.isEmpty ? nil : pmMentions
|
||||
)
|
||||
handlePrivateMessage(msg)
|
||||
// Send delivery ACK back over BLE
|
||||
meshService.sendDeliveryAck(for: pm.messageID, to: peerID)
|
||||
|
||||
case .delivered:
|
||||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||
if let name = unifiedPeerService.getPeer(by: peerID)?.nickname {
|
||||
if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[idx].deliveryStatus = .delivered(to: name, at: Date())
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
case .readReceipt:
|
||||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||
if let name = unifiedPeerService.getPeer(by: peerID)?.nickname {
|
||||
if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[idx].deliveryStatus = .read(by: name, at: Date())
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
||||
Task { @MainActor in
|
||||
let publicMentions = parseMentions(from: content)
|
||||
let msg = BitchatMessage(
|
||||
id: UUID().uuidString,
|
||||
sender: nickname,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
mentions: publicMentions.isEmpty ? nil : publicMentions
|
||||
)
|
||||
handlePublicMessage(msg)
|
||||
checkForMentions(msg)
|
||||
sendHapticFeedback(for: msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Mention parsing moved from BLE – use the existing non-optional helper below
|
||||
// MARK: - Peer Connection Events
|
||||
|
||||
func didConnectToPeer(_ peerID: String) {
|
||||
@@ -2688,122 +2756,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
|
||||
|
||||
@MainActor
|
||||
private func sendViaNostr(_ content: String, to recipientNostrPubkey: String, recipientPeerID: String, messageId: String) {
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
|
||||
SecureLogger.log("No Nostr identity available", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
|
||||
// Decode npub to hex if necessary
|
||||
var recipientHexPubkey = recipientNostrPubkey
|
||||
if recipientNostrPubkey.hasPrefix("npub") {
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNostrPubkey)
|
||||
if hrp == "npub" {
|
||||
recipientHexPubkey = data.hexEncodedString()
|
||||
SecureLogger.log("Decoded npub to hex: \(recipientHexPubkey)", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.log("Failed to decode recipient npub: \(error)", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Build embedded BitChat packet content (bitchat1:...)
|
||||
guard let embeddedContent = meshService.buildNostrEmbeddedPrivateMessageContent(
|
||||
content: content,
|
||||
to: recipientPeerID,
|
||||
messageID: messageId
|
||||
) else {
|
||||
SecureLogger.log("Failed to build embedded BitChat content for Nostr", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(
|
||||
content: embeddedContent,
|
||||
recipientPubkey: recipientHexPubkey,
|
||||
senderIdentity: senderIdentity
|
||||
) else {
|
||||
SecureLogger.log("Failed to create Nostr message for recipient: \(recipientHexPubkey)", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.log("📝 Created Nostr event ID: \(event.id.prefix(16))... for recipient: \(recipientHexPubkey.prefix(16))...",
|
||||
category: SecureLogger.session, level: .info)
|
||||
|
||||
if let relayManager = nostrRelayManager {
|
||||
relayManager.sendEvent(event)
|
||||
SecureLogger.log("Sent Nostr message via relay", category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Update delivery status to sent for Nostr messages
|
||||
// Need to find which peerID (ephemeral or Noise key) contains this message
|
||||
var messageUpdated = false
|
||||
|
||||
// First try to find the message in any private chat
|
||||
for (chatPeerID, messages) in privateChats {
|
||||
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
||||
privateChats[chatPeerID]?[index].deliveryStatus = .sent
|
||||
messageUpdated = true
|
||||
SecureLogger.log("✅ Updated delivery status to sent for message \(messageId) in chat \(chatPeerID)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
objectWillChange.send()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !messageUpdated {
|
||||
SecureLogger.log("⚠️ Could not find message \(messageId) to update delivery status",
|
||||
category: SecureLogger.session, level: .warning)
|
||||
}
|
||||
} else {
|
||||
SecureLogger.log("NostrRelayManager is nil - cannot send message", category: SecureLogger.session, level: .error)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func sendNostrAcknowledgment(messageId: String, to recipientNostrPubkey: String, type: String, recipientPeerID: String) {
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
|
||||
SecureLogger.log("No Nostr identity for sending ACK", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
|
||||
// Decode npub to hex if necessary
|
||||
var recipientHexPubkey = recipientNostrPubkey
|
||||
if recipientNostrPubkey.hasPrefix("npub") {
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNostrPubkey)
|
||||
if hrp == "npub" {
|
||||
recipientHexPubkey = data.hexEncodedString()
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.log("Failed to decode recipient npub for ACK: \(error)",
|
||||
category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Build embedded BitChat ACK content
|
||||
let ackType: NoisePayloadType? = (type == "DELIVERED") ? .delivered : (type == "READ" ? .readReceipt : nil)
|
||||
guard let ackTypeUnwrapped = ackType,
|
||||
let ackContent = meshService.buildNostrEmbeddedAckContent(type: ackTypeUnwrapped, messageID: messageId, to: recipientPeerID),
|
||||
let event = try? NostrProtocol.createPrivateMessage(
|
||||
content: ackContent,
|
||||
recipientPubkey: recipientHexPubkey,
|
||||
senderIdentity: senderIdentity
|
||||
) else {
|
||||
SecureLogger.log("Failed to create Nostr ACK for message \(messageId)",
|
||||
category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.log("📮 Sending \(type) ACK for message \(messageId.prefix(16))... to \(recipientHexPubkey.prefix(16))...",
|
||||
category: SecureLogger.session, level: .info)
|
||||
|
||||
if let relayManager = nostrRelayManager {
|
||||
relayManager.sendEvent(event)
|
||||
}
|
||||
}
|
||||
// Removed inlined Nostr send helpers in favor of MessageRouter
|
||||
|
||||
@MainActor
|
||||
private func setupNostrMessageHandling() {
|
||||
@@ -2893,7 +2846,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
switch noisePayload.type {
|
||||
case .privateMessage:
|
||||
guard let (messageId, messageContent) = Self.decodePrivateMessageTLV(noisePayload.data) else { return }
|
||||
guard let pm = PrivateMessagePacket.decode(from: noisePayload.data) else { return }
|
||||
let messageId = pm.messageID
|
||||
let messageContent = pm.content
|
||||
|
||||
// Favorite/unfavorite notifications embedded as private messages
|
||||
if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") {
|
||||
@@ -2963,7 +2918,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// Send delivery ack via Nostr embedded if not previously read and we know sender's Noise key
|
||||
if !wasReadBefore, let key = actualSenderNoiseKey {
|
||||
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "DELIVERED", recipientPeerID: key.hexEncodedString())
|
||||
messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString())
|
||||
}
|
||||
|
||||
if wasReadBefore {
|
||||
@@ -2975,7 +2930,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
unreadPrivateMessages.remove(ephemeralPeerID)
|
||||
}
|
||||
if !sentReadReceipts.contains(messageId), let key = actualSenderNoiseKey {
|
||||
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "READ", recipientPeerID: key.hexEncodedString())
|
||||
let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname)
|
||||
messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString())
|
||||
sentReadReceipts.insert(messageId)
|
||||
}
|
||||
} else {
|
||||
@@ -3101,26 +3057,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
return Data(base64Encoded: str)
|
||||
}
|
||||
|
||||
// Decode PrivateMessagePacket TLV (local minimal decoder)
|
||||
private static func decodePrivateMessageTLV(_ data: Data) -> (String, String)? {
|
||||
var offset = 0
|
||||
var messageID: String?
|
||||
var content: String?
|
||||
while offset + 2 <= data.count {
|
||||
let type = data[offset]; offset += 1
|
||||
let length = Int(data[offset]); offset += 1
|
||||
guard offset + length <= data.count else { return nil }
|
||||
let value = data[offset..<offset+length]
|
||||
offset += length
|
||||
if type == 0x00 {
|
||||
messageID = String(data: value, encoding: .utf8)
|
||||
} else if type == 0x01 {
|
||||
content = String(data: value, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
if let id = messageID, let c = content { return (id, c) }
|
||||
return nil
|
||||
}
|
||||
// Removed local TLV decoder; using PrivateMessagePacket.decode from Protocols
|
||||
|
||||
@MainActor
|
||||
private func handleFavoriteNotificationFromMesh(_ content: String, from peerID: String, senderNickname: String) {
|
||||
@@ -3331,25 +3268,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
@MainActor
|
||||
private func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||
// Send favorite notification via Nostr when we don't have ephemeral peer ID
|
||||
if let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey),
|
||||
let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey {
|
||||
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
|
||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||
let recipientPeerID = noisePublicKey.hexEncodedString()
|
||||
guard let embedded = meshService.buildNostrEmbeddedPrivateMessageContent(content: content, to: recipientPeerID, messageID: UUID().uuidString),
|
||||
let event = try? NostrProtocol.createPrivateMessage(
|
||||
content: embedded,
|
||||
recipientPubkey: recipientNostrPubkey,
|
||||
senderIdentity: senderIdentity
|
||||
) else { return }
|
||||
if let relayManager = nostrRelayManager {
|
||||
relayManager.sendEvent(event)
|
||||
SecureLogger.log("Sent favorite notification via Nostr", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
}
|
||||
let peerIDHex = noisePublicKey.hexEncodedString()
|
||||
messageRouter.sendFavoriteNotification(to: peerIDHex, isFavorite: isFavorite)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -3369,34 +3289,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// Try mesh first for connected peers
|
||||
if meshService.isPeerConnected(peerID) {
|
||||
meshService.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
SecureLogger.log("📤 Sent favorite notification via BLE to \(peerID)", category: SecureLogger.session, level: .debug)
|
||||
} else if let key = noiseKey,
|
||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: key),
|
||||
let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey {
|
||||
// Send via Nostr for offline peers
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
|
||||
SecureLogger.log("❌ No Nostr identity for sending favorite notification", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
|
||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||
} else if let key = noiseKey {
|
||||
// Send via Nostr for offline peers (using router)
|
||||
let recipientPeerID = key.hexEncodedString()
|
||||
guard let embedded = meshService.buildNostrEmbeddedPrivateMessageContent(content: content, to: recipientPeerID, messageID: UUID().uuidString),
|
||||
let event = try? NostrProtocol.createPrivateMessage(
|
||||
content: embedded,
|
||||
recipientPubkey: recipientNostrPubkey,
|
||||
senderIdentity: senderIdentity
|
||||
) else {
|
||||
SecureLogger.log("❌ Failed to create Nostr message for favorite notification", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
if let relayManager = nostrRelayManager {
|
||||
relayManager.sendEvent(event)
|
||||
SecureLogger.log("📤 Sent favorite notification via Nostr to \(favoriteStatus.peerNickname)", category: SecureLogger.session, level: .debug)
|
||||
} else {
|
||||
SecureLogger.log("❌ NostrRelayManager is nil - cannot send favorite notification", category: SecureLogger.session, level: .error)
|
||||
}
|
||||
messageRouter.sendFavoriteNotification(to: recipientPeerID, isFavorite: isFavorite)
|
||||
} else {
|
||||
SecureLogger.log("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: SecureLogger.session, level: .warning)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user