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