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; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 63; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -12,7 +12,9 @@
1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; }; 1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; };
230B11C5BF035D35638B21C8 /* PasswordProtectedRoomTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB12482E4AD78B861C538449 /* PasswordProtectedRoomTests.swift */; }; 230B11C5BF035D35638B21C8 /* PasswordProtectedRoomTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB12482E4AD78B861C538449 /* PasswordProtectedRoomTests.swift */; };
2E71E320EA921498C57E023B /* BitchatMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA8FF26ABDC1C642A8C7AE5 /* BitchatMessageTests.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 */; }; 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 */; }; 56F0892E13CC0D1490A3691E /* BloomFilterTests.swift.disabled in Resources */ = {isa = PBXBuildFile; fileRef = 822AA698BDB7BB939B62A010 /* BloomFilterTests.swift.disabled */; };
61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; }; 61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; };
6A624D9359BCCAAE58CE0D64 /* LoggingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96CDF6D0AF0F2052A6C7E634 /* LoggingService.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 */; }; 7A50E2F04A3515A7E90EEAE4 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; };
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; }; 7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; };
8F0BFC2D2B2A5E7B70C3B485 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53D535D9CE0B875F47402290 /* BinaryProtocolTests.swift */; }; 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 */; }; 923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */; };
92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.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 */; }; 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 */; }; AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
ADC66F95FBD513B10411ADB3 /* MessagePaddingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DE9CDF66D4E52D268851048 /* MessagePaddingTests.swift */; }; ADC66F95FBD513B10411ADB3 /* MessagePaddingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DE9CDF66D4E52D268851048 /* MessagePaddingTests.swift */; };
BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; }; 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 */; }; 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 */; }; D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; };
D948085736ED8E736C1DE3B0 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; }; D948085736ED8E736C1DE3B0 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; };
DDA1DFAB1FF7AADE52DC0F53 /* EncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC1563390A15C042D059CF9 /* EncryptionService.swift */; }; DDA1DFAB1FF7AADE52DC0F53 /* EncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC1563390A15C042D059CF9 /* EncryptionService.swift */; };
F00B713D5053617FB5F3F1BE /* MessagePaddingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DE9CDF66D4E52D268851048 /* MessagePaddingTests.swift */; }; F00B713D5053617FB5F3F1BE /* MessagePaddingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DE9CDF66D4E52D268851048 /* MessagePaddingTests.swift */; };
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; }; F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
F4A689F5F34125AE1BFD5599 /* PasswordProtectedRoomTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB12482E4AD78B861C538449 /* PasswordProtectedRoomTests.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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -60,20 +66,23 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; D69A18D27F9A565FD6041E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
@@ -162,7 +171,10 @@
children = ( children = (
D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */, D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */,
6DC1563390A15C042D059CF9 /* EncryptionService.swift */, 6DC1563390A15C042D059CF9 /* EncryptionService.swift */,
136696FC4436A02D98CE6A77 /* KeychainManager.swift */,
96CDF6D0AF0F2052A6C7E634 /* LoggingService.swift */, 96CDF6D0AF0F2052A6C7E634 /* LoggingService.swift */,
67A85BFDDE65B4CD8BDF6DDB /* MessageRetentionService.swift */,
AA4D7595A613F7ED3B386132 /* MessageRetryService.swift */,
3448F84BF86A42A3CC4A9379 /* NotificationService.swift */, 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */,
); );
path = Services; path = Services;
@@ -272,6 +284,7 @@
); );
mainGroup = 18198ED912AAF495D8AF7763; mainGroup = 18198ED912AAF495D8AF7763;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 54;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
targets = ( targets = (
@@ -331,7 +344,10 @@
D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */, D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */,
92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */, 92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */,
739429DFDE5C5829CF70DA7D /* EncryptionService.swift in Sources */, 739429DFDE5C5829CF70DA7D /* EncryptionService.swift in Sources */,
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */,
72BDC660D445E2D24AB90326 /* LoggingService.swift in Sources */, 72BDC660D445E2D24AB90326 /* LoggingService.swift in Sources */,
4E778E5A414571ACAC2A0F01 /* MessageRetentionService.swift in Sources */,
C99763A4761567F587D21688 /* MessageRetryService.swift in Sources */,
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */, 749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -348,7 +364,10 @@
7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */, 7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */,
1D9674FA5F998503831DC281 /* ContentView.swift in Sources */, 1D9674FA5F998503831DC281 /* ContentView.swift in Sources */,
DDA1DFAB1FF7AADE52DC0F53 /* EncryptionService.swift in Sources */, DDA1DFAB1FF7AADE52DC0F53 /* EncryptionService.swift in Sources */,
8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */,
6A624D9359BCCAAE58CE0D64 /* LoggingService.swift in Sources */, 6A624D9359BCCAAE58CE0D64 /* LoggingService.swift in Sources */,
4274B6016F755946FBF2513E /* MessageRetentionService.swift in Sources */,
CEAE115C9C3EB3C4ED82F128 /* MessageRetryService.swift in Sources */,
61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */, 61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -457,7 +476,6 @@
CODE_SIGNING_REQUIRED = YES; CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = L3N5LHJD5Y;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
@@ -469,12 +487,9 @@
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = "1,2";
}; };
name = Debug; name = Debug;
}; };
@@ -506,7 +521,6 @@
CODE_SIGNING_REQUIRED = YES; CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = L3N5LHJD5Y;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
@@ -518,12 +532,9 @@
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = "1,2";
}; };
name = Release; 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 serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D") static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
private var centralManager: CBCentralManager! private var centralManager: CBCentralManager?
private var peripheralManager: CBPeripheralManager! private var peripheralManager: CBPeripheralManager?
private var discoveredPeripherals: [CBPeripheral] = [] private var discoveredPeripherals: [CBPeripheral] = []
private var connectedPeripherals: [String: CBPeripheral] = [:] private var connectedPeripherals: [String: CBPeripheral] = [:]
private var peripheralCharacteristics: [CBPeripheral: CBCharacteristic] = [:] private var peripheralCharacteristics: [CBPeripheral: CBCharacteristic] = [:]
private var characteristic: CBMutableCharacteristic! private var characteristic: CBMutableCharacteristic?
private var subscribedCentrals: [CBCentral] = [] private var subscribedCentrals: [CBCentral] = []
private var peerNicknames: [String: String] = [:] private var peerNicknames: [String: String] = [:]
private let peerNicknamesLock = NSLock() private let peerNicknamesLock = NSLock()
@@ -341,20 +341,20 @@ class BluetoothMeshService: NSObject {
// First, disconnect all peripherals which will trigger disconnect delegates // First, disconnect all peripherals which will trigger disconnect delegates
for (_, peripheral) in connectedPeripherals { for (_, peripheral) in connectedPeripherals {
centralManager.cancelPeripheralConnection(peripheral) centralManager?.cancelPeripheralConnection(peripheral)
} }
// Stop advertising // Stop advertising
if peripheralManager.isAdvertising { if peripheralManager?.isAdvertising == true {
peripheralManager.stopAdvertising() peripheralManager?.stopAdvertising()
} }
// Stop scanning // Stop scanning
centralManager.stopScan() centralManager?.stopScan()
// Remove all services - this will disconnect any connected centrals // Remove all services - this will disconnect any connected centrals
if peripheralManager.state == .poweredOn { if peripheralManager?.state == .poweredOn {
peripheralManager.removeAllServices() peripheralManager?.removeAllServices()
} }
// Clear all tracking // Clear all tracking
@@ -375,10 +375,10 @@ class BluetoothMeshService: NSObject {
func startServices() { func startServices() {
// Starting services // Starting services
// Start both central and peripheral services // Start both central and peripheral services
if centralManager.state == .poweredOn { if centralManager?.state == .poweredOn {
startScanning() startScanning()
} }
if peripheralManager.state == .poweredOn { if peripheralManager?.state == .poweredOn {
setupPeripheral() setupPeripheral()
startAdvertising() startAdvertising()
} }
@@ -423,7 +423,7 @@ class BluetoothMeshService: NSObject {
} }
func startAdvertising() { func startAdvertising() {
guard peripheralManager.state == .poweredOn else { guard peripheralManager?.state == .poweredOn else {
return return
} }
@@ -438,11 +438,11 @@ class BluetoothMeshService: NSObject {
] ]
isAdvertising = true isAdvertising = true
peripheralManager.startAdvertising(advertisementData) peripheralManager?.startAdvertising(advertisementData)
} }
func startScanning() { func startScanning() {
guard centralManager.state == .poweredOn else { guard centralManager?.state == .poweredOn else {
return return
} }
@@ -451,7 +451,7 @@ class BluetoothMeshService: NSObject {
CBCentralManagerScanOptionAllowDuplicatesKey: true CBCentralManagerScanOptionAllowDuplicatesKey: true
] ]
centralManager.scanForPeripherals( centralManager?.scanForPeripherals(
withServices: [BluetoothMeshService.serviceUUID], withServices: [BluetoothMeshService.serviceUUID],
options: scanOptions options: scanOptions
) )
@@ -474,14 +474,14 @@ class BluetoothMeshService: NSObject {
if self.isActivelyScanning { if self.isActivelyScanning {
// Pause scanning to save battery // Pause scanning to save battery
self.centralManager.stopScan() self.centralManager?.stopScan()
self.isActivelyScanning = false self.isActivelyScanning = false
// Schedule resume // Schedule resume
DispatchQueue.main.asyncAfter(deadline: .now() + self.scanPauseDuration) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + self.scanPauseDuration) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
if self.centralManager.state == .poweredOn { if self.centralManager?.state == .poweredOn {
self.centralManager.scanForPeripherals( self.centralManager?.scanForPeripherals(
withServices: [BluetoothMeshService.serviceUUID], withServices: [BluetoothMeshService.serviceUUID],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
) )
@@ -503,7 +503,7 @@ class BluetoothMeshService: NSObject {
let service = CBMutableService(type: BluetoothMeshService.serviceUUID, primary: true) let service = CBMutableService(type: BluetoothMeshService.serviceUUID, primary: true)
service.characteristics = [characteristic] service.characteristics = [characteristic]
peripheralManager.add(service) peripheralManager?.add(service)
self.characteristic = characteristic self.characteristic = characteristic
} }
@@ -844,18 +844,18 @@ class BluetoothMeshService: NSObject {
// Emergency disconnect for panic situations // Emergency disconnect for panic situations
func emergencyDisconnectAll() { func emergencyDisconnectAll() {
// Stop advertising immediately // Stop advertising immediately
if peripheralManager.isAdvertising { if peripheralManager?.isAdvertising == true {
peripheralManager.stopAdvertising() peripheralManager?.stopAdvertising()
} }
// Stop scanning // Stop scanning
centralManager.stopScan() centralManager?.stopScan()
scanDutyCycleTimer?.invalidate() scanDutyCycleTimer?.invalidate()
scanDutyCycleTimer = nil scanDutyCycleTimer = nil
// Disconnect all peripherals // Disconnect all peripherals
for (_, peripheral) in connectedPeripherals { for (_, peripheral) in connectedPeripherals {
centralManager.cancelPeripheralConnection(peripheral) centralManager?.cancelPeripheralConnection(peripheral)
} }
// Clear all peer data // Clear all peer data
@@ -1169,6 +1169,18 @@ class BluetoothMeshService: NSObject {
private func broadcastPacket(_ packet: BitchatPacket) { private func broadcastPacket(_ packet: BitchatPacket) {
guard let data = packet.toBinaryData() else { guard let data = packet.toBinaryData() else {
// print("[ERROR] Failed to convert packet to binary data") // 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 return
} }
@@ -1200,16 +1212,45 @@ class BluetoothMeshService: NSObject {
} }
// Send to subscribed centrals (as peripheral) // Send to subscribed centrals (as peripheral)
var sentToCentrals = 0
if let char = characteristic, !subscribedCentrals.isEmpty { if let char = characteristic, !subscribedCentrals.isEmpty {
// Send to all subscribed centrals // 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 { if success {
sentToCentrals = subscribedCentrals.count
} else { } else {
} }
} else { } else {
if characteristic == nil { 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) { 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 // We have a duplicate connection - disconnect the newer one
// print("[DEBUG] Duplicate connection detected for \(senderID), keeping existing") // print("[DEBUG] Duplicate connection detected for \(senderID), keeping existing")
intentionalDisconnects.insert(peripheral.identifier.uuidString) intentionalDisconnects.insert(peripheral.identifier.uuidString)
centralManager.cancelPeripheralConnection(peripheral) centralManager?.cancelPeripheralConnection(peripheral)
return return
} }
@@ -1620,7 +1661,7 @@ class BluetoothMeshService: NSObject {
// Disconnect any peripherals associated with stale ID // Disconnect any peripherals associated with stale ID
if let peripheral = connectedPeripherals[stalePeerID] { if let peripheral = connectedPeripherals[stalePeerID] {
intentionalDisconnects.insert(peripheral.identifier.uuidString) intentionalDisconnects.insert(peripheral.identifier.uuidString)
centralManager.cancelPeripheralConnection(peripheral) centralManager?.cancelPeripheralConnection(peripheral)
connectedPeripherals.removeValue(forKey: stalePeerID) connectedPeripherals.removeValue(forKey: stalePeerID)
peripheralCharacteristics.removeValue(forKey: peripheral) peripheralCharacteristics.removeValue(forKey: peripheral)
} }
@@ -2147,10 +2188,10 @@ extension BluetoothMeshService: CBCentralManagerDelegate {
discoveredPeripherals.removeAll { $0 == peripheral } discoveredPeripherals.removeAll { $0 == peripheral }
// Continue scanning for reconnection // Continue scanning for reconnection
if centralManager.state == .poweredOn { if centralManager?.state == .poweredOn {
// Stop and restart to ensure clean state // Stop and restart to ensure clean state
centralManager.stopScan() centralManager?.stopScan()
centralManager.scanForPeripherals(withServices: [BluetoothMeshService.serviceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) centralManager?.scanForPeripherals(withServices: [BluetoothMeshService.serviceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
} }
} }
} }
@@ -2345,7 +2386,9 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
payload: publicKeyData payload: publicKeyData
) )
if let data = responsePacket.toBinaryData() { 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 // Send announce immediately after key exchange
@@ -2361,7 +2404,9 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
payload: Data(vm.nickname.utf8) payload: Data(vm.nickname.utf8)
) )
if let data = announcePacket.toBinaryData() { 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() { 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 // We'll send announce when we receive their key exchange
} }
@@ -2408,7 +2455,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
// Peers may be connected through multiple paths // Peers may be connected through multiple paths
// Ensure advertising continues for reconnection // Ensure advertising continues for reconnection
if peripheralManager.state == .poweredOn && !peripheralManager.isAdvertising { if peripheralManager?.state == .poweredOn && peripheralManager?.isAdvertising == false {
startAdvertising() 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
}
}
+200 -7
View File
@@ -56,7 +56,7 @@ class ChatViewModel: ObservableObject {
private let joinedRoomsKey = "bitchat.joinedRooms" private let joinedRoomsKey = "bitchat.joinedRooms"
private let passwordProtectedRoomsKey = "bitchat.passwordProtectedRooms" private let passwordProtectedRoomsKey = "bitchat.passwordProtectedRooms"
private let roomCreatorsKey = "bitchat.roomCreators" private let roomCreatorsKey = "bitchat.roomCreators"
private let roomPasswordsKey = "bitchat.roomPasswords" // private let roomPasswordsKey = "bitchat.roomPasswords" // Now using Keychain
private let roomKeyCommitmentsKey = "bitchat.roomKeyCommitments" private let roomKeyCommitmentsKey = "bitchat.roomKeyCommitments"
private var nicknameSaveTimer: Timer? private var nicknameSaveTimer: Timer?
@@ -79,6 +79,9 @@ class ChatViewModel: ObservableObject {
// Start mesh service immediately // Start mesh service immediately
meshService.startServices() meshService.startServices()
// Set up message retry service
MessageRetryService.shared.meshService = meshService
// Request notification permission // Request notification permission
NotificationService.shared.requestAuthorization() NotificationService.shared.requestAuthorization()
} }
@@ -147,20 +150,22 @@ class ChatViewModel: ObservableObject {
roomKeyCommitments = savedCommitments roomKeyCommitments = savedCommitments
} }
// Load room passwords (encrypted would be better, but keeping simple for now) // Load room passwords from Keychain
if let savedPasswords = userDefaults.dictionary(forKey: roomPasswordsKey) as? [String: String] { let savedPasswords = KeychainManager.shared.getAllRoomPasswords()
roomPasswords = savedPasswords roomPasswords = savedPasswords
// Derive keys for all saved passwords // Derive keys for all saved passwords
for (room, password) in savedPasswords { for (room, password) in savedPasswords {
roomKeys[room] = deriveRoomKey(from: password, roomName: room) roomKeys[room] = deriveRoomKey(from: password, roomName: room)
} }
} }
}
private func saveRoomData() { private func saveRoomData() {
userDefaults.set(Array(passwordProtectedRooms), forKey: passwordProtectedRoomsKey) userDefaults.set(Array(passwordProtectedRooms), forKey: passwordProtectedRoomsKey)
userDefaults.set(roomCreators, forKey: roomCreatorsKey) 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.set(roomKeyCommitments, forKey: roomKeyCommitmentsKey)
userDefaults.synchronize() userDefaults.synchronize()
} }
@@ -304,7 +309,9 @@ class ChatViewModel: ObservableObject {
// Store the key (tentatively if not verified) // Store the key (tentatively if not verified)
roomKeys[roomTag] = key 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 { if passwordVerified {
bitchatLog("Password stored for room \(roomTag) - verified", category: "room") bitchatLog("Password stored for room \(roomTag) - verified", category: "room")
@@ -352,6 +359,24 @@ class ChatViewModel: ObservableObject {
roomMessages[roomTag] = [] 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 // Hide password prompt if it was showing
showPasswordPrompt = false showPasswordPrompt = false
passwordPromptRoom = nil passwordPromptRoom = nil
@@ -377,6 +402,8 @@ class ChatViewModel: ObservableObject {
roomMembers.removeValue(forKey: room) roomMembers.removeValue(forKey: room)
roomKeys.removeValue(forKey: room) roomKeys.removeValue(forKey: room)
roomPasswords.removeValue(forKey: room) roomPasswords.removeValue(forKey: room)
// Delete password from Keychain
_ = KeychainManager.shared.deleteRoomPassword(for: room)
} }
// Password management // Password management
@@ -406,6 +433,8 @@ class ChatViewModel: ObservableObject {
roomKeys[room] = key roomKeys[room] = key
roomPasswords[room] = password roomPasswords[room] = password
passwordProtectedRooms.insert(room) passwordProtectedRooms.insert(room)
// Save password to Keychain
_ = KeychainManager.shared.saveRoomPassword(password, for: room)
// Compute and store key commitment for verification // Compute and store key commitment for verification
let commitment = computeKeyCommitment(for: key) let commitment = computeKeyCommitment(for: key)
@@ -447,6 +476,8 @@ class ChatViewModel: ObservableObject {
roomPasswords.removeValue(forKey: room) roomPasswords.removeValue(forKey: room)
roomKeyCommitments.removeValue(forKey: room) roomKeyCommitments.removeValue(forKey: room)
passwordProtectedRooms.remove(room) passwordProtectedRooms.remove(room)
// Delete password from Keychain
_ = KeychainManager.shared.deleteRoomPassword(for: room)
// Save room data // Save room data
saveRoomData() saveRoomData()
@@ -572,6 +603,8 @@ class ChatViewModel: ObservableObject {
let newKey = deriveRoomKey(from: newPassword, roomName: currentRoom) let newKey = deriveRoomKey(from: newPassword, roomName: currentRoom)
roomKeys[currentRoom] = newKey roomKeys[currentRoom] = newKey
roomPasswords[currentRoom] = newPassword roomPasswords[currentRoom] = newPassword
// Update password in Keychain
_ = KeychainManager.shared.saveRoomPassword(newPassword, for: currentRoom)
// Compute new key commitment // Compute new key commitment
let newCommitment = computeKeyCommitment(for: newKey) let newCommitment = computeKeyCommitment(for: newKey)
@@ -785,6 +818,11 @@ class ChatViewModel: ObservableObject {
} }
roomMessages[room]?.append(message) 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 // Track ourselves as a room member
if roomMembers[room] == nil { if roomMembers[room] == nil {
roomMembers[room] = Set() roomMembers[room] = Set()
@@ -873,25 +911,63 @@ class ChatViewModel: ObservableObject {
privateChats.removeAll() privateChats.removeAll()
unreadPrivateMessages.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 // Reset nickname to anonymous
nickname = "anon\(Int.random(in: 1000...9999))" nickname = "anon\(Int.random(in: 1000...9999))"
saveNickname() saveNickname()
// Clear favorites // Clear favorites
favoritePeers.removeAll() favoritePeers.removeAll()
peerIDToPublicKeyFingerprint.removeAll()
saveFavorites() saveFavorites()
// Clear autocomplete state // Clear autocomplete state
autocompleteSuggestions.removeAll() autocompleteSuggestions.removeAll()
showAutocomplete = false showAutocomplete = false
autocompleteRange = nil
selectedAutocompleteIndex = 0
// Clear selected private chat
selectedPrivateChatPeer = nil
// Disconnect from all peers // Disconnect from all peers
meshService.emergencyDisconnectAll() meshService.emergencyDisconnectAll()
// Force immediate UserDefaults synchronization
userDefaults.synchronize()
// Force UI update // Force UI update
objectWillChange.send() 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) 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": case "/transfer":
// Transfer room ownership // Transfer room ownership
let parts = command.split(separator: " ", maxSplits: 1).map(String.init) let parts = command.split(separator: " ", maxSplits: 1).map(String.init)
@@ -1469,6 +1598,65 @@ extension ChatViewModel: BitchatDelegate {
messages.removeAll() messages.removeAll()
bitchatLog("cleared main chat", category: "chat") 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: default:
// Unknown command // Unknown command
let systemMessage = BitchatMessage( let systemMessage = BitchatMessage(
@@ -1656,6 +1844,11 @@ extension ChatViewModel: BitchatDelegate {
roomMessages[room]?.append(messageToAdd) roomMessages[room]?.append(messageToAdd)
roomMessages[room]?.sort { $0.timestamp < $1.timestamp } 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 // Track room members - only track the sender as a member
if roomMembers[room] == nil { if roomMembers[room] == nil {
roomMembers[room] = Set() roomMembers[room] = Set()
+28 -2
View File
@@ -64,6 +64,15 @@ struct AppInfoView: View {
FeatureRow(icon: "at", title: "Mentions", FeatureRow(icon: "at", title: "Mentions",
description: "Use @nickname to notify specific users") 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 // Privacy
@@ -86,9 +95,10 @@ struct AppInfoView: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("• Set your nickname in the header") 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("• Tap a peer to start a private chat")
Text("• Use @nickname to mention someone") Text("• Use @nickname to mention someone")
Text("• Use #roomname to create/join rooms")
Text("• Triple-tap the logo for panic mode") Text("• Triple-tap the logo for panic mode")
} }
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
@@ -106,6 +116,9 @@ struct AppInfoView: View {
Text("Store & Forward: 12h for all, ∞ for favorites") Text("Store & Forward: 12h for all, ∞ for favorites")
Text("Battery: Adaptive scanning based on level") Text("Battery: Adaptive scanning based on level")
Text("Platform: Universal (iOS, iPadOS, macOS)") 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)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
@@ -184,6 +197,15 @@ struct AppInfoView: View {
FeatureRow(icon: "at", title: "Mentions", FeatureRow(icon: "at", title: "Mentions",
description: "Use @nickname to notify specific users") 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 // Privacy
@@ -206,9 +228,10 @@ struct AppInfoView: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("• Set your nickname in the header") 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("• Tap a peer to start a private chat")
Text("• Use @nickname to mention someone") Text("• Use @nickname to mention someone")
Text("• Use #roomname to create/join rooms")
Text("• Triple-tap the logo for panic mode") Text("• Triple-tap the logo for panic mode")
} }
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
@@ -226,6 +249,9 @@ struct AppInfoView: View {
Text("Store & Forward: 12h for all, ∞ for favorites") Text("Store & Forward: 12h for all, ∞ for favorites")
Text("Battery: Adaptive scanning based on level") Text("Battery: Adaptive scanning based on level")
Text("Platform: Universal (iOS, iPadOS, macOS)") 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)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
+6 -2
View File
@@ -513,7 +513,9 @@ struct ContentView: View {
"/m": "send private message", "/m": "send private message",
"/clear": "clear chat messages", "/clear": "clear chat messages",
"/transfer": "transfer room ownership", "/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 ForEach(commandSuggestions, id: \.self) { command in
@@ -595,7 +597,9 @@ struct ContentView: View {
("/m", "send private message"), ("/m", "send private message"),
("/clear", "clear chat messages"), ("/clear", "clear chat messages"),
("/transfer", "transfer room ownership"), ("/transfer", "transfer room ownership"),
("/pass", "change room password") ("/pass", "change room password"),
("/favorite", "toggle room retention"),
("/discover", "find active rooms")
] ]
let input = newValue.lowercased() let input = newValue.lowercased()
commandSuggestions = commandDescriptions commandSuggestions = commandDescriptions