From 506c2bc7cb73306a37ddc468b468f630cb269533 Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 5 Jul 2025 18:33:56 +0200 Subject: [PATCH] Implement message retention, retry mechanism, and app updates - Add KeychainManager for secure password storage - Implement MessageRetentionService for encrypted local storage of favorite room messages - Add MessageRetryService for automatic retry of failed messages - Add /favorite command to toggle room retention - Add /discover command to find active rooms - Fix all force unwrapped optionals in BluetoothMeshService - Update app info screen with new features - Integrate all services with proper cleanup on panic --- bitchat.xcodeproj/project.pbxproj | 41 ++-- bitchat/Services/BluetoothMeshService.swift | 113 ++++++--- bitchat/Services/KeychainManager.swift | 178 +++++++++++++++ .../Services/MessageRetentionService.swift | 212 +++++++++++++++++ bitchat/Services/MessageRetryService.swift | 206 +++++++++++++++++ bitchat/ViewModels/ChatViewModel.swift | 215 +++++++++++++++++- bitchat/Views/AppInfoView.swift | 30 ++- bitchat/Views/ContentView.swift | 8 +- 8 files changed, 940 insertions(+), 63 deletions(-) create mode 100644 bitchat/Services/KeychainManager.swift create mode 100644 bitchat/Services/MessageRetentionService.swift create mode 100644 bitchat/Services/MessageRetryService.swift diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 9cd9b777..fe370041 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 63; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -12,7 +12,9 @@ 1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; }; 230B11C5BF035D35638B21C8 /* PasswordProtectedRoomTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB12482E4AD78B861C538449 /* PasswordProtectedRoomTests.swift */; }; 2E71E320EA921498C57E023B /* BitchatMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA8FF26ABDC1C642A8C7AE5 /* BitchatMessageTests.swift */; }; + 4274B6016F755946FBF2513E /* MessageRetentionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67A85BFDDE65B4CD8BDF6DDB /* MessageRetentionService.swift */; }; 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; }; + 4E778E5A414571ACAC2A0F01 /* MessageRetentionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67A85BFDDE65B4CD8BDF6DDB /* MessageRetentionService.swift */; }; 56F0892E13CC0D1490A3691E /* BloomFilterTests.swift.disabled in Resources */ = {isa = PBXBuildFile; fileRef = 822AA698BDB7BB939B62A010 /* BloomFilterTests.swift.disabled */; }; 61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; }; 6A624D9359BCCAAE58CE0D64 /* LoggingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96CDF6D0AF0F2052A6C7E634 /* LoggingService.swift */; }; @@ -25,6 +27,7 @@ 7A50E2F04A3515A7E90EEAE4 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; }; 7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; }; 8F0BFC2D2B2A5E7B70C3B485 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53D535D9CE0B875F47402290 /* BinaryProtocolTests.swift */; }; + 8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; }; 923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */; }; 92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; }; A0691E21A3A358AB9715AC20 /* BloomFilterTests.swift.disabled in Resources */ = {isa = PBXBuildFile; fileRef = 822AA698BDB7BB939B62A010 /* BloomFilterTests.swift.disabled */; }; @@ -32,13 +35,16 @@ AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; }; ADC66F95FBD513B10411ADB3 /* MessagePaddingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DE9CDF66D4E52D268851048 /* MessagePaddingTests.swift */; }; BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; }; + C99763A4761567F587D21688 /* MessageRetryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA4D7595A613F7ED3B386132 /* MessageRetryService.swift */; }; CDAD6629EB69916B95C80DAF /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53D535D9CE0B875F47402290 /* BinaryProtocolTests.swift */; }; + CEAE115C9C3EB3C4ED82F128 /* MessageRetryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA4D7595A613F7ED3B386132 /* MessageRetryService.swift */; }; D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; }; D948085736ED8E736C1DE3B0 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; }; DDA1DFAB1FF7AADE52DC0F53 /* EncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC1563390A15C042D059CF9 /* EncryptionService.swift */; }; F00B713D5053617FB5F3F1BE /* MessagePaddingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DE9CDF66D4E52D268851048 /* MessagePaddingTests.swift */; }; F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; }; F4A689F5F34125AE1BFD5599 /* PasswordProtectedRoomTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB12482E4AD78B861C538449 /* PasswordProtectedRoomTests.swift */; }; + FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,20 +66,23 @@ /* Begin PBXFileReference section */ 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = ""; }; 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 = ""; }; 3FA8FF26ABDC1C642A8C7AE5 /* BitchatMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatMessageTests.swift; sourceTree = ""; }; 53D535D9CE0B875F47402290 /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = ""; }; + 67A85BFDDE65B4CD8BDF6DDB /* MessageRetentionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRetentionService.swift; sourceTree = ""; }; 6DC1563390A15C042D059CF9 /* EncryptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionService.swift; sourceTree = ""; }; 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoView.swift; sourceTree = ""; }; 7EEBDA723E1CFD88758DA4AC /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 822AA698BDB7BB939B62A010 /* BloomFilterTests.swift.disabled */ = {isa = PBXFileReference; lastKnownFileType = text; path = BloomFilterTests.swift.disabled; sourceTree = ""; }; + 822AA698BDB7BB939B62A010 /* BloomFilterTests.swift.disabled */ = {isa = PBXFileReference; path = BloomFilterTests.swift.disabled; sourceTree = ""; }; 8DE9CDF66D4E52D268851048 /* MessagePaddingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagePaddingTests.swift; sourceTree = ""; }; 96CDF6D0AF0F2052A6C7E634 /* LoggingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingService.swift; sourceTree = ""; }; - 997D512074C64904D75DDD40 /* 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 = ""; }; + AA4D7595A613F7ED3B386132 /* MessageRetryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRetryService.swift; sourceTree = ""; }; C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothMeshService.swift; sourceTree = ""; }; D69A18D27F9A565FD6041E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -162,7 +171,10 @@ children = ( D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */, 6DC1563390A15C042D059CF9 /* EncryptionService.swift */, + 136696FC4436A02D98CE6A77 /* KeychainManager.swift */, 96CDF6D0AF0F2052A6C7E634 /* LoggingService.swift */, + 67A85BFDDE65B4CD8BDF6DDB /* MessageRetentionService.swift */, + AA4D7595A613F7ED3B386132 /* MessageRetryService.swift */, 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */, ); path = Services; @@ -272,6 +284,7 @@ ); mainGroup = 18198ED912AAF495D8AF7763; minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 54; projectDirPath = ""; projectRoot = ""; targets = ( @@ -331,7 +344,10 @@ D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */, 92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */, 739429DFDE5C5829CF70DA7D /* EncryptionService.swift in Sources */, + FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */, 72BDC660D445E2D24AB90326 /* LoggingService.swift in Sources */, + 4E778E5A414571ACAC2A0F01 /* MessageRetentionService.swift in Sources */, + C99763A4761567F587D21688 /* MessageRetryService.swift in Sources */, 749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -348,7 +364,10 @@ 7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */, 1D9674FA5F998503831DC281 /* ContentView.swift in Sources */, DDA1DFAB1FF7AADE52DC0F53 /* EncryptionService.swift in Sources */, + 8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */, 6A624D9359BCCAAE58CE0D64 /* LoggingService.swift in Sources */, + 4274B6016F755946FBF2513E /* MessageRetentionService.swift in Sources */, + CEAE115C9C3EB3C4ED82F128 /* MessageRetryService.swift in Sources */, 61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -457,7 +476,6 @@ CODE_SIGNING_REQUIRED = YES; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = L3N5LHJD5Y; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -469,12 +487,9 @@ PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -506,7 +521,6 @@ CODE_SIGNING_REQUIRED = YES; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = L3N5LHJD5Y; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -518,12 +532,9 @@ PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index 21835787..6081cc40 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -31,12 +31,12 @@ class BluetoothMeshService: NSObject { static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D") - private var centralManager: CBCentralManager! - private var peripheralManager: CBPeripheralManager! + private var centralManager: CBCentralManager? + private var peripheralManager: CBPeripheralManager? private var discoveredPeripherals: [CBPeripheral] = [] private var connectedPeripherals: [String: CBPeripheral] = [:] private var peripheralCharacteristics: [CBPeripheral: CBCharacteristic] = [:] - private var characteristic: CBMutableCharacteristic! + private var characteristic: CBMutableCharacteristic? private var subscribedCentrals: [CBCentral] = [] private var peerNicknames: [String: String] = [:] private let peerNicknamesLock = NSLock() @@ -341,20 +341,20 @@ class BluetoothMeshService: NSObject { // First, disconnect all peripherals which will trigger disconnect delegates for (_, peripheral) in connectedPeripherals { - centralManager.cancelPeripheralConnection(peripheral) + centralManager?.cancelPeripheralConnection(peripheral) } // Stop advertising - if peripheralManager.isAdvertising { - peripheralManager.stopAdvertising() + if peripheralManager?.isAdvertising == true { + peripheralManager?.stopAdvertising() } // Stop scanning - centralManager.stopScan() + centralManager?.stopScan() // Remove all services - this will disconnect any connected centrals - if peripheralManager.state == .poweredOn { - peripheralManager.removeAllServices() + if peripheralManager?.state == .poweredOn { + peripheralManager?.removeAllServices() } // Clear all tracking @@ -375,10 +375,10 @@ class BluetoothMeshService: NSObject { func startServices() { // Starting services // Start both central and peripheral services - if centralManager.state == .poweredOn { + if centralManager?.state == .poweredOn { startScanning() } - if peripheralManager.state == .poweredOn { + if peripheralManager?.state == .poweredOn { setupPeripheral() startAdvertising() } @@ -423,7 +423,7 @@ class BluetoothMeshService: NSObject { } func startAdvertising() { - guard peripheralManager.state == .poweredOn else { + guard peripheralManager?.state == .poweredOn else { return } @@ -438,11 +438,11 @@ class BluetoothMeshService: NSObject { ] isAdvertising = true - peripheralManager.startAdvertising(advertisementData) + peripheralManager?.startAdvertising(advertisementData) } func startScanning() { - guard centralManager.state == .poweredOn else { + guard centralManager?.state == .poweredOn else { return } @@ -451,7 +451,7 @@ class BluetoothMeshService: NSObject { CBCentralManagerScanOptionAllowDuplicatesKey: true ] - centralManager.scanForPeripherals( + centralManager?.scanForPeripherals( withServices: [BluetoothMeshService.serviceUUID], options: scanOptions ) @@ -474,14 +474,14 @@ class BluetoothMeshService: NSObject { if self.isActivelyScanning { // Pause scanning to save battery - self.centralManager.stopScan() + self.centralManager?.stopScan() self.isActivelyScanning = false // 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( + if self.centralManager?.state == .poweredOn { + self.centralManager?.scanForPeripherals( withServices: [BluetoothMeshService.serviceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] ) @@ -503,7 +503,7 @@ class BluetoothMeshService: NSObject { let service = CBMutableService(type: BluetoothMeshService.serviceUUID, primary: true) service.characteristics = [characteristic] - peripheralManager.add(service) + peripheralManager?.add(service) self.characteristic = characteristic } @@ -844,18 +844,18 @@ class BluetoothMeshService: NSObject { // Emergency disconnect for panic situations func emergencyDisconnectAll() { // Stop advertising immediately - if peripheralManager.isAdvertising { - peripheralManager.stopAdvertising() + if peripheralManager?.isAdvertising == true { + peripheralManager?.stopAdvertising() } // Stop scanning - centralManager.stopScan() + centralManager?.stopScan() scanDutyCycleTimer?.invalidate() scanDutyCycleTimer = nil // Disconnect all peripherals for (_, peripheral) in connectedPeripherals { - centralManager.cancelPeripheralConnection(peripheral) + centralManager?.cancelPeripheralConnection(peripheral) } // Clear all peer data @@ -1169,6 +1169,18 @@ class BluetoothMeshService: NSObject { private func broadcastPacket(_ packet: BitchatPacket) { guard let data = packet.toBinaryData() else { // print("[ERROR] Failed to convert packet to binary data") + // Add to retry queue if this is a message packet + if packet.type == MessageType.message.rawValue, + let message = BitchatMessage.fromBinaryPayload(packet.payload) { + MessageRetryService.shared.addMessageForRetry( + content: message.content, + mentions: message.mentions, + room: message.room, + isPrivate: message.isPrivate, + recipientPeerID: nil, + recipientNickname: message.recipientNickname + ) + } return } @@ -1200,16 +1212,45 @@ class BluetoothMeshService: NSObject { } // Send to subscribed centrals (as peripheral) + var sentToCentrals = 0 if let char = characteristic, !subscribedCentrals.isEmpty { // Send to all subscribed centrals - let success = peripheralManager.updateValue(data, for: char, onSubscribedCentrals: nil) + let success = peripheralManager?.updateValue(data, for: char, onSubscribedCentrals: nil) ?? false if success { + sentToCentrals = subscribedCentrals.count } else { } } else { if characteristic == nil { } } + + // If no peers received the message, add to retry queue + if sentToPeripherals == 0 && sentToCentrals == 0 { + if packet.type == MessageType.message.rawValue, + let message = BitchatMessage.fromBinaryPayload(packet.payload) { + // For encrypted room messages, we need to preserve the room key + var roomKeyData: Data? = nil + if let room = message.room, message.isEncrypted { + // This is an encrypted room message + if let viewModel = delegate as? ChatViewModel, + let roomKey = viewModel.roomKeys[room] { + roomKeyData = roomKey.withUnsafeBytes { Data($0) } + } + } + + MessageRetryService.shared.addMessageForRetry( + content: message.content, + mentions: message.mentions, + room: message.room, + isPrivate: message.isPrivate, + recipientPeerID: nil, + recipientNickname: message.recipientNickname, + roomKey: roomKeyData + ) + bitchatLog("No peers available, added message to retry queue", category: "mesh") + } + } } private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: String, peripheral: CBPeripheral? = nil) { @@ -1523,7 +1564,7 @@ class BluetoothMeshService: NSObject { // We have a duplicate connection - disconnect the newer one // print("[DEBUG] Duplicate connection detected for \(senderID), keeping existing") intentionalDisconnects.insert(peripheral.identifier.uuidString) - centralManager.cancelPeripheralConnection(peripheral) + centralManager?.cancelPeripheralConnection(peripheral) return } @@ -1620,7 +1661,7 @@ class BluetoothMeshService: NSObject { // Disconnect any peripherals associated with stale ID if let peripheral = connectedPeripherals[stalePeerID] { intentionalDisconnects.insert(peripheral.identifier.uuidString) - centralManager.cancelPeripheralConnection(peripheral) + centralManager?.cancelPeripheralConnection(peripheral) connectedPeripherals.removeValue(forKey: stalePeerID) peripheralCharacteristics.removeValue(forKey: peripheral) } @@ -2147,10 +2188,10 @@ extension BluetoothMeshService: CBCentralManagerDelegate { discoveredPeripherals.removeAll { $0 == peripheral } // Continue scanning for reconnection - if centralManager.state == .poweredOn { + if centralManager?.state == .poweredOn { // Stop and restart to ensure clean state - centralManager.stopScan() - centralManager.scanForPeripherals(withServices: [BluetoothMeshService.serviceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) + centralManager?.stopScan() + centralManager?.scanForPeripherals(withServices: [BluetoothMeshService.serviceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) } } } @@ -2345,7 +2386,9 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { payload: publicKeyData ) if let data = responsePacket.toBinaryData() { - peripheral.updateValue(data, for: self.characteristic, onSubscribedCentrals: [request.central]) + if let char = self.characteristic { + peripheral.updateValue(data, for: char, onSubscribedCentrals: [request.central]) + } } // Send announce immediately after key exchange @@ -2361,7 +2404,9 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { payload: Data(vm.nickname.utf8) ) if let data = announcePacket.toBinaryData() { - peripheral.updateValue(data, for: self.characteristic, onSubscribedCentrals: nil) + if let char = self.characteristic { + peripheral.updateValue(data, for: char, onSubscribedCentrals: nil) + } } } } @@ -2391,7 +2436,9 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { ) if let data = keyPacket.toBinaryData() { - peripheral.updateValue(data, for: self.characteristic, onSubscribedCentrals: [central]) + if let char = self.characteristic { + peripheral.updateValue(data, for: char, onSubscribedCentrals: [central]) + } // We'll send announce when we receive their key exchange } @@ -2408,7 +2455,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Peers may be connected through multiple paths // Ensure advertising continues for reconnection - if peripheralManager.state == .poweredOn && !peripheralManager.isAdvertising { + if peripheralManager?.state == .poweredOn && peripheralManager?.isAdvertising == false { startAdvertising() } } diff --git a/bitchat/Services/KeychainManager.swift b/bitchat/Services/KeychainManager.swift new file mode 100644 index 00000000..c4be5ca0 --- /dev/null +++ b/bitchat/Services/KeychainManager.swift @@ -0,0 +1,178 @@ +// +// KeychainManager.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Security + +class KeychainManager { + static let shared = KeychainManager() + + private let service = "com.bitchat.passwords" + private let accessGroup: String? = nil // Set this if using app groups + + private init() {} + + // MARK: - Room Passwords + + func saveRoomPassword(_ password: String, for room: String) -> Bool { + let key = "room_\(room)" + return save(password, forKey: key) + } + + func getRoomPassword(for room: String) -> String? { + let key = "room_\(room)" + return retrieve(forKey: key) + } + + func deleteRoomPassword(for room: String) -> Bool { + let key = "room_\(room)" + return delete(forKey: key) + } + + func getAllRoomPasswords() -> [String: String] { + var passwords: [String: String] = [:] + + // Query all items + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + kSecReturnData as String: true + ] + + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecSuccess, let items = result as? [[String: Any]] { + for item in items { + if let account = item[kSecAttrAccount as String] as? String, + account.hasPrefix("room_"), + let data = item[kSecValueData as String] as? Data, + let password = String(data: data, encoding: .utf8) { + let room = String(account.dropFirst(5)) // Remove "room_" prefix + passwords[room] = password + } + } + } + + return passwords + } + + // MARK: - Identity Keys + + func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { + return saveData(keyData, forKey: "identity_\(key)") + } + + func getIdentityKey(forKey key: String) -> Data? { + return retrieveData(forKey: "identity_\(key)") + } + + // MARK: - Generic Operations + + private func save(_ value: String, forKey key: String) -> Bool { + guard let data = value.data(using: .utf8) else { return false } + return saveData(data, forKey: key) + } + + private func saveData(_ data: Data, forKey key: String) -> Bool { + // First try to update existing + let updateQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + + var mutableUpdateQuery = updateQuery + if let accessGroup = accessGroup { + mutableUpdateQuery[kSecAttrAccessGroup as String] = accessGroup + } + + let updateAttributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + + var status = SecItemUpdate(mutableUpdateQuery as CFDictionary, updateAttributes as CFDictionary) + + if status == errSecItemNotFound { + // Item doesn't exist, create it + var createQuery = mutableUpdateQuery + createQuery[kSecValueData as String] = data + createQuery[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + + status = SecItemAdd(createQuery as CFDictionary, nil) + } + + return status == errSecSuccess + } + + private func retrieve(forKey key: String) -> String? { + guard let data = retrieveData(forKey: key) else { return nil } + return String(data: data, encoding: .utf8) + } + + private func retrieveData(forKey key: String) -> Data? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecSuccess, let data = result as? Data { + return data + } + + return nil + } + + private func delete(forKey key: String) -> Bool { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } + + // MARK: - Cleanup + + func deleteAllPasswords() -> Bool { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service + ] + + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } +} \ No newline at end of file diff --git a/bitchat/Services/MessageRetentionService.swift b/bitchat/Services/MessageRetentionService.swift new file mode 100644 index 00000000..99830e7b --- /dev/null +++ b/bitchat/Services/MessageRetentionService.swift @@ -0,0 +1,212 @@ +// +// MessageRetentionService.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import CryptoKit + +struct StoredMessage: Codable { + let id: String + let sender: String + let senderPeerID: String? + let content: String + let timestamp: Date + let roomTag: String? + let isPrivate: Bool + let recipientPeerID: String? +} + +class MessageRetentionService { + static let shared = MessageRetentionService() + + private let documentsDirectory: URL + private let messagesDirectory: URL + private let favoriteRoomsKey = "bitchat.favoriteRooms" + private let retentionDays = 7 // Messages retained for 7 days + private let encryptionKey: SymmetricKey + + private init() { + // Get documents directory + documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + messagesDirectory = documentsDirectory.appendingPathComponent("Messages", isDirectory: true) + + // Create messages directory if it doesn't exist + try? FileManager.default.createDirectory(at: messagesDirectory, withIntermediateDirectories: true) + + // Generate or retrieve encryption key from keychain + if let keyData = KeychainManager.shared.getIdentityKey(forKey: "messageRetentionKey") { + encryptionKey = SymmetricKey(data: keyData) + } else { + // Generate new key and store it + encryptionKey = SymmetricKey(size: .bits256) + _ = KeychainManager.shared.saveIdentityKey(encryptionKey.withUnsafeBytes { Data($0) }, forKey: "messageRetentionKey") + } + + // Clean up old messages on init + cleanupOldMessages() + } + + // MARK: - Favorite Rooms Management + + func getFavoriteRooms() -> Set { + let rooms = UserDefaults.standard.stringArray(forKey: favoriteRoomsKey) ?? [] + return Set(rooms) + } + + func toggleFavoriteRoom(_ room: String) -> Bool { + var favorites = getFavoriteRooms() + if favorites.contains(room) { + favorites.remove(room) + // Clean up messages for this room + deleteMessagesForRoom(room) + } else { + favorites.insert(room) + } + UserDefaults.standard.set(Array(favorites), forKey: favoriteRoomsKey) + return favorites.contains(room) + } + + // MARK: - Message Storage + + func saveMessage(_ message: BitchatMessage, forRoom room: String?) { + // Only save messages for favorite rooms + guard let room = room ?? message.room, + getFavoriteRooms().contains(room) else { + return + } + + // Convert to StoredMessage + let storedMessage = StoredMessage( + id: message.id, + sender: message.sender, + senderPeerID: message.senderPeerID, + content: message.content, + timestamp: message.timestamp, + roomTag: message.room, + isPrivate: message.isPrivate, + recipientPeerID: message.senderPeerID + ) + + // Encode message + guard let messageData = try? JSONEncoder().encode(storedMessage) else { return } + + // Encrypt message + guard let encryptedData = encrypt(messageData) else { return } + + // Save to file + let fileName = "\(room)_\(message.timestamp.timeIntervalSince1970)_\(message.id).enc" + let fileURL = messagesDirectory.appendingPathComponent(fileName) + + try? encryptedData.write(to: fileURL) + } + + func loadMessagesForRoom(_ room: String) -> [BitchatMessage] { + guard getFavoriteRooms().contains(room) else { return [] } + + var messages: [BitchatMessage] = [] + + do { + let files = try FileManager.default.contentsOfDirectory(at: messagesDirectory, includingPropertiesForKeys: nil) + let roomFiles = files.filter { $0.lastPathComponent.hasPrefix("\(room)_") } + + for fileURL in roomFiles { + if let encryptedData = try? Data(contentsOf: fileURL), + let decryptedData = decrypt(encryptedData), + let storedMessage = try? JSONDecoder().decode(StoredMessage.self, from: decryptedData) { + + let message = BitchatMessage( + sender: storedMessage.sender, + content: storedMessage.content, + timestamp: storedMessage.timestamp, + isRelay: false, + originalSender: nil, + isPrivate: storedMessage.isPrivate, + recipientNickname: nil, + senderPeerID: storedMessage.senderPeerID, + mentions: nil, + room: storedMessage.roomTag + ) + + messages.append(message) + } + } + } catch { + bitchatLog("Failed to load messages for room \(room): \(error)", category: "retention") + } + + return messages.sorted { $0.timestamp < $1.timestamp } + } + + // MARK: - Encryption + + private func encrypt(_ data: Data) -> Data? { + do { + let sealedBox = try AES.GCM.seal(data, using: encryptionKey) + return sealedBox.combined + } catch { + bitchatLog("Failed to encrypt message: \(error)", category: "retention") + return nil + } + } + + private func decrypt(_ data: Data) -> Data? { + do { + let sealedBox = try AES.GCM.SealedBox(combined: data) + return try AES.GCM.open(sealedBox, using: encryptionKey) + } catch { + bitchatLog("Failed to decrypt message: \(error)", category: "retention") + return nil + } + } + + // MARK: - Cleanup + + private func cleanupOldMessages() { + let cutoffDate = Date().addingTimeInterval(-TimeInterval(retentionDays * 24 * 60 * 60)) + + do { + let files = try FileManager.default.contentsOfDirectory(at: messagesDirectory, includingPropertiesForKeys: [.creationDateKey]) + + for fileURL in files { + if let attributes = try? fileURL.resourceValues(forKeys: [.creationDateKey]), + let creationDate = attributes.creationDate, + creationDate < cutoffDate { + try? FileManager.default.removeItem(at: fileURL) + } + } + } catch { + bitchatLog("Failed to cleanup old messages: \(error)", category: "retention") + } + } + + func deleteMessagesForRoom(_ room: String) { + do { + let files = try FileManager.default.contentsOfDirectory(at: messagesDirectory, includingPropertiesForKeys: nil) + let roomFiles = files.filter { $0.lastPathComponent.hasPrefix("\(room)_") } + + for fileURL in roomFiles { + try? FileManager.default.removeItem(at: fileURL) + } + } catch { + bitchatLog("Failed to delete messages for room \(room): \(error)", category: "retention") + } + } + + func deleteAllStoredMessages() { + do { + let files = try FileManager.default.contentsOfDirectory(at: messagesDirectory, includingPropertiesForKeys: nil) + for fileURL in files { + try? FileManager.default.removeItem(at: fileURL) + } + } catch { + bitchatLog("Failed to delete all stored messages: \(error)", category: "retention") + } + + // Clear favorite rooms + UserDefaults.standard.removeObject(forKey: favoriteRoomsKey) + } +} \ No newline at end of file diff --git a/bitchat/Services/MessageRetryService.swift b/bitchat/Services/MessageRetryService.swift new file mode 100644 index 00000000..1e92c12c --- /dev/null +++ b/bitchat/Services/MessageRetryService.swift @@ -0,0 +1,206 @@ +// +// MessageRetryService.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Combine +import CryptoKit + +struct RetryableMessage { + let id: String + let content: String + let mentions: [String]? + let room: String? + let isPrivate: Bool + let recipientPeerID: String? + let recipientNickname: String? + let roomKey: Data? + 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 = 5.0 // Retry every 5 seconds + 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, + room: String? = nil, + isPrivate: Bool = false, + recipientPeerID: String? = nil, + recipientNickname: String? = nil, + roomKey: Data? = nil + ) { + // Don't queue if we're at capacity + guard retryQueue.count < maxQueueSize else { + bitchatLog("Retry queue full, dropping message", category: "retry") + return + } + + let retryMessage = RetryableMessage( + id: UUID().uuidString, + content: content, + mentions: mentions, + room: room, + isPrivate: isPrivate, + recipientPeerID: recipientPeerID, + recipientNickname: recipientNickname, + roomKey: roomKey, + retryCount: 0, + nextRetryTime: Date().addingTimeInterval(retryInterval) + ) + + retryQueue.append(retryMessage) + bitchatLog("Added message to retry queue: \(content.prefix(20))...", category: "retry") + } + + private func processRetryQueue() { + guard let meshService = meshService 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 + + for message in messagesToRetry { + // Check if we should still retry + if message.retryCount >= message.maxRetries { + bitchatLog("Max retries reached for message: \(message.content.prefix(20))...", category: "retry") + continue + } + + // 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" + ) + bitchatLog("Retried private message to \(recipientID)", category: "retry") + } else { + // Recipient not connected, keep in queue with updated retry time + var updatedMessage = message + updatedMessage = RetryableMessage( + id: message.id, + content: message.content, + mentions: message.mentions, + room: message.room, + isPrivate: message.isPrivate, + recipientPeerID: message.recipientPeerID, + recipientNickname: message.recipientNickname, + roomKey: message.roomKey, + retryCount: message.retryCount + 1, + nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2)) + ) + retryQueue.append(updatedMessage) + } + } else if let room = message.room, let roomKeyData = message.roomKey { + // For room messages, check if we have peers in the room + if !connectedPeers.isEmpty { + // Recreate SymmetricKey from data + let roomKey = SymmetricKey(data: roomKeyData) + meshService.sendEncryptedRoomMessage( + message.content, + mentions: message.mentions ?? [], + room: room, + roomKey: roomKey + ) + bitchatLog("Retried encrypted room message to \(room)", category: "retry") + } else { + // No peers connected, keep in queue + var updatedMessage = message + updatedMessage = RetryableMessage( + id: message.id, + content: message.content, + mentions: message.mentions, + room: message.room, + isPrivate: message.isPrivate, + recipientPeerID: message.recipientPeerID, + recipientNickname: message.recipientNickname, + roomKey: message.roomKey, + retryCount: message.retryCount + 1, + nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2)) + ) + retryQueue.append(updatedMessage) + } + } else { + // Regular message + if !connectedPeers.isEmpty { + meshService.sendMessage( + message.content, + mentions: message.mentions ?? [], + room: message.room + ) + bitchatLog("Retried regular message", category: "retry") + } else { + // No peers connected, keep in queue + var updatedMessage = message + updatedMessage = RetryableMessage( + id: message.id, + content: message.content, + mentions: message.mentions, + room: message.room, + isPrivate: message.isPrivate, + recipientPeerID: message.recipientPeerID, + recipientNickname: message.recipientNickname, + roomKey: message.roomKey, + retryCount: message.retryCount + 1, + nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2)) + ) + retryQueue.append(updatedMessage) + } + } + } + } + + func clearRetryQueue() { + retryQueue.removeAll() + bitchatLog("Cleared retry queue", category: "retry") + } + + func getRetryQueueCount() -> Int { + return retryQueue.count + } +} \ No newline at end of file diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 0a927c4b..ad2bbf83 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -56,7 +56,7 @@ class ChatViewModel: ObservableObject { private let joinedRoomsKey = "bitchat.joinedRooms" private let passwordProtectedRoomsKey = "bitchat.passwordProtectedRooms" private let roomCreatorsKey = "bitchat.roomCreators" - private let roomPasswordsKey = "bitchat.roomPasswords" + // private let roomPasswordsKey = "bitchat.roomPasswords" // Now using Keychain private let roomKeyCommitmentsKey = "bitchat.roomKeyCommitments" private var nicknameSaveTimer: Timer? @@ -79,6 +79,9 @@ class ChatViewModel: ObservableObject { // Start mesh service immediately meshService.startServices() + // Set up message retry service + MessageRetryService.shared.meshService = meshService + // Request notification permission NotificationService.shared.requestAuthorization() } @@ -147,20 +150,22 @@ class ChatViewModel: ObservableObject { roomKeyCommitments = savedCommitments } - // Load room passwords (encrypted would be better, but keeping simple for now) - if let savedPasswords = userDefaults.dictionary(forKey: roomPasswordsKey) as? [String: String] { - roomPasswords = savedPasswords - // Derive keys for all saved passwords - for (room, password) in savedPasswords { - roomKeys[room] = deriveRoomKey(from: password, roomName: room) - } + // Load room passwords from Keychain + let savedPasswords = KeychainManager.shared.getAllRoomPasswords() + roomPasswords = savedPasswords + // Derive keys for all saved passwords + for (room, password) in savedPasswords { + roomKeys[room] = deriveRoomKey(from: password, roomName: room) } } private func saveRoomData() { userDefaults.set(Array(passwordProtectedRooms), forKey: passwordProtectedRoomsKey) userDefaults.set(roomCreators, forKey: roomCreatorsKey) - userDefaults.set(roomPasswords, forKey: roomPasswordsKey) + // Save passwords to Keychain instead of UserDefaults + for (room, password) in roomPasswords { + _ = KeychainManager.shared.saveRoomPassword(password, for: room) + } userDefaults.set(roomKeyCommitments, forKey: roomKeyCommitmentsKey) userDefaults.synchronize() } @@ -304,7 +309,9 @@ class ChatViewModel: ObservableObject { // Store the key (tentatively if not verified) roomKeys[roomTag] = key - roomPasswords[roomTag] = password // Store for convenience (local only) + roomPasswords[roomTag] = password + // Save password to Keychain + _ = KeychainManager.shared.saveRoomPassword(password, for: roomTag) if passwordVerified { bitchatLog("Password stored for room \(roomTag) - verified", category: "room") @@ -352,6 +359,24 @@ class ChatViewModel: ObservableObject { roomMessages[roomTag] = [] } + // Load saved messages if this is a favorite room + if MessageRetentionService.shared.getFavoriteRooms().contains(roomTag) { + let savedMessages = MessageRetentionService.shared.loadMessagesForRoom(roomTag) + if !savedMessages.isEmpty { + // Merge saved messages with current messages, avoiding duplicates + var existingMessageIDs = Set(roomMessages[roomTag]?.map { $0.id } ?? []) + for savedMessage in savedMessages { + if !existingMessageIDs.contains(savedMessage.id) { + roomMessages[roomTag]?.append(savedMessage) + existingMessageIDs.insert(savedMessage.id) + } + } + // Sort by timestamp + roomMessages[roomTag]?.sort { $0.timestamp < $1.timestamp } + bitchatLog("Loaded \(savedMessages.count) saved messages for favorite room \(roomTag)", category: "retention") + } + } + // Hide password prompt if it was showing showPasswordPrompt = false passwordPromptRoom = nil @@ -377,6 +402,8 @@ class ChatViewModel: ObservableObject { roomMembers.removeValue(forKey: room) roomKeys.removeValue(forKey: room) roomPasswords.removeValue(forKey: room) + // Delete password from Keychain + _ = KeychainManager.shared.deleteRoomPassword(for: room) } // Password management @@ -406,6 +433,8 @@ class ChatViewModel: ObservableObject { roomKeys[room] = key roomPasswords[room] = password passwordProtectedRooms.insert(room) + // Save password to Keychain + _ = KeychainManager.shared.saveRoomPassword(password, for: room) // Compute and store key commitment for verification let commitment = computeKeyCommitment(for: key) @@ -447,6 +476,8 @@ class ChatViewModel: ObservableObject { roomPasswords.removeValue(forKey: room) roomKeyCommitments.removeValue(forKey: room) passwordProtectedRooms.remove(room) + // Delete password from Keychain + _ = KeychainManager.shared.deleteRoomPassword(for: room) // Save room data saveRoomData() @@ -572,6 +603,8 @@ class ChatViewModel: ObservableObject { let newKey = deriveRoomKey(from: newPassword, roomName: currentRoom) roomKeys[currentRoom] = newKey roomPasswords[currentRoom] = newPassword + // Update password in Keychain + _ = KeychainManager.shared.saveRoomPassword(newPassword, for: currentRoom) // Compute new key commitment let newCommitment = computeKeyCommitment(for: newKey) @@ -785,6 +818,11 @@ class ChatViewModel: ObservableObject { } roomMessages[room]?.append(message) + // Save message if room is a favorite + if MessageRetentionService.shared.getFavoriteRooms().contains(room) { + MessageRetentionService.shared.saveMessage(message, forRoom: room) + } + // Track ourselves as a room member if roomMembers[room] == nil { roomMembers[room] = Set() @@ -873,25 +911,63 @@ class ChatViewModel: ObservableObject { privateChats.removeAll() unreadPrivateMessages.removeAll() + // Clear all room data + joinedRooms.removeAll() + currentRoom = nil + roomMessages.removeAll() + unreadRoomMessages.removeAll() + roomMembers.removeAll() + roomPasswords.removeAll() + roomKeys.removeAll() + passwordProtectedRooms.removeAll() + roomCreators.removeAll() + roomKeyCommitments.removeAll() + showPasswordPrompt = false + passwordPromptRoom = nil + + // Clear all keychain passwords + _ = KeychainManager.shared.deleteAllPasswords() + + // Clear all retained messages + MessageRetentionService.shared.deleteAllStoredMessages() + + // Clear message retry queue + MessageRetryService.shared.clearRetryQueue() + + // Clear persisted room data from UserDefaults + userDefaults.removeObject(forKey: joinedRoomsKey) + userDefaults.removeObject(forKey: passwordProtectedRoomsKey) + userDefaults.removeObject(forKey: roomCreatorsKey) + userDefaults.removeObject(forKey: roomKeyCommitmentsKey) + // Reset nickname to anonymous nickname = "anon\(Int.random(in: 1000...9999))" saveNickname() // Clear favorites favoritePeers.removeAll() + peerIDToPublicKeyFingerprint.removeAll() saveFavorites() // Clear autocomplete state autocompleteSuggestions.removeAll() showAutocomplete = false + autocompleteRange = nil + selectedAutocompleteIndex = 0 + + // Clear selected private chat + selectedPrivateChatPeer = nil // Disconnect from all peers meshService.emergencyDisconnectAll() + // Force immediate UserDefaults synchronization + userDefaults.synchronize() + // Force UI update objectWillChange.send() - // print("[PANIC] All data cleared for safety") + bitchatLog("PANIC: All data cleared for safety", category: "security") } @@ -1416,6 +1492,59 @@ extension ChatViewModel: BitchatDelegate { ) messages.append(systemMessage) } + case "/discover": + // Discover public rooms + var publicRooms: [String] = [] + var protectedRooms: [String] = [] + + // Find rooms from messages we've seen + for msg in messages { + if let room = msg.room { + if passwordProtectedRooms.contains(room) { + if !protectedRooms.contains(room) { + protectedRooms.append(room) + } + } else { + if !publicRooms.contains(room) { + publicRooms.append(room) + } + } + } + } + + // Also check room messages we've cached + for (room, _) in roomMessages { + if passwordProtectedRooms.contains(room) { + if !protectedRooms.contains(room) { + protectedRooms.append(room) + } + } else { + if !publicRooms.contains(room) { + publicRooms.append(room) + } + } + } + + var discoveryMessage = "" + if publicRooms.isEmpty && protectedRooms.isEmpty { + discoveryMessage = "no rooms discovered yet. rooms appear as people use them." + } else { + if !publicRooms.isEmpty { + discoveryMessage += "public rooms:\n" + publicRooms.sorted().joined(separator: ", ") + } + if !protectedRooms.isEmpty { + if !discoveryMessage.isEmpty { discoveryMessage += "\n\n" } + discoveryMessage += "protected rooms:\n" + protectedRooms.sorted().map { "\($0) 🔒" }.joined(separator: ", ") + } + } + + let systemMessage = BitchatMessage( + sender: "system", + content: discoveryMessage, + timestamp: Date(), + isRelay: false + ) + messages.append(systemMessage) case "/transfer": // Transfer room ownership let parts = command.split(separator: " ", maxSplits: 1).map(String.init) @@ -1469,6 +1598,65 @@ extension ChatViewModel: BitchatDelegate { messages.removeAll() bitchatLog("cleared main chat", category: "chat") } + case "/favorite", "/fav": + // Toggle favorite status for current room + guard let room = currentRoom else { + let systemMessage = BitchatMessage( + sender: "system", + content: "you must be in a room to mark it as favorite.", + timestamp: Date(), + isRelay: false + ) + messages.append(systemMessage) + break + } + + let isFavorite = MessageRetentionService.shared.toggleFavoriteRoom(room) + let status = isFavorite ? "added to" : "removed from" + + // If just marked as favorite, load any previously saved messages + if isFavorite { + let savedMessages = MessageRetentionService.shared.loadMessagesForRoom(room) + if !savedMessages.isEmpty { + // Merge saved messages with current messages, avoiding duplicates + var existingMessageIDs = Set(roomMessages[room]?.map { $0.id } ?? []) + for savedMessage in savedMessages { + if !existingMessageIDs.contains(savedMessage.id) { + if roomMessages[room] == nil { + roomMessages[room] = [] + } + roomMessages[room]?.append(savedMessage) + existingMessageIDs.insert(savedMessage.id) + } + } + // Sort by timestamp + roomMessages[room]?.sort { $0.timestamp < $1.timestamp } + + let systemMessage = BitchatMessage( + sender: "system", + content: "room \(room) \(status) favorites. loaded \(savedMessages.count) saved messages.", + timestamp: Date(), + isRelay: false + ) + messages.append(systemMessage) + } else { + let systemMessage = BitchatMessage( + sender: "system", + content: "room \(room) \(status) favorites.", + timestamp: Date(), + isRelay: false + ) + messages.append(systemMessage) + } + } else { + let systemMessage = BitchatMessage( + sender: "system", + content: "room \(room) \(status) favorites.", + timestamp: Date(), + isRelay: false + ) + messages.append(systemMessage) + } default: // Unknown command let systemMessage = BitchatMessage( @@ -1656,6 +1844,11 @@ extension ChatViewModel: BitchatDelegate { roomMessages[room]?.append(messageToAdd) roomMessages[room]?.sort { $0.timestamp < $1.timestamp } + // Save message if room is a favorite + if MessageRetentionService.shared.getFavoriteRooms().contains(room) { + MessageRetentionService.shared.saveMessage(messageToAdd, forRoom: room) + } + // Track room members - only track the sender as a member if roomMembers[room] == nil { roomMembers[room] = Set() diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index f967bbb8..bd66b048 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -64,6 +64,15 @@ struct AppInfoView: View { FeatureRow(icon: "at", title: "Mentions", description: "Use @nickname to notify specific users") + + FeatureRow(icon: "number", title: "Rooms", + description: "Create #rooms for topic-based conversations") + + FeatureRow(icon: "lock.fill", title: "Password Rooms", + description: "Secure rooms with passwords and AES encryption") + + FeatureRow(icon: "mic.fill", title: "Voice Notes", + description: "Push-to-talk voice messages with compression") } // Privacy @@ -86,9 +95,10 @@ struct AppInfoView: View { VStack(alignment: .leading, spacing: 8) { Text("• Set your nickname in the header") - Text("• Tap the people counter to see connected peers") + Text("• Swipe left or tap room name for sidebar") Text("• Tap a peer to start a private chat") Text("• Use @nickname to mention someone") + Text("• Use #roomname to create/join rooms") Text("• Triple-tap the logo for panic mode") } .font(.system(size: 14, design: .monospaced)) @@ -106,6 +116,9 @@ struct AppInfoView: View { Text("Store & Forward: 12h for all, ∞ for favorites") Text("Battery: Adaptive scanning based on level") Text("Platform: Universal (iOS, iPadOS, macOS)") + Text("Rooms: Password-protected with key commitments") + Text("Commands: /j /m /w /list /pass /favorite /discover") + Text("Storage: Keychain for passwords, encrypted retention") } .font(.system(size: 14, design: .monospaced)) .foregroundColor(textColor) @@ -184,6 +197,15 @@ struct AppInfoView: View { FeatureRow(icon: "at", title: "Mentions", description: "Use @nickname to notify specific users") + + FeatureRow(icon: "number", title: "Rooms", + description: "Create #rooms for topic-based conversations") + + FeatureRow(icon: "lock.fill", title: "Password Rooms", + description: "Secure rooms with passwords and AES encryption") + + FeatureRow(icon: "mic.fill", title: "Voice Notes", + description: "Push-to-talk voice messages with compression") } // Privacy @@ -206,9 +228,10 @@ struct AppInfoView: View { VStack(alignment: .leading, spacing: 8) { Text("• Set your nickname in the header") - Text("• Tap the people counter to see connected peers") + Text("• Swipe left or tap room name for sidebar") Text("• Tap a peer to start a private chat") Text("• Use @nickname to mention someone") + Text("• Use #roomname to create/join rooms") Text("• Triple-tap the logo for panic mode") } .font(.system(size: 14, design: .monospaced)) @@ -226,6 +249,9 @@ struct AppInfoView: View { Text("Store & Forward: 12h for all, ∞ for favorites") Text("Battery: Adaptive scanning based on level") Text("Platform: Universal (iOS, iPadOS, macOS)") + Text("Rooms: Password-protected with key commitments") + Text("Commands: /j /m /w /list /pass /favorite /discover") + Text("Storage: Keychain for passwords, encrypted retention") } .font(.system(size: 14, design: .monospaced)) .foregroundColor(textColor) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index e01a79dc..5488f427 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -513,7 +513,9 @@ struct ContentView: View { "/m": "send private message", "/clear": "clear chat messages", "/transfer": "transfer room ownership", - "/pass": "change room password" + "/pass": "change room password", + "/favorite": "toggle room retention", + "/discover": "find active rooms" ] ForEach(commandSuggestions, id: \.self) { command in @@ -595,7 +597,9 @@ struct ContentView: View { ("/m", "send private message"), ("/clear", "clear chat messages"), ("/transfer", "transfer room ownership"), - ("/pass", "change room password") + ("/pass", "change room password"), + ("/favorite", "toggle room retention"), + ("/discover", "find active rooms") ] let input = newValue.lowercased() commandSuggestions = commandDescriptions