Refactor/robustness (#446)

* Refactor BitChat for improved robustness and performance

Major refactoring to simplify architecture and fix critical issues:

Architecture Improvements:
- Replace complex BluetoothMeshService with simplified SimplifiedBluetoothService
- Consolidate message routing and peer management into unified services
- Remove redundant caching layers and optimize performance

Bug Fixes:
- Fix critical BLE peer mapping corruption in mesh networks
- Fix encrypted message routing failures in multi-peer scenarios
- Fix app freezes and Main Thread Checker warnings
- Fix BLE message delivery in dual-role connections
- Fix favorite toggle UI not updating instantly
- Fix Nostr offline messaging with 24-hour message filtering

Features:
- Add command processor for chat commands
- Add autocomplete service for mentions and commands
- Improve private chat management with dedicated service
- Add unified peer service for consistent state management

Performance:
- Optimize BLE reconnection speed
- Reduce excessive logging throughout codebase
- Improve message deduplication efficiency
- Optimize UI updates and state management

* Improvements refactor robust (#441)

* remove unused code

* remove more

* TLV for announcement

* restore

* restore

* restore?

* messages tlv too (#442)

* Fix Nostr notification and read receipt issues, add TLV encoding, cleanup unused code

## Notification & Read Receipt Fixes
- Fixed toolbar notification icon appearing incorrectly on app restart for already-read messages
- Fixed read receipts being incorrectly deleted on startup when privateChats was empty
- Fixed messages not being marked as read when opening chat for first time
- Fixed senderPeerID not being updated during message consolidation
- Added startup phase logic to block old messages (>30s) while allowing recent ones
- Fixed unread status checking across all storage locations (ephemeral, stable Noise keys, temporary Nostr IDs)

## TLV Encoding Implementation
- Implemented Type-Length-Value encoding for private message payloads
- Added PrivateMessagePacket struct with TLV encode/decode methods
- Enhanced message structure for better extensibility and robustness

## Code Cleanup
- Removed unused PeerStateManager class and related dependencies
- Removed dead protocol types (DeliveryAck, ProtocolAck/Nack, NoiseIdentityAnnouncement)
- Cleaned up BitchatDelegate by removing unused methods
- Removed excessive debug logging throughout ChatViewModel
- Added test-only ProtocolNack helper for integration tests

## Technical Details
- Messages stored under three ID types: ephemeral peer IDs, stable Noise key hexes, temporary Nostr IDs
- Fixed cleanupOldReadReceipts() to skip when privateChats is empty or during startup
- Updated message consolidation to properly update senderPeerID
- Restored NIP-17 timestamp randomization (±15 minutes) for privacy

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
jack
2025-08-17 01:51:54 +02:00
committed by GitHub
co-authored by jack callebtc
parent 47b0829685
commit 845ffc601b
38 changed files with 6423 additions and 12246 deletions
+104
View File
@@ -0,0 +1,104 @@
//
// AutocompleteService.swift
// bitchat
//
// Handles autocomplete suggestions for mentions and commands
// This is free and unencumbered software released into the public domain.
//
import Foundation
/// Manages autocomplete functionality for chat
class AutocompleteService {
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
private let commands = [
"/msg", "/who", "/clear", "/help",
"/hug", "/slap", "/fav", "/unfav",
"/block", "/unblock"
]
/// Get autocomplete suggestions for current text
func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) {
let textToPosition = String(text.prefix(cursorPosition))
// Check for mention autocomplete
if let (mentionSuggestions, mentionRange) = getMentionSuggestions(textToPosition, peers: peers) {
return (mentionSuggestions, mentionRange)
}
// Don't handle command autocomplete here - ContentView handles it with better UI
// if let (commandSuggestions, commandRange) = getCommandSuggestions(textToPosition) {
// return (commandSuggestions, commandRange)
// }
return ([], nil)
}
/// Apply selected suggestion to text
func applySuggestion(_ suggestion: String, to text: String, range: NSRange) -> String {
guard let textRange = Range(range, in: text) else { return text }
var replacement = suggestion
// Add space after command if it takes arguments
if suggestion.hasPrefix("/") && needsArgument(command: suggestion) {
replacement += " "
}
return text.replacingCharacters(in: textRange, with: replacement)
}
// MARK: - Private Methods
private func getMentionSuggestions(_ text: String, peers: [String]) -> ([String], NSRange)? {
guard let regex = mentionRegex else { return nil }
let nsText = text as NSString
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))
guard let match = matches.last else { return nil }
let fullRange = match.range(at: 0)
let captureRange = match.range(at: 1)
let prefix = nsText.substring(with: captureRange).lowercased()
let suggestions = peers
.filter { $0.lowercased().hasPrefix(prefix) }
.sorted()
.prefix(5)
.map { "@\($0)" }
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
}
private func getCommandSuggestions(_ text: String) -> ([String], NSRange)? {
guard let regex = commandRegex else { return nil }
let nsText = text as NSString
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))
guard let match = matches.last else { return nil }
let fullRange = match.range(at: 0)
let captureRange = match.range(at: 1)
let prefix = nsText.substring(with: captureRange).lowercased()
let suggestions = commands
.filter { $0.hasPrefix("/\(prefix)") }
.sorted()
.prefix(5)
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
}
private func needsArgument(command: String) -> Bool {
switch command {
case "/who", "/clear", "/help":
return false
default:
return true
}
}
}
File diff suppressed because it is too large Load Diff
+267
View File
@@ -0,0 +1,267 @@
//
// CommandProcessor.swift
// bitchat
//
// Handles command parsing and execution for BitChat
// This is free and unencumbered software released into the public domain.
//
import Foundation
/// Result of command processing
enum CommandResult {
case success(message: String?)
case error(message: String)
case handled // Command handled, no message needed
}
/// Processes chat commands in a focused, efficient way
@MainActor
class CommandProcessor {
weak var chatViewModel: ChatViewModel?
weak var meshService: SimplifiedBluetoothService?
init(chatViewModel: ChatViewModel? = nil, meshService: SimplifiedBluetoothService? = nil) {
self.chatViewModel = chatViewModel
self.meshService = meshService
}
/// Process a command string
@MainActor
func process(_ command: String) -> CommandResult {
let parts = command.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false)
guard let cmd = parts.first else { return .error(message: "Invalid command") }
let args = parts.count > 1 ? String(parts[1]) : ""
switch cmd {
case "/m", "/msg":
return handleMessage(args)
case "/w", "/who":
return handleWho()
case "/clear":
return handleClear()
case "/hug":
return handleEmote(args, action: "hugs", emoji: "🫂")
case "/slap":
return handleEmote(args, action: "slaps", emoji: "🐟", suffix: " around a bit with a large trout")
case "/block":
return handleBlock(args)
case "/unblock":
return handleUnblock(args)
case "/fav":
return handleFavorite(args, add: true)
case "/unfav":
return handleFavorite(args, add: false)
case "/help", "/h":
return handleHelp()
default:
return .error(message: "unknown command: \(cmd)")
}
}
// MARK: - Command Handlers
private func handleMessage(_ args: String) -> CommandResult {
let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false)
guard !parts.isEmpty else {
return .error(message: "usage: /msg @nickname [message]")
}
let targetName = String(parts[0])
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname) else {
return .error(message: "'\(nickname)' not found")
}
chatViewModel?.startPrivateChat(with: peerID)
if parts.count > 1 {
let message = String(parts[1])
chatViewModel?.sendPrivateMessage(message, to: peerID)
}
return .success(message: "started private chat with \(nickname)")
}
private func handleWho() -> CommandResult {
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {
return .success(message: "no one else is online right now")
}
let onlineList = peers.values.sorted().joined(separator: ", ")
return .success(message: "online: \(onlineList)")
}
private func handleClear() -> CommandResult {
if let peerID = chatViewModel?.selectedPrivateChatPeer {
chatViewModel?.privateChats[peerID]?.removeAll()
} else {
chatViewModel?.messages.removeAll()
}
return .handled
}
private func handleEmote(_ args: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces)
guard !targetName.isEmpty else {
return .error(message: "usage: /\(action) <nickname>")
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname),
let myNickname = chatViewModel?.nickname else {
return .error(message: "cannot \(action) \(nickname): not found")
}
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
if chatViewModel?.selectedPrivateChatPeer != nil {
// In private chat
if let peerNickname = meshService?.getPeerNicknames()[targetPeerID] {
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
recipientNickname: peerNickname,
messageID: UUID().uuidString)
}
} else {
// In public chat
meshService?.sendMessage(emoteContent)
}
return .handled
}
private func handleBlock(_ args: String) -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces)
if targetName.isEmpty {
// List blocked users
guard let blockedUsers = chatViewModel?.blockedUsers, !blockedUsers.isEmpty else {
return .success(message: "no blocked peers")
}
var blockedNicknames: [String] = []
if let peers = meshService?.getPeerNicknames() {
for (peerID, nickname) in peers {
if let fingerprint = meshService?.getPeerFingerprint(peerID),
blockedUsers.contains(fingerprint) {
blockedNicknames.append(nickname)
}
}
}
let list = blockedNicknames.isEmpty ? "blocked peers (not currently online)"
: blockedNicknames.sorted().joined(separator: ", ")
return .success(message: "blocked peers: \(list)")
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getPeerFingerprint(peerID) else {
return .error(message: "cannot block \(nickname): not found or unable to verify identity")
}
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is already blocked")
}
// Block the user
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
identity.isBlocked = true
identity.isFavorite = false
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
} else {
let blockedIdentity = SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: nickname,
trustLevel: .unknown,
isFavorite: false,
isBlocked: true,
notes: nil
)
SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity)
}
// The peerStateManager and SecureIdentityStateManager handle the blocking state
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
}
private func handleUnblock(_ args: String) -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces)
guard !targetName.isEmpty else {
return .error(message: "usage: /unblock <nickname>")
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getPeerFingerprint(peerID) else {
return .error(message: "cannot unblock \(nickname): not found")
}
if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is not blocked")
}
SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false)
// The SecureIdentityStateManager handles the unblocking state
return .success(message: "unblocked \(nickname)")
}
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces)
guard !targetName.isEmpty else {
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let noisePublicKey = Data(hexString: peerID) else {
return .error(message: "can't find peer: \(nickname)")
}
if add {
let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noisePublicKey,
peerNostrPublicKey: existingFavorite?.peerNostrPublicKey,
peerNickname: nickname
)
chatViewModel?.toggleFavorite(peerID: peerID)
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: true)
return .success(message: "added \(nickname) to favorites")
} else {
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
chatViewModel?.toggleFavorite(peerID: peerID)
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: false)
return .success(message: "removed \(nickname) from favorites")
}
}
private func handleHelp() -> CommandResult {
let helpText = """
commands:
/msg @name - start private chat
/who - list who's online
/clear - clear messages
/hug @name - send a hug
/slap @name - slap with a trout
/fav @name - add to favorites
/unfav @name - remove from favorites
/block @name - block
/unblock @name - unblock
"""
return .success(message: helpText)
}
}
-294
View File
@@ -1,294 +0,0 @@
//
// DeliveryTracker.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Combine
class DeliveryTracker {
static let shared = DeliveryTracker()
// Track pending deliveries
private var pendingDeliveries: [String: PendingDelivery] = [:]
private let pendingLock = NSLock()
// Track received ACKs to prevent duplicates
private var receivedAckIDs = Set<String>()
private var sentAckIDs = Set<String>()
// Timeout configuration
private let privateMessageTimeout: TimeInterval = 120 // 2 minutes
private let roomMessageTimeout: TimeInterval = 180 // 3 minutes
private let favoriteTimeout: TimeInterval = 600 // 10 minutes for favorites
// Retry configuration
private let maxRetries = 3
private let retryDelay: TimeInterval = 5 // Base retry delay
// Publishers for UI updates
let deliveryStatusUpdated = PassthroughSubject<(messageID: String, status: DeliveryStatus), Never>()
// Cleanup timer
private var cleanupTimer: Timer?
struct PendingDelivery {
let messageID: String
let sentAt: Date
let recipientID: String
let recipientNickname: String
let retryCount: Int
let isFavorite: Bool
var timeoutTimer: Timer?
var isTimedOut: Bool {
let timeout: TimeInterval = isFavorite ? 300 : 30
return Date().timeIntervalSince(sentAt) > timeout
}
var shouldRetry: Bool {
return retryCount < 3 && isFavorite
}
}
private init() {
startCleanupTimer()
}
deinit {
cleanupTimer?.invalidate()
}
// MARK: - Public Methods
func trackMessage(_ message: BitchatMessage, recipientID: String, recipientNickname: String, isFavorite: Bool = false, expectedRecipients: Int = 1) {
// Only track private messages
guard message.isPrivate else { return }
SecureLogger.log("Tracking message \(message.id) - private: \(message.isPrivate), recipient: \(recipientNickname)", category: SecureLogger.session, level: .info)
let delivery = PendingDelivery(
messageID: message.id,
sentAt: Date(),
recipientID: recipientID,
recipientNickname: recipientNickname,
retryCount: 0,
isFavorite: isFavorite,
timeoutTimer: nil
)
// Store the delivery with lock
pendingLock.lock()
pendingDeliveries[message.id] = delivery
pendingLock.unlock()
// Update status to sent (only if not already delivered)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self = self else { return }
self.pendingLock.lock()
let stillPending = self.pendingDeliveries[message.id] != nil
self.pendingLock.unlock()
// Only update to sent if still pending (not already delivered)
if stillPending {
SecureLogger.log("Updating message \(message.id) to sent status (still pending)", category: SecureLogger.session, level: .debug)
self.updateDeliveryStatus(message.id, status: .sent)
} else {
SecureLogger.log("Skipping sent status update for \(message.id) - already delivered", category: SecureLogger.session, level: .debug)
}
}
// Schedule timeout (outside of lock)
scheduleTimeout(for: message.id)
}
func processDeliveryAck(_ ack: DeliveryAck) {
pendingLock.lock()
defer { pendingLock.unlock() }
SecureLogger.log("Processing delivery ACK for message \(ack.originalMessageID) from \(ack.recipientNickname)", category: SecureLogger.session, level: .info)
// Prevent duplicate ACK processing
guard !receivedAckIDs.contains(ack.ackID) else {
SecureLogger.log("Duplicate ACK \(ack.ackID) - ignoring", category: SecureLogger.session, level: .warning)
return
}
receivedAckIDs.insert(ack.ackID)
// Find the pending delivery
guard let delivery = pendingDeliveries[ack.originalMessageID] else {
// Message might have already been delivered or timed out
SecureLogger.log("No pending delivery found for message \(ack.originalMessageID)", category: SecureLogger.session, level: .warning)
return
}
// Cancel timeout timer
delivery.timeoutTimer?.invalidate()
// Direct message - mark as delivered
SecureLogger.log("Marking private message \(ack.originalMessageID) as delivered to \(ack.recipientNickname)", category: SecureLogger.session, level: .info)
updateDeliveryStatus(ack.originalMessageID, status: .delivered(to: ack.recipientNickname, at: Date()))
pendingDeliveries.removeValue(forKey: ack.originalMessageID)
}
func generateAck(for message: BitchatMessage, myPeerID: String, myNickname: String, hopCount: UInt8) -> DeliveryAck? {
// Don't ACK our own messages
guard message.senderPeerID != myPeerID else {
return nil
}
// Only ACK private messages
guard message.isPrivate else {
return nil
}
// Don't ACK if we've already sent an ACK for this message
guard !sentAckIDs.contains(message.id) else {
return nil
}
sentAckIDs.insert(message.id)
return DeliveryAck(
originalMessageID: message.id,
recipientID: myPeerID,
recipientNickname: myNickname,
hopCount: hopCount
)
}
func clearDeliveryStatus(for messageID: String) {
pendingLock.lock()
defer { pendingLock.unlock() }
if let delivery = pendingDeliveries[messageID] {
delivery.timeoutTimer?.invalidate()
}
pendingDeliveries.removeValue(forKey: messageID)
}
// MARK: - Private Methods
private func updateDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
SecureLogger.log("Updating delivery status for message \(messageID): \(status)", category: SecureLogger.session, level: .debug)
DispatchQueue.main.async { [weak self] in
self?.deliveryStatusUpdated.send((messageID: messageID, status: status))
}
}
private func scheduleTimeout(for messageID: String) {
// Get delivery info with lock
pendingLock.lock()
guard let delivery = pendingDeliveries[messageID] else {
pendingLock.unlock()
return
}
let isFavorite = delivery.isFavorite
pendingLock.unlock()
let timeout = isFavorite ? favoriteTimeout : privateMessageTimeout
let timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in
self?.handleTimeout(messageID: messageID)
}
pendingLock.lock()
if var updatedDelivery = pendingDeliveries[messageID] {
updatedDelivery.timeoutTimer = timer
pendingDeliveries[messageID] = updatedDelivery
}
pendingLock.unlock()
}
private func handleTimeout(messageID: String) {
pendingLock.lock()
guard let delivery = pendingDeliveries[messageID] else {
pendingLock.unlock()
return
}
let shouldRetry = delivery.shouldRetry
if shouldRetry {
pendingLock.unlock()
// Retry for favorites (outside of lock)
retryDelivery(messageID: messageID)
} else {
// Mark as failed
let reason = "Message not delivered"
pendingDeliveries.removeValue(forKey: messageID)
pendingLock.unlock()
updateDeliveryStatus(messageID, status: .failed(reason: reason))
}
}
private func retryDelivery(messageID: String) {
pendingLock.lock()
guard let delivery = pendingDeliveries[messageID] else {
pendingLock.unlock()
return
}
// Increment retry count
let newDelivery = PendingDelivery(
messageID: delivery.messageID,
sentAt: delivery.sentAt,
recipientID: delivery.recipientID,
recipientNickname: delivery.recipientNickname,
retryCount: delivery.retryCount + 1,
isFavorite: delivery.isFavorite,
timeoutTimer: nil
)
pendingDeliveries[messageID] = newDelivery
let retryCount = delivery.retryCount
pendingLock.unlock()
// Exponential backoff for retry
let delay = retryDelay * pow(2, Double(retryCount))
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
// Trigger resend through delegate or notification
NotificationCenter.default.post(
name: Notification.Name("bitchat.retryMessage"),
object: nil,
userInfo: ["messageID": messageID]
)
// Schedule new timeout
self?.scheduleTimeout(for: messageID)
}
}
private func startCleanupTimer() {
cleanupTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
self?.cleanupOldDeliveries()
}
}
private func cleanupOldDeliveries() {
pendingLock.lock()
defer { pendingLock.unlock() }
let now = Date()
let maxAge: TimeInterval = 3600 // 1 hour
// Clean up old pending deliveries
pendingDeliveries = pendingDeliveries.filter { (_, delivery) in
now.timeIntervalSince(delivery.sentAt) < maxAge
}
// Clean up old ACK IDs (keep last 1000)
if receivedAckIDs.count > 1000 {
receivedAckIDs.removeAll()
}
if sentAckIDs.count > 1000 {
sentAckIDs.removeAll()
}
}
}
-212
View File
@@ -1,212 +0,0 @@
//
// MessageRetryService.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Combine
import CryptoKit
struct RetryableMessage {
let id: String
let originalMessageID: String?
let originalTimestamp: Date?
let content: String
let mentions: [String]?
let isPrivate: Bool
let recipientPeerID: String?
let recipientNickname: String?
let retryCount: Int
let maxRetries: Int = 3
let nextRetryTime: Date
}
class MessageRetryService {
static let shared = MessageRetryService()
private var retryQueue: [RetryableMessage] = []
private var retryTimer: Timer?
private let retryInterval: TimeInterval = 2.0 // Retry every 2 seconds for faster sync
private let maxQueueSize = 50
weak var meshService: BluetoothMeshService?
private init() {
startRetryTimer()
}
deinit {
retryTimer?.invalidate()
}
private func startRetryTimer() {
retryTimer = Timer.scheduledTimer(withTimeInterval: retryInterval, repeats: true) { [weak self] _ in
self?.processRetryQueue()
}
}
func addMessageForRetry(
content: String,
mentions: [String]? = nil,
isPrivate: Bool = false,
recipientPeerID: String? = nil,
recipientNickname: String? = nil,
originalMessageID: String? = nil,
originalTimestamp: Date? = nil
) {
// Don't queue empty or whitespace-only messages
guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return
}
// Don't queue if we're at capacity
guard retryQueue.count < maxQueueSize else {
return
}
// Check if this message is already in the queue
if let messageID = originalMessageID {
let alreadyQueued = retryQueue.contains { msg in
msg.originalMessageID == messageID
}
if alreadyQueued {
return // Don't add duplicate
}
}
let retryMessage = RetryableMessage(
id: UUID().uuidString,
originalMessageID: originalMessageID,
originalTimestamp: originalTimestamp,
content: content,
mentions: mentions,
isPrivate: isPrivate,
recipientPeerID: recipientPeerID,
recipientNickname: recipientNickname,
retryCount: 0,
nextRetryTime: Date().addingTimeInterval(retryInterval)
)
retryQueue.append(retryMessage)
// Sort the queue by original timestamp to maintain message order
retryQueue.sort { (msg1, msg2) in
let time1 = msg1.originalTimestamp ?? Date.distantPast
let time2 = msg2.originalTimestamp ?? Date.distantPast
return time1 < time2
}
}
private func processRetryQueue() {
guard meshService != nil else { return }
let now = Date()
var messagesToRetry: [RetryableMessage] = []
var updatedQueue: [RetryableMessage] = []
for message in retryQueue {
if message.nextRetryTime <= now {
messagesToRetry.append(message)
} else {
updatedQueue.append(message)
}
}
retryQueue = updatedQueue
// Sort messages by original timestamp to maintain order
messagesToRetry.sort { (msg1, msg2) in
let time1 = msg1.originalTimestamp ?? Date.distantPast
let time2 = msg2.originalTimestamp ?? Date.distantPast
return time1 < time2
}
// Send messages with delay to maintain order
for (index, message) in messagesToRetry.enumerated() {
// Check if we should still retry
if message.retryCount >= message.maxRetries {
continue
}
// Add delay between messages to ensure proper ordering
let delay = Double(index) * 0.05 // 50ms between messages
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self = self,
let meshService = self.meshService else { return }
// Check connectivity before retrying
let viewModel = meshService.delegate as? ChatViewModel
let connectedPeers = viewModel?.connectedPeers ?? []
if message.isPrivate {
// For private messages, check if recipient is connected
if let recipientID = message.recipientPeerID,
connectedPeers.contains(recipientID) {
// Retry private message
meshService.sendPrivateMessage(
message.content,
to: recipientID,
recipientNickname: message.recipientNickname ?? "unknown",
messageID: message.originalMessageID
)
} else {
// Recipient not connected, keep in queue with updated retry time
var updatedMessage = message
updatedMessage = RetryableMessage(
id: message.id,
originalMessageID: message.originalMessageID,
originalTimestamp: message.originalTimestamp,
content: message.content,
mentions: message.mentions,
isPrivate: message.isPrivate,
recipientPeerID: message.recipientPeerID,
recipientNickname: message.recipientNickname,
retryCount: message.retryCount + 1,
nextRetryTime: Date().addingTimeInterval(self.retryInterval * Double(message.retryCount + 2))
)
self.retryQueue.append(updatedMessage)
}
} else {
// Regular message
if !connectedPeers.isEmpty {
meshService.sendMessage(
message.content,
mentions: message.mentions ?? [],
to: nil,
messageID: message.originalMessageID,
timestamp: message.originalTimestamp
)
} else {
// No peers connected, keep in queue
var updatedMessage = message
updatedMessage = RetryableMessage(
id: message.id,
originalMessageID: message.originalMessageID,
originalTimestamp: message.originalTimestamp,
content: message.content,
mentions: message.mentions,
isPrivate: message.isPrivate,
recipientPeerID: message.recipientPeerID,
recipientNickname: message.recipientNickname,
retryCount: message.retryCount + 1,
nextRetryTime: Date().addingTimeInterval(self.retryInterval * Double(message.retryCount + 2))
)
self.retryQueue.append(updatedMessage)
}
}
}
}
}
func clearRetryQueue() {
retryQueue.removeAll()
}
func getRetryQueueCount() -> Int {
return retryQueue.count
}
}
-655
View File
@@ -1,655 +0,0 @@
import Foundation
import Combine
/// Routes messages through the appropriate transport (Bluetooth mesh or Nostr)
@MainActor
class MessageRouter: ObservableObject {
enum Transport {
case bluetoothMesh
case nostr
}
enum DeliveryStatus {
case pending
case sent
case delivered
case failed(Error)
}
struct RoutedMessage {
let id: String
let content: String
let recipientNoisePublicKey: Data
let transport: Transport
let timestamp: Date
var status: DeliveryStatus
}
@Published private(set) var pendingMessages: [String: RoutedMessage] = [:]
private let meshService: BluetoothMeshService
private let nostrRelay: NostrRelayManager
private let favoritesService: FavoritesPersistenceService
private let processedMessagesService = ProcessedMessagesService.shared
private var cancellables = Set<AnyCancellable>()
private let messageDeduplication = LRUCache<String, Date>(maxSize: 1000)
init(
meshService: BluetoothMeshService,
nostrRelay: NostrRelayManager
) {
self.meshService = meshService
self.nostrRelay = nostrRelay
self.favoritesService = FavoritesPersistenceService.shared
setupBindings()
}
/// Send a message to a peer, automatically selecting the best transport
func sendMessage(
_ content: String,
to recipientNoisePublicKey: Data,
preferredTransport: Transport? = nil,
messageId: String? = nil
) async throws {
let finalMessageId = messageId ?? UUID().uuidString
// Check if peer is available on mesh (actually connected, not just known)
let recipientHexID = recipientNoisePublicKey.hexEncodedString()
let peerAvailableOnMesh = meshService.isPeerConnected(recipientHexID)
// Check if this is a mutual favorite
let isMutualFavorite = favoritesService.isMutualFavorite(recipientNoisePublicKey)
// Determine transport
let transport: Transport
if let preferred = preferredTransport {
transport = preferred
} else if peerAvailableOnMesh {
// Always prefer mesh when available
transport = .bluetoothMesh
} else if isMutualFavorite {
// Use Nostr for mutual favorites when not on mesh
transport = .nostr
} else {
throw MessageRouterError.peerNotReachable
}
// Create routed message
let routedMessage = RoutedMessage(
id: finalMessageId,
content: content,
recipientNoisePublicKey: recipientNoisePublicKey,
transport: transport,
timestamp: Date(),
status: .pending
)
pendingMessages[finalMessageId] = routedMessage
// Route based on transport
switch transport {
case .bluetoothMesh:
try await sendViaMesh(routedMessage)
case .nostr:
try await sendViaNostr(routedMessage)
}
}
/// Send a favorite/unfavorite notification
func sendFavoriteNotification(
to recipientNoisePublicKey: Data,
isFavorite: Bool
) async throws {
// messageType is used for logging below
// let messageType: MessageType = isFavorite ? .favorited : .unfavorited
let recipientHexID = recipientNoisePublicKey.hexEncodedString()
let action = isFavorite ? "favorite" : "unfavorite"
// Try mesh first
if meshService.getPeerNicknames()[recipientHexID] != nil {
// Send via mesh as a system message
meshService.sendFavoriteNotification(to: recipientHexID, isFavorite: isFavorite)
} else if let favoriteStatus = favoritesService.getFavoriteStatus(for: recipientNoisePublicKey),
let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey {
// Send via Nostr as a special message
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
throw MessageRouterError.noNostrIdentity
}
// Include our npub in the content
let content = isFavorite ? "FAVORITED:\(senderIdentity.npub)" : "UNFAVORITED:\(senderIdentity.npub)"
let event = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipientNostrPubkey,
senderIdentity: senderIdentity
)
nostrRelay.sendEvent(event)
} else {
SecureLogger.log("⚠️ Cannot send \(action) notification - peer not reachable via mesh or Nostr",
category: SecureLogger.session, level: .warning)
}
}
// MARK: - Private Methods
private func sendViaMesh(_ message: RoutedMessage) async throws {
// Send the message through mesh - using sendPrivateMessage for now
let recipientHexID = message.recipientNoisePublicKey.hexEncodedString()
if let recipientNickname = meshService.getPeerNicknames()[recipientHexID] {
meshService.sendPrivateMessage(message.content, to: recipientHexID, recipientNickname: recipientNickname, messageID: message.id)
}
// Update status
pendingMessages[message.id]?.status = .sent
}
private func sendViaNostr(_ message: RoutedMessage) async throws {
// Get recipient's Nostr public key
let favoriteStatus = favoritesService.getFavoriteStatus(for: message.recipientNoisePublicKey)
// Looking up Nostr key for recipient
if favoriteStatus != nil {
// Found favorite relationship
} else {
SecureLogger.log("❌ No favorite relationship found",
category: SecureLogger.session, level: .error)
}
guard let favoriteStatus = favoriteStatus,
let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey else {
throw MessageRouterError.noNostrPublicKey
}
// Get sender's Nostr identity
guard let senderIdentity = try NostrIdentityBridge.getCurrentNostrIdentity() else {
throw MessageRouterError.noNostrIdentity
}
// Create NIP-17 encrypted message with structured content
let structuredContent = "MSG:\(message.id):\(message.content)"
let event = try NostrProtocol.createPrivateMessage(
content: structuredContent,
recipientPubkey: recipientNostrPubkey,
senderIdentity: senderIdentity
)
// Created gift wrap event
// Send via relay
nostrRelay.sendEvent(event)
// Update status
pendingMessages[message.id]?.status = .sent
}
private func setupBindings() {
// Monitor Nostr messages
setupNostrMessageHandling()
// Clean up old pending messages periodically
Timer.publish(every: 60, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
self?.cleanupOldMessages()
}
.store(in: &cancellables)
// Listen for app becoming active to check for messages
NotificationCenter.default.publisher(for: .appDidBecomeActive)
.sink { [weak self] _ in
self?.checkForNostrMessages()
}
.store(in: &cancellables)
}
private func setupNostrMessageHandling() {
guard (try? NostrIdentityBridge.getCurrentNostrIdentity()) != nil else {
SecureLogger.log("⚠️ No Nostr identity available for initial setup", category: SecureLogger.session, level: .warning)
return
}
// Connect to relays if not already connected
if !nostrRelay.isConnected {
nostrRelay.connect()
// Wait for connections to establish before subscribing
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
self?.subscribeToNostrMessages()
}
} else {
// Already connected, subscribe immediately
subscribeToNostrMessages()
}
}
/// Check for Nostr messages when app becomes active
func checkForNostrMessages() {
// Checking for Nostr messages
guard (try? NostrIdentityBridge.getCurrentNostrIdentity()) != nil else {
SecureLogger.log("⚠️ No Nostr identity available for message check", category: SecureLogger.session, level: .warning)
return
}
// Ensure we're connected to relays first
if !nostrRelay.isConnected {
// Connecting to Nostr relays
nostrRelay.connect()
// Wait a bit for connections to establish before subscribing
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.subscribeToNostrMessages()
}
} else {
// Already connected, subscribe immediately
subscribeToNostrMessages()
}
}
private func subscribeToNostrMessages() {
guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
// Subscribing to Nostr messages
// Full pubkey recorded
// Pubkey length verified
// Unsubscribe existing subscription to refresh
nostrRelay.unsubscribe(id: "router-messages")
// Create a new subscription for recent messages
let sinceDate = processedMessagesService.getSubscriptionSinceDate()
let filter = NostrFilter.giftWrapsFor(
pubkey: currentIdentity.publicKeyHex,
since: sinceDate
)
// Subscribing to messages since date
// Subscribing to gift wraps
nostrRelay.subscribe(filter: filter, id: "router-messages") { [weak self] event in
// Received Nostr event
self?.handleNostrMessage(event)
}
}
private func handleNostrMessage(_ giftWrap: NostrEvent) {
// Check if we've already processed this event
if processedMessagesService.isMessageProcessed(giftWrap.id) {
// Skipping already processed event
return
}
// Attempting to decrypt gift wrap
// Full event ID recorded
// Event timestamp recorded
// Event tags recorded
// Decrypt the message
guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
SecureLogger.log("❌ No current Nostr identity available",
category: SecureLogger.session, level: .error)
return
}
// Check if this event is actually tagged for us
let ourPubkey = currentIdentity.publicKeyHex
let isTaggedForUs = giftWrap.tags.contains { tag in
tag.count >= 2 && tag[0] == "p" && tag[1] == ourPubkey
}
if !isTaggedForUs {
SecureLogger.log("⚠️ Gift wrap not tagged for us! Our pubkey: \(ourPubkey.prefix(8))..., Event tags: \(giftWrap.tags)",
category: SecureLogger.session, level: .warning)
return
}
do {
let (content, senderPubkey) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: currentIdentity
)
// Mark this event as processed to avoid duplicates on app restart
let eventTimestamp = Date(timeIntervalSince1970: TimeInterval(giftWrap.created_at))
processedMessagesService.markMessageAsProcessed(giftWrap.id, timestamp: eventTimestamp)
// Check for deduplication within current session
let messageHash = "\(senderPubkey)-\(content)-\(giftWrap.created_at)"
if messageDeduplication.get(messageHash) != nil {
return // Already processed in this session
}
messageDeduplication.set(messageHash, value: Date())
// Handle special messages
if content.hasPrefix("FAVORITED") || content.hasPrefix("UNFAVORITED") {
let parts = content.split(separator: ":")
let isFavorite = parts.first == "FAVORITED"
let nostrNpub = parts.count > 1 ? String(parts[1]) : nil
handleFavoriteNotification(from: senderPubkey, isFavorite: isFavorite, nostrNpub: nostrNpub)
return
}
// Handle delivery acknowledgments
if content.hasPrefix("DELIVERED:") {
let parts = content.split(separator: ":")
if parts.count > 1 {
let messageId = String(parts[1])
handleDeliveryAcknowledgment(messageId: messageId, from: senderPubkey)
}
return
}
// Handle read receipts
if content.hasPrefix("READ:") {
let parts = content.split(separator: ":", maxSplits: 1)
if parts.count > 1 {
let receiptDataString = String(parts[1])
if let receiptData = Data(base64Encoded: receiptDataString),
let receipt = ReadReceipt.fromBinaryData(receiptData) {
handleReadReceipt(receipt, from: senderPubkey)
}
}
return
}
// Find the sender's Noise public key
guard let senderNoiseKey = findNoisePublicKey(for: senderPubkey) else { return }
// Parse structured message content
var messageId = UUID().uuidString
var messageContent = content
if content.hasPrefix("MSG:") {
let parts = content.split(separator: ":", maxSplits: 2)
if parts.count >= 3 {
messageId = String(parts[1])
messageContent = String(parts[2])
}
}
// Create a BitchatMessage and inject into the stream
let chatMessage = BitchatMessage(
id: messageId,
sender: favoritesService.getFavoriteStatus(for: senderNoiseKey)?.peerNickname ?? "Unknown",
content: messageContent,
timestamp: Date(timeIntervalSince1970: TimeInterval(giftWrap.created_at)),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: nil,
senderPeerID: senderNoiseKey.hexEncodedString(),
mentions: nil,
deliveryStatus: .delivered(to: "nostr", at: Date())
)
// Post notification for ChatViewModel to handle
NotificationCenter.default.post(
name: .nostrMessageReceived,
object: nil,
userInfo: ["message": chatMessage]
)
// Send delivery acknowledgment back to sender
sendDeliveryAcknowledgment(for: chatMessage.id, to: senderPubkey)
} catch {
SecureLogger.log("❌ Failed to decrypt gift wrap: \(error)",
category: SecureLogger.session, level: .error)
}
}
private func handleFavoriteNotification(from nostrPubkey: String, isFavorite: Bool, nostrNpub: String? = nil) {
// Find the sender's Noise public key
guard let senderNoiseKey = findNoisePublicKey(for: nostrPubkey) else { return }
// Update favorites service - nostrPubkey is already the hex public key
favoritesService.updatePeerFavoritedUs(
peerNoisePublicKey: senderNoiseKey,
favorited: isFavorite,
peerNostrPublicKey: nostrPubkey
)
// Post notification for UI update
NotificationCenter.default.post(
name: .favoriteStatusChanged,
object: nil,
userInfo: [
"peerPublicKey": senderNoiseKey,
"isFavorite": isFavorite
]
)
}
private func findNoisePublicKey(for nostrPubkey: String) -> Data? {
// Search through favorites for matching Nostr pubkey
for (noiseKey, relationship) in favoritesService.favorites {
if relationship.peerNostrPublicKey == nostrPubkey {
return noiseKey
}
}
return nil
}
private func handleDeliveryAcknowledgment(messageId: String, from senderPubkey: String) {
// Find the sender's Noise public key
guard let senderNoiseKey = findNoisePublicKey(for: senderPubkey) else { return }
// Post notification for ChatViewModel to update delivery status
NotificationCenter.default.post(
name: .messageDeliveryAcknowledged,
object: nil,
userInfo: [
"messageId": messageId,
"senderNoiseKey": senderNoiseKey
]
)
}
private func handleReadReceipt(_ receipt: ReadReceipt, from senderPubkey: String) {
// Find the sender's Noise public key
guard let senderNoiseKey = findNoisePublicKey(for: senderPubkey) else { return }
let senderHexID = senderNoiseKey.hexEncodedString()
// Update the receipt with the correct sender ID
var updatedReceipt = receipt
updatedReceipt.readerID = senderHexID
// Post notification for ChatViewModel to process
NotificationCenter.default.post(
name: .readReceiptReceived,
object: nil,
userInfo: ["receipt": updatedReceipt]
)
}
func sendReadReceipt(
for originalMessageID: String,
to recipientNoisePublicKey: Data,
preferredTransport: Transport? = nil
) async throws {
// Get nickname from delegate or use default
let nickname = (meshService.delegate as? ChatViewModel)?.nickname ?? "Anonymous"
// Create read receipt
let receipt = ReadReceipt(
originalMessageID: originalMessageID,
readerID: meshService.myPeerID,
readerNickname: nickname
)
// Encode receipt
let receiptData = receipt.toBinaryData()
let content = "READ:\(receiptData.base64EncodedString())"
// Check if peer is connected via mesh (mesh takes precedence)
let recipientHexID = recipientNoisePublicKey.hexEncodedString()
// First check if the peer is currently connected with the given ID
var actualRecipientHexID = recipientHexID
var actualRecipientNoiseKey = recipientNoisePublicKey
// Always check if they reconnected with a new ID, even if preferredTransport is specified
if let favoriteStatus = favoritesService.getFavoriteStatus(for: recipientNoisePublicKey) {
let peerNickname = favoriteStatus.peerNickname
// Search through all current peers to find one with the same nickname
for (currentPeerID, currentNickname) in meshService.getPeerNicknames() {
if currentNickname == peerNickname,
currentPeerID != recipientHexID,
let currentNoiseKey = Data(hexString: currentPeerID) {
SecureLogger.log("🔄 Found updated peer ID for \(peerNickname): \(recipientHexID) -> \(currentPeerID)",
category: SecureLogger.session, level: .info)
actualRecipientHexID = currentPeerID
actualRecipientNoiseKey = currentNoiseKey
break
}
}
// If still not found in connected peers, check all favorites for the current key
if meshService.getPeerNicknames()[actualRecipientHexID] == nil {
// Search through all favorites to find the current noise key for this nickname
for (noiseKey, relationship) in favoritesService.favorites {
if relationship.peerNickname == peerNickname && relationship.peerNostrPublicKey != nil {
SecureLogger.log("🔄 Using current favorite key for \(peerNickname): \(recipientHexID) -> \(noiseKey.hexEncodedString())",
category: SecureLogger.session, level: .info)
actualRecipientHexID = noiseKey.hexEncodedString()
actualRecipientNoiseKey = noiseKey
break
}
}
}
}
let isConnectedOnMesh = meshService.isPeerConnected(actualRecipientHexID)
if isConnectedOnMesh && preferredTransport != .nostr {
// Send via mesh
SecureLogger.log("📡 Sending read receipt via mesh to \(actualRecipientHexID)",
category: SecureLogger.session, level: .debug)
meshService.sendReadReceipt(receipt, to: actualRecipientHexID)
} else {
// Send via Nostr
SecureLogger.log("🌐 Sending read receipt via Nostr to \(actualRecipientHexID)",
category: SecureLogger.session, level: .debug)
// Get recipient's Nostr public key using the actual current noise key
let favoriteStatus = favoritesService.getFavoriteStatus(for: actualRecipientNoiseKey)
guard let recipientNostrPubkey = favoriteStatus?.peerNostrPublicKey else {
SecureLogger.log("❌ Cannot send read receipt - no Nostr key for recipient",
category: SecureLogger.session, level: .error)
throw MessageRouterError.noNostrKey
}
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
SecureLogger.log("⚠️ No Nostr identity available for read receipt",
category: SecureLogger.session, level: .warning)
throw MessageRouterError.noIdentity
}
// Create read receipt message
guard let event = try? NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipientNostrPubkey,
senderIdentity: senderIdentity
) else {
SecureLogger.log("❌ Failed to create read receipt",
category: SecureLogger.session, level: .error)
throw MessageRouterError.encryptionFailed
}
// Send via relay
nostrRelay.sendEvent(event)
}
}
private func sendDeliveryAcknowledgment(for messageId: String, to recipientNostrPubkey: String) {
SecureLogger.log("📤 Sending delivery acknowledgment for message \(messageId)",
category: SecureLogger.session, level: .debug)
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
SecureLogger.log("⚠️ No Nostr identity available for acknowledgment",
category: SecureLogger.session, level: .warning)
return
}
// Create acknowledgment message
let content = "DELIVERED:\(messageId)"
guard let event = try? NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipientNostrPubkey,
senderIdentity: senderIdentity
) else {
SecureLogger.log("❌ Failed to create delivery acknowledgment",
category: SecureLogger.session, level: .error)
return
}
// Send via relay
nostrRelay.sendEvent(event)
}
private func cleanupOldMessages() {
let cutoff = Date().addingTimeInterval(-300) // 5 minutes
pendingMessages = pendingMessages.filter { $0.value.timestamp > cutoff }
}
}
// MARK: - Errors
enum MessageRouterError: LocalizedError {
case peerNotReachable
case noNostrPublicKey
case noNostrIdentity
case transportFailed
case noNostrKey
case noIdentity
case encryptionFailed
var errorDescription: String? {
switch self {
case .peerNotReachable:
return "Peer is not reachable via mesh or Nostr"
case .noNostrPublicKey:
return "Peer's Nostr public key is unknown"
case .noNostrIdentity:
return "No Nostr identity available"
case .transportFailed:
return "Failed to send message"
case .noNostrKey:
return "No Nostr key available for recipient"
case .noIdentity:
return "No identity available"
case .encryptionFailed:
return "Failed to encrypt message"
}
}
}
// MARK: - Notification Names
extension Notification.Name {
static let nostrMessageReceived = Notification.Name("NostrMessageReceived")
static let messageDeliveryAcknowledged = Notification.Name("MessageDeliveryAcknowledged")
static let readReceiptReceived = Notification.Name("ReadReceiptReceived")
static let appDidBecomeActive = Notification.Name("AppDidBecomeActive")
}
+26 -5
View File
@@ -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 (BluetoothMeshService) and the cryptographic layer (NoiseProtocol).
/// layer (SimplifiedBluetoothService) and the cryptographic layer (NoiseProtocol).
///
/// ## Overview
/// This service provides a simplified API for establishing and managing encrypted
@@ -60,7 +60,7 @@
/// ```
///
/// ## Integration Points
/// - **BluetoothMeshService**: Calls this service for all private messages
/// - **SimplifiedBluetoothService**: 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
@@ -162,9 +162,26 @@ class NoiseEncryptionService {
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
// Callbacks
var onPeerAuthenticated: ((String, String) -> Void)? // peerID, fingerprint
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
var onHandshakeRequired: ((String) -> Void)? // peerID needs handshake
// Add a handler for peer authentication
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
serviceQueue.async(flags: .barrier) { [weak self] in
self?.onPeerAuthenticatedHandlers.append(handler)
}
}
// Legacy support - setting this will add to the handlers array
var onPeerAuthenticated: ((String, String) -> Void)? {
get { nil } // Always return nil for backward compatibility
set {
if let handler = newValue {
addOnPeerAuthenticatedHandler(handler)
}
}
}
init() {
// Load or create static identity key (ONLY from keychain)
let loadedKey: Curve25519.KeyAgreement.PrivateKey
@@ -434,8 +451,12 @@ class NoiseEncryptionService {
// Log security event
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
// Notify about authentication
onPeerAuthenticated?(peerID, fingerprint)
// Notify all handlers about authentication
serviceQueue.async { [weak self] in
self?.onPeerAuthenticatedHandlers.forEach { handler in
handler(peerID, fingerprint)
}
}
}
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
+189
View File
@@ -0,0 +1,189 @@
//
// PrivateChatManager.swift
// bitchat
//
// Manages private chat sessions and messages
// This is free and unencumbered software released into the public domain.
//
import Foundation
import SwiftUI
/// Manages all private chat functionality
class PrivateChatManager: ObservableObject {
@Published var privateChats: [String: [BitchatMessage]] = [:]
@Published var selectedPeer: String? = nil
@Published var unreadMessages: Set<String> = []
private var selectedPeerFingerprint: String? = nil
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
weak var meshService: SimplifiedBluetoothService?
init(meshService: SimplifiedBluetoothService? = nil) {
self.meshService = meshService
}
/// Start a private chat with a peer
func startChat(with peerID: String) {
selectedPeer = peerID
// Store fingerprint for persistence across reconnections
if let fingerprint = meshService?.getPeerFingerprint(peerID) {
selectedPeerFingerprint = fingerprint
}
// Mark messages as read
markAsRead(from: peerID)
// Initialize chat if needed
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
}
/// End the current private chat
func endChat() {
selectedPeer = nil
selectedPeerFingerprint = nil
}
/// Send a private message
func sendMessage(_ content: String, to peerID: String) {
guard let meshService = meshService,
let peerNickname = meshService.getPeerNicknames()[peerID] else {
return
}
let messageID = UUID().uuidString
// Create local message
let message = BitchatMessage(
id: messageID,
sender: meshService.myNickname,
content: content,
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: peerNickname,
senderPeerID: meshService.myPeerID,
mentions: nil,
deliveryStatus: .sending
)
// Add to chat
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
privateChats[peerID]?.append(message)
// Send via mesh service
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: peerNickname, messageID: messageID)
}
/// Handle incoming private message
func handleIncomingMessage(_ message: BitchatMessage) {
guard let senderPeerID = message.senderPeerID else { return }
// Initialize chat if needed
if privateChats[senderPeerID] == nil {
privateChats[senderPeerID] = []
}
// Add message
privateChats[senderPeerID]?.append(message)
// Mark as unread if not in this chat
if selectedPeer != senderPeerID {
unreadMessages.insert(senderPeerID)
// Send notification
NotificationService.shared.sendPrivateMessageNotification(
from: message.sender,
message: message.content,
peerID: senderPeerID
)
} else {
// Send read receipt if viewing this chat
sendReadReceipt(for: message)
}
}
/// Mark messages from a peer as read
func markAsRead(from peerID: String) {
unreadMessages.remove(peerID)
// Send read receipts for unread messages that haven't been sent yet
if let messages = privateChats[peerID] {
for message in messages {
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
sendReadReceipt(for: message)
}
}
}
}
/// Update the selected peer if fingerprint matches (for reconnections)
func updateSelectedPeer(peers: [String: String]) {
guard let fingerprint = selectedPeerFingerprint else { return }
// Find peer with matching fingerprint
for (peerID, _) in peers {
if meshService?.getPeerFingerprint(peerID) == fingerprint {
selectedPeer = peerID
break
}
}
}
/// Get chat messages for current context
func getCurrentMessages() -> [BitchatMessage] {
guard let peer = selectedPeer else { return [] }
return privateChats[peer] ?? []
}
/// Clear a private chat
func clearChat(with peerID: String) {
privateChats[peerID]?.removeAll()
}
/// Handle delivery acknowledgment
func handleDeliveryAck(messageID: String, from peerID: String) {
guard privateChats[peerID] != nil else { return }
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[index].deliveryStatus = .delivered(to: "recipient", at: Date())
}
}
/// Handle read receipt
func handleReadReceipt(messageID: String, from peerID: String) {
guard privateChats[peerID] != nil else { return }
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[index].deliveryStatus = .read(by: "recipient", at: Date())
}
}
// MARK: - Private Methods
private func sendReadReceipt(for message: BitchatMessage) {
guard !sentReadReceipts.contains(message.id),
let senderPeerID = message.senderPeerID else {
return
}
sentReadReceipts.insert(message.id)
// Create read receipt using the simplified method
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: meshService?.myPeerID ?? "",
readerNickname: meshService?.myNickname ?? ""
)
// Send through mesh service's read receipt method
meshService?.sendReadReceipt(receipt, to: senderPeerID)
}
}
@@ -1,106 +0,0 @@
import Foundation
/// Service to track processed messages across app restarts to prevent duplicates
@MainActor
final class ProcessedMessagesService {
static let shared = ProcessedMessagesService()
private let userDefaults = UserDefaults.standard
private let processedMessagesKey = "ProcessedNostrMessages"
private let lastProcessedTimestampKey = "LastProcessedNostrTimestamp"
private let maxStoredMessages = 1000 // Keep last 1000 message IDs
private var processedMessageIDs: Set<String> = []
private var lastProcessedTimestamp: Date?
private init() {
loadProcessedMessages()
}
/// Check if a message has already been processed
func isMessageProcessed(_ messageID: String) -> Bool {
return processedMessageIDs.contains(messageID)
}
/// Mark a message as processed
func markMessageAsProcessed(_ messageID: String, timestamp: Date) {
processedMessageIDs.insert(messageID)
// Update last processed timestamp if this message is newer
if let lastTimestamp = lastProcessedTimestamp {
if timestamp > lastTimestamp {
lastProcessedTimestamp = timestamp
}
} else {
lastProcessedTimestamp = timestamp
}
// Trim if we have too many stored IDs
if processedMessageIDs.count > maxStoredMessages {
trimOldestMessages()
}
saveProcessedMessages()
}
/// Get the timestamp to use for Nostr subscription filters
func getSubscriptionSinceDate() -> Date {
// If we have a last processed timestamp, use it minus a small buffer
if let lastTimestamp = lastProcessedTimestamp {
// Go back 1 hour before last processed message for safety
return lastTimestamp.addingTimeInterval(-3600)
}
// Default: look back 24 hours on first run
return Date().addingTimeInterval(-86400)
}
/// Clear all processed messages (useful for debugging)
func clearProcessedMessages() {
processedMessageIDs.removeAll()
lastProcessedTimestamp = nil
saveProcessedMessages()
}
// MARK: - Private Methods
private func loadProcessedMessages() {
if let data = userDefaults.data(forKey: processedMessagesKey),
let decoded = try? JSONDecoder().decode([String].self, from: data) {
processedMessageIDs = Set(decoded)
}
if let timestampInterval = userDefaults.object(forKey: lastProcessedTimestampKey) as? TimeInterval {
lastProcessedTimestamp = Date(timeIntervalSince1970: timestampInterval)
}
}
private func saveProcessedMessages() {
// Convert Set to Array for encoding
let messageArray = Array(processedMessageIDs)
if let encoded = try? JSONEncoder().encode(messageArray) {
userDefaults.set(encoded, forKey: processedMessagesKey)
}
if let timestamp = lastProcessedTimestamp {
userDefaults.set(timestamp.timeIntervalSince1970, forKey: lastProcessedTimestampKey)
}
userDefaults.synchronize()
}
private func trimOldestMessages() {
// Since we don't track insertion order, we'll just keep the most recent N messages
// In a production app, you might want to track timestamps for each message
let excess = processedMessageIDs.count - maxStoredMessages
if excess > 0 {
// Remove random excess messages (not ideal, but simple)
for _ in 0..<excess {
if let first = processedMessageIDs.first {
processedMessageIDs.remove(first)
}
}
}
}
}
File diff suppressed because it is too large Load Diff
+405
View File
@@ -0,0 +1,405 @@
//
// UnifiedPeerService.swift
// bitchat
//
// Unified peer state management combining mesh connectivity and favorites
// This is free and unencumbered software released into the public domain.
//
import Foundation
import Combine
import SwiftUI
import CryptoKit
/// Single source of truth for peer state, combining mesh connectivity and favorites
@MainActor
class UnifiedPeerService: ObservableObject {
// MARK: - Published Properties
@Published private(set) var peers: [BitchatPeer] = []
@Published private(set) var connectedPeerIDs: Set<String> = []
@Published private(set) var favorites: [BitchatPeer] = []
@Published private(set) var mutualFavorites: [BitchatPeer] = []
// MARK: - Private Properties
private var peerIndex: [String: BitchatPeer] = [:]
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
private let meshService: SimplifiedBluetoothService
private let favoritesService = FavoritesPersistenceService.shared
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(meshService: SimplifiedBluetoothService) {
self.meshService = meshService
// Subscribe to changes from both services
setupSubscriptions()
// Perform initial update
Task { @MainActor in
updatePeers()
}
}
// 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)
// Also listen for favorite change notifications
NotificationCenter.default.publisher(for: .favoriteStatusChanged)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.updatePeers()
}
.store(in: &cancellables)
}
// MARK: - Core Update Logic
private func updatePeers() {
let meshPeers = meshService.fullPeersPublisher.value
let favorites = favoritesService.favorites
var enrichedPeers: [BitchatPeer] = []
var connected: Set<String> = []
var addedPeerIDs: Set<String> = []
// Phase 1: Add all connected mesh peers
for (peerID, peerInfo) in meshPeers where peerInfo.isConnected {
guard peerID != meshService.myPeerID else { continue } // Never add self
let peer = buildPeerFromMesh(
peerID: peerID,
peerInfo: peerInfo,
favorites: favorites
)
enrichedPeers.append(peer)
connected.insert(peerID)
addedPeerIDs.insert(peerID)
// Update fingerprint cache
if let publicKey = peerInfo.noisePublicKey {
fingerprintCache[peerID] = publicKey.sha256Fingerprint()
}
}
// Phase 2: Add offline favorites that we actively favorite
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
let peerID = favoriteKey.hexEncodedString()
// Skip if already added (connected peer)
if addedPeerIDs.contains(peerID) { continue }
// Skip if connected under different ID but same nickname
let isConnectedByNickname = enrichedPeers.contains {
$0.nickname == favorite.peerNickname && $0.isConnected
}
if isConnectedByNickname { continue }
let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID)
enrichedPeers.append(peer)
addedPeerIDs.insert(peerID)
// Update fingerprint cache
fingerprintCache[peerID] = favoriteKey.sha256Fingerprint()
}
// Phase 3: Sort peers
enrichedPeers.sort { lhs, rhs in
// Connected first
if lhs.isConnected != rhs.isConnected {
return lhs.isConnected
}
// Then favorites
if lhs.isFavorite != rhs.isFavorite {
return lhs.isFavorite
}
// Finally alphabetical
return lhs.displayName < rhs.displayName
}
// Phase 4: Build subsets and indices
var favoritesList: [BitchatPeer] = []
var mutualsList: [BitchatPeer] = []
var newIndex: [String: BitchatPeer] = [:]
for peer in enrichedPeers {
newIndex[peer.id] = peer
if peer.isFavorite {
favoritesList.append(peer)
}
if peer.isMutualFavorite {
mutualsList.append(peer)
}
}
// Phase 5: Update published properties
self.peers = enrichedPeers
self.connectedPeerIDs = connected
self.favorites = favoritesList
self.mutualFavorites = mutualsList
self.peerIndex = newIndex
// Log summary (commented out to reduce noise)
// let connectedCount = connected.count
// let offlineCount = enrichedPeers.count - connectedCount
// Peer update: \(enrichedPeers.count) total (\(connectedCount) connected, \(offlineCount) offline)
}
// MARK: - Peer Building Helpers
private func buildPeerFromMesh(
peerID: String,
peerInfo: SimplifiedBluetoothService.PeerInfoSnapshot,
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship]
) -> BitchatPeer {
var peer = BitchatPeer(
id: peerID,
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
nickname: peerInfo.nickname,
lastSeen: peerInfo.lastSeen,
isConnected: true
)
// Check for favorite status
if let noiseKey = peerInfo.noisePublicKey,
let favoriteStatus = favorites[noiseKey] {
peer.favoriteStatus = favoriteStatus
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
} else {
// Check by nickname for reconnected peers
let favoriteByNickname = favorites.values.first {
$0.peerNickname == peerInfo.nickname
}
if let favorite = favoriteByNickname,
let noiseKey = peerInfo.noisePublicKey {
SecureLogger.log(
"🔄 Found favorite for '\(peerInfo.nickname)' by nickname, updating noise key",
category: SecureLogger.session,
level: .info
)
// Update the favorite's key in persistence
favoritesService.updateNoisePublicKey(
from: favorite.peerNoisePublicKey,
to: noiseKey,
peerNickname: peerInfo.nickname
)
// Get updated favorite
peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey)
peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey
}
}
return peer
}
private func buildPeerFromFavorite(
favorite: FavoritesPersistenceService.FavoriteRelationship,
peerID: String
) -> BitchatPeer {
var peer = BitchatPeer(
id: peerID,
noisePublicKey: favorite.peerNoisePublicKey,
nickname: favorite.peerNickname,
lastSeen: favorite.lastUpdated,
isConnected: false
)
peer.favoriteStatus = favorite
peer.nostrPublicKey = favorite.peerNostrPublicKey
return peer
}
// MARK: - Public Methods
/// Get peer by ID
func getPeer(by id: String) -> BitchatPeer? {
return peerIndex[id]
}
/// Get peer ID for nickname
func getPeerID(for nickname: String) -> String? {
for peer in peers {
if peer.displayName == nickname || peer.nickname == nickname {
return peer.id
}
}
return nil
}
/// Check if peer is online
func isOnline(_ peerID: String) -> Bool {
return connectedPeerIDs.contains(peerID)
}
/// Check if peer is blocked
func isBlocked(_ peerID: String) -> Bool {
// Get fingerprint
guard let fingerprint = getFingerprint(for: peerID) else { return false }
// Check SecureIdentityStateManager for block status
if let identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
return identity.isBlocked
}
return false
}
/// Toggle favorite status
func toggleFavorite(_ peerID: String) {
guard let peer = getPeer(by: peerID) else {
SecureLogger.log("⚠️ Cannot toggle favorite - peer not found: \(peerID)",
category: SecureLogger.session, level: .warning)
return
}
let wasFavorite = peer.isFavorite
// Get the actual nickname for logging and saving
var actualNickname = peer.nickname
// Debug logging to understand the issue
SecureLogger.log("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)",
category: SecureLogger.session, level: .info)
if actualNickname.isEmpty {
// Try to get from mesh service's current peer list
if let meshPeerNickname = meshService.getPeerNicknames()[peerID] {
actualNickname = meshPeerNickname
SecureLogger.log("🔍 Got nickname from mesh service: '\(actualNickname)'",
category: SecureLogger.session, level: .debug)
}
}
// Use displayName as fallback (which shows ID prefix if nickname is empty)
let finalNickname = actualNickname.isEmpty ? peer.displayName : actualNickname
if wasFavorite {
// Remove favorite
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
} else {
// Get or derive peer's Nostr public key if not already known
var peerNostrKey = peer.nostrPublicKey
if peerNostrKey == nil {
// Try to get from NostrIdentityBridge association
peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey)
}
// Add favorite
favoritesService.addFavorite(
peerNoisePublicKey: peer.noisePublicKey,
peerNostrPublicKey: peerNostrKey,
peerNickname: finalNickname
)
}
// Log the final nickname being saved
SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))",
category: SecureLogger.session, level: .info)
// Send favorite notification to the peer
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
// Force update of peers to reflect the change
updatePeers()
// Force UI update by notifying SwiftUI directly
DispatchQueue.main.async { [weak self] in
self?.objectWillChange.send()
}
}
/// Toggle blocked status
func toggleBlocked(_ peerID: String) {
guard let fingerprint = getFingerprint(for: peerID) else { return }
// Get or create social identity
var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint)
?? SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: getPeer(by: peerID)?.displayName ?? "Unknown",
trustLevel: .unknown,
isFavorite: false,
isBlocked: false,
notes: nil
)
// Toggle blocked status
identity.isBlocked = !identity.isBlocked
// Can't be both favorite and blocked
if identity.isBlocked {
identity.isFavorite = false
// Also remove from favorites service
if let peer = getPeer(by: peerID) {
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
}
}
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
}
/// Get fingerprint for peer ID
func getFingerprint(for peerID: String) -> String? {
// Check cache first
if let cached = fingerprintCache[peerID] {
return cached
}
// Try to get from mesh service
if let fingerprint = meshService.getPeerFingerprint(peerID) {
fingerprintCache[peerID] = fingerprint
return fingerprint
}
// Try to get from peer's public key
if let peer = getPeer(by: peerID) {
let fingerprint = peer.noisePublicKey.sha256Fingerprint()
fingerprintCache[peerID] = fingerprint
return fingerprint
}
return nil
}
// MARK: - Compatibility Methods (for easy migration)
var allPeers: [BitchatPeer] { peers }
var connectedPeers: [String] { Array(connectedPeerIDs) }
var favoritePeers: Set<String> {
Set(favorites.compactMap { getFingerprint(for: $0.id) })
}
var blockedUsers: Set<String> {
Set(peers.compactMap { peer in
isBlocked(peer.id) ? getFingerprint(for: peer.id) : nil
})
}
}
// MARK: - Helper Extensions
extension Data {
func sha256Fingerprint() -> String {
// Implementation matches existing fingerprint generation in NoiseEncryptionService
let hash = SHA256.hash(data: self)
return hash.map { String(format: "%02x", $0) }.joined()
}
}