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:
jack
2025-07-03 22:13:28 +02:00
parent 94a9888ef5
commit 2993fc3e06
11 changed files with 816 additions and 221 deletions
+26 -7
View File
@@ -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
View File
@@ -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>
+3
View File
@@ -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()
+78 -23
View File
@@ -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"
} }
} }
} }
+255 -5
View File
@@ -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)
}
}
+67 -5
View File
@@ -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
View File
@@ -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)
}
}
} }
-82
View File
@@ -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
View File
@@ -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