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
This commit is contained in:
jack
2025-07-05 19:35:37 +02:00
parent 7151896102
commit 506c2bc7cb
8 changed files with 940 additions and 63 deletions
+26 -15
View File
@@ -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 = "<group>"; };
229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = "<group>"; };
3448F84BF86A42A3CC4A9379 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
3A69677D382F1C3D5ED03F7D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
3FA8FF26ABDC1C642A8C7AE5 /* BitchatMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatMessageTests.swift; sourceTree = "<group>"; };
53D535D9CE0B875F47402290 /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; };
67A85BFDDE65B4CD8BDF6DDB /* MessageRetentionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRetentionService.swift; sourceTree = "<group>"; };
6DC1563390A15C042D059CF9 /* EncryptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionService.swift; sourceTree = "<group>"; };
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
822AA698BDB7BB939B62A010 /* BloomFilterTests.swift.disabled */ = {isa = PBXFileReference; path = BloomFilterTests.swift.disabled; sourceTree = "<group>"; };
8DE9CDF66D4E52D268851048 /* MessagePaddingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagePaddingTests.swift; sourceTree = "<group>"; };
96CDF6D0AF0F2052A6C7E634 /* LoggingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingService.swift; sourceTree = "<group>"; };
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 = "<group>"; };
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
AA4D7595A613F7ED3B386132 /* MessageRetryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRetryService.swift; sourceTree = "<group>"; };
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 = "<group>"; };
D69A18D27F9A565FD6041E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
@@ -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;
};
+80 -33
View File
@@ -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()
}
}
+178
View File
@@ -0,0 +1,178 @@
//
// KeychainManager.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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
}
}
@@ -0,0 +1,212 @@
//
// MessageRetentionService.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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<String> {
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)
}
}
+206
View File
@@ -0,0 +1,206 @@
//
// MessageRetryService.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Combine
import CryptoKit
struct RetryableMessage {
let id: String
let 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
}
}
+204 -11
View File
@@ -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()
+28 -2
View File
@@ -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)
+6 -2
View File
@@ -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