mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user