diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 919f7ad8..b3b1b933 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -11,13 +11,17 @@ 1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; }; 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.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 */; }; 739429DFDE5C5829CF70DA7D /* EncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC1563390A15C042D059CF9 /* EncryptionService.swift */; }; 7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; }; 7A50E2F04A3515A7E90EEAE4 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; }; 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 */; }; 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 */; }; D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; }; D948085736ED8E736C1DE3B0 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; }; @@ -28,11 +32,13 @@ /* Begin PBXFileReference section */ 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = ""; }; 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 6C8801D22A8F987A7696BB90 /* AudioRecordingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecordingService.swift; sourceTree = ""; }; 6DC1563390A15C042D059CF9 /* EncryptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionService.swift; sourceTree = ""; }; 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; }; A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = ""; }; + B90B7FFCA6DA409F3CB78704 /* AudioPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackService.swift; sourceTree = ""; }; D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothMeshService.swift; sourceTree = ""; }; E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = ""; }; EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -99,6 +105,8 @@ D98A3186D7E4C72E35BDF7FE /* Services */ = { isa = PBXGroup; children = ( + B90B7FFCA6DA409F3CB78704 /* AudioPlaybackService.swift */, + 6C8801D22A8F987A7696BB90 /* AudioRecordingService.swift */, D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */, 6DC1563390A15C042D059CF9 /* EncryptionService.swift */, ); @@ -154,10 +162,11 @@ LastUpgradeCheck = 1430; TargetAttributes = { 0576A29205865664C0937536 = { - DevelopmentTeam = ""; + DevelopmentTeam = L3N5LHJD5Y; ProvisioningStyle = Automatic; }; AF077EA0474EDEDE2C72716C = { + DevelopmentTeam = L3N5LHJD5Y; ProvisioningStyle = Automatic; }; }; @@ -205,6 +214,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 6E628AC6B7E1754F3BD5090D /* AudioPlaybackService.swift in Sources */, + A7F37567974153A90E8C9F12 /* AudioRecordingService.swift in Sources */, 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */, 6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */, 923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */, @@ -219,6 +230,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 940A9CE820FBF517F0C7AA7E /* AudioPlaybackService.swift in Sources */, + 817B221A5676873EAF5C5FA7 /* AudioRecordingService.swift in Sources */, F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */, 10E68BB889356219189E38EC /* BitchatApp.swift in Sources */, 6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */, @@ -237,9 +250,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = L3N5LHJD5Y; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -248,7 +262,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - PRODUCT_BUNDLE_IDENTIFIER = com.bitchat.app; + PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.app; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; @@ -262,9 +276,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = L3N5LHJD5Y; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -273,7 +288,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - PRODUCT_BUNDLE_IDENTIFIER = com.bitchat.app; + PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.app; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; @@ -287,6 +302,8 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; ENABLE_PREVIEWS = YES; @@ -341,7 +358,7 @@ COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = L3N5LHJD5Y; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -369,6 +386,8 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; ENABLE_PREVIEWS = YES; @@ -423,7 +442,7 @@ COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = L3N5LHJD5Y; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; diff --git a/bitchat/Info.plist b/bitchat/Info.plist index 79b59dd7..78b57dd9 100644 --- a/bitchat/Info.plist +++ b/bitchat/Info.plist @@ -28,16 +28,16 @@ bitchat uses Bluetooth to discover and connect with other bitchat users nearby. NSMicrophoneUsageDescription bitchat needs access to your microphone to record voice notes. - UILaunchScreen - - UIColorName - Black - UIBackgroundModes bluetooth-central bluetooth-peripheral + UILaunchScreen + + UIColorName + Black + UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 4a92d646..78e29182 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -11,6 +11,9 @@ enum MessageType: UInt8 { case leave = 0x07 case privateMessage = 0x08 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 { diff --git a/bitchat/Services/AudioPlaybackService.swift b/bitchat/Services/AudioPlaybackService.swift index aacc7343..f478b956 100644 --- a/bitchat/Services/AudioPlaybackService.swift +++ b/bitchat/Services/AudioPlaybackService.swift @@ -31,13 +31,21 @@ class AudioPlaybackService: NSObject, ObservableObject { // Stop any current playback stopPlayback() + print("[AUDIO] Attempting to play voice note with data size: \(audioData.count) bytes") + // Create temporary file for audio data let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("voice_note_\(messageID).m4a") do { + // Ensure directory exists + try FileManager.default.createDirectory(at: tempURL.deletingLastPathComponent(), withIntermediateDirectories: true) + + // Write audio data try audioData.write(to: 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?.delegate = self audioPlayer?.prepareToPlay() diff --git a/bitchat/Services/AudioRecordingService.swift b/bitchat/Services/AudioRecordingService.swift index 036731ce..af254305 100644 --- a/bitchat/Services/AudioRecordingService.swift +++ b/bitchat/Services/AudioRecordingService.swift @@ -8,7 +8,8 @@ class AudioRecordingService: NSObject, ObservableObject { private var audioRecorder: AVAudioRecorder? private var recordingTimer: Timer? private var recordingStartTime: Date? - private let maxDuration: TimeInterval = 10.0 + private let maxDuration: TimeInterval = 30.0 + private var recordingCompletion: ((Result) -> Void)? override init() { super.init() @@ -30,6 +31,24 @@ class AudioRecordingService: NSObject, ObservableObject { func startRecording(completion: @escaping (Result) -> Void) { 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) -> Void) { + // Store completion handler for later use + self.recordingCompletion = completion + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let audioFilename = documentsPath.appendingPathComponent("voice_note_\(Date().timeIntervalSince1970).m4a") @@ -42,27 +61,40 @@ class AudioRecordingService: NSObject, ObservableObject { ] 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?.delegate = self - audioRecorder?.record() + audioRecorder?.prepareToRecord() // Important: prepare before recording - isRecording = true - 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 audioRecorder?.record() == true { + isRecording = true + recordingStartTime = Date() + recordingTime = 0 - if self.recordingTime >= self.maxDuration { - self.stopRecording { result in - completion(result) + // 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 { + self.stopRecording { result in + // Result already handled by recordingCompletion + } } } + } else { + print("[AUDIO] Failed to start recording") + completion(.failure(AudioError.recordingFailed)) } } catch { + print("[AUDIO] Error setting up recording: \(error)") completion(.failure(error)) } } @@ -73,29 +105,46 @@ class AudioRecordingService: NSObject, ObservableObject { 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() isRecording = false recordingTimer?.invalidate() recordingTimer = nil let url = recorder.url - let _ = recordingTime + let recordedDuration = recordingTime // Reset for next recording audioRecorder = nil recordingTime = 0 recordingStartTime = nil + recordingCompletion = nil - // Verify file exists and has content - do { - let attributes = try FileManager.default.attributesOfItem(atPath: url.path) - if let fileSize = attributes[.size] as? Int, fileSize > 0 { - completion(.success(url)) - } else { - completion(.failure(AudioError.emptyRecording)) + // Give the recorder a moment to finalize the file + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + // Verify file exists and has content + do { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + if let fileSize = attributes[.size] as? Int, fileSize > 1000 { // At least 1KB + 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 { case notRecording case emptyRecording + case microphonePermissionDenied + case recordingFailed var errorDescription: String? { switch self { @@ -123,6 +174,10 @@ class AudioRecordingService: NSObject, ObservableObject { return "Not currently recording" case .emptyRecording: return "Recording produced no audio data" + case .microphonePermissionDenied: + return "Microphone permission denied" + case .recordingFailed: + return "Failed to start recording" } } } diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index b2887d78..899842b2 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -7,6 +7,16 @@ import AppKit import UIKit #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 { static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D") @@ -31,6 +41,11 @@ class BluetoothMeshService: NSObject { private var announcedToPeers = Set() // Track which peers we've announced to private var announcedPeers = Set() // 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 override init() { @@ -183,12 +198,14 @@ class BluetoothMeshService: NSObject { messageQueue.async { [weak self] in 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 senderNick = nickname?.nickname ?? self.myPeerID let message = BitchatMessage( sender: senderNick, - content: "🎤 Voice note (\(String(format: "%.1f", duration))s)", + content: "🎤 \(String(format: "%.1f", duration))s", timestamp: Date(), isRelay: false, originalSender: nil, @@ -197,6 +214,7 @@ class BluetoothMeshService: NSObject { ) if let messageData = message.toBinaryPayload() { + print("[VOICE] Message payload size: \(messageData.count) bytes") let packet = BitchatPacket( type: MessageType.voiceNote.rawValue, ttl: self.maxTTL, @@ -204,7 +222,18 @@ class BluetoothMeshService: NSObject { 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) { messageQueue.async(flags: .barrier) { [weak self] in 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) ?? "")" 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: 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..> 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.. = [] + init() { loadNickname() + loadFavorites() meshService.delegate = self // Start mesh service immediately meshService.startServices() + + // Request notification permission + NotificationService.shared.requestAuthorization() } private func loadNickname() { @@ -56,6 +63,30 @@ class ChatViewModel: ObservableObject { 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) { guard !content.isEmpty else { return } @@ -200,10 +231,9 @@ class ChatViewModel: ObservableObject { let partial = String(beforeCursor[range]).lowercased() - // Get all available nicknames + // Get all available nicknames (excluding self) let peerNicknames = meshService.getPeerNicknames() - var allNicknames = Array(peerNicknames.values) - allNicknames.append(nickname) // Include self + let allNicknames = Array(peerNicknames.values) // Filter suggestions let suggestions = allNicknames.filter { nick in @@ -279,6 +309,34 @@ class ChatViewModel: ObservableObject { senderStyle.font = .system(size: 12, weight: .medium, design: .monospaced) 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 let contentText = message.content var processedContent = AttributedString() @@ -376,8 +434,10 @@ extension ChatViewModel: BitchatDelegate { // Check if we're mentioned 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 { + NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content) + // Very prominent haptic for @mentions - triple tap with heavy impact let impactFeedback = UIImpactFeedbackGenerator(style: .heavy) impactFeedback.prepare() @@ -390,6 +450,8 @@ extension ChatViewModel: BitchatDelegate { impactFeedback.impactOccurred() } } else if message.isPrivate && message.sender != nickname { + NotificationService.shared.sendPrivateMessageNotification(from: message.sender, message: message.content) + // Heavy haptic for private messages - more pronounced let impactFeedback = UIImpactFeedbackGenerator(style: .heavy) impactFeedback.prepare() @@ -477,7 +539,7 @@ extension ChatViewModel: BitchatDelegate { func sendVoiceNote(_ audioData: Data, duration: TimeInterval) { let message = BitchatMessage( sender: nickname, - content: "🎤 Voice note (\(String(format: "%.1f", duration))s)", + content: "🎤 \(String(format: "%.1f", duration))s", timestamp: Date(), isRelay: false, originalSender: nil, diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 14a67db4..6d22b9f2 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -8,6 +8,10 @@ struct ContentView: View { @Environment(\.colorScheme) var colorScheme @State private var showPeerList = 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 { colorScheme == .dark ? Color.black : Color.white @@ -23,15 +27,134 @@ struct ContentView: View { var body: some View { ZStack { - VStack(spacing: 0) { - headerView - Divider() - messagesView - Divider() - inputView + // Main content + GeometryReader { geometry in + ZStack { + VStack(spacing: 0) { + headerView + 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 if let notification = viewModel.privateMessageNotification { @@ -108,25 +231,18 @@ struct ContentView: View { .opacity(0) } else { // Public chat header - HStack(spacing: 8) { + HStack(spacing: 4) { Text("bitchat") .font(.system(size: 18, weight: .medium, design: .monospaced)) .foregroundColor(textColor) - // Peer status section - peerStatusView - } - - Spacer() - - HStack(spacing: 4) { Text("name:") - .font(.system(size: 11, design: .monospaced)) + .font(.system(size: 14, design: .monospaced)) .foregroundColor(secondaryTextColor) TextField("nickname", text: $viewModel.nickname) .textFieldStyle(.plain) - .font(.system(size: 12, design: .monospaced)) + .font(.system(size: 14, design: .monospaced)) .frame(maxWidth: 100) .foregroundColor(textColor) .onChange(of: viewModel.nickname) { _ in @@ -136,6 +252,20 @@ struct ContentView: View { 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 @@ -184,7 +314,7 @@ struct ContentView: View { HStack(spacing: 2) { // Text 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) .font(.system(size: 12, design: .monospaced)) #else @@ -229,32 +359,16 @@ struct ContentView: View { // Check if current user is mentioned let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false - if message.voiceNoteData != nil { - // Voice note message - HStack(spacing: 8) { - Button(action: { + 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) + .onTapGesture { + if message.voiceNoteData != nil { 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) @@ -285,61 +399,21 @@ struct ContentView: View { } private var inputView: some View { - ZStack(alignment: .bottom) { - 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) - + HStack(alignment: .center, spacing: 4) { if viewModel.selectedPrivateChatPeer != nil { Text("<\(viewModel.nickname)> →") .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundColor(Color.orange) .lineLimit(1) .fixedSize() + .padding(.leading, 12) } else { Text("<\(viewModel.nickname)>") .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundColor(textColor) .lineLimit(1) .fixedSize() + .padding(.leading, 12) } TextField("", text: $messageText) @@ -358,9 +432,29 @@ struct ContentView: View { // Push to talk button Button(action: {}) { - Image(systemName: "mic.circle.fill") - .font(.system(size: 20)) - .foregroundColor(isRecordingVoice ? Color.red : textColor) + ZStack { + // Mic icon + 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) .simultaneousGesture( @@ -378,16 +472,15 @@ struct ContentView: View { ) Button(action: sendMessage) { - Image(systemName: "arrow.right.circle.fill") + Image(systemName: "arrow.up.circle.fill") .font(.system(size: 20)) .foregroundColor(textColor) } .buttonStyle(.plain) .padding(.trailing, 12) } - .padding(.vertical, 10) + .padding(.vertical, 8) .background(backgroundColor.opacity(0.95)) - } .onAppear { isTextFieldFocused = true } @@ -400,6 +493,9 @@ struct ContentView: View { private func startVoiceRecording() { isRecordingVoice = true + withAnimation(.easeOut(duration: 0.3)) { + recordingScale = 50.0 // Scale up to cover entire screen from mic button size + } #if os(iOS) // Light haptic feedback let impactFeedback = UIImpactFeedbackGenerator(style: .light) @@ -412,15 +508,18 @@ struct ContentView: View { } private func stopVoiceRecording() { + // Capture duration before stopping + let duration = viewModel.audioRecorder.recordingTime isRecordingVoice = false + recordingScale = 1.0 viewModel.audioRecorder.stopRecording { result in switch result { case .success(let audioURL): // Read audio file and send as voice note if let audioData = try? Data(contentsOf: audioURL) { - let duration = viewModel.audioRecorder.recordingTime - viewModel.sendVoiceNote(audioData, duration: duration) + // Use the captured duration, ensure it's at least 0.1s + viewModel.sendVoiceNote(audioData, duration: max(0.1, duration)) // Clean up temporary file 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) + } + } } \ No newline at end of file diff --git a/bitchat_issues_analysis.md b/bitchat_issues_analysis.md deleted file mode 100644 index 0f721a97..00000000 --- a/bitchat_issues_analysis.md +++ /dev/null @@ -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 -UIBackgroundModes - - bluetooth-central - bluetooth-peripheral - -``` - -### 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 \ No newline at end of file diff --git a/project.yml b/project.yml index 10c61dcf..b4025ff9 100644 --- a/project.yml +++ b/project.yml @@ -9,7 +9,7 @@ options: settings: MARKETING_VERSION: 1.0.0 CURRENT_PROJECT_VERSION: 1 - DEVELOPMENT_TEAM: "" + DEVELOPMENT_TEAM: "L3N5LHJD5Y" targets: bitchat: @@ -46,5 +46,7 @@ targets: MACOSX_DEPLOYMENT_TARGET: 13.0 SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: YES CODE_SIGN_STYLE: Automatic + CODE_SIGNING_REQUIRED: YES + CODE_SIGNING_ALLOWED: YES ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES \ No newline at end of file