mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:25:20 +00:00
Implement favorites, notifications, and improve fragment handling
- Add favorites functionality for peers with persistent storage - Star icon to toggle favorites in sidebar - Favorites appear at top of peer list - Storage persists across app launches using peer ID - Add local notifications for mentions and private messages - Notifications appear when app is in background - Separate notification types for mentions vs private messages - Request notification permissions on app launch - Fix sidebar header alignment to match main toolbar (44pt) - Add background color to sidebar header for visual consistency - Improve voice note fragment transmission - Send fragments in batches of 5 with delays to prevent congestion - Better logging to debug fragment transmission issues - Reduce delay between fragments from 50ms to batch-based timing - Fix other UI issues - Ensure mic recording ripples originate from mic button - Update sidebar to swipe from right edge smoothly
This commit is contained in:
@@ -11,13 +11,17 @@
|
|||||||
1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; };
|
1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; };
|
||||||
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
|
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
|
||||||
6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */; };
|
6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */; };
|
||||||
|
6E628AC6B7E1754F3BD5090D /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90B7FFCA6DA409F3CB78704 /* AudioPlaybackService.swift */; };
|
||||||
6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
|
6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
|
||||||
739429DFDE5C5829CF70DA7D /* EncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC1563390A15C042D059CF9 /* EncryptionService.swift */; };
|
739429DFDE5C5829CF70DA7D /* EncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC1563390A15C042D059CF9 /* EncryptionService.swift */; };
|
||||||
7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; };
|
7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; };
|
||||||
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 */; };
|
||||||
|
817B221A5676873EAF5C5FA7 /* AudioRecordingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8801D22A8F987A7696BB90 /* AudioRecordingService.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 */; };
|
||||||
|
940A9CE820FBF517F0C7AA7E /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90B7FFCA6DA409F3CB78704 /* AudioPlaybackService.swift */; };
|
||||||
|
A7F37567974153A90E8C9F12 /* AudioRecordingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8801D22A8F987A7696BB90 /* AudioRecordingService.swift */; };
|
||||||
BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; };
|
BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; };
|
||||||
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 */; };
|
||||||
@@ -28,11 +32,13 @@
|
|||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
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>"; };
|
||||||
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>"; };
|
||||||
|
6C8801D22A8F987A7696BB90 /* AudioRecordingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecordingService.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>"; };
|
||||||
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; };
|
||||||
997D512074C64904D75DDD40 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
997D512074C64904D75DDD40 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; 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>"; };
|
||||||
|
B90B7FFCA6DA409F3CB78704 /* AudioPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackService.swift; sourceTree = "<group>"; };
|
||||||
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>"; };
|
||||||
E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = "<group>"; };
|
E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = "<group>"; };
|
||||||
EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
@@ -99,6 +105,8 @@
|
|||||||
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
B90B7FFCA6DA409F3CB78704 /* AudioPlaybackService.swift */,
|
||||||
|
6C8801D22A8F987A7696BB90 /* AudioRecordingService.swift */,
|
||||||
D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */,
|
D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */,
|
||||||
6DC1563390A15C042D059CF9 /* EncryptionService.swift */,
|
6DC1563390A15C042D059CF9 /* EncryptionService.swift */,
|
||||||
);
|
);
|
||||||
@@ -154,10 +162,11 @@
|
|||||||
LastUpgradeCheck = 1430;
|
LastUpgradeCheck = 1430;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
0576A29205865664C0937536 = {
|
0576A29205865664C0937536 = {
|
||||||
DevelopmentTeam = "";
|
DevelopmentTeam = L3N5LHJD5Y;
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
AF077EA0474EDEDE2C72716C = {
|
AF077EA0474EDEDE2C72716C = {
|
||||||
|
DevelopmentTeam = L3N5LHJD5Y;
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -205,6 +214,8 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
6E628AC6B7E1754F3BD5090D /* AudioPlaybackService.swift in Sources */,
|
||||||
|
A7F37567974153A90E8C9F12 /* AudioRecordingService.swift in Sources */,
|
||||||
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */,
|
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */,
|
||||||
6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */,
|
6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */,
|
||||||
923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */,
|
923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */,
|
||||||
@@ -219,6 +230,8 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
940A9CE820FBF517F0C7AA7E /* AudioPlaybackService.swift in Sources */,
|
||||||
|
817B221A5676873EAF5C5FA7 /* AudioRecordingService.swift in Sources */,
|
||||||
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */,
|
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */,
|
||||||
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */,
|
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */,
|
||||||
6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */,
|
6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */,
|
||||||
@@ -237,9 +250,10 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
|
CODE_SIGNING_ALLOWED = 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;
|
||||||
@@ -248,7 +262,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.bitchat.app;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.app;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
@@ -262,9 +276,10 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
|
CODE_SIGNING_ALLOWED = 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;
|
||||||
@@ -273,7 +288,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.bitchat.app;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.app;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
@@ -287,6 +302,8 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -341,7 +358,7 @@
|
|||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = L3N5LHJD5Y;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
@@ -369,6 +386,8 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -423,7 +442,7 @@
|
|||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = L3N5LHJD5Y;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
|||||||
+5
-5
@@ -28,16 +28,16 @@
|
|||||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>bitchat needs access to your microphone to record voice notes.</string>
|
<string>bitchat needs access to your microphone to record voice notes.</string>
|
||||||
<key>UILaunchScreen</key>
|
|
||||||
<dict>
|
|
||||||
<key>UIColorName</key>
|
|
||||||
<string>Black</string>
|
|
||||||
</dict>
|
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>bluetooth-central</string>
|
<string>bluetooth-central</string>
|
||||||
<string>bluetooth-peripheral</string>
|
<string>bluetooth-peripheral</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIColorName</key>
|
||||||
|
<string>Black</string>
|
||||||
|
</dict>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ enum MessageType: UInt8 {
|
|||||||
case leave = 0x07
|
case leave = 0x07
|
||||||
case privateMessage = 0x08
|
case privateMessage = 0x08
|
||||||
case voiceNote = 0x09
|
case voiceNote = 0x09
|
||||||
|
case fragmentStart = 0x0A // First fragment of a large message
|
||||||
|
case fragmentContinue = 0x0B // Continuation fragment
|
||||||
|
case fragmentEnd = 0x0C // Last fragment
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BitchatPacket: Codable {
|
struct BitchatPacket: Codable {
|
||||||
|
|||||||
@@ -31,13 +31,21 @@ class AudioPlaybackService: NSObject, ObservableObject {
|
|||||||
// Stop any current playback
|
// Stop any current playback
|
||||||
stopPlayback()
|
stopPlayback()
|
||||||
|
|
||||||
|
print("[AUDIO] Attempting to play voice note with data size: \(audioData.count) bytes")
|
||||||
|
|
||||||
// Create temporary file for audio data
|
// Create temporary file for audio data
|
||||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("voice_note_\(messageID).m4a")
|
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("voice_note_\(messageID).m4a")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
// Ensure directory exists
|
||||||
|
try FileManager.default.createDirectory(at: tempURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
// Write audio data
|
||||||
try audioData.write(to: tempURL)
|
try audioData.write(to: tempURL)
|
||||||
audioFiles[messageID] = tempURL
|
audioFiles[messageID] = tempURL
|
||||||
|
|
||||||
|
print("[AUDIO] Written audio file to: \(tempURL.path), size: \(try FileManager.default.attributesOfItem(atPath: tempURL.path)[.size] ?? 0)")
|
||||||
|
|
||||||
audioPlayer = try AVAudioPlayer(contentsOf: tempURL)
|
audioPlayer = try AVAudioPlayer(contentsOf: tempURL)
|
||||||
audioPlayer?.delegate = self
|
audioPlayer?.delegate = self
|
||||||
audioPlayer?.prepareToPlay()
|
audioPlayer?.prepareToPlay()
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ class AudioRecordingService: NSObject, ObservableObject {
|
|||||||
private var audioRecorder: AVAudioRecorder?
|
private var audioRecorder: AVAudioRecorder?
|
||||||
private var recordingTimer: Timer?
|
private var recordingTimer: Timer?
|
||||||
private var recordingStartTime: Date?
|
private var recordingStartTime: Date?
|
||||||
private let maxDuration: TimeInterval = 10.0
|
private let maxDuration: TimeInterval = 30.0
|
||||||
|
private var recordingCompletion: ((Result<URL, Error>) -> Void)?
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -30,6 +31,24 @@ class AudioRecordingService: NSObject, ObservableObject {
|
|||||||
func startRecording(completion: @escaping (Result<URL, Error>) -> Void) {
|
func startRecording(completion: @escaping (Result<URL, Error>) -> Void) {
|
||||||
guard !isRecording else { return }
|
guard !isRecording else { return }
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
// Request microphone permission
|
||||||
|
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
||||||
|
if !granted {
|
||||||
|
completion(.failure(AudioError.microphonePermissionDenied))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.performRecording(completion: completion)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
performRecording(completion: completion)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performRecording(completion: @escaping (Result<URL, Error>) -> Void) {
|
||||||
|
// Store completion handler for later use
|
||||||
|
self.recordingCompletion = completion
|
||||||
|
|
||||||
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
let audioFilename = documentsPath.appendingPathComponent("voice_note_\(Date().timeIntervalSince1970).m4a")
|
let audioFilename = documentsPath.appendingPathComponent("voice_note_\(Date().timeIntervalSince1970).m4a")
|
||||||
|
|
||||||
@@ -42,27 +61,40 @@ class AudioRecordingService: NSObject, ObservableObject {
|
|||||||
]
|
]
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
// Ensure audio session is properly configured
|
||||||
|
#if os(iOS)
|
||||||
|
let audioSession = AVAudioSession.sharedInstance()
|
||||||
|
try audioSession.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
|
||||||
|
try audioSession.setActive(true)
|
||||||
|
#endif
|
||||||
|
|
||||||
audioRecorder = try AVAudioRecorder(url: audioFilename, settings: settings)
|
audioRecorder = try AVAudioRecorder(url: audioFilename, settings: settings)
|
||||||
audioRecorder?.delegate = self
|
audioRecorder?.delegate = self
|
||||||
audioRecorder?.record()
|
audioRecorder?.prepareToRecord() // Important: prepare before recording
|
||||||
|
|
||||||
isRecording = true
|
if audioRecorder?.record() == true {
|
||||||
recordingStartTime = Date()
|
isRecording = true
|
||||||
recordingTime = 0
|
recordingStartTime = Date()
|
||||||
|
recordingTime = 0
|
||||||
// Start timer to update recording time and enforce max duration
|
|
||||||
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
|
||||||
guard let self = self, let startTime = self.recordingStartTime else { return }
|
|
||||||
self.recordingTime = Date().timeIntervalSince(startTime)
|
|
||||||
|
|
||||||
if self.recordingTime >= self.maxDuration {
|
// Start timer to update recording time and enforce max duration
|
||||||
self.stopRecording { result in
|
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
||||||
completion(result)
|
guard let self = self, let startTime = self.recordingStartTime else { return }
|
||||||
|
self.recordingTime = Date().timeIntervalSince(startTime)
|
||||||
|
|
||||||
|
if self.recordingTime >= self.maxDuration {
|
||||||
|
self.stopRecording { result in
|
||||||
|
// Result already handled by recordingCompletion
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
print("[AUDIO] Failed to start recording")
|
||||||
|
completion(.failure(AudioError.recordingFailed))
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
|
print("[AUDIO] Error setting up recording: \(error)")
|
||||||
completion(.failure(error))
|
completion(.failure(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,29 +105,46 @@ class AudioRecordingService: NSObject, ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure minimum recording duration
|
||||||
|
let minDuration: TimeInterval = 1.0
|
||||||
|
if recordingTime < minDuration {
|
||||||
|
// Continue recording until minimum duration
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + (minDuration - recordingTime)) { [weak self] in
|
||||||
|
self?.stopRecording(completion: completion)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
recorder.stop()
|
recorder.stop()
|
||||||
isRecording = false
|
isRecording = false
|
||||||
recordingTimer?.invalidate()
|
recordingTimer?.invalidate()
|
||||||
recordingTimer = nil
|
recordingTimer = nil
|
||||||
|
|
||||||
let url = recorder.url
|
let url = recorder.url
|
||||||
let _ = recordingTime
|
let recordedDuration = recordingTime
|
||||||
|
|
||||||
// Reset for next recording
|
// Reset for next recording
|
||||||
audioRecorder = nil
|
audioRecorder = nil
|
||||||
recordingTime = 0
|
recordingTime = 0
|
||||||
recordingStartTime = nil
|
recordingStartTime = nil
|
||||||
|
recordingCompletion = nil
|
||||||
|
|
||||||
// Verify file exists and has content
|
// Give the recorder a moment to finalize the file
|
||||||
do {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
// Verify file exists and has content
|
||||||
if let fileSize = attributes[.size] as? Int, fileSize > 0 {
|
do {
|
||||||
completion(.success(url))
|
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||||
} else {
|
if let fileSize = attributes[.size] as? Int, fileSize > 1000 { // At least 1KB
|
||||||
completion(.failure(AudioError.emptyRecording))
|
print("[AUDIO] Recording completed successfully. File size: \(fileSize) bytes, duration: \(recordedDuration)s")
|
||||||
|
completion(.success(url))
|
||||||
|
} else {
|
||||||
|
print("[AUDIO] Recording failed: file too small (\(attributes[.size] ?? 0) bytes)")
|
||||||
|
completion(.failure(AudioError.emptyRecording))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("[AUDIO] Recording failed: \(error)")
|
||||||
|
completion(.failure(error))
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +165,8 @@ class AudioRecordingService: NSObject, ObservableObject {
|
|||||||
enum AudioError: LocalizedError {
|
enum AudioError: LocalizedError {
|
||||||
case notRecording
|
case notRecording
|
||||||
case emptyRecording
|
case emptyRecording
|
||||||
|
case microphonePermissionDenied
|
||||||
|
case recordingFailed
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -123,6 +174,10 @@ class AudioRecordingService: NSObject, ObservableObject {
|
|||||||
return "Not currently recording"
|
return "Not currently recording"
|
||||||
case .emptyRecording:
|
case .emptyRecording:
|
||||||
return "Recording produced no audio data"
|
return "Recording produced no audio data"
|
||||||
|
case .microphonePermissionDenied:
|
||||||
|
return "Microphone permission denied"
|
||||||
|
case .recordingFailed:
|
||||||
|
return "Failed to start recording"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ import AppKit
|
|||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Extension for hex encoding
|
||||||
|
extension Data {
|
||||||
|
func hexEncodedString() -> String {
|
||||||
|
if self.isEmpty {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return self.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class BluetoothMeshService: NSObject {
|
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")
|
||||||
@@ -31,6 +41,11 @@ class BluetoothMeshService: NSObject {
|
|||||||
private var announcedToPeers = Set<String>() // Track which peers we've announced to
|
private var announcedToPeers = Set<String>() // Track which peers we've announced to
|
||||||
private var announcedPeers = Set<String>() // Track peers who have already been announced
|
private var announcedPeers = Set<String>() // Track peers who have already been announced
|
||||||
|
|
||||||
|
// Fragment handling
|
||||||
|
private var incomingFragments: [String: [Int: Data]] = [:] // fragmentID -> [index: data]
|
||||||
|
private var fragmentMetadata: [String: (originalType: UInt8, totalFragments: Int, timestamp: Date)] = [:]
|
||||||
|
private let maxFragmentSize = 400 // Leave room for protocol overhead
|
||||||
|
|
||||||
let myPeerID: String
|
let myPeerID: String
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
@@ -183,12 +198,14 @@ class BluetoothMeshService: NSObject {
|
|||||||
messageQueue.async { [weak self] in
|
messageQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
print("[VOICE] Sending voice note: audio size = \(audioData.count) bytes, duration = \(duration)s")
|
||||||
|
|
||||||
let nickname = self.delegate as? ChatViewModel
|
let nickname = self.delegate as? ChatViewModel
|
||||||
let senderNick = nickname?.nickname ?? self.myPeerID
|
let senderNick = nickname?.nickname ?? self.myPeerID
|
||||||
|
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
sender: senderNick,
|
sender: senderNick,
|
||||||
content: "🎤 Voice note (\(String(format: "%.1f", duration))s)",
|
content: "🎤 \(String(format: "%.1f", duration))s",
|
||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
@@ -197,6 +214,7 @@ class BluetoothMeshService: NSObject {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if let messageData = message.toBinaryPayload() {
|
if let messageData = message.toBinaryPayload() {
|
||||||
|
print("[VOICE] Message payload size: \(messageData.count) bytes")
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.voiceNote.rawValue,
|
type: MessageType.voiceNote.rawValue,
|
||||||
ttl: self.maxTTL,
|
ttl: self.maxTTL,
|
||||||
@@ -204,7 +222,18 @@ class BluetoothMeshService: NSObject {
|
|||||||
payload: messageData
|
payload: messageData
|
||||||
)
|
)
|
||||||
|
|
||||||
self.broadcastPacket(packet)
|
if let packetData = packet.toBinaryData() {
|
||||||
|
print("[VOICE] Final packet size: \(packetData.count) bytes")
|
||||||
|
if packetData.count > 512 {
|
||||||
|
print("[VOICE] WARNING: Packet size exceeds typical BLE MTU of 512 bytes!")
|
||||||
|
print("[VOICE] Fragmenting voice note into smaller packets...")
|
||||||
|
self.sendFragmentedPacket(packet)
|
||||||
|
} else {
|
||||||
|
self.broadcastPacket(packet)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.broadcastPacket(packet)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,7 +369,16 @@ class BluetoothMeshService: NSObject {
|
|||||||
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: String, peripheral: CBPeripheral? = nil) {
|
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: String, peripheral: CBPeripheral? = nil) {
|
||||||
messageQueue.async(flags: .barrier) { [weak self] in
|
messageQueue.async(flags: .barrier) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard packet.ttl > 0 else { return }
|
guard packet.ttl > 0 else {
|
||||||
|
print("[PACKET] Dropping packet with TTL 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate packet has payload
|
||||||
|
guard !packet.payload.isEmpty else {
|
||||||
|
print("[PACKET] Dropping packet with empty payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let messageID = "\(packet.timestamp)-\(String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) ?? "")"
|
let messageID = "\(packet.timestamp)-\(String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) ?? "")"
|
||||||
guard !processedMessages.contains(messageID) else {
|
guard !processedMessages.contains(messageID) else {
|
||||||
@@ -562,11 +600,208 @@ class BluetoothMeshService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case .fragmentStart, .fragmentContinue, .fragmentEnd:
|
||||||
|
let fragmentTypeStr = packet.type == 10 ? "START" : (packet.type == 11 ? "CONTINUE" : "END")
|
||||||
|
print("[PACKET] Handling fragment type: \(fragmentTypeStr) (\(packet.type)), payload size: \(packet.payload.count), from: \(peerID)")
|
||||||
|
|
||||||
|
// Validate fragment has minimum required size
|
||||||
|
if packet.payload.count < 13 {
|
||||||
|
print("[PACKET] Fragment payload too small: \(packet.payload.count) bytes, dropping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFragment(packet, from: peerID)
|
||||||
|
|
||||||
|
// Relay fragments if TTL > 0
|
||||||
|
var relayPacket = packet
|
||||||
|
relayPacket.ttl -= 1
|
||||||
|
if relayPacket.ttl > 0 {
|
||||||
|
print("[PACKET] Relaying fragment with TTL: \(relayPacket.ttl)")
|
||||||
|
self.broadcastPacket(relayPacket)
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sendFragmentedPacket(_ packet: BitchatPacket) {
|
||||||
|
guard let fullData = packet.toBinaryData() else { return }
|
||||||
|
|
||||||
|
// Generate a fixed 8-byte fragment ID
|
||||||
|
var fragmentID = Data(count: 8)
|
||||||
|
fragmentID.withUnsafeMutableBytes { bytes in
|
||||||
|
arc4random_buf(bytes.baseAddress, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
let fragments = stride(from: 0, to: fullData.count, by: maxFragmentSize).map { offset in
|
||||||
|
fullData[offset..<min(offset + maxFragmentSize, fullData.count)]
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[FRAGMENT] Splitting into \(fragments.count) fragments of max \(maxFragmentSize) bytes")
|
||||||
|
print("[FRAGMENT] Fragment ID: \(fragmentID.hexEncodedString())")
|
||||||
|
print("[FRAGMENT] Original packet size: \(fullData.count) bytes")
|
||||||
|
|
||||||
|
// Send fragments in batches to avoid congestion
|
||||||
|
let batchSize = 5
|
||||||
|
let delayBetweenFragments: TimeInterval = 0.05 // 50ms between fragments
|
||||||
|
let delayBetweenBatches: TimeInterval = 0.2 // 200ms between batches
|
||||||
|
|
||||||
|
for (index, fragmentData) in fragments.enumerated() {
|
||||||
|
var fragmentPayload = Data()
|
||||||
|
|
||||||
|
// Fragment header: fragmentID (8) + index (2) + total (2) + originalType (1) + data
|
||||||
|
fragmentPayload.append(fragmentID)
|
||||||
|
fragmentPayload.append(UInt8((index >> 8) & 0xFF))
|
||||||
|
fragmentPayload.append(UInt8(index & 0xFF))
|
||||||
|
fragmentPayload.append(UInt8((fragments.count >> 8) & 0xFF))
|
||||||
|
fragmentPayload.append(UInt8(fragments.count & 0xFF))
|
||||||
|
fragmentPayload.append(packet.type)
|
||||||
|
fragmentPayload.append(fragmentData)
|
||||||
|
|
||||||
|
let fragmentType: MessageType
|
||||||
|
if index == 0 {
|
||||||
|
fragmentType = .fragmentStart
|
||||||
|
} else if index == fragments.count - 1 {
|
||||||
|
fragmentType = .fragmentEnd
|
||||||
|
} else {
|
||||||
|
fragmentType = .fragmentContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
let fragmentPacket = BitchatPacket(
|
||||||
|
type: fragmentType.rawValue,
|
||||||
|
ttl: packet.ttl,
|
||||||
|
senderID: myPeerID,
|
||||||
|
payload: fragmentPayload
|
||||||
|
)
|
||||||
|
|
||||||
|
// Calculate delay based on batch
|
||||||
|
let batchNumber = index / batchSize
|
||||||
|
let indexInBatch = index % batchSize
|
||||||
|
let totalDelay = (Double(batchNumber) * delayBetweenBatches) + (Double(indexInBatch) * delayBetweenFragments)
|
||||||
|
|
||||||
|
// Send fragments on background queue with calculated delay
|
||||||
|
messageQueue.asyncAfter(deadline: .now() + totalDelay) { [weak self] in
|
||||||
|
self?.broadcastPacket(fragmentPacket)
|
||||||
|
print("[FRAGMENT] Sent fragment \(index + 1)/\(fragments.count) type: \(fragmentType) (batch \(batchNumber + 1))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalTime = Double((fragments.count - 1) / batchSize) * delayBetweenBatches + Double((fragments.count - 1) % batchSize) * delayBetweenFragments
|
||||||
|
print("[FRAGMENT] Total send time: \(totalTime)s for \(fragments.count) fragments")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleFragment(_ packet: BitchatPacket, from peerID: String) {
|
||||||
|
print("[FRAGMENT] Starting to handle fragment, payload size: \(packet.payload.count)")
|
||||||
|
|
||||||
|
guard packet.payload.count >= 13 else {
|
||||||
|
print("[FRAGMENT] Payload too small: \(packet.payload.count) bytes (need at least 13)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to array for safer access
|
||||||
|
let payloadArray = Array(packet.payload)
|
||||||
|
var offset = 0
|
||||||
|
|
||||||
|
// Extract fragment ID as binary data (8 bytes)
|
||||||
|
guard payloadArray.count >= 8 else {
|
||||||
|
print("[FRAGMENT] Payload too small for fragment ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let fragmentIDData = Data(payloadArray[0..<8])
|
||||||
|
let fragmentID = fragmentIDData.hexEncodedString()
|
||||||
|
print("[FRAGMENT] Fragment ID: \(fragmentID)")
|
||||||
|
offset = 8
|
||||||
|
|
||||||
|
// Safely extract index
|
||||||
|
guard payloadArray.count >= offset + 2 else {
|
||||||
|
print("[FRAGMENT] Not enough data for index at offset \(offset)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let index = Int(payloadArray[offset]) << 8 | Int(payloadArray[offset + 1])
|
||||||
|
offset += 2
|
||||||
|
print("[FRAGMENT] Index: \(index)")
|
||||||
|
|
||||||
|
// Safely extract total
|
||||||
|
guard payloadArray.count >= offset + 2 else {
|
||||||
|
print("[FRAGMENT] Not enough data for total at offset \(offset)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let total = Int(payloadArray[offset]) << 8 | Int(payloadArray[offset + 1])
|
||||||
|
offset += 2
|
||||||
|
print("[FRAGMENT] Total fragments: \(total)")
|
||||||
|
|
||||||
|
// Safely extract original type
|
||||||
|
guard payloadArray.count >= offset + 1 else {
|
||||||
|
print("[FRAGMENT] Not enough data for type at offset \(offset)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let originalType = payloadArray[offset]
|
||||||
|
offset += 1
|
||||||
|
print("[FRAGMENT] Original type: \(originalType)")
|
||||||
|
|
||||||
|
// Extract fragment data
|
||||||
|
let fragmentData: Data
|
||||||
|
if payloadArray.count > offset {
|
||||||
|
fragmentData = Data(payloadArray[offset...])
|
||||||
|
} else {
|
||||||
|
fragmentData = Data()
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[FRAGMENT] Received fragment \(index + 1)/\(total) for ID: \(fragmentID), data size: \(fragmentData.count)")
|
||||||
|
|
||||||
|
// Initialize fragment collection if needed
|
||||||
|
if incomingFragments[fragmentID] == nil {
|
||||||
|
incomingFragments[fragmentID] = [:]
|
||||||
|
fragmentMetadata[fragmentID] = (originalType, total, Date())
|
||||||
|
print("[FRAGMENT] Started collecting fragments for ID: \(fragmentID), expecting \(total) fragments")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store fragment
|
||||||
|
incomingFragments[fragmentID]?[index] = fragmentData
|
||||||
|
|
||||||
|
print("[FRAGMENT] Progress for ID \(fragmentID): \(incomingFragments[fragmentID]?.count ?? 0)/\(total) fragments collected")
|
||||||
|
|
||||||
|
// Check if we have all fragments
|
||||||
|
if let fragments = incomingFragments[fragmentID],
|
||||||
|
fragments.count == total {
|
||||||
|
|
||||||
|
// Reassemble the original packet
|
||||||
|
var reassembledData = Data()
|
||||||
|
for i in 0..<total {
|
||||||
|
if let fragment = fragments[i] {
|
||||||
|
reassembledData.append(fragment)
|
||||||
|
} else {
|
||||||
|
print("[FRAGMENT] Missing fragment \(i) for ID: \(fragmentID)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[FRAGMENT] Successfully reassembled \(total) fragments into \(reassembledData.count) bytes")
|
||||||
|
|
||||||
|
// Parse and handle the reassembled packet
|
||||||
|
if let reassembledPacket = BitchatPacket.from(reassembledData) {
|
||||||
|
// Clean up
|
||||||
|
incomingFragments.removeValue(forKey: fragmentID)
|
||||||
|
fragmentMetadata.removeValue(forKey: fragmentID)
|
||||||
|
|
||||||
|
// Handle the reassembled packet
|
||||||
|
handleReceivedPacket(reassembledPacket, from: peerID, peripheral: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up old fragments (older than 30 seconds)
|
||||||
|
let cutoffTime = Date().addingTimeInterval(-30)
|
||||||
|
for (fragID, metadata) in fragmentMetadata {
|
||||||
|
if metadata.timestamp < cutoffTime {
|
||||||
|
incomingFragments.removeValue(forKey: fragID)
|
||||||
|
fragmentMetadata.removeValue(forKey: fragID)
|
||||||
|
print("[FRAGMENT] Cleaned up expired fragments for ID: \(fragID)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension BluetoothMeshService: CBCentralManagerDelegate {
|
extension BluetoothMeshService: CBCentralManagerDelegate {
|
||||||
@@ -717,8 +952,13 @@ extension BluetoothMeshService: CBPeripheralDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||||
guard let data = characteristic.value,
|
guard let data = characteristic.value else {
|
||||||
let packet = BitchatPacket.from(data) else {
|
print("[PERIPHERAL] No data in characteristic")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let packet = BitchatPacket.from(data) else {
|
||||||
|
print("[PERIPHERAL] Failed to parse packet from data of size: \(data.count)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,6 +966,14 @@ extension BluetoothMeshService: CBPeripheralDelegate {
|
|||||||
let localPeerID = connectedPeripherals.first(where: { $0.value == peripheral })?.key ?? "unknown"
|
let localPeerID = connectedPeripherals.first(where: { $0.value == peripheral })?.key ?? "unknown"
|
||||||
let packetSenderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) ?? "unknown"
|
let packetSenderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) ?? "unknown"
|
||||||
print("[PERIPHERAL] Received data from localPeerID: \(localPeerID), packetSenderID: \(packetSenderID), packet type: \(packet.type)")
|
print("[PERIPHERAL] Received data from localPeerID: \(localPeerID), packetSenderID: \(packetSenderID), packet type: \(packet.type)")
|
||||||
|
|
||||||
|
// Handle fragments directly if it's a fragment type
|
||||||
|
if packet.type == MessageType.fragmentStart.rawValue ||
|
||||||
|
packet.type == MessageType.fragmentContinue.rawValue ||
|
||||||
|
packet.type == MessageType.fragmentEnd.rawValue {
|
||||||
|
print("[PERIPHERAL] Processing fragment directly")
|
||||||
|
}
|
||||||
|
|
||||||
handleReceivedPacket(packet, from: packetSenderID, peripheral: peripheral)
|
handleReceivedPacket(packet, from: packetSenderID, peripheral: peripheral)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,6 +1045,8 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
|||||||
// Try to identify peer from packet
|
// Try to identify peer from packet
|
||||||
let peerID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) ?? "unknown"
|
let peerID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) ?? "unknown"
|
||||||
|
|
||||||
|
print("[PERIPHERAL_MANAGER] Received write from peer: \(peerID), packet type: \(packet.type)")
|
||||||
|
|
||||||
// Store the central for updates
|
// Store the central for updates
|
||||||
if !subscribedCentrals.contains(request.central) {
|
if !subscribedCentrals.contains(request.central) {
|
||||||
subscribedCentrals.append(request.central)
|
subscribedCentrals.append(request.central)
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import Foundation
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
import UserNotifications
|
||||||
|
#else
|
||||||
|
import UserNotifications
|
||||||
|
#endif
|
||||||
|
|
||||||
|
class NotificationService {
|
||||||
|
static let shared = NotificationService()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func requestAuthorization() {
|
||||||
|
#if os(iOS)
|
||||||
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||||
|
if granted {
|
||||||
|
print("[NOTIFICATIONS] Permission granted")
|
||||||
|
} else if let error = error {
|
||||||
|
print("[NOTIFICATIONS] Permission error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendLocalNotification(title: String, body: String, identifier: String) {
|
||||||
|
#if os(iOS)
|
||||||
|
guard UIApplication.shared.applicationState == .background else {
|
||||||
|
print("[NOTIFICATIONS] App is in foreground, skipping notification")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = title
|
||||||
|
content.body = body
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: identifier,
|
||||||
|
content: content,
|
||||||
|
trigger: nil // Deliver immediately
|
||||||
|
)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request) { error in
|
||||||
|
if let error = error {
|
||||||
|
print("[NOTIFICATIONS] Error sending notification: \(error)")
|
||||||
|
} else {
|
||||||
|
print("[NOTIFICATIONS] Notification sent: \(title)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMentionNotification(from sender: String, message: String) {
|
||||||
|
let title = "Mentioned by \(sender)"
|
||||||
|
let body = message
|
||||||
|
let identifier = "mention-\(UUID().uuidString)"
|
||||||
|
|
||||||
|
sendLocalNotification(title: title, body: body, identifier: identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendPrivateMessageNotification(from sender: String, message: String) {
|
||||||
|
let title = "Private message from \(sender)"
|
||||||
|
let body = message
|
||||||
|
let identifier = "private-\(UUID().uuidString)"
|
||||||
|
|
||||||
|
sendLocalNotification(title: title, body: body, identifier: identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,15 +31,22 @@ class ChatViewModel: ObservableObject {
|
|||||||
let audioPlayer = AudioPlaybackService()
|
let audioPlayer = AudioPlaybackService()
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
private let nicknameKey = "bitchat.nickname"
|
private let nicknameKey = "bitchat.nickname"
|
||||||
|
private let favoritesKey = "bitchat.favorites"
|
||||||
private var nicknameSaveTimer: Timer?
|
private var nicknameSaveTimer: Timer?
|
||||||
private var notificationTimer: Timer?
|
private var notificationTimer: Timer?
|
||||||
|
|
||||||
|
@Published var favoritePeers: Set<String> = []
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
loadNickname()
|
loadNickname()
|
||||||
|
loadFavorites()
|
||||||
meshService.delegate = self
|
meshService.delegate = self
|
||||||
|
|
||||||
// Start mesh service immediately
|
// Start mesh service immediately
|
||||||
meshService.startServices()
|
meshService.startServices()
|
||||||
|
|
||||||
|
// Request notification permission
|
||||||
|
NotificationService.shared.requestAuthorization()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadNickname() {
|
private func loadNickname() {
|
||||||
@@ -56,6 +63,30 @@ class ChatViewModel: ObservableObject {
|
|||||||
userDefaults.synchronize() // Force immediate save
|
userDefaults.synchronize() // Force immediate save
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func loadFavorites() {
|
||||||
|
if let savedFavorites = userDefaults.stringArray(forKey: favoritesKey) {
|
||||||
|
favoritePeers = Set(savedFavorites)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveFavorites() {
|
||||||
|
userDefaults.set(Array(favoritePeers), forKey: favoritesKey)
|
||||||
|
userDefaults.synchronize()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleFavorite(peerID: String) {
|
||||||
|
if favoritePeers.contains(peerID) {
|
||||||
|
favoritePeers.remove(peerID)
|
||||||
|
} else {
|
||||||
|
favoritePeers.insert(peerID)
|
||||||
|
}
|
||||||
|
saveFavorites()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFavorite(peerID: String) -> Bool {
|
||||||
|
return favoritePeers.contains(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
func sendMessage(_ content: String) {
|
func sendMessage(_ content: String) {
|
||||||
guard !content.isEmpty else { return }
|
guard !content.isEmpty else { return }
|
||||||
|
|
||||||
@@ -200,10 +231,9 @@ class ChatViewModel: ObservableObject {
|
|||||||
|
|
||||||
let partial = String(beforeCursor[range]).lowercased()
|
let partial = String(beforeCursor[range]).lowercased()
|
||||||
|
|
||||||
// Get all available nicknames
|
// Get all available nicknames (excluding self)
|
||||||
let peerNicknames = meshService.getPeerNicknames()
|
let peerNicknames = meshService.getPeerNicknames()
|
||||||
var allNicknames = Array(peerNicknames.values)
|
let allNicknames = Array(peerNicknames.values)
|
||||||
allNicknames.append(nickname) // Include self
|
|
||||||
|
|
||||||
// Filter suggestions
|
// Filter suggestions
|
||||||
let suggestions = allNicknames.filter { nick in
|
let suggestions = allNicknames.filter { nick in
|
||||||
@@ -279,6 +309,34 @@ class ChatViewModel: ObservableObject {
|
|||||||
senderStyle.font = .system(size: 12, weight: .medium, design: .monospaced)
|
senderStyle.font = .system(size: 12, weight: .medium, design: .monospaced)
|
||||||
result.append(sender.mergingAttributes(senderStyle))
|
result.append(sender.mergingAttributes(senderStyle))
|
||||||
|
|
||||||
|
// Special handling for voice notes
|
||||||
|
if message.voiceNoteData != nil {
|
||||||
|
// Add the content (emoji + duration) with play button integrated
|
||||||
|
var contentStyle = AttributeContainer()
|
||||||
|
contentStyle.font = .system(size: 14, design: .monospaced)
|
||||||
|
contentStyle.foregroundColor = isDark ? Color.white : Color.black
|
||||||
|
|
||||||
|
// Parse the emoji and duration from content
|
||||||
|
let parts = message.content.components(separatedBy: " ")
|
||||||
|
if parts.count >= 2 {
|
||||||
|
// Emoji
|
||||||
|
result.append(AttributedString(parts[0] + " ").mergingAttributes(contentStyle))
|
||||||
|
|
||||||
|
// Play/pause button as text
|
||||||
|
let playSymbol = audioPlayer.isPlaying && audioPlayer.currentPlayingMessageID == message.id ? "⏸" : "▶"
|
||||||
|
var playStyle = AttributeContainer()
|
||||||
|
playStyle.font = .system(size: 12, design: .monospaced)
|
||||||
|
playStyle.foregroundColor = primaryColor
|
||||||
|
result.append(AttributedString(playSymbol + " ").mergingAttributes(playStyle))
|
||||||
|
|
||||||
|
// Duration
|
||||||
|
result.append(AttributedString(parts[1]).mergingAttributes(contentStyle))
|
||||||
|
} else {
|
||||||
|
result.append(AttributedString(message.content).mergingAttributes(contentStyle))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// Process content to highlight mentions
|
// Process content to highlight mentions
|
||||||
let contentText = message.content
|
let contentText = message.content
|
||||||
var processedContent = AttributedString()
|
var processedContent = AttributedString()
|
||||||
@@ -376,8 +434,10 @@ extension ChatViewModel: BitchatDelegate {
|
|||||||
// Check if we're mentioned
|
// Check if we're mentioned
|
||||||
let isMentioned = message.mentions?.contains(nickname) ?? false
|
let isMentioned = message.mentions?.contains(nickname) ?? false
|
||||||
|
|
||||||
// Different haptic feedback for mentions, private messages, and regular messages
|
// Send notifications for mentions and private messages when app is in background
|
||||||
if isMentioned && message.sender != nickname {
|
if isMentioned && message.sender != nickname {
|
||||||
|
NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content)
|
||||||
|
|
||||||
// Very prominent haptic for @mentions - triple tap with heavy impact
|
// Very prominent haptic for @mentions - triple tap with heavy impact
|
||||||
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
|
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
|
||||||
impactFeedback.prepare()
|
impactFeedback.prepare()
|
||||||
@@ -390,6 +450,8 @@ extension ChatViewModel: BitchatDelegate {
|
|||||||
impactFeedback.impactOccurred()
|
impactFeedback.impactOccurred()
|
||||||
}
|
}
|
||||||
} else if message.isPrivate && message.sender != nickname {
|
} else if message.isPrivate && message.sender != nickname {
|
||||||
|
NotificationService.shared.sendPrivateMessageNotification(from: message.sender, message: message.content)
|
||||||
|
|
||||||
// Heavy haptic for private messages - more pronounced
|
// Heavy haptic for private messages - more pronounced
|
||||||
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
|
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
|
||||||
impactFeedback.prepare()
|
impactFeedback.prepare()
|
||||||
@@ -477,7 +539,7 @@ extension ChatViewModel: BitchatDelegate {
|
|||||||
func sendVoiceNote(_ audioData: Data, duration: TimeInterval) {
|
func sendVoiceNote(_ audioData: Data, duration: TimeInterval) {
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
sender: nickname,
|
sender: nickname,
|
||||||
content: "🎤 Voice note (\(String(format: "%.1f", duration))s)",
|
content: "🎤 \(String(format: "%.1f", duration))s",
|
||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
|
|||||||
+302
-93
@@ -8,6 +8,10 @@ struct ContentView: View {
|
|||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@State private var showPeerList = false
|
@State private var showPeerList = false
|
||||||
@State private var isRecordingVoice = false
|
@State private var isRecordingVoice = false
|
||||||
|
@State private var recordingPulse = false
|
||||||
|
@State private var recordingScale: CGFloat = 1.0
|
||||||
|
@State private var showSidebar = false
|
||||||
|
@State private var sidebarDragOffset: CGFloat = 0
|
||||||
|
|
||||||
private var backgroundColor: Color {
|
private var backgroundColor: Color {
|
||||||
colorScheme == .dark ? Color.black : Color.white
|
colorScheme == .dark ? Color.black : Color.white
|
||||||
@@ -23,15 +27,134 @@ struct ContentView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
VStack(spacing: 0) {
|
// Main content
|
||||||
headerView
|
GeometryReader { geometry in
|
||||||
Divider()
|
ZStack {
|
||||||
messagesView
|
VStack(spacing: 0) {
|
||||||
Divider()
|
headerView
|
||||||
inputView
|
Divider()
|
||||||
|
messagesView
|
||||||
|
Divider()
|
||||||
|
inputView
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.gesture(
|
||||||
|
DragGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
// Only respond to leftward swipes when sidebar is closed
|
||||||
|
// or rightward swipes when sidebar is open
|
||||||
|
if !showSidebar && value.translation.width < 0 {
|
||||||
|
sidebarDragOffset = max(value.translation.width, -geometry.size.width * 0.7)
|
||||||
|
} else if showSidebar && value.translation.width > 0 {
|
||||||
|
sidebarDragOffset = min(-geometry.size.width * 0.7 + value.translation.width, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onEnded { value in
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
|
if !showSidebar {
|
||||||
|
// Opening gesture (swipe left)
|
||||||
|
if value.translation.width < -100 || (value.translation.width < -50 && value.velocity.width < -500) {
|
||||||
|
showSidebar = true
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
} else {
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Closing gesture (swipe right)
|
||||||
|
if value.translation.width > 100 || (value.translation.width > 50 && value.velocity.width > 500) {
|
||||||
|
showSidebar = false
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
} else {
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sidebar overlay
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
// Tap to dismiss area
|
||||||
|
Color.black.opacity(showSidebar ? 0.3 : 0.3 * (-sidebarDragOffset / (geometry.size.width * 0.7)))
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
|
showSidebar = false
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebarView
|
||||||
|
.frame(width: geometry.size.width * 0.7)
|
||||||
|
.transition(.move(edge: .trailing))
|
||||||
|
}
|
||||||
|
.offset(x: showSidebar ? -sidebarDragOffset : geometry.size.width - sidebarDragOffset)
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: showSidebar)
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: sidebarDragOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recording ripples overlay
|
||||||
|
if isRecordingVoice {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack {
|
||||||
|
ForEach(0..<6) { index in
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.red.opacity(0.4 - Double(index) * 0.05), lineWidth: 1.5)
|
||||||
|
.frame(width: 20, height: 20) // Start at mic button size
|
||||||
|
.scaleEffect(1 + (recordingScale - 1) * (1.0 - Double(index) * 0.1))
|
||||||
|
.opacity(max(0, 1.0 - ((recordingScale - 1) / 40.0) * (1.0 - Double(index) * 0.1)))
|
||||||
|
.position(
|
||||||
|
// Position at mic button location
|
||||||
|
x: geometry.size.width - 70, // Account for padding and button position
|
||||||
|
y: geometry.size.height - 32 // Account for input bar height
|
||||||
|
)
|
||||||
|
.animation(
|
||||||
|
Animation.easeOut(duration: 3.0)
|
||||||
|
.repeatForever(autoreverses: false)
|
||||||
|
.delay(Double(index) * 0.4),
|
||||||
|
value: recordingScale
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autocomplete overlay
|
||||||
|
if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
ForEach(Array(viewModel.autocompleteSuggestions.enumerated()), id: \.element) { index, suggestion in
|
||||||
|
Button(action: {
|
||||||
|
_ = viewModel.completeNickname(suggestion, in: &messageText)
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text("@\(suggestion)")
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(index == viewModel.selectedAutocompleteIndex ? backgroundColor : textColor)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(index == viewModel.selectedAutocompleteIndex ? textColor : Color.clear)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.frame(maxWidth: 200, alignment: .leading)
|
||||||
|
.offset(x: 100) // Align with input field
|
||||||
|
.padding(.bottom, 45) // Position just above input
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.background(backgroundColor)
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
|
|
||||||
// Private message notification overlay
|
// Private message notification overlay
|
||||||
if let notification = viewModel.privateMessageNotification {
|
if let notification = viewModel.privateMessageNotification {
|
||||||
@@ -108,25 +231,18 @@ struct ContentView: View {
|
|||||||
.opacity(0)
|
.opacity(0)
|
||||||
} else {
|
} else {
|
||||||
// Public chat header
|
// Public chat header
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 4) {
|
||||||
Text("bitchat")
|
Text("bitchat")
|
||||||
.font(.system(size: 18, weight: .medium, design: .monospaced))
|
.font(.system(size: 18, weight: .medium, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
// Peer status section
|
|
||||||
peerStatusView
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text("name:")
|
Text("name:")
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
|
|
||||||
TextField("nickname", text: $viewModel.nickname)
|
TextField("nickname", text: $viewModel.nickname)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.frame(maxWidth: 100)
|
.frame(maxWidth: 100)
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.onChange(of: viewModel.nickname) { _ in
|
.onChange(of: viewModel.nickname) { _ in
|
||||||
@@ -136,6 +252,20 @@ struct ContentView: View {
|
|||||||
viewModel.saveNickname()
|
viewModel.saveNickname()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// People counter
|
||||||
|
let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count
|
||||||
|
Text(viewModel.isConnected ? "\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")" : "alone :/")
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(viewModel.isConnected ? textColor : Color.red)
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
|
showSidebar.toggle()
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 44) // Fixed height to prevent bouncing
|
.frame(height: 44) // Fixed height to prevent bouncing
|
||||||
@@ -184,7 +314,7 @@ struct ContentView: View {
|
|||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
// Text
|
// Text
|
||||||
let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count
|
let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count
|
||||||
Text(viewModel.isConnected ? "\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")" : "scanning")
|
Text(viewModel.isConnected ? "\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")" : "alone :/")
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.system(size: 12, design: .monospaced))
|
||||||
#else
|
#else
|
||||||
@@ -229,32 +359,16 @@ struct ContentView: View {
|
|||||||
// Check if current user is mentioned
|
// Check if current user is mentioned
|
||||||
let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false
|
let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false
|
||||||
|
|
||||||
if message.voiceNoteData != nil {
|
Text(viewModel.formatMessage(message, colorScheme: colorScheme))
|
||||||
// Voice note message
|
.font(.system(size: 14, design: .monospaced))
|
||||||
HStack(spacing: 8) {
|
.fontWeight(isMentioned ? .bold : .regular)
|
||||||
Button(action: {
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.onTapGesture {
|
||||||
|
if message.voiceNoteData != nil {
|
||||||
viewModel.playVoiceNote(message: message)
|
viewModel.playVoiceNote(message: message)
|
||||||
}) {
|
|
||||||
Image(systemName: viewModel.audioPlayer.isPlaying && viewModel.audioPlayer.currentPlayingMessageID == message.id ? "pause.circle.fill" : "play.circle.fill")
|
|
||||||
.font(.system(size: 20))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
Text(viewModel.formatMessage(message, colorScheme: colorScheme))
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
.fontWeight(isMentioned ? .bold : .regular)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Regular text message
|
|
||||||
Text(viewModel.formatMessage(message, colorScheme: colorScheme))
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
.fontWeight(isMentioned ? .bold : .regular)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -285,61 +399,21 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var inputView: some View {
|
private var inputView: some View {
|
||||||
ZStack(alignment: .bottom) {
|
HStack(alignment: .center, spacing: 4) {
|
||||||
VStack(spacing: 0) {
|
|
||||||
// Autocomplete suggestions overlay
|
|
||||||
if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty {
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
|
||||||
ForEach(Array(viewModel.autocompleteSuggestions.enumerated()), id: \.element) { index, suggestion in
|
|
||||||
Button(action: {
|
|
||||||
_ = viewModel.completeNickname(suggestion, in: &messageText)
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Text("@\(suggestion)")
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
.foregroundColor(index == viewModel.selectedAutocompleteIndex ? backgroundColor : textColor)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(index == viewModel.selectedAutocompleteIndex ? textColor : Color.clear)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(backgroundColor)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 4)
|
|
||||||
.stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
|
|
||||||
)
|
|
||||||
.frame(maxWidth: 200, alignment: .leading)
|
|
||||||
.padding(.leading, 100) // Align with input field
|
|
||||||
.padding(.bottom, 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text("[\(viewModel.formatTimestamp(Date()))]")
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
.foregroundColor(secondaryTextColor)
|
|
||||||
.lineLimit(1)
|
|
||||||
.fixedSize()
|
|
||||||
.padding(.leading, 12)
|
|
||||||
|
|
||||||
if viewModel.selectedPrivateChatPeer != nil {
|
if viewModel.selectedPrivateChatPeer != nil {
|
||||||
Text("<\(viewModel.nickname)> →")
|
Text("<\(viewModel.nickname)> →")
|
||||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||||
.foregroundColor(Color.orange)
|
.foregroundColor(Color.orange)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
|
.padding(.leading, 12)
|
||||||
} else {
|
} else {
|
||||||
Text("<\(viewModel.nickname)>")
|
Text("<\(viewModel.nickname)>")
|
||||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
|
.padding(.leading, 12)
|
||||||
}
|
}
|
||||||
|
|
||||||
TextField("", text: $messageText)
|
TextField("", text: $messageText)
|
||||||
@@ -358,9 +432,29 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// Push to talk button
|
// Push to talk button
|
||||||
Button(action: {}) {
|
Button(action: {}) {
|
||||||
Image(systemName: "mic.circle.fill")
|
ZStack {
|
||||||
.font(.system(size: 20))
|
// Mic icon
|
||||||
.foregroundColor(isRecordingVoice ? Color.red : textColor)
|
Image(systemName: "mic.circle.fill")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundColor(isRecordingVoice ? Color.red.opacity(0.8) : textColor)
|
||||||
|
|
||||||
|
// Local ripples that start from the button itself
|
||||||
|
if isRecordingVoice {
|
||||||
|
ForEach(0..<4) { index in
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.red.opacity(0.3), lineWidth: 1)
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
.scaleEffect(1 + Double(index) * 0.5)
|
||||||
|
.opacity(isRecordingVoice ? 0.5 - Double(index) * 0.1 : 0)
|
||||||
|
.animation(
|
||||||
|
Animation.easeOut(duration: 1.5)
|
||||||
|
.repeatForever(autoreverses: false)
|
||||||
|
.delay(Double(index) * 0.2),
|
||||||
|
value: isRecordingVoice
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.simultaneousGesture(
|
.simultaneousGesture(
|
||||||
@@ -378,16 +472,15 @@ struct ContentView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
Button(action: sendMessage) {
|
Button(action: sendMessage) {
|
||||||
Image(systemName: "arrow.right.circle.fill")
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
.font(.system(size: 20))
|
.font(.system(size: 20))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 8)
|
||||||
.background(backgroundColor.opacity(0.95))
|
.background(backgroundColor.opacity(0.95))
|
||||||
}
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
isTextFieldFocused = true
|
isTextFieldFocused = true
|
||||||
}
|
}
|
||||||
@@ -400,6 +493,9 @@ struct ContentView: View {
|
|||||||
|
|
||||||
private func startVoiceRecording() {
|
private func startVoiceRecording() {
|
||||||
isRecordingVoice = true
|
isRecordingVoice = true
|
||||||
|
withAnimation(.easeOut(duration: 0.3)) {
|
||||||
|
recordingScale = 50.0 // Scale up to cover entire screen from mic button size
|
||||||
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// Light haptic feedback
|
// Light haptic feedback
|
||||||
let impactFeedback = UIImpactFeedbackGenerator(style: .light)
|
let impactFeedback = UIImpactFeedbackGenerator(style: .light)
|
||||||
@@ -412,15 +508,18 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func stopVoiceRecording() {
|
private func stopVoiceRecording() {
|
||||||
|
// Capture duration before stopping
|
||||||
|
let duration = viewModel.audioRecorder.recordingTime
|
||||||
isRecordingVoice = false
|
isRecordingVoice = false
|
||||||
|
recordingScale = 1.0
|
||||||
|
|
||||||
viewModel.audioRecorder.stopRecording { result in
|
viewModel.audioRecorder.stopRecording { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let audioURL):
|
case .success(let audioURL):
|
||||||
// Read audio file and send as voice note
|
// Read audio file and send as voice note
|
||||||
if let audioData = try? Data(contentsOf: audioURL) {
|
if let audioData = try? Data(contentsOf: audioURL) {
|
||||||
let duration = viewModel.audioRecorder.recordingTime
|
// Use the captured duration, ensure it's at least 0.1s
|
||||||
viewModel.sendVoiceNote(audioData, duration: duration)
|
viewModel.sendVoiceNote(audioData, duration: max(0.1, duration))
|
||||||
|
|
||||||
// Clean up temporary file
|
// Clean up temporary file
|
||||||
try? FileManager.default.removeItem(at: audioURL)
|
try? FileManager.default.removeItem(at: audioURL)
|
||||||
@@ -430,4 +529,114 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var sidebarView: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
// Grey vertical bar for visual continuity
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.gray.opacity(0.3))
|
||||||
|
.frame(width: 1)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Header - match main toolbar height
|
||||||
|
HStack {
|
||||||
|
Text("connected")
|
||||||
|
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(height: 44) // Match header height
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.background(backgroundColor.opacity(0.95))
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// People list
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if viewModel.connectedPeers.isEmpty {
|
||||||
|
Text("No one connected")
|
||||||
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
.foregroundColor(secondaryTextColor)
|
||||||
|
.padding(.horizontal)
|
||||||
|
} else {
|
||||||
|
let peerNicknames = viewModel.meshService.getPeerNicknames()
|
||||||
|
let peerRSSI = viewModel.meshService.getPeerRSSI()
|
||||||
|
let myPeerID = viewModel.meshService.myPeerID
|
||||||
|
|
||||||
|
// Sort peers: favorites first, then alphabetically by nickname
|
||||||
|
let sortedPeers = viewModel.connectedPeers.filter { $0 != myPeerID }.sorted { peer1, peer2 in
|
||||||
|
let isFav1 = viewModel.isFavorite(peerID: peer1)
|
||||||
|
let isFav2 = viewModel.isFavorite(peerID: peer2)
|
||||||
|
|
||||||
|
if isFav1 != isFav2 {
|
||||||
|
return isFav1 // Favorites come first
|
||||||
|
}
|
||||||
|
|
||||||
|
let name1 = peerNicknames[peer1] ?? "person-\(peer1.prefix(4))"
|
||||||
|
let name2 = peerNicknames[peer2] ?? "person-\(peer2.prefix(4))"
|
||||||
|
return name1 < name2
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(sortedPeers, id: \.self) { peerID in
|
||||||
|
let displayName = peerNicknames[peerID] ?? "person-\(peerID.prefix(4))"
|
||||||
|
let rssi = peerRSSI[peerID]?.intValue ?? -100
|
||||||
|
let isFavorite = viewModel.isFavorite(peerID: peerID)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// Signal strength indicator
|
||||||
|
Circle()
|
||||||
|
.fill(viewModel.getRSSIColor(rssi: rssi, colorScheme: colorScheme))
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
|
||||||
|
// Favorite star
|
||||||
|
Button(action: {
|
||||||
|
viewModel.toggleFavorite(peerID: peerID)
|
||||||
|
}) {
|
||||||
|
Image(systemName: isFavorite ? "star.fill" : "star")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(isFavorite ? Color.yellow : secondaryTextColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
// Peer name button
|
||||||
|
Button(action: {
|
||||||
|
if peerNicknames[peerID] != nil {
|
||||||
|
viewModel.startPrivateChat(with: peerID)
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
|
showSidebar = false
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text(displayName)
|
||||||
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
.foregroundColor(peerNicknames[peerID] != nil ? textColor : secondaryTextColor)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if viewModel.unreadPrivateMessages.contains(peerID) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.orange)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(peerNicknames[peerID] == nil)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# Bitchat Issues Analysis
|
|
||||||
|
|
||||||
## Issue 1: Background Messages on iOS Stopped Working
|
|
||||||
|
|
||||||
### Root Cause
|
|
||||||
The Info.plist file is missing the required `UIBackgroundModes` configuration for Bluetooth background operation. Without this, iOS will suspend the app when it goes to background, preventing BLE message delivery.
|
|
||||||
|
|
||||||
### Required Info.plist Entries
|
|
||||||
```xml
|
|
||||||
<key>UIBackgroundModes</key>
|
|
||||||
<array>
|
|
||||||
<string>bluetooth-central</string>
|
|
||||||
<string>bluetooth-peripheral</string>
|
|
||||||
</array>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Protocol Size Analysis
|
|
||||||
The binary protocol is significantly smaller than JSON:
|
|
||||||
- **Message packet**: 71 bytes (binary) vs 189 bytes (JSON) - 62% reduction
|
|
||||||
- **Announce packet**: 29 bytes (binary) vs 96 bytes (JSON) - 69% reduction
|
|
||||||
|
|
||||||
While the binary protocol is more efficient, the size reduction alone shouldn't break background delivery. The missing background modes configuration is the primary issue.
|
|
||||||
|
|
||||||
### Additional Considerations
|
|
||||||
- In `BluetoothMeshService.swift`, messages are sent with `.withResponse` type (line 281), which is good for reliability
|
|
||||||
- The app uses both central and peripheral modes, so both background modes are needed
|
|
||||||
|
|
||||||
## Issue 2: RSSI Indicator Dots Not Showing
|
|
||||||
|
|
||||||
### Analysis
|
|
||||||
The RSSI dots ARE implemented in `ContentView.swift` (lines 164-175), but there are several issues:
|
|
||||||
|
|
||||||
1. **RSSI Collection**: RSSI values are being collected and stored in the `peerRSSI` dictionary (line 504, 659)
|
|
||||||
2. **UI Implementation**: The dots are properly implemented with color coding based on signal strength
|
|
||||||
3. **Timing Issue**: The RSSI is read after connection (line 527) and periodically updated (line 665)
|
|
||||||
|
|
||||||
### Potential Issues
|
|
||||||
- The RSSI might not be available immediately when the peer list is displayed
|
|
||||||
- The peer ID mapping might be using temporary IDs initially (line 650-656 shows handling of temp IDs)
|
|
||||||
- The UI might not be updating when RSSI values are set
|
|
||||||
|
|
||||||
## Issue 3: Peer Nicknames Not Showing Until Message
|
|
||||||
|
|
||||||
### Analysis
|
|
||||||
The nickname flow works as follows:
|
|
||||||
|
|
||||||
1. **Connection**: When peers connect, they exchange keys first
|
|
||||||
2. **Announce Timing**: Announce packets are sent:
|
|
||||||
- After key exchange as central (lines 599-617)
|
|
||||||
- After key exchange as peripheral (lines 719-737)
|
|
||||||
- Targeted announce when receiving key exchange (line 362)
|
|
||||||
|
|
||||||
3. **Display Logic**: In `ContentView.swift` (line 155), peers show as "person-[ID]" until nickname is received
|
|
||||||
|
|
||||||
### Issues Found
|
|
||||||
1. **Race Condition**: The announce packet is sent immediately after key exchange, but there's a 0.1s delay for the broadcast announce (line 602)
|
|
||||||
2. **Peer ID Mapping**: Temporary peripheral IDs are used until the real peer ID is received (lines 521-524)
|
|
||||||
3. **Announce Tracking**: The `announcedToPeers` set prevents re-announcing (line 221), which could be problematic if the first announce fails
|
|
||||||
|
|
||||||
### The Real Problem
|
|
||||||
Looking at lines 384-391 and 648-656, there's a complex mapping issue where peripherals are initially stored with their system UUID, then remapped when the real peer ID is learned. This remapping happens during announce or key exchange, but the UI might be showing peers before this remapping completes.
|
|
||||||
|
|
||||||
## Recommendations
|
|
||||||
|
|
||||||
### 1. Fix Background Modes (Priority 1)
|
|
||||||
Add the missing background modes to Info.plist to restore background BLE functionality.
|
|
||||||
|
|
||||||
### 2. Fix RSSI Display (Priority 2)
|
|
||||||
- Ensure RSSI reading starts immediately after getting the real peer ID
|
|
||||||
- Force UI update when RSSI values change
|
|
||||||
- Consider showing a placeholder dot (gray) while RSSI is being read
|
|
||||||
|
|
||||||
### 3. Fix Nickname Display (Priority 3)
|
|
||||||
- Send multiple announce packets with retries to ensure delivery
|
|
||||||
- Consider sending announce both as broadcast and targeted to ensure all peers receive it
|
|
||||||
- Improve the peer ID mapping to handle the transition from temp ID to real ID more smoothly
|
|
||||||
- Add debugging to track announce packet delivery success
|
|
||||||
|
|
||||||
### 4. Additional Improvements
|
|
||||||
- Add retry logic for critical packets (announce, key exchange)
|
|
||||||
- Implement packet acknowledgment for announce messages
|
|
||||||
- Add connection state tracking to better handle the peer discovery flow
|
|
||||||
+3
-1
@@ -9,7 +9,7 @@ options:
|
|||||||
settings:
|
settings:
|
||||||
MARKETING_VERSION: 1.0.0
|
MARKETING_VERSION: 1.0.0
|
||||||
CURRENT_PROJECT_VERSION: 1
|
CURRENT_PROJECT_VERSION: 1
|
||||||
DEVELOPMENT_TEAM: ""
|
DEVELOPMENT_TEAM: "L3N5LHJD5Y"
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
bitchat:
|
bitchat:
|
||||||
@@ -46,5 +46,7 @@ targets:
|
|||||||
MACOSX_DEPLOYMENT_TARGET: 13.0
|
MACOSX_DEPLOYMENT_TARGET: 13.0
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: YES
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: YES
|
||||||
CODE_SIGN_STYLE: Automatic
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
CODE_SIGNING_REQUIRED: YES
|
||||||
|
CODE_SIGNING_ALLOWED: YES
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES
|
||||||
Reference in New Issue
Block a user