From fe986ed397fba5a878267492a9b0bdc97bfbe603 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 4 Jul 2025 01:43:16 +0200 Subject: [PATCH] Fix encryption key exchange and add persistent identity for favorites - Implement three-tier key system: ephemeral encryption, ephemeral signing, persistent identity - Fix signature verification by properly separating KeyAgreement and Signing keys - Add persistent identity key for favorites that survives app restarts - Fix Data subscript crashes by converting to byte arrays - Add proper error handling for key exchange - Private messages remain fully encrypted with ephemeral keys --- bitchat.xcodeproj/project.pbxproj | 12 - bitchat/BitchatApp.swift | 55 ++ bitchat/Info.plist | 2 - bitchat/Protocols/BinaryProtocol.swift | 50 +- bitchat/Protocols/BitchatProtocol.swift | 7 +- bitchat/Services/AudioPlaybackService.swift | 119 ----- bitchat/Services/AudioRecordingService.swift | 196 ------- bitchat/Services/BluetoothMeshService.swift | 512 +++++++++++++------ bitchat/Services/EncryptionService.swift | 115 ++++- bitchat/Services/NotificationService.swift | 8 + bitchat/ViewModels/ChatViewModel.swift | 202 ++++---- bitchat/Views/ContentView.swift | 330 ++---------- 12 files changed, 679 insertions(+), 929 deletions(-) delete mode 100644 bitchat/Services/AudioPlaybackService.swift delete mode 100644 bitchat/Services/AudioRecordingService.swift diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index e5a930fe..27aab305 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -12,18 +12,14 @@ 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; }; 61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; }; 6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */; }; - 6E628AC6B7E1754F3BD5090D /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90B7FFCA6DA409F3CB78704 /* AudioPlaybackService.swift */; }; 6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; }; 739429DFDE5C5829CF70DA7D /* EncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC1563390A15C042D059CF9 /* EncryptionService.swift */; }; 749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; }; 7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; }; 7A50E2F04A3515A7E90EEAE4 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; }; 7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; }; - 817B221A5676873EAF5C5FA7 /* AudioRecordingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8801D22A8F987A7696BB90 /* AudioRecordingService.swift */; }; 923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */; }; 92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; }; - 940A9CE820FBF517F0C7AA7E /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90B7FFCA6DA409F3CB78704 /* AudioPlaybackService.swift */; }; - A7F37567974153A90E8C9F12 /* AudioRecordingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8801D22A8F987A7696BB90 /* AudioRecordingService.swift */; }; BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; }; D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; }; D948085736ED8E736C1DE3B0 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; }; @@ -35,13 +31,11 @@ 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = ""; }; 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 6C8801D22A8F987A7696BB90 /* AudioRecordingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecordingService.swift; sourceTree = ""; }; 6DC1563390A15C042D059CF9 /* EncryptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionService.swift; sourceTree = ""; }; 7EEBDA723E1CFD88758DA4AC /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; }; 997D512074C64904D75DDD40 /* bitchat.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; }; A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = ""; }; - B90B7FFCA6DA409F3CB78704 /* AudioPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackService.swift; sourceTree = ""; }; D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothMeshService.swift; sourceTree = ""; }; E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = ""; }; EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -108,8 +102,6 @@ D98A3186D7E4C72E35BDF7FE /* Services */ = { isa = PBXGroup; children = ( - B90B7FFCA6DA409F3CB78704 /* AudioPlaybackService.swift */, - 6C8801D22A8F987A7696BB90 /* AudioRecordingService.swift */, D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */, 6DC1563390A15C042D059CF9 /* EncryptionService.swift */, 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */, @@ -219,8 +211,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 6E628AC6B7E1754F3BD5090D /* AudioPlaybackService.swift in Sources */, - A7F37567974153A90E8C9F12 /* AudioRecordingService.swift in Sources */, 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */, 6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */, 923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */, @@ -236,8 +226,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 940A9CE820FBF517F0C7AA7E /* AudioPlaybackService.swift in Sources */, - 817B221A5676873EAF5C5FA7 /* AudioRecordingService.swift in Sources */, F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */, 10E68BB889356219189E38EC /* BitchatApp.swift in Sources */, 6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */, diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index 084fce76..b64cdf86 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -1,17 +1,72 @@ import SwiftUI +import UserNotifications @main struct BitchatApp: App { @StateObject private var chatViewModel = ChatViewModel() + #if os(iOS) + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + #endif + + init() { + UNUserNotificationCenter.current().delegate = NotificationDelegate.shared + } var body: some Scene { WindowGroup { ContentView() .environmentObject(chatViewModel) + .onAppear { + NotificationDelegate.shared.chatViewModel = chatViewModel + } } #if os(macOS) .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) #endif } +} + +#if os(iOS) +class AppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + return true + } +} +#endif + +class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { + static let shared = NotificationDelegate() + weak var chatViewModel: ChatViewModel? + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + let identifier = response.notification.request.identifier + + // Check if this is a private message notification + if identifier.hasPrefix("private-") { + // Extract sender from notification title + let title = response.notification.request.content.title + if let senderName = title.replacingOccurrences(of: "Private message from ", with: "").nilIfEmpty { + // Find peer ID and open chat + if let peerID = chatViewModel?.getPeerIDForNickname(senderName) { + DispatchQueue.main.async { + self.chatViewModel?.startPrivateChat(with: peerID) + } + } + } + } + + completionHandler() + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + // Show notification even when app is in foreground (for testing) + completionHandler([.banner, .sound]) + } +} + +extension String { + var nilIfEmpty: String? { + self.isEmpty ? nil : self + } } \ No newline at end of file diff --git a/bitchat/Info.plist b/bitchat/Info.plist index f4bbfc29..78b57dd9 100644 --- a/bitchat/Info.plist +++ b/bitchat/Info.plist @@ -28,8 +28,6 @@ bitchat uses Bluetooth to discover and connect with other bitchat users nearby. NSMicrophoneUsageDescription bitchat needs access to your microphone to record voice notes. - NSUserNotificationsUsageDescription - bitchat needs permission to send notifications for mentions and private messages. UIBackgroundModes bluetooth-central diff --git a/bitchat/Protocols/BinaryProtocol.swift b/bitchat/Protocols/BinaryProtocol.swift index 53214c75..fc8013ed 100644 --- a/bitchat/Protocols/BinaryProtocol.swift +++ b/bitchat/Protocols/BinaryProtocol.swift @@ -185,7 +185,6 @@ extension BitchatMessage { // - Recipient nickname length + data // - Sender peer ID length + data // - Mentions array - // - Voice note duration (8 bytes, double) + data length (4 bytes) + data var flags: UInt8 = 0 if isRelay { flags |= 0x01 } @@ -194,7 +193,6 @@ extension BitchatMessage { if recipientNickname != nil { flags |= 0x08 } if senderPeerID != nil { flags |= 0x10 } if mentions != nil && !mentions!.isEmpty { flags |= 0x20 } - if voiceNoteData != nil { flags |= 0x40 } data.append(flags) @@ -261,24 +259,6 @@ extension BitchatMessage { } } - // Voice note data - if let voiceData = voiceNoteData, let duration = voiceNoteDuration { - // Duration as 8 bytes (double) - let durationBits = duration.bitPattern - for i in (0..<8).reversed() { - data.append(UInt8((durationBits >> (i * 8)) & 0xFF)) - } - - // Voice data length as 4 bytes (UInt32) - let voiceLength = UInt32(min(voiceData.count, Int(UInt32.max))) - for i in (0..<4).reversed() { - data.append(UInt8((voiceLength >> (i * 8)) & 0xFF)) - } - - // Voice data - data.append(voiceData.prefix(Int(voiceLength))) - } - return data } @@ -304,7 +284,6 @@ extension BitchatMessage { let hasRecipientNickname = (flags & 0x08) != 0 let hasSenderPeerID = (flags & 0x10) != 0 let hasMentions = (flags & 0x20) != 0 - let hasVoiceNote = (flags & 0x40) != 0 // Timestamp guard offset + 8 <= dataCopy.count else { @@ -402,31 +381,6 @@ extension BitchatMessage { } } - // Voice note data - var voiceNoteData: Data? - var voiceNoteDuration: TimeInterval? - if hasVoiceNote && offset + 12 <= dataCopy.count { - // Duration (8 bytes) - let durationData = dataCopy[offset.. temporary file URL - - override init() { - super.init() - setupAudioSession() - } - - private func setupAudioSession() { - #if os(iOS) - let audioSession = AVAudioSession.sharedInstance() - do { - try audioSession.setCategory(.playback, mode: .default) - try audioSession.setActive(true) - } catch { - print("[AUDIO] Failed to set up audio session: \(error)") - } - #endif - } - - func playVoiceNote(messageID: String, audioData: Data, completion: @escaping (Result) -> Void) { - // Stop any current playback - stopPlayback() - - print("[AUDIO] Attempting to play voice note with data size: \(audioData.count) bytes") - - // Create temporary file for audio data - let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("voice_note_\(messageID).m4a") - - do { - // Ensure directory exists - try FileManager.default.createDirectory(at: tempURL.deletingLastPathComponent(), withIntermediateDirectories: true) - - // Write audio data - try audioData.write(to: tempURL) - audioFiles[messageID] = tempURL - - print("[AUDIO] Written audio file to: \(tempURL.path), size: \(try FileManager.default.attributesOfItem(atPath: tempURL.path)[.size] ?? 0)") - - audioPlayer = try AVAudioPlayer(contentsOf: tempURL) - audioPlayer?.delegate = self - audioPlayer?.prepareToPlay() - - if audioPlayer?.play() == true { - isPlaying = true - currentPlayingMessageID = messageID - playbackProgress = 0 - - // Start progress timer - playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in - guard let self = self, let player = self.audioPlayer else { return } - self.playbackProgress = player.currentTime / player.duration - } - - completion(.success(())) - } else { - completion(.failure(AudioError.playbackFailed)) - } - - } catch { - completion(.failure(error)) - } - } - - func stopPlayback() { - audioPlayer?.stop() - isPlaying = false - currentPlayingMessageID = nil - playbackProgress = 0 - playbackTimer?.invalidate() - playbackTimer = nil - } - - func togglePlayback(messageID: String, audioData: Data, completion: @escaping (Result) -> Void) { - if isPlaying && currentPlayingMessageID == messageID { - stopPlayback() - completion(.success(())) - } else { - playVoiceNote(messageID: messageID, audioData: audioData, completion: completion) - } - } - - func cleanupTemporaryFiles() { - for (_, url) in audioFiles { - try? FileManager.default.removeItem(at: url) - } - audioFiles.removeAll() - } - - deinit { - cleanupTemporaryFiles() - } - - enum AudioError: LocalizedError { - case playbackFailed - - var errorDescription: String? { - switch self { - case .playbackFailed: - return "Failed to start audio playback" - } - } - } -} - -extension AudioPlaybackService: AVAudioPlayerDelegate { - func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { - stopPlayback() - } -} \ No newline at end of file diff --git a/bitchat/Services/AudioRecordingService.swift b/bitchat/Services/AudioRecordingService.swift deleted file mode 100644 index 85f1ae2d..00000000 --- a/bitchat/Services/AudioRecordingService.swift +++ /dev/null @@ -1,196 +0,0 @@ -import Foundation -import AVFoundation - -class AudioRecordingService: NSObject, ObservableObject { - @Published var isRecording = false - @Published var recordingTime: TimeInterval = 0 - - private var audioRecorder: AVAudioRecorder? - private var recordingTimer: Timer? - private var recordingStartTime: Date? - private let maxDuration: TimeInterval = 30.0 - private var recordingCompletion: ((Result) -> Void)? - - override init() { - super.init() - setupAudioSession() - } - - private func setupAudioSession() { - #if os(iOS) - let audioSession = AVAudioSession.sharedInstance() - do { - try audioSession.setCategory(.playAndRecord, mode: .default) - try audioSession.setActive(true) - } catch { - print("[AUDIO] Failed to set up audio session: \(error)") - } - #endif - } - - func startRecording(completion: @escaping (Result) -> Void) { - guard !isRecording else { return } - - #if os(iOS) - // Request microphone permission - AVAudioSession.sharedInstance().requestRecordPermission { granted in - if !granted { - completion(.failure(AudioError.microphonePermissionDenied)) - return - } - self.performRecording(completion: completion) - } - #else - performRecording(completion: completion) - #endif - } - - private func performRecording(completion: @escaping (Result) -> Void) { - // Store completion handler for later use - self.recordingCompletion = completion - - let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - let audioFilename = documentsPath.appendingPathComponent("voice_note_\(Date().timeIntervalSince1970).m4a") - - let settings = [ - AVFormatIDKey: Int(kAudioFormatMPEG4AAC), - AVSampleRateKey: 16000, // Lower sample rate for smaller files - AVNumberOfChannelsKey: 1, // Mono for voice - AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue, - AVEncoderBitRateKey: 32000 // 32 kbps for voice - ] - - do { - // Ensure audio session is properly configured - #if os(iOS) - let audioSession = AVAudioSession.sharedInstance() - try audioSession.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) - try audioSession.setActive(true) - #endif - - audioRecorder = try AVAudioRecorder(url: audioFilename, settings: settings) - audioRecorder?.delegate = self - audioRecorder?.prepareToRecord() // Important: prepare before recording - - print("[AUDIO] Created recorder with URL: \(audioFilename)") - print("[AUDIO] Recorder prepared: \(audioRecorder?.prepareToRecord() ?? false)") - - if audioRecorder?.record() == true { - isRecording = true - recordingStartTime = Date() - recordingTime = 0 - print("[AUDIO] Recording started successfully") - - // Start timer to update recording time and enforce max duration - recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in - guard let self = self, let startTime = self.recordingStartTime else { return } - self.recordingTime = Date().timeIntervalSince(startTime) - - if self.recordingTime >= self.maxDuration { - self.stopRecording { result in - // Result already handled by recordingCompletion - } - } - } - } else { - print("[AUDIO] Failed to start recording") - completion(.failure(AudioError.recordingFailed)) - } - - } catch { - print("[AUDIO] Error setting up recording: \(error)") - completion(.failure(error)) - } - } - - func stopRecording(completion: @escaping (Result) -> Void) { - guard isRecording, let recorder = audioRecorder else { - completion(.failure(AudioError.notRecording)) - return - } - - // Ensure minimum recording duration - let minDuration: TimeInterval = 1.0 - if recordingTime < minDuration { - // Continue recording until minimum duration - DispatchQueue.main.asyncAfter(deadline: .now() + (minDuration - recordingTime)) { [weak self] in - self?.stopRecording(completion: completion) - } - return - } - - recorder.stop() - isRecording = false - recordingTimer?.invalidate() - recordingTimer = nil - - let url = recorder.url - let recordedDuration = recordingTime - - // Reset for next recording - audioRecorder = nil - recordingTime = 0 - recordingStartTime = nil - recordingCompletion = nil - - // Give the recorder a moment to finalize the file - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - // Verify file exists and has content - do { - let attributes = try FileManager.default.attributesOfItem(atPath: url.path) - if let fileSize = attributes[.size] as? Int, fileSize > 1000 { // At least 1KB - print("[AUDIO] Recording completed successfully. File size: \(fileSize) bytes, duration: \(recordedDuration)s") - completion(.success(url)) - } else { - print("[AUDIO] Recording failed: file too small (\(attributes[.size] ?? 0) bytes)") - completion(.failure(AudioError.emptyRecording)) - } - } catch { - print("[AUDIO] Recording failed: \(error)") - completion(.failure(error)) - } - } - } - - func cancelRecording() { - guard isRecording, let recorder = audioRecorder else { return } - - recorder.stop() - recorder.deleteRecording() - - isRecording = false - recordingTimer?.invalidate() - recordingTimer = nil - audioRecorder = nil - recordingTime = 0 - recordingStartTime = nil - } - - enum AudioError: LocalizedError { - case notRecording - case emptyRecording - case microphonePermissionDenied - case recordingFailed - - var errorDescription: String? { - switch self { - case .notRecording: - return "Not currently recording" - case .emptyRecording: - return "Recording produced no audio data" - case .microphonePermissionDenied: - return "Microphone permission denied" - case .recordingFailed: - return "Failed to start recording" - } - } - } -} - -extension AudioRecordingService: AVAudioRecorderDelegate { - func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { - if !flag { - print("[AUDIO] Recording finished unsuccessfully") - } - } -} \ No newline at end of file diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index 234e521c..8423a17e 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -37,21 +37,33 @@ class BluetoothMeshService: NSObject { private let encryptionService = EncryptionService() private let messageQueue = DispatchQueue(label: "bitchat.messageQueue", attributes: .concurrent) private var processedMessages = Set() - private let maxTTL: UInt8 = 5 + private let maxTTL: UInt8 = 3 // Reduced for efficiency private var announcedToPeers = Set() // Track which peers we've announced to private var announcedPeers = Set() // Track peers who have already been announced + // Battery and range optimizations + private var scanDutyCycleTimer: Timer? + private var isActivelyScanning = true + private let activeScanDuration: TimeInterval = 2.0 // Scan actively for 2 seconds + private let scanPauseDuration: TimeInterval = 3.0 // Pause for 3 seconds + private var lastRSSIUpdate: [String: Date] = [:] // Throttle RSSI updates + // Fragment handling private var incomingFragments: [String: [Int: Data]] = [:] // fragmentID -> [index: data] private var fragmentMetadata: [String: (originalType: UInt8, totalFragments: Int, timestamp: Date)] = [:] - private let maxFragmentSize = 200 // Smaller fragments for better reliability + private let maxFragmentSize = 500 // Optimized for BLE 5.0 extended data length let myPeerID: String override init() { - self.myPeerID = UUID().uuidString.prefix(8).lowercased() + // Generate ephemeral peer ID for each session to prevent tracking + // Use random bytes instead of UUID for better anonymity + var randomBytes = [UInt8](repeating: 0, count: 4) + _ = SecRandomCopyBytes(kSecRandomDefault, 4, &randomBytes) + self.myPeerID = randomBytes.map { String(format: "%02x", $0) }.joined() + super.init() - print("[STARTUP] My Bluetooth ID (myPeerID): \(myPeerID)") + print("[STARTUP] Generated ephemeral peer ID: \(myPeerID)") centralManager = CBCentralManager(delegate: self, queue: nil) peripheralManager = CBPeripheralManager(delegate: self, queue: nil) @@ -76,6 +88,7 @@ class BluetoothMeshService: NSObject { deinit { cleanup() + scanDutyCycleTimer?.invalidate() } @objc private func appWillTerminate() { @@ -151,7 +164,7 @@ class BluetoothMeshService: NSObject { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self = self else { return } self.broadcastPacket(announcePacket) - print("[ANNOUNCE] Re-sending broadcast announce (delay: \(delay)s)") + // [ANNOUNCE] Re-sending broadcast announce } } } @@ -161,11 +174,15 @@ class BluetoothMeshService: NSObject { return } + // Use generic advertising to avoid identification + // No identifying prefixes or app names for activist safety let advertisementData: [String: Any] = [ CBAdvertisementDataServiceUUIDsKey: [BluetoothMeshService.serviceUUID], - CBAdvertisementDataLocalNameKey: "bitchat-\(myPeerID)" + // Use only peer ID without any identifying prefix + CBAdvertisementDataLocalNameKey: myPeerID, + CBAdvertisementDataIsConnectable: true ] - print("[BLUETOOTH] Starting to advertise as: bitchat-\(myPeerID)") + // [BLUETOOTH] Starting advertising peripheralManager.startAdvertising(advertisementData) } @@ -173,7 +190,51 @@ class BluetoothMeshService: NSObject { guard centralManager.state == .poweredOn else { return } - centralManager.scanForPeripherals(withServices: [BluetoothMeshService.serviceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) + + // [BLUETOOTH] Starting scan + // Enable duplicate detection for RSSI tracking + let scanOptions: [String: Any] = [ + CBCentralManagerScanOptionAllowDuplicatesKey: true + ] + + centralManager.scanForPeripherals( + withServices: [BluetoothMeshService.serviceUUID], + options: scanOptions + ) + + // Implement scan duty cycling for battery efficiency + scheduleScanDutyCycle() + } + + private func scheduleScanDutyCycle() { + guard scanDutyCycleTimer == nil else { return } + + // Start with active scanning + isActivelyScanning = true + + scanDutyCycleTimer = Timer.scheduledTimer(withTimeInterval: activeScanDuration, repeats: true) { [weak self] _ in + guard let self = self else { return } + + if self.isActivelyScanning { + // Pause scanning to save battery + self.centralManager.stopScan() + self.isActivelyScanning = false + // [BLUETOOTH] Pausing scan + + // Schedule resume + DispatchQueue.main.asyncAfter(deadline: .now() + self.scanPauseDuration) { [weak self] in + guard let self = self else { return } + if self.centralManager.state == .poweredOn { + self.centralManager.scanForPeripherals( + withServices: [BluetoothMeshService.serviceUUID], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] + ) + self.isActivelyScanning = true + // [BLUETOOTH] Resuming scan + } + } + } + } } private func setupPeripheral() { @@ -208,64 +269,41 @@ class BluetoothMeshService: NSObject { ) if let messageData = message.toBinaryPayload() { + // Sign the message payload (no encryption for broadcasts) + let signature: Data? + do { + signature = try self.encryptionService.sign(messageData) + print("[CRYPTO] Successfully signed broadcast message") + } catch { + print("[CRYPTO] Failed to sign message: \(error)") + signature = nil + } + let packet = BitchatPacket( type: MessageType.message.rawValue, - ttl: self.maxTTL, - senderID: self.myPeerID, - payload: messageData + senderID: Data(self.myPeerID.utf8), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970), + payload: messageData, + signature: signature, + ttl: self.maxTTL ) self.broadcastPacket(packet) - } - } - } - - func sendVoiceNote(_ audioData: Data, duration: TimeInterval) { - messageQueue.async { [weak self] in - guard let self = self else { return } - - print("[VOICE] Sending voice note: audio size = \(audioData.count) bytes, duration = \(duration)s") - - let nickname = self.delegate as? ChatViewModel - let senderNick = nickname?.nickname ?? self.myPeerID - - let message = BitchatMessage( - sender: senderNick, - content: "🎤 \(String(format: "%.1f", duration))s", - timestamp: Date(), - isRelay: false, - originalSender: nil, - voiceNoteData: audioData, - voiceNoteDuration: duration - ) - - if let messageData = message.toBinaryPayload() { - print("[VOICE] Message payload size: \(messageData.count) bytes") - let packet = BitchatPacket( - type: MessageType.voiceNote.rawValue, - ttl: self.maxTTL, - senderID: self.myPeerID, - payload: messageData - ) + print("[MESSAGE] Sending: \(content)") - if let packetData = packet.toBinaryData() { - print("[VOICE] Final packet size: \(packetData.count) bytes") - // Check if packet exceeds safe BLE size (leave margin for overhead) - if packetData.count > 400 { - print("[VOICE] Packet size \(packetData.count) exceeds safe BLE limit") - print("[VOICE] Fragmenting voice note into smaller packets...") - self.sendFragmentedPacket(packet) - } else { - print("[VOICE] Sending voice note as single packet") - self.broadcastPacket(packet) + // Retry for reliability (like announces) + for delay in [0.2, 0.5] { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self, packet] in + self?.broadcastPacket(packet) + // Re-sending message } - } else { - print("[VOICE] ERROR: Failed to convert packet to binary data") } } } } + func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String) { messageQueue.async { [weak self] in guard let self = self else { return } @@ -285,18 +323,39 @@ class BluetoothMeshService: NSObject { ) if let messageData = message.toBinaryPayload() { + // Encrypt the message for the recipient + let encryptedPayload: Data + do { + encryptedPayload = try self.encryptionService.encrypt(messageData, for: recipientPeerID) + print("[CRYPTO] Successfully encrypted private message for \(recipientPeerID)") + } catch { + print("[CRYPTO] Failed to encrypt private message: \(error)") + // Don't send unencrypted private messages + return + } + + // Sign the encrypted payload + let signature: Data? + do { + signature = try self.encryptionService.sign(encryptedPayload) + print("[CRYPTO] Successfully signed private message") + } catch { + print("[CRYPTO] Failed to sign private message: \(error)") + signature = nil + } + // Create packet with recipient ID for proper routing let packet = BitchatPacket( type: MessageType.privateMessage.rawValue, senderID: Data(self.myPeerID.utf8), recipientID: Data(recipientPeerID.utf8), timestamp: UInt64(Date().timeIntervalSince1970), - payload: messageData, - signature: nil, + payload: encryptedPayload, + signature: signature, ttl: self.maxTTL ) - print("[PRIVATE] Sending private message to \(recipientPeerID)") + print("[PRIVATE] Sending encrypted message to \(recipientPeerID): \(content)") self.broadcastPacket(packet) // Don't call didReceiveMessage here - let the view model handle it directly @@ -361,26 +420,81 @@ class BluetoothMeshService: NSObject { return peerRSSI } + // Emergency disconnect for panic situations + func emergencyDisconnectAll() { + // Stop advertising immediately + if peripheralManager.isAdvertising { + peripheralManager.stopAdvertising() + } + + // Stop scanning + centralManager.stopScan() + scanDutyCycleTimer?.invalidate() + scanDutyCycleTimer = nil + + // Disconnect all peripherals + for (_, peripheral) in connectedPeripherals { + centralManager.cancelPeripheralConnection(peripheral) + } + + // Clear all peer data + connectedPeripherals.removeAll() + peripheralCharacteristics.removeAll() + discoveredPeripherals.removeAll() + subscribedCentrals.removeAll() + peerNicknames.removeAll() + activePeers.removeAll() + peerRSSI.removeAll() + peripheralRSSI.removeAll() + announcedToPeers.removeAll() + announcedPeers.removeAll() + processedMessages.removeAll() + incomingFragments.removeAll() + fragmentMetadata.removeAll() + + // Clear persistent identity + encryptionService.clearPersistentIdentity() + + print("[PANIC] Emergency disconnect completed") + } + private func getAllConnectedPeerIDs() -> [String] { // Only return peers who have announced (have nicknames) - let announcedPeers = Set(activePeers.filter { peerID in - peerID != "unknown" && - peerID != myPeerID && - peerID.count <= 8 && // Filter out temp IDs - peerNicknames[peerID] != nil // Only include peers who have announced + let announcedPeers = Set(activePeers.compactMap { peerID -> String? in + // Ensure peerID is valid and not nil + guard !peerID.isEmpty, + peerID != "unknown", + peerID != myPeerID, + peerID.count <= 8, // Filter out temp IDs + peerNicknames[peerID] != nil else { // Only include peers who have announced + return nil + } + return peerID }) - print("[DEBUG] Active peers: \(activePeers), announced: \(announcedPeers), nicknames: \(peerNicknames.keys)") + // Active peers: \(announcedPeers.count) return Array(announcedPeers).sorted() } + private func estimateDistance(rssi: Int) -> Int { + // Rough distance estimation based on RSSI + // Using path loss formula: RSSI = TxPower - 10 * n * log10(distance) + // Assuming TxPower = -59 dBm at 1m, n = 2.0 (free space) + let txPower = -59.0 + let pathLossExponent = 2.0 + + let ratio = (txPower - Double(rssi)) / (10.0 * pathLossExponent) + let distance = pow(10.0, ratio) + + return Int(distance) + } + private func broadcastPacket(_ packet: BitchatPacket) { guard let data = packet.toBinaryData() else { print("[ERROR] Failed to convert packet to binary data") return } - - print("[BROADCAST] Broadcasting packet type: \(packet.type), data size: \(data.count)") + // [BROADCAST] Type: \(packet.type), peripherals: \(connectedPeripherals.count), centrals: \(subscribedCentrals.count) // Send to connected peripherals (as central) var sentToPeripherals = 0 @@ -388,32 +502,32 @@ class BluetoothMeshService: NSObject { if let characteristic = peripheralCharacteristics[peripheral] { // Check if peripheral is connected before writing if peripheral.state == .connected { - // Always use withResponse for reliability, especially for background - peripheral.writeValue(data, for: characteristic, type: .withResponse) + // Use withoutResponse for faster transmission when possible + // Only use withResponse for critical messages or when MTU negotiation needed + let writeType: CBCharacteristicWriteType = data.count > 512 ? .withResponse : .withoutResponse + peripheral.writeValue(data, for: characteristic, type: writeType) sentToPeripherals += 1 } else { print("[BROADCAST] Peripheral \(peerID) not connected (state: \(peripheral.state.rawValue))") } } else { - print("[BROADCAST] No characteristic found for peripheral \(peerID)") + // No characteristic for peripheral } } - print("[BROADCAST] Sent to \(sentToPeripherals) connected peripherals") + // Sent to \(sentToPeripherals) peripherals // Send to subscribed centrals (as peripheral) - if characteristic != nil && !subscribedCentrals.isEmpty { - let success = peripheralManager.updateValue(data, for: characteristic, onSubscribedCentrals: subscribedCentrals) + if let char = characteristic, !subscribedCentrals.isEmpty { + // Send to all subscribed centrals + let success = peripheralManager.updateValue(data, for: char, onSubscribedCentrals: nil) if success { - print("[BROADCAST] Successfully sent to \(subscribedCentrals.count) subscribed centrals") + // Sent to centrals } else { print("[BROADCAST] Failed to send to centrals - queue full, will retry on delegate callback") } } else { if characteristic == nil { - print("[BROADCAST] No characteristic available") - } - if subscribedCentrals.isEmpty { - print("[BROADCAST] No subscribed centrals") + // No characteristic or centrals } } } @@ -431,8 +545,26 @@ class BluetoothMeshService: NSObject { print("[PACKET] Dropping packet with empty payload") return } + + // Replay attack protection: Check timestamp is within reasonable window (5 minutes) + let currentTime = UInt64(Date().timeIntervalSince1970) + let timeDiff = abs(Int64(currentTime) - Int64(packet.timestamp)) + if timeDiff > 300 { // 5 minutes + print("[SECURITY] Dropping packet with timestamp too far from current time: \(timeDiff) seconds") + return + } + + // For fragments, include packet type in messageID to avoid dropping CONTINUE/END fragments + let messageID: String + if packet.type == MessageType.fragmentStart.rawValue || + packet.type == MessageType.fragmentContinue.rawValue || + packet.type == MessageType.fragmentEnd.rawValue { + // Include both type and payload hash for fragments to ensure uniqueness + messageID = "\(packet.timestamp)-\(String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) ?? "")-\(packet.type)-\(packet.payload.hashValue)" + } else { + messageID = "\(packet.timestamp)-\(String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) ?? "")" + } - let messageID = "\(packet.timestamp)-\(String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) ?? "")" guard !processedMessages.contains(messageID) else { return } @@ -444,17 +576,44 @@ class BluetoothMeshService: NSObject { let _ = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) ?? "unknown" - print("[PACKET] Received packet type: \(packet.type) from peerID: \(peerID)") + // Received packet type: \(packet.type) from \(peerID) // Note: We'll decode messages in the switch statement below, not here switch MessageType(rawValue: packet.type) { case .message: - if let message = BitchatMessage.fromBinaryPayload(packet.payload) { - // Ignore our own messages - if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8), senderID == myPeerID { - return + // Process broadcast message (no decryption needed) + guard let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) else { + return + } + + // Ignore our own messages + if senderID == myPeerID { + return + } + + // Verify signature if present + if let signature = packet.signature { + do { + let isValid = try encryptionService.verify(signature, for: packet.payload, from: senderID) + if !isValid { + print("[CRYPTO] Invalid signature from \(senderID), dropping message") + return + } + // Valid signature + } catch { + print("[CRYPTO] Failed to verify signature from \(senderID): \(error)") + // If we don't have the public key yet, continue without verification + // Continuing without signature verification } + } else { + print("[CRYPTO] No signature present in message from \(senderID)") + } + + let messagePayload = packet.payload + + if let message = BitchatMessage.fromBinaryPayload(messagePayload) { + print("[MESSAGE] Received from \(message.sender): \(message.content)") // Store nickname mapping if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) { @@ -470,6 +629,8 @@ class BluetoothMeshService: NSObject { if relayPacket.ttl > 0 { self.broadcastPacket(relayPacket) } + } else { + print("[MESSAGE] Failed to parse message from payload") } case .keyExchange: @@ -477,7 +638,18 @@ class BluetoothMeshService: NSObject { if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) { if packet.payload.count > 0 { let publicKeyData = packet.payload - try? encryptionService.addPeerPublicKey(senderID, publicKeyData: publicKeyData) + do { + try encryptionService.addPeerPublicKey(senderID, publicKeyData: publicKeyData) + print("[KEY_EXCHANGE] Successfully added public key for \(senderID)") + } catch { + print("[KEY_EXCHANGE] Failed to add public key for \(senderID): \(error)") + } + + // Register identity key with view model for persistent favorites + if let viewModel = self.delegate as? ChatViewModel, + let identityKeyData = encryptionService.getPeerIdentityKey(senderID) { + viewModel.registerPeerPublicKey(peerID: senderID, publicKeyData: identityKeyData) + } // Track this peer temporarily if senderID != "unknown" && senderID != myPeerID { @@ -518,7 +690,7 @@ class BluetoothMeshService: NSObject { print("[ANNOUNCE] Processing announce packet, payload size: \(packet.payload.count)") if let nickname = String(data: packet.payload, encoding: .utf8), let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) { - print("[DEBUG] Received announce from \(senderID): \(nickname), myPeerID: \(myPeerID)") + // Received announce from \(senderID): \(nickname) // Ignore if it's from ourselves if senderID == myPeerID { @@ -531,7 +703,7 @@ class BluetoothMeshService: NSObject { // Store the nickname peerNicknames[senderID] = nickname print("[ANNOUNCE] Stored nickname for \(senderID): \(nickname)") - print("[ANNOUNCE] Current nicknames: \(peerNicknames)") + // Updated nicknames // Note: We can't update peripheral mapping here since we don't have // access to which peripheral sent this announce. The mapping will be @@ -549,6 +721,15 @@ class BluetoothMeshService: NSObject { DispatchQueue.main.async { self.delegate?.didConnectToPeer(nickname) self.delegate?.didUpdatePeerList(self.getAllConnectedPeerIDs()) + + // Check if this is a favorite peer and send notification + // Note: This might not work immediately if key exchange hasn't happened yet + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if let viewModel = self.delegate as? ChatViewModel, + viewModel.isFavorite(peerID: senderID) { + NotificationService.shared.sendFavoriteOnlineNotification(nickname: nickname) + } + } } } else { // Just update the peer list @@ -563,9 +744,12 @@ class BluetoothMeshService: NSObject { } case .leave: + print("[LEAVE] Processing leave packet") if let nickname = String(data: packet.payload, encoding: .utf8), let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) { + print("[LEAVE] \(nickname) (\(senderID)) is leaving") + // Remove from active peers activePeers.remove(senderID) announcedPeers.remove(senderID) @@ -578,25 +762,53 @@ class BluetoothMeshService: NSObject { // Clean up peer data peerNicknames.removeValue(forKey: senderID) + } else { + print("[LEAVE] Failed to parse leave packet") } case .privateMessage: print("[PRIVATE] Received private message packet") - if let message = BitchatMessage.fromBinaryPayload(packet.payload) { - // Check if this private message is for us - if let recipientID = packet.recipientID, - let recipientIDString = String(data: recipientID.trimmingNullBytes(), encoding: .utf8) { - print("[PRIVATE] Message recipient: \(recipientIDString), myPeerID: \(myPeerID)") - - if recipientIDString == myPeerID { - // Get sender ID - if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) { - // Ignore our own messages - if senderID == myPeerID { - print("[PRIVATE] Ignoring own message") - return + // Check if this private message is for us + if let recipientID = packet.recipientID, + let recipientIDString = String(data: recipientID.trimmingNullBytes(), encoding: .utf8) { + print("[PRIVATE] Message recipient: \(recipientIDString), myPeerID: \(myPeerID)") + + if recipientIDString == myPeerID { + // Get sender ID + if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) { + // Ignore our own messages + if senderID == myPeerID { + print("[PRIVATE] Ignoring own message") + return + } + + // Verify signature if present + if let signature = packet.signature { + do { + let isValid = try encryptionService.verify(signature, for: packet.payload, from: senderID) + if !isValid { + print("[CRYPTO] Invalid signature on private message from \(senderID), dropping") + return + } + print("[CRYPTO] Valid signature on private message from \(senderID)") + } catch { + print("[CRYPTO] Failed to verify signature from \(senderID): \(error)") + // Continue without signature verification for now } - + } + + // Decrypt the message + let decryptedPayload: Data + do { + decryptedPayload = try encryptionService.decrypt(packet.payload, from: senderID) + print("[CRYPTO] Successfully decrypted private message from \(senderID)") + } catch { + print("[CRYPTO] Failed to decrypt private message from \(senderID): \(error)") + return + } + + // Parse the decrypted message + if let message = BitchatMessage.fromBinaryPayload(decryptedPayload) { print("[PRIVATE] Received private message from \(senderID): \(message.content)") // Store nickname mapping if we don't have it @@ -609,7 +821,6 @@ class BluetoothMeshService: NSObject { } } - // Create a new message with the sender peer ID let messageWithPeerID = BitchatMessage( sender: message.sender, @@ -625,44 +836,22 @@ class BluetoothMeshService: NSObject { DispatchQueue.main.async { self.delegate?.didReceiveMessage(messageWithPeerID) } + } else { + print("[PRIVATE] Failed to parse decrypted message") } - } else if packet.ttl > 0 { - // Relay private messages that aren't for us - print("[PRIVATE] Relaying message not meant for us (TTL: \(packet.ttl))") - var relayPacket = packet - relayPacket.ttl -= 1 - self.broadcastPacket(relayPacket) } - } else { - print("[PRIVATE] No recipient ID in packet") - } - } else { - print("[PRIVATE] Failed to decode message from payload") - } - - case .voiceNote: - if let message = BitchatMessage.fromBinaryPayload(packet.payload) { - // Ignore our own messages - if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8), senderID == myPeerID { - return - } - - // Store nickname mapping - if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) { - peerNicknames[senderID] = message.sender - } - - DispatchQueue.main.async { - self.delegate?.didReceiveMessage(message) - } - - var relayPacket = packet - relayPacket.ttl -= 1 - if relayPacket.ttl > 0 { + } else if packet.ttl > 0 { + // Relay private messages that aren't for us + print("[PRIVATE] Relaying message not meant for us (TTL: \(packet.ttl))") + var relayPacket = packet + relayPacket.ttl -= 1 self.broadcastPacket(relayPacket) } + } else { + print("[PRIVATE] No recipient ID in packet") } + case .fragmentStart, .fragmentContinue, .fragmentEnd: let fragmentTypeStr = packet.type == 10 ? "START" : (packet.type == 11 ? "CONTINUE" : "END") print("[PACKET] Handling fragment type: \(fragmentTypeStr) (\(packet.type)), payload size: \(packet.payload.count), from: \(peerID)") @@ -706,8 +895,9 @@ class BluetoothMeshService: NSObject { print("[FRAGMENT] Fragment ID: \(fragmentID.hexEncodedString())") print("[FRAGMENT] Original packet size: \(fullData.count) bytes") - // Send fragments with delays to avoid congestion - let delayBetweenFragments: TimeInterval = 0.1 // 100ms between fragments + // Optimize fragment transmission for speed + // Use minimal delay for BLE 5.0 which supports better throughput + let delayBetweenFragments: TimeInterval = 0.02 // 20ms between fragments for faster transmission for (index, fragmentData) in fragments.enumerated() { var fragmentPayload = Data() @@ -876,22 +1066,44 @@ extension BluetoothMeshService: CBCentralManagerDelegate { } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { + // Optimize for 300m range - only connect to strong enough signals + let rssiValue = RSSI.intValue + + // Filter out very weak signals (below -90 dBm) to save battery + guard rssiValue > -90 else { return } + + // Throttle RSSI updates to save CPU + let peripheralID = peripheral.identifier.uuidString + if let lastUpdate = lastRSSIUpdate[peripheralID], + Date().timeIntervalSince(lastUpdate) < 1.0 { + return // Skip update if less than 1 second since last update + } + lastRSSIUpdate[peripheralID] = Date() // Store RSSI by peripheral ID for later use - peripheralRSSI[peripheral.identifier.uuidString] = RSSI + peripheralRSSI[peripheralID] = RSSI - // Extract peer ID and store RSSI - if let name = peripheral.name, name.hasPrefix("bitchat-") { - let peerID = String(name.dropFirst(8)) + // Extract peer ID from name (no prefix for stealth) + if let name = peripheral.name, name.count == 8 { + // Assume 8-character names are peer IDs + let peerID = name peerRSSI[peerID] = RSSI - print("[BLUETOOTH] Discovered peer: \(peerID) with RSSI: \(RSSI), my ID: \(myPeerID)") + print("[BLUETOOTH] Discovered potential peer: \(peerID) with RSSI: \(RSSI) dBm (range: ~\(estimateDistance(rssi: rssiValue))m)") } // Connect to any device we discover - we'll filter by service later if !discoveredPeripherals.contains(peripheral) { discoveredPeripherals.append(peripheral) peripheral.delegate = self - central.connect(peripheral, options: nil) + + // Use optimized connection parameters for better range + let connectionOptions: [String: Any] = [ + CBConnectPeripheralOptionNotifyOnConnectionKey: true, + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, + CBConnectPeripheralOptionNotifyOnNotificationKey: true + ] + + central.connect(peripheral, options: connectionOptions) } } @@ -962,8 +1174,12 @@ extension BluetoothMeshService: CBPeripheralDelegate { peripheral.setNotifyValue(true, for: characteristic) peripheralCharacteristics[peripheral] = characteristic + // Request maximum MTU for faster data transfer + // iOS supports up to 512 bytes with BLE 5.0 + peripheral.maximumWriteValueLength(for: .withoutResponse) + // Send key exchange and announce immediately without any delay - let publicKeyData = self.encryptionService.publicKey.rawRepresentation + let publicKeyData = self.encryptionService.getCombinedPublicKeyData() let packet = BitchatPacket( type: MessageType.keyExchange.rawValue, ttl: 1, @@ -973,7 +1189,7 @@ extension BluetoothMeshService: CBPeripheralDelegate { if let data = packet.toBinaryData() { peripheral.writeValue(data, for: characteristic, type: .withResponse) - print("[KEY_EXCHANGE] Sent key exchange to peripheral as central") + // Sent key exchange } // Send announce packet immediately after key exchange @@ -990,7 +1206,7 @@ extension BluetoothMeshService: CBPeripheralDelegate { payload: Data(vm.nickname.utf8) ) self.broadcastPacket(announcePacket) - print("[KEY_EXCHANGE] Sent announce broadcast (delay: \(delay)s)") + // [KEY_EXCHANGE] Sent announce broadcast } } @@ -1040,7 +1256,7 @@ extension BluetoothMeshService: CBPeripheralDelegate { if let error = error { print("[PERIPHERAL] Write failed: \(error)") } else { - print("[PERIPHERAL] Write completed successfully") + // Write completed } } @@ -1122,7 +1338,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { if peerID != "unknown" && peerID != myPeerID { // Send key exchange back if we haven't already if packet.type == MessageType.keyExchange.rawValue { - let publicKeyData = self.encryptionService.publicKey.rawRepresentation + let publicKeyData = self.encryptionService.getCombinedPublicKeyData() let responsePacket = BitchatPacket( type: MessageType.keyExchange.rawValue, ttl: 1, @@ -1131,7 +1347,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { ) if let data = responsePacket.toBinaryData() { peripheral.updateValue(data, for: self.characteristic, onSubscribedCentrals: [request.central]) - print("[KEY_EXCHANGE] Sent key exchange response as peripheral") + // Sent key exchange response } // Send announce immediately after key exchange @@ -1148,7 +1364,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { ) if let data = announcePacket.toBinaryData() { peripheral.updateValue(data, for: self.characteristic, onSubscribedCentrals: nil) - print("[ANNOUNCE] Sent broadcast announce as peripheral (delay: \(delay)s)") + // Sent announce } } } @@ -1171,7 +1387,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { subscribedCentrals.append(central) // Send our public key to the newly connected central - let publicKeyData = encryptionService.publicKey.rawRepresentation + let publicKeyData = encryptionService.getCombinedPublicKeyData() let keyPacket = BitchatPacket( type: MessageType.keyExchange.rawValue, ttl: 1, diff --git a/bitchat/Services/EncryptionService.swift b/bitchat/Services/EncryptionService.swift index c6e680dc..0f8792c4 100644 --- a/bitchat/Services/EncryptionService.swift +++ b/bitchat/Services/EncryptionService.swift @@ -2,28 +2,114 @@ import Foundation import CryptoKit class EncryptionService { + // Key agreement keys for encryption private var privateKey: Curve25519.KeyAgreement.PrivateKey public let publicKey: Curve25519.KeyAgreement.PublicKey + + // Signing keys for authentication + private var signingPrivateKey: Curve25519.Signing.PrivateKey + public let signingPublicKey: Curve25519.Signing.PublicKey + + // Storage for peer keys private var peerPublicKeys: [String: Curve25519.KeyAgreement.PublicKey] = [:] + private var peerSigningKeys: [String: Curve25519.Signing.PublicKey] = [:] + private var peerIdentityKeys: [String: Curve25519.Signing.PublicKey] = [:] private var sharedSecrets: [String: SymmetricKey] = [:] + // Persistent identity for favorites (separate from ephemeral keys) + private let identityKey: Curve25519.Signing.PrivateKey + public let identityPublicKey: Curve25519.Signing.PublicKey + init() { + // Generate ephemeral key pairs for this session self.privateKey = Curve25519.KeyAgreement.PrivateKey() self.publicKey = privateKey.publicKey + + self.signingPrivateKey = Curve25519.Signing.PrivateKey() + self.signingPublicKey = signingPrivateKey.publicKey + + // Load or create persistent identity key + if let identityData = UserDefaults.standard.data(forKey: "bitchat.identityKey"), + let loadedKey = try? Curve25519.Signing.PrivateKey(rawRepresentation: identityData) { + self.identityKey = loadedKey + } else { + // First run - create and save identity key + self.identityKey = Curve25519.Signing.PrivateKey() + UserDefaults.standard.set(identityKey.rawRepresentation, forKey: "bitchat.identityKey") + } + self.identityPublicKey = identityKey.publicKey } + // Create combined public key data for exchange + func getCombinedPublicKeyData() -> Data { + var data = Data() + data.append(publicKey.rawRepresentation) // 32 bytes - ephemeral encryption key + data.append(signingPublicKey.rawRepresentation) // 32 bytes - ephemeral signing key + data.append(identityPublicKey.rawRepresentation) // 32 bytes - persistent identity key + return data // Total: 96 bytes + } + + // Add peer's combined public keys func addPeerPublicKey(_ peerID: String, publicKeyData: Data) throws { - let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: publicKeyData) - peerPublicKeys[peerID] = publicKey + print("[CRYPTO] Received public key data of size: \(publicKeyData.count)") - let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: publicKey) - let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey( - using: SHA256.self, - salt: "bitchat-v1".data(using: .utf8)!, - sharedInfo: Data(), - outputByteCount: 32 - ) - sharedSecrets[peerID] = symmetricKey + // Convert to array for safe access + let keyBytes = [UInt8](publicKeyData) + + if keyBytes.count == 96 { + // New format: 32 for key agreement + 32 for signing + 32 for identity + let keyAgreementData = Data(keyBytes[0..<32]) + let signingKeyData = Data(keyBytes[32..<64]) + let identityKeyData = Data(keyBytes[64..<96]) + + let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyAgreementData) + peerPublicKeys[peerID] = publicKey + + let signingKey = try Curve25519.Signing.PublicKey(rawRepresentation: signingKeyData) + peerSigningKeys[peerID] = signingKey + + let identityKey = try Curve25519.Signing.PublicKey(rawRepresentation: identityKeyData) + peerIdentityKeys[peerID] = identityKey + + print("[CRYPTO] Stored all three keys for peer \(peerID)") + } else if keyBytes.count == 64 { + // Handle old format (64 bytes) for backward compatibility + print("[CRYPTO] Received old key format (64 bytes), no identity key") + let keyAgreementData = Data(keyBytes[0..<32]) + let signingKeyData = Data(keyBytes[32..<64]) + + let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyAgreementData) + peerPublicKeys[peerID] = publicKey + + let signingKey = try Curve25519.Signing.PublicKey(rawRepresentation: signingKeyData) + peerSigningKeys[peerID] = signingKey + } else { + print("[CRYPTO] Invalid public key data size: \(keyBytes.count)") + throw EncryptionError.invalidPublicKey + } + + // Generate shared secret for encryption + if let publicKey = peerPublicKeys[peerID] { + let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: publicKey) + let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey( + using: SHA256.self, + salt: "bitchat-v1".data(using: .utf8)!, + sharedInfo: Data(), + outputByteCount: 32 + ) + sharedSecrets[peerID] = symmetricKey + } + } + + // Get peer's persistent identity key for favorites + func getPeerIdentityKey(_ peerID: String) -> Data? { + return peerIdentityKeys[peerID]?.rawRepresentation + } + + // Clear persistent identity (for panic mode) + func clearPersistentIdentity() { + UserDefaults.standard.removeObject(forKey: "bitchat.identityKey") + print("[CRYPTO] Cleared persistent identity key") } func encrypt(_ data: Data, for peerID: String) throws -> Data { @@ -45,18 +131,17 @@ class EncryptionService { } func sign(_ data: Data) throws -> Data { - let signingKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKey.rawRepresentation) - return try signingKey.signature(for: data) + return try signingPrivateKey.signature(for: data) } func verify(_ signature: Data, for data: Data, from peerID: String) throws -> Bool { - guard let peerPublicKey = peerPublicKeys[peerID] else { - return false + guard let verifyingKey = peerSigningKeys[peerID] else { + throw EncryptionError.noSharedSecret } - let verifyingKey = try Curve25519.Signing.PublicKey(rawRepresentation: peerPublicKey.rawRepresentation) return verifyingKey.isValidSignature(signature, for: data) } + } enum EncryptionError: Error { diff --git a/bitchat/Services/NotificationService.swift b/bitchat/Services/NotificationService.swift index 70cf385b..6c5dabf7 100644 --- a/bitchat/Services/NotificationService.swift +++ b/bitchat/Services/NotificationService.swift @@ -73,4 +73,12 @@ class NotificationService { sendLocalNotification(title: title, body: body, identifier: identifier) } + + func sendFavoriteOnlineNotification(nickname: String) { + let title = "⭐ \(nickname) is online" + let body = "Your favorite peer just joined the chat" + let identifier = "favorite-online-\(UUID().uuidString)" + + sendLocalNotification(title: title, body: body, identifier: identifier) + } } \ No newline at end of file diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 3b38e107..cfbca732 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1,6 +1,7 @@ import Foundation import SwiftUI import Combine +import CryptoKit #if os(iOS) import UIKit #endif @@ -24,18 +25,19 @@ class ChatViewModel: ObservableObject { @Published var showAutocomplete: Bool = false @Published var autocompleteRange: NSRange? = nil @Published var selectedAutocompleteIndex: Int = 0 - @Published var privateMessageNotification: (sender: String, message: String)? = nil let meshService = BluetoothMeshService() - let audioRecorder = AudioRecordingService() - let audioPlayer = AudioPlaybackService() private let userDefaults = UserDefaults.standard private let nicknameKey = "bitchat.nickname" private let favoritesKey = "bitchat.favorites" private var nicknameSaveTimer: Timer? - private var notificationTimer: Timer? - @Published var favoritePeers: Set = [] + @Published var favoritePeers: Set = [] // Now stores public key fingerprints instead of peer IDs + private var peerIDToPublicKeyFingerprint: [String: String] = [:] // Maps ephemeral peer IDs to persistent fingerprints + + // Ephemeral message settings + private var messageAutoDeleteTimer: Timer? + private let messageRetentionTime: TimeInterval = 300 // 5 minutes init() { loadNickname() @@ -47,6 +49,9 @@ class ChatViewModel: ObservableObject { // Request notification permission NotificationService.shared.requestAuthorization() + + // Start auto-delete timer for ephemeral messages + startAutoDeleteTimer() } private func loadNickname() { @@ -78,16 +83,40 @@ class ChatViewModel: ObservableObject { } func toggleFavorite(peerID: String) { - if favoritePeers.contains(peerID) { - favoritePeers.remove(peerID) + // Use public key fingerprints for persistent favorites + guard let fingerprint = peerIDToPublicKeyFingerprint[peerID] else { + print("[FAVORITES] No public key fingerprint for peer \(peerID)") + return + } + + if favoritePeers.contains(fingerprint) { + favoritePeers.remove(fingerprint) } else { - favoritePeers.insert(peerID) + favoritePeers.insert(fingerprint) } saveFavorites() + + print("[FAVORITES] Toggled favorite for fingerprint: \(fingerprint)") } func isFavorite(peerID: String) -> Bool { - return favoritePeers.contains(peerID) + guard let fingerprint = peerIDToPublicKeyFingerprint[peerID] else { + return false + } + return favoritePeers.contains(fingerprint) + } + + // Called when we receive a peer's public key + func registerPeerPublicKey(peerID: String, publicKeyData: Data) { + // Create a fingerprint from the public key + let fingerprint = SHA256.hash(data: publicKeyData) + .compactMap { String(format: "%02x", $0) } + .joined() + .prefix(16) // Use first 16 chars for brevity + .lowercased() + + peerIDToPublicKeyFingerprint[peerID] = String(fingerprint) + print("[FAVORITES] Registered fingerprint \(fingerprint) for peer \(peerID)") } func sendMessage(_ content: String) { @@ -168,17 +197,66 @@ class ChatViewModel: ObservableObject { return nicknames.first(where: { $0.value == nickname })?.key } - private func showPrivateMessageNotification(from sender: String, content: String) { - // Show notification - privateMessageNotification = (sender: sender, message: content) + // PANIC: Emergency data clearing for activist safety + func panicClearAllData() { + // Clear all messages + messages.removeAll() + privateChats.removeAll() + unreadPrivateMessages.removeAll() - // Auto-dismiss after 3 seconds - notificationTimer?.invalidate() - notificationTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in - self?.privateMessageNotification = nil + // Reset nickname to anonymous + nickname = "anon\(Int.random(in: 1000...9999))" + saveNickname() + + // Clear favorites + favoritePeers.removeAll() + saveFavorites() + + // Clear autocomplete state + autocompleteSuggestions.removeAll() + showAutocomplete = false + + // Disconnect from all peers + meshService.emergencyDisconnectAll() + + // Force UI update + objectWillChange.send() + + print("[PANIC] All data cleared for safety") + } + + // Ephemeral message auto-deletion + private func startAutoDeleteTimer() { + messageAutoDeleteTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + self?.deleteOldMessages() } } + private func deleteOldMessages() { + let cutoffTime = Date().addingTimeInterval(-messageRetentionTime) + + // Delete old public messages + let beforeCount = messages.count + messages.removeAll { message in + message.timestamp < cutoffTime && message.sender != "system" + } + + if messages.count < beforeCount { + print("[EPHEMERAL] Deleted \(beforeCount - messages.count) old messages") + } + + // Delete old private messages + for (peerID, messageList) in privateChats { + let oldCount = messageList.count + privateChats[peerID] = messageList.filter { $0.timestamp >= cutoffTime } + + if let newCount = privateChats[peerID]?.count, newCount < oldCount { + print("[EPHEMERAL] Deleted \(oldCount - newCount) old private messages from \(peerID)") + } + } + } + + func formatTimestamp(_ date: Date) -> String { let formatter = DateFormatter() @@ -288,36 +366,6 @@ class ChatViewModel: ObservableObject { } } - func formatVoiceNoteContent(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { - let isDark = colorScheme == .dark - let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0) - - var result = AttributedString() - var contentStyle = AttributeContainer() - contentStyle.font = .system(size: 14, design: .monospaced) - contentStyle.foregroundColor = isDark ? Color.white : Color.black - - // Parse the emoji and duration from content - let parts = message.content.components(separatedBy: " ") - if parts.count >= 2 { - // Emoji - result.append(AttributedString(parts[0] + " ").mergingAttributes(contentStyle)) - - // Play/pause button as text - let playSymbol = audioPlayer.isPlaying && audioPlayer.currentPlayingMessageID == message.id ? "⏸" : "▶" - var playStyle = AttributeContainer() - playStyle.font = .system(size: 12, design: .monospaced) - playStyle.foregroundColor = primaryColor - result.append(AttributedString(playSymbol + " ").mergingAttributes(playStyle)) - - // Duration - result.append(AttributedString(parts[1]).mergingAttributes(contentStyle)) - } else { - result.append(AttributedString(message.content).mergingAttributes(contentStyle)) - } - - return result - } func formatMessageContent(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { let isDark = colorScheme == .dark @@ -403,33 +451,6 @@ class ChatViewModel: ObservableObject { senderStyle.font = .system(size: 12, weight: .medium, design: .monospaced) result.append(sender.mergingAttributes(senderStyle)) - // Special handling for voice notes - if message.voiceNoteData != nil { - // Add the content (emoji + duration) with play button integrated - var contentStyle = AttributeContainer() - contentStyle.font = .system(size: 14, design: .monospaced) - contentStyle.foregroundColor = isDark ? Color.white : Color.black - - // Parse the emoji and duration from content - let parts = message.content.components(separatedBy: " ") - if parts.count >= 2 { - // Emoji - result.append(AttributedString(parts[0] + " ").mergingAttributes(contentStyle)) - - // Play/pause button as text - let playSymbol = audioPlayer.isPlaying && audioPlayer.currentPlayingMessageID == message.id ? "⏸" : "▶" - var playStyle = AttributeContainer() - playStyle.font = .system(size: 12, design: .monospaced) - playStyle.foregroundColor = primaryColor - result.append(AttributedString(playSymbol + " ").mergingAttributes(playStyle)) - - // Duration - result.append(AttributedString(parts[1]).mergingAttributes(contentStyle)) - } else { - result.append(AttributedString(message.content).mergingAttributes(contentStyle)) - } - return result - } // Process content to highlight mentions let contentText = message.content @@ -510,8 +531,6 @@ extension ChatViewModel: BitchatDelegate { if selectedPrivateChatPeer != peerID { unreadPrivateMessages.insert(peerID) - // Show notification banner - showPrivateMessageNotification(from: message.sender, content: message.content) } else { // We're viewing this chat, make sure unread is cleared unreadPrivateMessages.remove(peerID) @@ -570,7 +589,7 @@ extension ChatViewModel: BitchatDelegate { isConnected = true let systemMessage = BitchatMessage( sender: "system", - content: "\(peerID) has joined the channel", + content: "\(peerID) connected", timestamp: Date(), isRelay: false, originalSender: nil @@ -584,7 +603,7 @@ extension ChatViewModel: BitchatDelegate { func didDisconnectFromPeer(_ peerID: String) { let systemMessage = BitchatMessage( sender: "system", - content: "\(peerID) has left the channel", + content: "\(peerID) disconnected", timestamp: Date(), isRelay: false, originalSender: nil @@ -633,33 +652,4 @@ extension ChatViewModel: BitchatDelegate { return Array(Set(mentions)) // Remove duplicates } - func sendVoiceNote(_ audioData: Data, duration: TimeInterval) { - print("[VIEWMODEL] sendVoiceNote called with audio data: \(audioData.count) bytes, duration: \(duration)s") - - let message = BitchatMessage( - sender: nickname, - content: "🎤 \(String(format: "%.1f", duration))s", - timestamp: Date(), - isRelay: false, - originalSender: nil, - voiceNoteData: audioData, - voiceNoteDuration: duration - ) - messages.append(message) - - print("[VIEWMODEL] Added voice note to local messages, sending via mesh...") - - // Send via mesh - meshService.sendVoiceNote(audioData, duration: duration) - } - - func playVoiceNote(message: BitchatMessage) { - guard let audioData = message.voiceNoteData else { return } - - audioPlayer.togglePlayback(messageID: message.id, audioData: audioData) { result in - if case .failure(let error) = result { - print("[AUDIO] Failed to play voice note: \(error)") - } - } - } } \ No newline at end of file diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 8a367621..8afe4c0b 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -7,9 +7,6 @@ struct ContentView: View { @FocusState private var isTextFieldFocused: Bool @Environment(\.colorScheme) var colorScheme @State private var showPeerList = false - @State private var isRecordingVoice = false - @State private var recordingPulse = false - @State private var recordingScale: CGFloat = 1.0 @State private var showSidebar = false @State private var sidebarDragOffset: CGFloat = 0 @@ -98,34 +95,6 @@ struct ContentView: View { } } - // Recording ripples overlay - if isRecordingVoice { - GeometryReader { geometry in - ZStack { - ForEach(0..<6) { index in - Circle() - .stroke(Color.red.opacity(0.4 - Double(index) * 0.05), lineWidth: 1.5) - .frame(width: 20, height: 20) // Start at mic button size - .scaleEffect(1 + (recordingScale - 1) * (1.0 - Double(index) * 0.1)) - .opacity(max(0, 1.0 - ((recordingScale - 1) / 40.0) * (1.0 - Double(index) * 0.1))) - .position( - // Position at mic button location - x: geometry.size.width - 70, // Account for padding and button position - y: geometry.size.height - 32 // Account for input bar height - ) - .animation( - Animation.easeOut(duration: 3.0) - .repeatForever(autoreverses: false) - .delay(Double(index) * 0.4), - value: recordingScale - ) - } - } - } - .ignoresSafeArea() - .allowsHitTesting(false) - } - // Autocomplete overlay if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty { VStack { @@ -159,39 +128,6 @@ struct ContentView: View { .padding(.horizontal, 12) } } - - // Private message notification overlay - if let notification = viewModel.privateMessageNotification { - VStack { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Message from \(notification.sender)") - .font(.system(size: 14, weight: .medium, design: .monospaced)) - .foregroundColor(.white) - Text(notification.message) - .font(.system(size: 12, design: .monospaced)) - .foregroundColor(.white.opacity(0.9)) - .lineLimit(2) - } - Spacer() - } - .padding() - .background(Color.orange) - .cornerRadius(8) - .shadow(radius: 4) - .padding(.horizontal) - .onTapGesture { - if let peerID = viewModel.getPeerIDForNickname(notification.sender) { - viewModel.startPrivateChat(with: peerID) - viewModel.privateMessageNotification = nil - } - } - Spacer() - } - .padding(.top, 60) - .transition(.move(edge: .top).combined(with: .opacity)) - .animation(.easeInOut, value: viewModel.privateMessageNotification != nil) - } } #if os(macOS) .frame(minWidth: 600, minHeight: 400) @@ -240,37 +176,51 @@ struct ContentView: View { Text("bitchat*") .font(.system(size: 18, weight: .medium, design: .monospaced)) .foregroundColor(textColor) - - Text("name:") - .font(.system(size: 14, design: .monospaced)) - .foregroundColor(secondaryTextColor) - - TextField("nickname", text: $viewModel.nickname) - .textFieldStyle(.plain) - .font(.system(size: 14, design: .monospaced)) - .frame(maxWidth: 100) - .foregroundColor(textColor) - .onChange(of: viewModel.nickname) { _ in - viewModel.saveNickname() - } - .onSubmit { - viewModel.saveNickname() + .onTapGesture(count: 3) { + // PANIC: Triple-tap to clear all data + viewModel.panicClearAllData() } + + HStack(spacing: 0) { + Text("@") + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(secondaryTextColor) + + TextField("nickname", text: $viewModel.nickname) + .textFieldStyle(.plain) + .font(.system(size: 14, design: .monospaced)) + .frame(maxWidth: 100) + .foregroundColor(textColor) + .onChange(of: viewModel.nickname) { _ in + viewModel.saveNickname() + } + .onSubmit { + viewModel.saveNickname() + } + } } Spacer() - // People counter - let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count - Text(viewModel.isConnected ? "\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")" : "alone :/") - .font(.system(size: 12, design: .monospaced)) - .foregroundColor(viewModel.isConnected ? textColor : Color.red) - .onTapGesture { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - showSidebar.toggle() - sidebarDragOffset = 0 - } + // People counter with unread indicator + HStack(spacing: 4) { + if !viewModel.unreadPrivateMessages.isEmpty { + Image(systemName: "envelope.fill") + .font(.system(size: 12)) + .foregroundColor(Color.orange) } + + let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count + Text(viewModel.isConnected ? "\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")" : "alone :/") + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(viewModel.isConnected ? textColor : Color.red) + } + .onTapGesture { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showSidebar.toggle() + sidebarDragOffset = 0 + } + } } } .frame(height: 44) // Fixed height to prevent bouncing @@ -278,79 +228,6 @@ struct ContentView: View { .background(backgroundColor.opacity(0.95)) } - private var peerStatusView: some View { - Menu { - if viewModel.connectedPeers.isEmpty { - Text("No people connected") - .font(.system(size: 12, design: .monospaced)) - } else { - let peerNicknames = viewModel.meshService.getPeerNicknames() - let peerRSSI = viewModel.meshService.getPeerRSSI() - let myPeerID = viewModel.meshService.myPeerID - let _ = print("[UI DEBUG] connectedPeers: \(viewModel.connectedPeers), myPeerID: \(myPeerID), RSSI: \(peerRSSI)") - ForEach(viewModel.connectedPeers.filter { $0 != myPeerID }.sorted(), id: \.self) { peerID in - let displayName = peerNicknames[peerID] ?? "person-\(peerID.prefix(4))" - Button(action: { - // Only allow private chat if peer has announced - if peerNicknames[peerID] != nil { - viewModel.startPrivateChat(with: peerID) - } - }) { - HStack { - Text(displayName) - .font(.system(size: 12, design: .monospaced)) - .foregroundColor(peerNicknames[peerID] != nil ? textColor : secondaryTextColor) - Spacer() - if viewModel.unreadPrivateMessages.contains(peerID) { - Circle() - .fill(Color.orange) - .frame(width: 6, height: 6) - } - } - #if os(macOS) - .frame(minWidth: 120) - #endif - } - .buttonStyle(.plain) - .disabled(peerNicknames[peerID] == nil) - } - } - } label: { - HStack(spacing: 2) { - // Text - let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count - Text(viewModel.isConnected ? "\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")" : "alone :/") - #if os(iOS) - .font(.system(size: 12, design: .monospaced)) - #else - .font(.system(size: 14, design: .monospaced)) - #endif - .foregroundColor(viewModel.isConnected ? textColor : Color.red) - - #if os(iOS) - // Add chevron for iOS - Image(systemName: "chevron.down") - .font(.system(size: 10)) - .foregroundColor(textColor.opacity(0.6)) - #endif - - // Notification indicator (on the right after default chevron) - if !viewModel.unreadPrivateMessages.isEmpty { - Circle() - .fill(Color.orange) - .frame(width: 5, height: 5) - } - } - .fixedSize() - } - #if os(macOS) - .menuStyle(.borderlessButton) - .menuIndicator(.visible) - #else - .menuStyle(.automatic) - #endif - } - private var messagesView: some View { ScrollViewReader { proxy in ScrollView { @@ -401,20 +278,10 @@ struct ContentView: View { Text(" ") // Message content - if message.voiceNoteData != nil { - // Voice note with play button - Text(viewModel.formatVoiceNoteContent(message, colorScheme: colorScheme)) - .font(.system(size: 14, design: .monospaced)) - .fontWeight(isMentioned ? .bold : .regular) - .onTapGesture { - viewModel.playVoiceNote(message: message) - } - } else { - // Regular text content - Text(viewModel.formatMessageContent(message, colorScheme: colorScheme)) - .font(.system(size: 14, design: .monospaced)) - .fontWeight(isMentioned ? .bold : .regular) - } + // Regular text content + Text(viewModel.formatMessageContent(message, colorScheme: colorScheme)) + .font(.system(size: 14, design: .monospaced)) + .fontWeight(isMentioned ? .bold : .regular) Spacer() } @@ -479,51 +346,10 @@ struct ContentView: View { sendMessage() } - // Push to talk button - ZStack { - // Mic icon - Image(systemName: "mic.circle.fill") - .font(.system(size: 20)) - .foregroundColor(isRecordingVoice ? Color.red.opacity(0.8) : textColor) - - // Local ripples that start from the button itself - if isRecordingVoice { - ForEach(0..<4) { index in - Circle() - .stroke(Color.red.opacity(0.3), lineWidth: 1) - .frame(width: 20, height: 20) - .scaleEffect(1 + Double(index) * 0.5) - .opacity(isRecordingVoice ? 0.5 - Double(index) * 0.1 : 0) - .animation( - Animation.easeOut(duration: 1.5) - .repeatForever(autoreverses: false) - .delay(Double(index) * 0.2), - value: isRecordingVoice - ) - } - } - } - .contentShape(Rectangle()) // Make entire area tappable - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { _ in - if !isRecordingVoice { - print("[UI] Starting voice recording") - startVoiceRecording() - } - } - .onEnded { _ in - if isRecordingVoice { - print("[UI] Stopping voice recording") - stopVoiceRecording() - } - } - ) - Button(action: sendMessage) { Image(systemName: "arrow.up.circle.fill") .font(.system(size: 20)) - .foregroundColor(textColor) + .foregroundColor(viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor) } .buttonStyle(.plain) .padding(.trailing, 12) @@ -540,54 +366,6 @@ struct ContentView: View { messageText = "" } - private func startVoiceRecording() { - isRecordingVoice = true - withAnimation(.easeOut(duration: 0.3)) { - recordingScale = 50.0 // Scale up to cover entire screen from mic button size - } - #if os(iOS) - // Light haptic feedback - let impactFeedback = UIImpactFeedbackGenerator(style: .light) - impactFeedback.impactOccurred() - #endif - - viewModel.audioRecorder.startRecording { result in - if case .failure(let error) = result { - print("[UI] Failed to start recording: \(error)") - isRecordingVoice = false - recordingScale = 1.0 - } - } - } - - private func stopVoiceRecording() { - // Capture duration before stopping - let duration = viewModel.audioRecorder.recordingTime - isRecordingVoice = false - recordingScale = 1.0 - - viewModel.audioRecorder.stopRecording { result in - switch result { - case .success(let audioURL): - // Read audio file and send as voice note - do { - let audioData = try Data(contentsOf: audioURL) - print("[UI] Read audio file: \(audioData.count) bytes, duration: \(duration)s") - - // Use the captured duration, ensure it's at least 0.1s - viewModel.sendVoiceNote(audioData, duration: max(0.1, duration)) - - // Clean up temporary file - try FileManager.default.removeItem(at: audioURL) - } catch { - print("[UI] Failed to read audio file: \(error)") - } - case .failure(let error): - print("[AUDIO] Recording failed: \(error)") - } - } - } - private var sidebarView: some View { HStack(spacing: 0) { // Grey vertical bar for visual continuity @@ -642,10 +420,16 @@ struct ContentView: View { let isFavorite = viewModel.isFavorite(peerID: peerID) HStack(spacing: 8) { - // Signal strength indicator - Circle() - .fill(viewModel.getRSSIColor(rssi: rssi, colorScheme: colorScheme)) - .frame(width: 8, height: 8) + // Signal strength indicator or unread message icon + if viewModel.unreadPrivateMessages.contains(peerID) { + Image(systemName: "envelope.fill") + .font(.system(size: 12)) + .foregroundColor(Color.orange) + } else { + Circle() + .fill(viewModel.getRSSIColor(rssi: rssi, colorScheme: colorScheme)) + .frame(width: 8, height: 8) + } // Favorite star Button(action: { @@ -673,12 +457,6 @@ struct ContentView: View { .foregroundColor(peerNicknames[peerID] != nil ? textColor : secondaryTextColor) Spacer() - - if viewModel.unreadPrivateMessages.contains(peerID) { - Circle() - .fill(Color.orange) - .frame(width: 8, height: 8) - } } } .buttonStyle(.plain)