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:
jack
2025-08-17 15:17:04 +02:00
committed by GitHub
co-authored by jack
parent 3ebfa85e90
commit 6fbf7eee25
25 changed files with 873 additions and 1647 deletions
+96 -198
View File
@@ -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)
}